Add giftcard management
This commit is contained in:
@@ -24,6 +24,8 @@
|
||||
| 'cash-confirming'
|
||||
| 'gift-entering'
|
||||
| 'gift-confirming'
|
||||
| 'saved-card-selecting'
|
||||
| 'saved-card-processing'
|
||||
| 'success'
|
||||
| 'error';
|
||||
|
||||
@@ -35,7 +37,7 @@
|
||||
amount: number;
|
||||
};
|
||||
|
||||
type PaymentMethod = 'card' | 'cash' | 'giftcard' | null;
|
||||
type PaymentMethod = 'card' | 'cash' | 'giftcard' | 'savedcard' | null;
|
||||
|
||||
let status = $state<PaymentStatus>('idle');
|
||||
let selectedMethod = $state<PaymentMethod>(null);
|
||||
@@ -46,6 +48,8 @@
|
||||
let customerBalance = $state(0);
|
||||
let loadingCustomerBalance = $state(false);
|
||||
let giftCardPaymentAmount = $state('');
|
||||
let savedCardList = $state<Array<{ id: string; card_brand: string; card_last4: string; card_expiry: string; cardholder_name?: string }>>([]);
|
||||
let loadingSavedCardList = $state(false);
|
||||
|
||||
async function fetchCustomerGiftCardBalance() {
|
||||
if (!booking.user_id) return;
|
||||
@@ -69,6 +73,23 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSavedCardList() {
|
||||
if (!booking.user_id) return;
|
||||
loadingSavedCardList = true;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/users/${booking.user_id}/payment-methods`, {
|
||||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||
});
|
||||
if (res.ok) {
|
||||
savedCardList = await res.json();
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
loadingSavedCardList = false;
|
||||
}
|
||||
}
|
||||
|
||||
type ServiceOverride = {
|
||||
price: string;
|
||||
originalPrice: number;
|
||||
@@ -78,6 +99,7 @@
|
||||
|
||||
$effect(() => {
|
||||
fetchCustomerGiftCardBalance();
|
||||
fetchSavedCardList();
|
||||
const services = booking.services ?? [];
|
||||
const overrides: Record<string, ServiceOverride> = {};
|
||||
for (const s of services) {
|
||||
@@ -504,6 +526,77 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Saved cards
|
||||
let savedCards = $state<Array<{ id: string; card_brand: string; card_last4: string; card_expiry: string; cardholder_name?: string }>>([]);
|
||||
let loadingSavedCards = $state(false);
|
||||
let selectedSavedCardId = $state<string | null>(null);
|
||||
|
||||
async function fetchSavedCards() {
|
||||
if (!booking.user_id) return;
|
||||
loadingSavedCards = true;
|
||||
savedCards = [];
|
||||
selectedSavedCardId = null;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/users/${booking.user_id}/payment-methods`, {
|
||||
headers: { Authorization: `Bearer ${authStore.currentToken}` }
|
||||
});
|
||||
if (res.ok) {
|
||||
savedCards = await res.json();
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to load saved cards');
|
||||
} finally {
|
||||
loadingSavedCards = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSavedCardPayment() {
|
||||
if (!selectedSavedCardId) {
|
||||
toast.error('Please select a saved card');
|
||||
return;
|
||||
}
|
||||
|
||||
status = 'saved-card-processing';
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/bookings/${booking.id}/payment`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${authStore.currentToken}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
amount: Math.round(totalDue * 100),
|
||||
payment_type: 'full',
|
||||
payment_method: 'saved_card',
|
||||
saved_card_id: selectedSavedCardId
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errData = await response.text();
|
||||
throw new Error(errData || 'Failed to process saved card payment');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
status = 'success';
|
||||
paymentResult = {
|
||||
checkout_id: data.checkout_id || data.id || '',
|
||||
status: 'COMPLETED',
|
||||
card_brand: data.card_brand,
|
||||
last4: data.card_last4,
|
||||
amount: data.amount
|
||||
};
|
||||
toast.success('Saved card payment successful');
|
||||
onComplete(paymentResult);
|
||||
} catch (err) {
|
||||
status = 'error';
|
||||
error = err instanceof Error ? err.message : 'Failed to process saved card payment';
|
||||
toast.error(error ?? 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (selectedMethod === 'cash') {
|
||||
cashAmount = totalDue.toFixed(2);
|
||||
@@ -512,6 +605,9 @@
|
||||
if (selectedMethod === 'giftcard') {
|
||||
giftCardId = '';
|
||||
}
|
||||
if (selectedMethod === 'savedcard') {
|
||||
fetchSavedCards();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -618,7 +714,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
<div class="grid grid-cols-2 gap-3 {savedCardList.length > 0 ? 'sm:grid-cols-4' : 'sm:grid-cols-3'}">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors {selectedMethod ===
|
||||
@@ -665,6 +761,32 @@
|
||||
</svg>
|
||||
Cash
|
||||
</button>
|
||||
{#if savedCardList.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors sm:block {selectedMethod ===
|
||||
'savedcard'
|
||||
? 'border-input bg-fuchsia-100 text-foreground'
|
||||
: 'border-input hover:bg-fuchsia-50'}"
|
||||
onclick={() => {
|
||||
selectedMethod = 'savedcard';
|
||||
status = 'saved-card-selecting';
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
class="mx-auto mb-2 h-8 w-8"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
|
||||
<path d="M6 10h12" />
|
||||
<path d="M6 14h6" />
|
||||
</svg>
|
||||
Saved Card
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors sm:block {selectedMethod ===
|
||||
@@ -693,7 +815,19 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sm:hidden">
|
||||
<div class="sm:hidden flex flex-wrap gap-3">
|
||||
{#if savedCardList.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm text-gray-600 underline hover:text-gray-900"
|
||||
onclick={() => {
|
||||
selectedMethod = 'savedcard';
|
||||
status = 'saved-card-selecting';
|
||||
}}
|
||||
>
|
||||
Pay with Saved Card
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm text-gray-600 underline hover:text-gray-900"
|
||||
@@ -916,6 +1050,78 @@
|
||||
></div>
|
||||
<p class="text-lg font-medium text-gray-700">Processing gift card...</p>
|
||||
</div>
|
||||
{:else if status === 'saved-card-selecting'}
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between rounded-md border border-gray-200 bg-white p-4">
|
||||
<span class="text-base font-semibold text-gray-700">Total Due</span>
|
||||
<span class="text-xl font-bold text-gray-900">{formatCurrency(totalDue)}</span>
|
||||
</div>
|
||||
|
||||
{#if loadingSavedCards}
|
||||
<div class="flex justify-center py-8">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-fuchsia-600"></div>
|
||||
</div>
|
||||
{:else if savedCards.length === 0}
|
||||
<div class="rounded-md border border-gray-200 bg-gray-50 p-6 text-center">
|
||||
<p class="text-sm text-gray-600">No saved cards found for this customer.</p>
|
||||
<p class="mt-1 text-xs text-gray-500">Add a card via Square Dashboard or use another payment method.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider block">Select a Saved Card</span>
|
||||
{#each savedCards as card (card.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-lg border p-3 text-left transition-colors {selectedSavedCardId === card.id
|
||||
? 'border-fuchsia-600 bg-fuchsia-50'
|
||||
: 'border-gray-200 hover:bg-gray-50'}"
|
||||
onclick={() => selectedSavedCardId = card.id}
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="h-5 w-5 text-gray-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
|
||||
<line x1="1" y1="10" x2="23" y2="10" />
|
||||
</svg>
|
||||
<span class="font-medium text-gray-900">{card.card_brand} ••••{card.card_last4}</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500">{card.card_expiry}</span>
|
||||
</div>
|
||||
{#if card.cardholder_name}
|
||||
<div class="mt-1 text-xs text-gray-500">{card.cardholder_name}</div>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border border-amber-200 bg-amber-50 p-3 flex items-start gap-2">
|
||||
<svg class="mt-0.5 h-4 w-4 shrink-0 text-amber-600" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
<p class="text-xs text-amber-800">This card may require bank app confirmation to complete. Ensure the customer has their phone ready.</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Button variant="ghost" onclick={resetToSelect} class="flex-1">Back</Button>
|
||||
<Button
|
||||
onclick={handleSavedCardPayment}
|
||||
class="flex-1 bg-green-600 hover:bg-green-700 text-white"
|
||||
disabled={!selectedSavedCardId}
|
||||
>
|
||||
Charge Saved Card
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if status === 'saved-card-processing'}
|
||||
<div class="flex flex-col items-center justify-center py-8">
|
||||
<div
|
||||
class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-green-600"
|
||||
></div>
|
||||
<p class="text-lg font-medium text-gray-700">Processing saved card payment...</p>
|
||||
</div>
|
||||
{:else if status === 'error' && error}
|
||||
<div class="space-y-4">
|
||||
<div class="rounded-md border border-red-200 bg-red-50 p-3">
|
||||
|
||||
Reference in New Issue
Block a user