Add giftcard management
This commit is contained in:
@@ -0,0 +1,408 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"crussell/db"
|
||||
"crussell/internal/square"
|
||||
"crussell/mw"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type TillSaleRequest struct {
|
||||
ItemType string `json:"item_type"`
|
||||
Action string `json:"action"`
|
||||
Amount float64 `json:"amount"`
|
||||
GiftCardID *string `json:"gift_card_id,omitempty"`
|
||||
PaymentMethod string `json:"payment_method"`
|
||||
UserSavedCardID *string `json:"user_saved_card_id,omitempty"`
|
||||
UserID *string `json:"user_id,omitempty"`
|
||||
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
||||
CardNumber string `json:"card_number,omitempty"`
|
||||
CardExpMonth int `json:"card_exp_month,omitempty"`
|
||||
CardExpYear int `json:"card_exp_year,omitempty"`
|
||||
CardCVC string `json:"card_cvc,omitempty"`
|
||||
RedeemToUserID *string `json:"redeem_to_user_id,omitempty"`
|
||||
}
|
||||
|
||||
type TillSaleResponse struct {
|
||||
ID string `json:"id"`
|
||||
ItemType string `json:"item_type"`
|
||||
ItemID *string `json:"item_id,omitempty"`
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
PaymentMethod string `json:"payment_method"`
|
||||
Status string `json:"status"`
|
||||
CheckoutID *string `json:"checkout_id,omitempty"`
|
||||
}
|
||||
|
||||
func CreateTillSale(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
adminID, _ := ctx.Value(mw.UserIDKey).(string)
|
||||
|
||||
var req TillSaleRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.ItemType != "gift_card" {
|
||||
http.Error(w, "Unsupported item type", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Action != "create" && req.Action != "topup" {
|
||||
http.Error(w, "Action must be 'create' or 'topup'", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Amount <= 0 {
|
||||
http.Error(w, "Amount must be greater than zero", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.PaymentMethod != "cash" && req.PaymentMethod != "card_machine" && req.PaymentMethod != "saved_card" && req.PaymentMethod != "online_square" {
|
||||
http.Error(w, "Payment method must be 'cash', 'card_machine', 'saved_card', or 'online_square'", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.PaymentMethod == "saved_card" && (req.UserSavedCardID == nil || *req.UserSavedCardID == "") {
|
||||
http.Error(w, "user_saved_card_id is required when payment method is saved_card", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.PaymentMethod == "online_square" && req.CardNumber == "" {
|
||||
http.Error(w, "card_number is required when payment method is online_square", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Action == "topup" && (req.GiftCardID == nil || *req.GiftCardID == "") {
|
||||
http.Error(w, "gift_card_id is required for topup", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
service := NewPaymentService()
|
||||
|
||||
tx, err := db.DB.Begin(ctx)
|
||||
if err != nil {
|
||||
log.Printf("Failed to begin transaction: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var giftCardID string
|
||||
if req.Action == "create" {
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by)
|
||||
VALUES ($1, $1, $2)
|
||||
RETURNING id
|
||||
`, req.Amount, adminID).Scan(&giftCardID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create gift card: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
cardID := normalizeCode(*req.GiftCardID)
|
||||
var redeemedBy sql.NullString
|
||||
err = tx.QueryRow(ctx, "SELECT redeemed_by FROM gift_cards WHERE id = $1", cardID).Scan(&redeemedBy)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "Gift card not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Printf("Failed to check gift card: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if redeemedBy.Valid {
|
||||
http.Error(w, "Cannot top up a card that has been redeemed to an account", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE gift_cards
|
||||
SET total_funds_added = total_funds_added + $1,
|
||||
amount_remaining = amount_remaining + $1
|
||||
WHERE id = $2
|
||||
`, req.Amount, cardID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to top up gift card: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
giftCardID = cardID
|
||||
}
|
||||
|
||||
// If the gift card should be immediately redeemed to a user's account balance
|
||||
// (e.g. admin selected "add to account" rather than "generate gift code")
|
||||
if req.RedeemToUserID != nil && *req.RedeemToUserID != "" {
|
||||
_, err = tx.Exec(ctx, `
|
||||
UPDATE gift_cards
|
||||
SET amount_remaining = 0,
|
||||
redeemed_at = NOW(),
|
||||
redeemed_by = $1
|
||||
WHERE id = $2
|
||||
`, *req.RedeemToUserID, giftCardID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to redeem gift card to user account: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx, `
|
||||
INSERT INTO user_giftcard_balances (user_id, balance, updated_at)
|
||||
VALUES ($1, $2, NOW())
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
balance = user_giftcard_balances.balance + EXCLUDED.balance,
|
||||
updated_at = NOW()
|
||||
`, *req.RedeemToUserID, req.Amount)
|
||||
if err != nil {
|
||||
log.Printf("Failed to update user gift card balance: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
penceAmount := int64(req.Amount * 100)
|
||||
|
||||
var squarePaymentID *string
|
||||
var squareCheckoutID *string
|
||||
var saleStatus string
|
||||
var dbPaymentMethod string
|
||||
|
||||
switch req.PaymentMethod {
|
||||
case "cash":
|
||||
saleStatus = "completed"
|
||||
dbPaymentMethod = "cash"
|
||||
if req.IdempotencyKey == "" {
|
||||
req.IdempotencyKey = "till-cash-" + giftCardID + "-" + time.Now().Format("20060102150405.000000")
|
||||
}
|
||||
case "saved_card":
|
||||
dbPaymentMethod = "online_square"
|
||||
if req.UserID != nil && *req.UserID != "" {
|
||||
_, err = service.GetCardByID(ctx, *req.UserSavedCardID, *req.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "Saved card not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Printf("Failed to verify saved card: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var sqCardID string
|
||||
err = db.DB.QueryRow(ctx, `
|
||||
SELECT square_card_id
|
||||
FROM user_saved_cards
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
`, *req.UserSavedCardID).Scan(&sqCardID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to get saved card details: %v", err)
|
||||
http.Error(w, "Card not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if req.IdempotencyKey == "" {
|
||||
req.IdempotencyKey = "till-sale-" + giftCardID + "-" + time.Now().Format("20060102150405.000000")
|
||||
}
|
||||
|
||||
paymentReq := square.CreatePaymentReq{
|
||||
Amount: penceAmount,
|
||||
Currency: "GBP",
|
||||
SourceID: sqCardID,
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
Note: "Gift Card " + req.Action,
|
||||
}
|
||||
|
||||
paymentResult, err := SquareClient.CreatePayment(ctx, paymentReq)
|
||||
if err != nil {
|
||||
log.Printf("Failed to process saved card payment: %v", err)
|
||||
http.Error(w, "Payment failed", http.StatusPaymentRequired)
|
||||
return
|
||||
}
|
||||
|
||||
squarePaymentID = &paymentResult.SquarePayID
|
||||
saleStatus = "completed"
|
||||
case "card_machine":
|
||||
dbPaymentMethod = "in_person_card"
|
||||
if req.IdempotencyKey == "" {
|
||||
req.IdempotencyKey = "till-terminal-" + giftCardID + "-" + time.Now().Format("20060102150405.000000")
|
||||
}
|
||||
|
||||
checkoutReq := square.CreateCheckoutReq{
|
||||
Amount: penceAmount,
|
||||
Currency: "GBP",
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
ReferenceID: giftCardID,
|
||||
TipEnabled: false,
|
||||
}
|
||||
|
||||
checkout, err := SquareClient.CreateCheckout(ctx, checkoutReq)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create Square checkout: %v", err)
|
||||
http.Error(w, "Failed to create card machine payment", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
squareCheckoutID = &checkout.ID
|
||||
saleStatus = "pending"
|
||||
case "online_square":
|
||||
dbPaymentMethod = "online_square"
|
||||
cardOnFile, err := SquareClient.CreateCardOnFileRaw(ctx, "till-"+giftCardID, req.CardNumber, req.CardExpMonth, req.CardExpYear, req.CardCVC)
|
||||
if err != nil {
|
||||
log.Printf("Failed to tokenize ephemeral card: %v", err)
|
||||
http.Error(w, "Card tokenization failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if req.IdempotencyKey == "" {
|
||||
req.IdempotencyKey = "till-online-" + giftCardID + "-" + time.Now().Format("20060102150405.000000")
|
||||
}
|
||||
|
||||
paymentReq := square.CreatePaymentReq{
|
||||
Amount: penceAmount,
|
||||
Currency: "GBP",
|
||||
SourceID: cardOnFile.CardID,
|
||||
IdempotencyKey: req.IdempotencyKey,
|
||||
Note: "Gift Card " + req.Action,
|
||||
}
|
||||
|
||||
paymentResult, err := SquareClient.CreatePayment(ctx, paymentReq)
|
||||
if err != nil {
|
||||
log.Printf("Failed to process online card payment: %v", err)
|
||||
http.Error(w, "Payment failed", http.StatusPaymentRequired)
|
||||
return
|
||||
}
|
||||
|
||||
squarePaymentID = &paymentResult.SquarePayID
|
||||
saleStatus = "completed"
|
||||
}
|
||||
|
||||
desc := fmt.Sprintf("Gift Card %s (£%.2f)", req.Action, req.Amount)
|
||||
|
||||
var tillSaleID string
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO till_sales (
|
||||
item_type, item_id, description, quantity, unit_price, total_amount,
|
||||
payment_method, status, user_id, user_saved_card_id,
|
||||
square_payment_id, square_checkout_id, idempotency_key, notes, created_by, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, 1, $4, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), NOW())
|
||||
RETURNING id
|
||||
`,
|
||||
req.ItemType,
|
||||
giftCardID,
|
||||
desc,
|
||||
req.Amount,
|
||||
dbPaymentMethod,
|
||||
saleStatus,
|
||||
req.UserID,
|
||||
req.UserSavedCardID,
|
||||
squarePaymentID,
|
||||
squareCheckoutID,
|
||||
req.IdempotencyKey,
|
||||
"Admin till sale: "+req.Action+" gift card",
|
||||
adminID,
|
||||
).Scan(&tillSaleID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to insert till sale: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
log.Printf("Failed to commit till sale transaction: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(TillSaleResponse{
|
||||
ID: tillSaleID,
|
||||
ItemType: req.ItemType,
|
||||
ItemID: &giftCardID,
|
||||
TotalAmount: req.Amount,
|
||||
PaymentMethod: req.PaymentMethod,
|
||||
Status: saleStatus,
|
||||
CheckoutID: squareCheckoutID,
|
||||
})
|
||||
}
|
||||
|
||||
func GetTillCheckoutStatus(w http.ResponseWriter, r *http.Request) {
|
||||
checkoutID := chi.URLParam(r, "checkout_id")
|
||||
if checkoutID == "" {
|
||||
http.Error(w, "Checkout ID is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var tillSaleID string
|
||||
var currentStatus string
|
||||
err := db.DB.QueryRow(r.Context(), `
|
||||
SELECT id, status FROM till_sales
|
||||
WHERE square_checkout_id = $1
|
||||
`, checkoutID).Scan(&tillSaleID, ¤tStatus)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
http.Error(w, "Till sale not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
log.Printf("Failed to find till sale: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if currentStatus == "completed" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(PaymentStatusResponse{
|
||||
Status: "COMPLETED",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
paymentResult, err := SquareClient.GetCheckout(r.Context(), checkoutID)
|
||||
if err != nil {
|
||||
if err.Error() == "checkout pending" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(PaymentStatusResponse{Status: "PENDING"})
|
||||
return
|
||||
}
|
||||
log.Printf("Failed to get checkout status: %v", err)
|
||||
http.Error(w, "Failed to get checkout status", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if paymentResult.Status == "COMPLETED" {
|
||||
_, err = db.DB.Exec(r.Context(), `
|
||||
UPDATE till_sales
|
||||
SET status = 'completed',
|
||||
card_last4 = $1,
|
||||
card_brand = $2,
|
||||
square_payment_id = $3,
|
||||
updated_at = NOW()
|
||||
WHERE id = $4
|
||||
`, paymentResult.CardLast4, paymentResult.CardBrand, paymentResult.SquarePayID, tillSaleID)
|
||||
if err != nil {
|
||||
log.Printf("Failed to update till sale: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(PaymentStatusResponse{
|
||||
Status: "COMPLETED",
|
||||
PaymentID: tillSaleID,
|
||||
Amount: paymentResult.Amount,
|
||||
CardBrand: paymentResult.CardBrand,
|
||||
CardLast4: paymentResult.CardLast4,
|
||||
ReceiptURL: paymentResult.ReceiptURL,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(PaymentStatusResponse{Status: "PENDING"})
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
|
||||
type CartItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
price: number;
|
||||
qty: number;
|
||||
};
|
||||
|
||||
let cart = $state<CartItem[]>([]);
|
||||
let giftCardAmount = $state('25');
|
||||
let showGiftCardInput = $state(false);
|
||||
|
||||
let subtotal = $derived(cart.reduce((sum, item) => sum + item.price * item.qty, 0));
|
||||
let itemCount = $derived(cart.reduce((sum, item) => sum + item.qty, 0));
|
||||
|
||||
function formatCurrency(n: number): string {
|
||||
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(n);
|
||||
}
|
||||
|
||||
function addItem(label: string, price: number) {
|
||||
const existing = cart.find(i => i.label === label);
|
||||
if (existing) {
|
||||
existing.qty++;
|
||||
} else {
|
||||
cart = [...cart, { id: crypto.randomUUID(), label, price, qty: 1 }];
|
||||
}
|
||||
}
|
||||
|
||||
function addGiftCard() {
|
||||
const amt = parseFloat(giftCardAmount);
|
||||
if (isNaN(amt) || amt <= 0) return;
|
||||
addItem('Gift Card', amt);
|
||||
giftCardAmount = '25';
|
||||
showGiftCardInput = false;
|
||||
}
|
||||
|
||||
function removeItem(id: string) {
|
||||
cart = cart.filter(i => i.id !== id);
|
||||
}
|
||||
|
||||
function updateQty(id: string, delta: number) {
|
||||
cart = cart.map(i => {
|
||||
if (i.id !== id) return i;
|
||||
const next = i.qty + delta;
|
||||
return next <= 0 ? null : { ...i, qty: next };
|
||||
}).filter((i): i is CartItem => i !== null);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="rounded-xl border bg-card">
|
||||
<div class="flex items-center justify-between border-b border-gray-200 px-5 py-4">
|
||||
<h3 class="text-base font-semibold">Till Sales</h3>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 p-4 sm:grid-cols-3">
|
||||
<Button variant="outline" size="sm" class="justify-start gap-2" onclick={() => addItem('Cuticle Oil', 8)}>
|
||||
Cuticle Oil - £8
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="justify-start gap-2" onclick={() => addItem('Nail Files (Pack)', 5)}>
|
||||
Nail Files - £5
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="justify-start gap-2" onclick={() => addItem('Hand Cream', 6)}>
|
||||
Hand Cream - £6
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="justify-start gap-2" onclick={() => addItem('Base Coat', 7)}>
|
||||
Base Coat - £7
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" class="justify-start gap-2" onclick={() => addItem('Top Coat', 7)}>
|
||||
Top Coat - £7
|
||||
</Button>
|
||||
<div class="relative">
|
||||
{#if showGiftCardInput}
|
||||
<div class="flex gap-1">
|
||||
<div class="relative flex-1">
|
||||
<span class="absolute left-2 top-1/2 -translate-y-1/2 text-xs text-gray-400">£</span>
|
||||
<Input
|
||||
type="text"
|
||||
inputmode="decimal"
|
||||
bind:value={giftCardAmount}
|
||||
class="h-9 pl-5 text-sm"
|
||||
onkeydown={(e) => { if (e.key === 'Enter') addGiftCard(); }}
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onclick={addGiftCard} class="h-9 px-2 text-xs">Add</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<Button variant="outline" size="sm" class="w-full justify-start gap-2" onclick={() => showGiftCardInput = true}>
|
||||
Gift Card
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="px-4 py-3">
|
||||
{#if cart.length === 0}
|
||||
<p class="py-6 text-center text-sm text-muted-foreground">Tap items above to add them to the sale.</p>
|
||||
{:else}
|
||||
<div class="max-h-48 space-y-1 overflow-y-auto">
|
||||
{#each cart as item (item.id)}
|
||||
<div class="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
|
||||
<div class="min-w-0 flex-1">
|
||||
<span class="font-medium text-card-foreground">{item.label}</span>
|
||||
<span class="ml-2 text-xs text-muted-foreground">{formatCurrency(item.price)} each</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-6 w-6 items-center justify-center rounded border text-xs text-muted-foreground hover:bg-accent"
|
||||
onclick={() => updateQty(item.id, -1)}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span class="w-5 text-center text-sm font-semibold tabular-nums">{item.qty}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-6 w-6 items-center justify-center rounded border text-xs text-muted-foreground hover:bg-accent"
|
||||
onclick={() => updateQty(item.id, 1)}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<span class="w-14 text-right text-sm font-semibold tabular-nums">{formatCurrency(item.price * item.qty)}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove item"
|
||||
class="ml-1 flex h-6 w-6 items-center justify-center rounded text-xs text-muted-foreground hover:bg-red-50 hover:text-red-600"
|
||||
onclick={() => removeItem(item.id)}
|
||||
>
|
||||
<svg class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<Separator class="my-3" />
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">
|
||||
{itemCount} item{itemCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
<span class="text-lg font-bold tabular-nums">{formatCurrency(subtotal)}</span>
|
||||
</div>
|
||||
|
||||
<Button class="mt-3 w-full" disabled>
|
||||
Charge {formatCurrency(subtotal)}
|
||||
</Button>
|
||||
<p class="mt-1 text-xs text-muted-foreground">Payment flow and backend integration coming soon.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user