mirror of
https://github.com/rjNemo/payit
synced 2026-06-06 02:16:40 +00:00
feat: add checkout ui and driver-based payments
This commit is contained in:
parent
4e36eb9692
commit
f0a6b128a0
15 changed files with 416 additions and 104 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1 +1,2 @@
|
|||
*cache
|
||||
*env*
|
||||
|
|
|
|||
|
|
@ -97,8 +97,10 @@ func applyEnvFile(path string) (retErr error) {
|
|||
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
if _, exists := os.LookupEnv(key); !exists {
|
||||
_ = os.Setenv(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return err
|
||||
|
|
|
|||
69
internal/payments/driver/stripe/checkout.go
Normal file
69
internal/payments/driver/stripe/checkout.go
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
package stripe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
stripesdk "github.com/stripe/stripe-go/v78"
|
||||
"github.com/stripe/stripe-go/v78/client"
|
||||
|
||||
"github.com/rjNemo/payit/config"
|
||||
"github.com/rjNemo/payit/internal/payments"
|
||||
)
|
||||
|
||||
type sessionCreator interface {
|
||||
New(params *stripesdk.CheckoutSessionParams) (*stripesdk.CheckoutSession, error)
|
||||
}
|
||||
|
||||
// Driver implements the CheckoutDriver interface using the Stripe SDK.
|
||||
type Driver struct {
|
||||
product config.ProductConfig
|
||||
sessions sessionCreator
|
||||
}
|
||||
|
||||
// NewDriver creates a Stripe-backed checkout driver with the provided credentials.
|
||||
func NewDriver(apiKey string, product config.ProductConfig) *Driver {
|
||||
stripeClient := client.New(apiKey, nil)
|
||||
|
||||
return &Driver{
|
||||
product: product,
|
||||
sessions: stripeClient.CheckoutSessions,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateSession delegates session creation to Stripe, translating domain values to SDK params.
|
||||
func (d *Driver) CreateSession(ctx context.Context, req payments.CheckoutSessionRequest) (payments.CheckoutSessionResult, error) {
|
||||
quantity := req.Quantity
|
||||
if quantity <= 0 {
|
||||
quantity = 1
|
||||
}
|
||||
|
||||
params := &stripesdk.CheckoutSessionParams{}
|
||||
params.Context = ctx
|
||||
params.SuccessURL = stripesdk.String(d.product.SuccessURL)
|
||||
params.CancelURL = stripesdk.String(d.product.CancelURL)
|
||||
params.Mode = stripesdk.String(string(stripesdk.CheckoutSessionModePayment))
|
||||
params.PaymentMethodTypes = stripesdk.StringSlice([]string{"card"})
|
||||
|
||||
params.LineItems = append(params.LineItems, &stripesdk.CheckoutSessionLineItemParams{
|
||||
Quantity: stripesdk.Int64(quantity),
|
||||
PriceData: &stripesdk.CheckoutSessionLineItemPriceDataParams{
|
||||
Currency: stripesdk.String(d.product.Currency),
|
||||
UnitAmount: stripesdk.Int64(d.product.PriceCents),
|
||||
ProductData: &stripesdk.CheckoutSessionLineItemPriceDataProductDataParams{
|
||||
Name: stripesdk.String(d.product.Name),
|
||||
Description: stripesdk.String(d.product.Description),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
session, err := d.sessions.New(params)
|
||||
if err != nil {
|
||||
return payments.CheckoutSessionResult{}, err
|
||||
}
|
||||
if session == nil {
|
||||
return payments.CheckoutSessionResult{}, errors.New("stripe returned nil session")
|
||||
}
|
||||
|
||||
return payments.CheckoutSessionResult{ID: session.ID, URL: session.URL}, nil
|
||||
}
|
||||
|
|
@ -5,33 +5,35 @@ import (
|
|||
"errors"
|
||||
"testing"
|
||||
|
||||
stripesdk "github.com/stripe/stripe-go/v78"
|
||||
|
||||
"github.com/rjNemo/payit/config"
|
||||
stripeapi "github.com/stripe/stripe-go/v78"
|
||||
"github.com/rjNemo/payit/internal/payments"
|
||||
)
|
||||
|
||||
type fakeSessionCreator struct {
|
||||
lastParams *stripeapi.CheckoutSessionParams
|
||||
result *stripeapi.CheckoutSession
|
||||
lastParams *stripesdk.CheckoutSessionParams
|
||||
result *stripesdk.CheckoutSession
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeSessionCreator) New(params *stripeapi.CheckoutSessionParams) (*stripeapi.CheckoutSession, error) {
|
||||
func (f *fakeSessionCreator) New(params *stripesdk.CheckoutSessionParams) (*stripesdk.CheckoutSession, error) {
|
||||
f.lastParams = params
|
||||
return f.result, f.err
|
||||
}
|
||||
|
||||
func TestService_CreateSessionSuccess(t *testing.T) {
|
||||
func TestDriver_CreateSessionSuccess(t *testing.T) {
|
||||
product := testProductConfig()
|
||||
fake := &fakeSessionCreator{
|
||||
result: &stripeapi.CheckoutSession{
|
||||
result: &stripesdk.CheckoutSession{
|
||||
ID: "cs_test_123",
|
||||
URL: "https://stripe.test/checkout",
|
||||
},
|
||||
}
|
||||
|
||||
svc := &Service{product: product, sessions: fake}
|
||||
driver := &Driver{product: product, sessions: fake}
|
||||
|
||||
res, err := svc.CreateSession(context.Background(), CheckoutSessionRequest{})
|
||||
res, err := driver.CreateSession(context.Background(), payments.CheckoutSessionRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
|
@ -47,7 +49,7 @@ func TestService_CreateSessionSuccess(t *testing.T) {
|
|||
if params.Context == nil {
|
||||
t.Fatal("expected context to propagate")
|
||||
}
|
||||
if params.Mode == nil || *params.Mode != string(stripeapi.CheckoutSessionModePayment) {
|
||||
if params.Mode == nil || *params.Mode != string(stripesdk.CheckoutSessionModePayment) {
|
||||
t.Fatalf("unexpected mode: %v", params.Mode)
|
||||
}
|
||||
if len(params.PaymentMethodTypes) != 1 || params.PaymentMethodTypes[0] == nil || *params.PaymentMethodTypes[0] != "card" {
|
||||
|
|
@ -82,15 +84,15 @@ func TestService_CreateSessionSuccess(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestService_CreateSessionWithCustomQuantity(t *testing.T) {
|
||||
func TestDriver_CreateSessionWithCustomQuantity(t *testing.T) {
|
||||
product := testProductConfig()
|
||||
fake := &fakeSessionCreator{
|
||||
result: &stripeapi.CheckoutSession{},
|
||||
result: &stripesdk.CheckoutSession{},
|
||||
}
|
||||
|
||||
svc := &Service{product: product, sessions: fake}
|
||||
driver := &Driver{product: product, sessions: fake}
|
||||
|
||||
_, err := svc.CreateSession(context.Background(), CheckoutSessionRequest{Quantity: 3})
|
||||
_, err := driver.CreateSession(context.Background(), payments.CheckoutSessionRequest{Quantity: 3})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
|
@ -106,25 +108,25 @@ func TestService_CreateSessionWithCustomQuantity(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestService_CreateSessionError(t *testing.T) {
|
||||
func TestDriver_CreateSessionError(t *testing.T) {
|
||||
product := testProductConfig()
|
||||
fake := &fakeSessionCreator{err: errors.New("boom")}
|
||||
|
||||
svc := &Service{product: product, sessions: fake}
|
||||
driver := &Driver{product: product, sessions: fake}
|
||||
|
||||
_, err := svc.CreateSession(context.Background(), CheckoutSessionRequest{})
|
||||
_, err := driver.CreateSession(context.Background(), payments.CheckoutSessionRequest{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_CreateSessionNilSession(t *testing.T) {
|
||||
func TestDriver_CreateSessionNilSession(t *testing.T) {
|
||||
product := testProductConfig()
|
||||
fake := &fakeSessionCreator{}
|
||||
|
||||
svc := &Service{product: product, sessions: fake}
|
||||
driver := &Driver{product: product, sessions: fake}
|
||||
|
||||
_, err := svc.CreateSession(context.Background(), CheckoutSessionRequest{})
|
||||
_, err := driver.CreateSession(context.Background(), payments.CheckoutSessionRequest{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil session")
|
||||
}
|
||||
31
internal/payments/service/checkout.go
Normal file
31
internal/payments/service/checkout.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/rjNemo/payit/internal/payments"
|
||||
)
|
||||
|
||||
// CheckoutDriver represents a payment provider capable of creating checkout sessions.
|
||||
type CheckoutDriver interface {
|
||||
CreateSession(ctx context.Context, req payments.CheckoutSessionRequest) (payments.CheckoutSessionResult, error)
|
||||
}
|
||||
|
||||
// CheckoutService contains provider-agnostic business rules for initiating checkout flows.
|
||||
type CheckoutService struct {
|
||||
driver CheckoutDriver
|
||||
}
|
||||
|
||||
// NewCheckoutService wires the given driver into a reusable checkout service.
|
||||
func NewCheckoutService(driver CheckoutDriver) *CheckoutService {
|
||||
return &CheckoutService{driver: driver}
|
||||
}
|
||||
|
||||
// CreateSession applies domain defaults before delegating to the configured driver.
|
||||
func (s *CheckoutService) CreateSession(ctx context.Context, req payments.CheckoutSessionRequest) (payments.CheckoutSessionResult, error) {
|
||||
if req.Quantity <= 0 {
|
||||
req.Quantity = 1
|
||||
}
|
||||
|
||||
return s.driver.CreateSession(ctx, req)
|
||||
}
|
||||
61
internal/payments/service/checkout_test.go
Normal file
61
internal/payments/service/checkout_test.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/rjNemo/payit/internal/payments"
|
||||
)
|
||||
|
||||
type fakeDriver struct {
|
||||
lastReq payments.CheckoutSessionRequest
|
||||
result payments.CheckoutSessionResult
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeDriver) CreateSession(ctx context.Context, req payments.CheckoutSessionRequest) (payments.CheckoutSessionResult, error) {
|
||||
f.lastReq = req
|
||||
if f.err != nil {
|
||||
return payments.CheckoutSessionResult{}, f.err
|
||||
}
|
||||
return f.result, nil
|
||||
}
|
||||
|
||||
func TestCheckoutService_DefaultQuantity(t *testing.T) {
|
||||
drv := &fakeDriver{}
|
||||
svc := NewCheckoutService(drv)
|
||||
|
||||
_, err := svc.CreateSession(context.Background(), payments.CheckoutSessionRequest{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if drv.lastReq.Quantity != 1 {
|
||||
t.Fatalf("expected default quantity 1, got %d", drv.lastReq.Quantity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckoutService_PreservesQuantity(t *testing.T) {
|
||||
drv := &fakeDriver{}
|
||||
svc := NewCheckoutService(drv)
|
||||
|
||||
_, err := svc.CreateSession(context.Background(), payments.CheckoutSessionRequest{Quantity: 5})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if drv.lastReq.Quantity != 5 {
|
||||
t.Fatalf("expected quantity 5, got %d", drv.lastReq.Quantity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckoutService_PropagatesError(t *testing.T) {
|
||||
drv := &fakeDriver{err: errors.New("driver failed")}
|
||||
svc := NewCheckoutService(drv)
|
||||
|
||||
_, err := svc.CreateSession(context.Background(), payments.CheckoutSessionRequest{Quantity: 2})
|
||||
if err == nil {
|
||||
t.Fatal("expected error from driver")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package stripe
|
||||
package payments
|
||||
|
||||
// CheckoutSessionRequest captures optional inputs for creating a checkout session.
|
||||
type CheckoutSessionRequest struct {
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
package stripe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/rjNemo/payit/config"
|
||||
stripe "github.com/stripe/stripe-go/v78"
|
||||
"github.com/stripe/stripe-go/v78/client"
|
||||
)
|
||||
|
||||
type sessionCreator interface {
|
||||
New(params *stripe.CheckoutSessionParams) (*stripe.CheckoutSession, error)
|
||||
}
|
||||
|
||||
// Service coordinates Stripe Checkout session creation for the demo product.
|
||||
type Service struct {
|
||||
product config.ProductConfig
|
||||
sessions sessionCreator
|
||||
}
|
||||
|
||||
// NewService instantiates a Stripe-backed checkout service using the provided API key.
|
||||
func NewService(apiKey string, product config.ProductConfig) *Service {
|
||||
stripeClient := client.New(apiKey, nil)
|
||||
|
||||
return &Service{
|
||||
product: product,
|
||||
sessions: stripeClient.CheckoutSessions,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateSession creates a Stripe Checkout session for the configured demo product.
|
||||
func (s *Service) CreateSession(ctx context.Context, req CheckoutSessionRequest) (CheckoutSessionResult, error) {
|
||||
quantity := req.Quantity
|
||||
if quantity <= 0 {
|
||||
quantity = 1
|
||||
}
|
||||
|
||||
params := &stripe.CheckoutSessionParams{}
|
||||
params.Context = ctx
|
||||
params.SuccessURL = stripe.String(s.product.SuccessURL)
|
||||
params.CancelURL = stripe.String(s.product.CancelURL)
|
||||
params.Mode = stripe.String(string(stripe.CheckoutSessionModePayment))
|
||||
params.PaymentMethodTypes = stripe.StringSlice([]string{"card"})
|
||||
|
||||
params.LineItems = append(params.LineItems, &stripe.CheckoutSessionLineItemParams{
|
||||
Quantity: stripe.Int64(quantity),
|
||||
PriceData: &stripe.CheckoutSessionLineItemPriceDataParams{
|
||||
Currency: stripe.String(s.product.Currency),
|
||||
UnitAmount: stripe.Int64(s.product.PriceCents),
|
||||
ProductData: &stripe.CheckoutSessionLineItemPriceDataProductDataParams{
|
||||
Name: stripe.String(s.product.Name),
|
||||
Description: stripe.String(s.product.Description),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
session, err := s.sessions.New(params)
|
||||
if err != nil {
|
||||
return CheckoutSessionResult{}, err
|
||||
}
|
||||
if session == nil {
|
||||
return CheckoutSessionResult{}, errors.New("stripe returned nil session")
|
||||
}
|
||||
|
||||
return CheckoutSessionResult{ID: session.ID, URL: session.URL}, nil
|
||||
}
|
||||
|
|
@ -6,17 +6,16 @@ import (
|
|||
"io"
|
||||
"net/http"
|
||||
|
||||
stripe "github.com/rjNemo/payit/internal/stripe"
|
||||
"github.com/rjNemo/payit/internal/payments"
|
||||
)
|
||||
|
||||
func (h *Handler) createCheckoutSession(w http.ResponseWriter, r *http.Request) {
|
||||
var req stripe.CheckoutSessionRequest
|
||||
var req payments.CheckoutSessionRequest
|
||||
|
||||
if r.Body != nil {
|
||||
defer func(body io.ReadCloser) {
|
||||
_ = body.Close()
|
||||
}(r.Body)
|
||||
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
|
||||
|
|
|
|||
|
|
@ -9,19 +9,19 @@ import (
|
|||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
stripe "github.com/rjNemo/payit/internal/stripe"
|
||||
"github.com/rjNemo/payit/internal/payments"
|
||||
)
|
||||
|
||||
type fakeCheckoutService struct {
|
||||
result stripe.CheckoutSessionResult
|
||||
result payments.CheckoutSessionResult
|
||||
err error
|
||||
req stripe.CheckoutSessionRequest
|
||||
req payments.CheckoutSessionRequest
|
||||
}
|
||||
|
||||
func (f *fakeCheckoutService) CreateSession(ctx context.Context, req stripe.CheckoutSessionRequest) (stripe.CheckoutSessionResult, error) {
|
||||
func (f *fakeCheckoutService) CreateSession(ctx context.Context, req payments.CheckoutSessionRequest) (payments.CheckoutSessionResult, error) {
|
||||
f.req = req
|
||||
if f.err != nil {
|
||||
return stripe.CheckoutSessionResult{}, f.err
|
||||
return payments.CheckoutSessionResult{}, f.err
|
||||
}
|
||||
return f.result, nil
|
||||
}
|
||||
|
|
@ -29,7 +29,7 @@ func (f *fakeCheckoutService) CreateSession(ctx context.Context, req stripe.Chec
|
|||
func TestCreateCheckoutSessionSuccess(t *testing.T) {
|
||||
handler := &Handler{
|
||||
checkout: &fakeCheckoutService{
|
||||
result: stripe.CheckoutSessionResult{ID: "cs_test_1", URL: "https://stripe.test/checkout"},
|
||||
result: payments.CheckoutSessionResult{ID: "cs_test_1", URL: "https://stripe.test/checkout"},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -47,7 +47,7 @@ func TestCreateCheckoutSessionSuccess(t *testing.T) {
|
|||
t.Fatalf("expected json content type, got %s", ct)
|
||||
}
|
||||
|
||||
var payload stripe.CheckoutSessionResult
|
||||
var payload payments.CheckoutSessionResult
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("expected valid json response: %v", err)
|
||||
}
|
||||
|
|
@ -63,7 +63,7 @@ func TestCreateCheckoutSessionSuccess(t *testing.T) {
|
|||
|
||||
func TestCreateCheckoutSessionDefaultsQuantity(t *testing.T) {
|
||||
fakeSvc := &fakeCheckoutService{
|
||||
result: stripe.CheckoutSessionResult{ID: "cs_test_1"},
|
||||
result: payments.CheckoutSessionResult{ID: "cs_test_1"},
|
||||
}
|
||||
handler := &Handler{checkout: fakeSvc}
|
||||
|
||||
|
|
|
|||
30
internal/web/page.go
Normal file
30
internal/web/page.go
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type checkoutPageData struct {
|
||||
ProductName string
|
||||
ProductDescription string
|
||||
PriceDisplay string
|
||||
Currency string
|
||||
}
|
||||
|
||||
func (h *Handler) renderCheckoutPage(w http.ResponseWriter, r *http.Request) {
|
||||
price := float64(h.cfg.Product.PriceCents) / 100
|
||||
data := checkoutPageData{
|
||||
ProductName: h.cfg.Product.Name,
|
||||
ProductDescription: h.cfg.Product.Description,
|
||||
PriceDisplay: fmt.Sprintf("$%.2f", price),
|
||||
Currency: strings.ToUpper(h.cfg.Product.Currency),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := h.page.ExecuteTemplate(w, "index.html", data); err != nil {
|
||||
http.Error(w, "failed to render page", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -2,30 +2,45 @@ package web
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
|
||||
stripe "github.com/rjNemo/payit/internal/stripe"
|
||||
|
||||
"github.com/rjNemo/payit/config"
|
||||
"github.com/rjNemo/payit/internal/payments"
|
||||
"github.com/rjNemo/payit/internal/payments/driver/stripe"
|
||||
"github.com/rjNemo/payit/internal/payments/service"
|
||||
webassets "github.com/rjNemo/payit/web"
|
||||
)
|
||||
|
||||
type checkoutService interface {
|
||||
CreateSession(context.Context, stripe.CheckoutSessionRequest) (stripe.CheckoutSessionResult, error)
|
||||
CreateSession(context.Context, payments.CheckoutSessionRequest) (payments.CheckoutSessionResult, error)
|
||||
}
|
||||
|
||||
// Handler aggregates dependencies required by HTTP handlers.
|
||||
type Handler struct {
|
||||
cfg config.Config
|
||||
checkout checkoutService
|
||||
page *template.Template
|
||||
}
|
||||
|
||||
// NewServer constructs the root HTTP handler, wiring Stripe-backed endpoints as they are implemented.
|
||||
func NewServer(cfg config.Config) http.Handler {
|
||||
checkoutSvc := stripe.NewService(cfg.StripeSecretKey, cfg.Product)
|
||||
h := &Handler{cfg: cfg, checkout: checkoutSvc}
|
||||
driver := stripe.NewDriver(cfg.StripeSecretKey, cfg.Product)
|
||||
checkoutSvc := service.NewCheckoutService(driver)
|
||||
tmpl := template.Must(template.ParseFS(webassets.Assets, "templates/index.html"))
|
||||
staticFS, err := fs.Sub(webassets.Assets, "static")
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to load static assets: %w", err))
|
||||
}
|
||||
|
||||
h := &Handler{cfg: cfg, checkout: checkoutSvc, page: tmpl}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /api/checkout", h.createCheckoutSession)
|
||||
mux.Handle("GET /", http.HandlerFunc(h.renderCheckoutPage))
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS))))
|
||||
|
||||
return mux
|
||||
}
|
||||
|
|
|
|||
8
web/embed.go
Normal file
8
web/embed.go
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
package webassets
|
||||
|
||||
import "embed"
|
||||
|
||||
// Assets bundles HTML templates and static files for the checkout UI.
|
||||
//
|
||||
//go:embed templates/*.html static/*
|
||||
var Assets embed.FS
|
||||
59
web/static/app.js
Normal file
59
web/static/app.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
(() => {
|
||||
const form = document.getElementById('checkout-form');
|
||||
const button = document.getElementById('checkout-button');
|
||||
const qtyInput = document.getElementById('quantity');
|
||||
const message = document.getElementById('message');
|
||||
|
||||
if (!form || !button || !qtyInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const setMessage = (text, isError = true) => {
|
||||
if (!message) {
|
||||
return;
|
||||
}
|
||||
message.textContent = text;
|
||||
message.style.color = isError ? '#dc2626' : '#16a34a';
|
||||
};
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const quantity = Number.parseInt(qtyInput.value, 10);
|
||||
if (!Number.isFinite(quantity) || quantity <= 0) {
|
||||
setMessage('Enter a quantity of at least 1.');
|
||||
qtyInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
button.disabled = true;
|
||||
setMessage('Contacting Stripe…', false);
|
||||
|
||||
const response = await fetch('/api/checkout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ quantity }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || 'Checkout request failed.');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data || !data.url) {
|
||||
throw new Error('Checkout response missing redirect URL.');
|
||||
}
|
||||
|
||||
setMessage('Redirecting to Stripe…', false);
|
||||
window.location.href = data.url;
|
||||
} catch (err) {
|
||||
console.error('Checkout failed', err);
|
||||
setMessage('Unable to start checkout. Please try again.');
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
})();
|
||||
102
web/templates/index.html
Normal file
102
web/templates/index.html
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>PayIt Checkout</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" />
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
font-family: "Inter", system-ui, -apple-system, sans-serif;
|
||||
line-height: 1.5;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: radial-gradient(circle at top, #fdfbfb, #ebedee);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
.card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 20px 45px rgba(15, 23, 42, 0.12);
|
||||
padding: 2.5rem;
|
||||
max-width: 420px;
|
||||
width: 100%;
|
||||
}
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
font-size: 1.75rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
p {
|
||||
margin: 0.5rem 0 1.5rem;
|
||||
color: #475569;
|
||||
}
|
||||
.price {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #2563eb;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
label {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
input[type="number"] {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #cbd5f5;
|
||||
font-size: 1rem;
|
||||
}
|
||||
button {
|
||||
background: linear-gradient(135deg, #2563eb, #7c3aed);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
padding: 0.9rem 1.2rem;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
button:hover:not([disabled]) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 12px 30px rgba(37, 99, 235, 0.25);
|
||||
}
|
||||
button[disabled] {
|
||||
opacity: 0.65;
|
||||
cursor: wait;
|
||||
}
|
||||
#message {
|
||||
min-height: 1.5rem;
|
||||
color: #dc2626;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="card">
|
||||
<h1>{{ .ProductName }}</h1>
|
||||
<p>{{ .ProductDescription }}</p>
|
||||
<div class="price">{{ .PriceDisplay }} <span class="currency">{{ .Currency }}</span></div>
|
||||
<form id="checkout-form">
|
||||
<label for="quantity">Quantity</label>
|
||||
<input id="quantity" name="quantity" type="number" value="1" min="1" />
|
||||
<button id="checkout-button" type="submit">Buy now</button>
|
||||
</form>
|
||||
<div id="message" role="status" aria-live="polite"></div>
|
||||
</main>
|
||||
<script src="/static/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in a new issue