diff --git a/frontend/src/lib/components/admin/GiftCardsManagement.svelte b/frontend/src/lib/components/admin/GiftCardsManagement.svelte index 204022a..9c87380 100644 --- a/frontend/src/lib/components/admin/GiftCardsManagement.svelte +++ b/frontend/src/lib/components/admin/GiftCardsManagement.svelte @@ -6,7 +6,7 @@ import { Input } from '$lib/components/ui/input'; import * as Modal from '$lib/components/ui/dialog'; import { Skeleton } from '$lib/components/ui/skeleton'; - import TillPaymentModal from '$lib/components/payments/TillPaymentModal.svelte'; + interface GiftCard { id: string; @@ -16,6 +16,8 @@ created_at: string; redeemed_at?: string; redeemed_by?: string; + is_inventory?: boolean; + last_used_at?: string; } interface UserBalance { @@ -40,7 +42,28 @@ user_balances: [] }); - let activeSection = $state<'cards' | 'balances'>('cards'); + let cardQuery = $state(''); + let cards = $state([]); + let balances = $state([]); + let totalCards = $state(0); + let totalBalanceRecords = $state(0); + let currentPage = $state(1); + let totalPages = $state(1); + let loadingSearch = $state(false); + + let activeSection = $state<'cards' | 'balances' | 'expired' | 'expired_cards'>('cards'); + + let expiredBalances = $state>([]); + let loadingExpired = $state(false); + let claimingId = $state(null); let loading = $state(true); let showGenerateModal = $state(false); @@ -48,17 +71,94 @@ let showTransferModal = $state(false); let creating = $state(false); - let toppingUp = $state(false); let transferring = $state(false); let selectedCardId = $state(null); // Form inputs let generateAmount = $state(''); + let generateUserQuery = $state(''); + let generateUsers = $state< + Array<{ id: string; fullName: string; email?: string; phone?: string }> + >([]); + let generateLoadingUsers = $state(false); let topUpAmount = $state(''); let transferAmount = $state(''); let transferToCode = $state(''); + // Redesigned generate modal states + let generateStep = $state< + | 'type' + | 'customer' + | 'amount_email' + | 'payment' + | 'cash_entry' + | 'card_details' + | 'processing' + | 'success' + | 'error' + >('type'); + let generateType = $state<'code' | 'account' | 'stock'>('code'); + + // Page 2: Customer selection state + let generateCustomerTab = $state<'current' | 'member' | 'guest'>('current'); + let currentCustomerInfo = $state<{ id: string; name: string; email?: string; phone?: string } | null>(null); + let loadingCurrentCustomer = $state(false); + + // Selection from Page 2 + let selectedCustomer = $state<{ id: string; name: string; email?: string } | null>(null); + let isGuestSelected = $state(false); + + // Page 3: Recipient email input + let generateEmail = $state(''); + + let tillGuestEmail = $state(undefined); + + // Payment Processing States + let cashAmount = $state(''); + let cashTendered = $state(0); + let extraAsTip = $state(false); + + let ephemeralCardNumber = $state(''); + let ephemeralCardExpiry = $state(''); + let ephemeralCardCVC = $state(''); + let ephemeralCardError = $state(''); + + let paymentError = $state(''); + let paymentResult = $state<{ id: string; giftcard_code?: string; item_id?: string; status?: string } | null>(null); + let cardMachineItemID = $state(null); + let checkoutId = $state(null); + let processingMessage = $state('Processing payment...'); + + // Idempotency Key + let idempotencyKey = $state(''); + + function getIdempotencyKey(): string { + if (!idempotencyKey) { + const array = new Uint8Array(16); + if (typeof window !== 'undefined' && window.crypto) { + window.crypto.getRandomValues(array); + } else { + for (let i = 0; i < 16; i++) array[i] = Math.floor(Math.random() * 256); + } + array[6] = (array[6] & 0x0f) | 0x40; + array[8] = (array[8] & 0x3f) | 0x80; + idempotencyKey = [...array] + .map((b, i) => { + const hex = b.toString(16).padStart(2, '0'); + if (i === 4 || i === 6 || i === 8 || i === 10) return '-' + hex; + return hex; + }) + .join(''); + } + return idempotencyKey; + } + + let topUpStep = $state< + 'choice' | 'amount' | 'payment' | 'cash_entry' | 'card_details' | 'processing' | 'success' | 'error' + >('choice'); + let topUpMode = $state<'giveaway' | 'purchase'>('giveaway'); + // Validation let generateError = $derived( generateAmount && (isNaN(Number(generateAmount)) || Number(generateAmount) <= 0) @@ -81,34 +181,68 @@ : '' ); - let isGenerateValid = $derived(generateAmount && !generateError); + let isAmountValid = $derived( + generateType === 'stock' + ? true + : !!(generateAmount && !generateError) + ); + + let isEmailValid = $derived.by(() => { + if (generateType !== 'code') return true; + const trimmed = generateEmail.trim(); + if (isGuestSelected) { + // Mandatory for guest: non-empty & valid email format + return trimmed !== '' && trimmed.includes('@') && trimmed.includes('.'); + } + // Optional for member: if non-empty, must be valid email format + if (trimmed === '') return true; + return trimmed.includes('@') && trimmed.includes('.'); + }); + + let isGenerateValid = $derived( + generateType === 'stock' + ? true + : isAmountValid && isEmailValid + ); + let isTopUpValid = $derived(topUpAmount && !topUpError); let isTransferValid = $derived( transferAmount && !transferAmountError && transferToCode && !transferCodeError ); - // Choice flow state - let generateStep = $state<'choice' | 'amount'>('choice'); - let generateMode = $state<'giveaway' | 'purchase'>('giveaway'); - let topUpStep = $state<'choice' | 'amount'>('choice'); - let topUpMode = $state<'giveaway' | 'purchase'>('giveaway'); - // TillPayment state let showTillPayment = $state(false); let tillAmount = $state(0); let tillAction = $state<'create' | 'topup'>('create'); let tillGiftCardId = $state(undefined); + let tillUserId = $state(undefined); + let tillDelivery = $state<'account' | 'code'>('code'); - async function fetchGiftCards() { + async function fetchGiftCards(page: number = 1, search: string = '') { loading = true; + loadingSearch = true; try { - const res = await fetch('/api/admin/gift-cards', { + const params = new URLSearchParams({ + page: page.toString(), + per_page: '10' + }); + if (search.trim()) params.append('q', search.trim()); + + const res = await fetch(`/api/admin/gift-cards?${params}`, { headers: { Authorization: `Bearer ${authStore.currentToken}` } }); if (res.ok) { - summary = await res.json(); + const data = await res.json(); + summary.total_unclaimed = data.total_unclaimed; + summary.total_user_balances = data.total_user_balances; + cards = data.gift_cards; + balances = data.user_balances; + totalCards = data.total; + totalBalanceRecords = data.ub_total ?? data.user_balances?.length ?? 0; + currentPage = data.page; + totalPages = data.totalPages; } else { toast.error('Failed to fetch gift cards'); } @@ -116,19 +250,127 @@ toast.error('Network error fetching gift cards'); } finally { loading = false; + loadingSearch = false; + } + } + + function searchCards() { + currentPage = 1; + fetchGiftCards(1, cardQuery); + } + + async function fetchExpiredBalances() { + loadingExpired = true; + try { + const res = await fetch('/api/admin/gift-cards/expired-balances', { + headers: { + Authorization: `Bearer ${authStore.currentToken}` + } + }); + if (res.ok) { + const data = await res.json(); + expiredBalances = data.expired_balances || []; + } else { + toast.error('Failed to fetch expired balances'); + } + } catch (err) { + toast.error('Network error fetching expired balances'); + } finally { + loadingExpired = false; + } + } + + async function claimExpiredBalance(balanceId: string) { + claimingId = balanceId; + try { + const res = await fetch('/api/admin/gift-cards/expired-balances/claim', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authStore.currentToken}` + }, + body: JSON.stringify({ balance_id: balanceId }) + }); + if (res.ok) { + toast.success('Balance claimed successfully'); + await fetchExpiredBalances(); + } else { + const err = await res.json(); + toast.error(err.error || 'Failed to claim balance'); + } + } catch (err) { + toast.error('Network error claiming balance'); + } finally { + claimingId = null; + } + } + + function nextPage() { + if (currentPage < totalPages) { + fetchGiftCards(currentPage + 1, cardQuery); + } + } + + function previousPage() { + if (currentPage > 1) { + fetchGiftCards(currentPage - 1, cardQuery); + } + } + + async function fetchCurrentCustomerForGenerate() { + loadingCurrentCustomer = true; + currentCustomerInfo = null; + try { + const res = await fetch('/api/admin/today/current-next', { + headers: { Authorization: `Bearer ${authStore.currentToken}` } + }); + if (res.ok) { + const data = await res.json(); + const appointment = data.current || data.next; + if (appointment?.user?.id) { + currentCustomerInfo = { + id: appointment.user.id, + name: appointment.user.full_name || appointment.user.name || 'Current Customer', + email: appointment.user.email, + phone: appointment.user.phone + }; + } + } + } catch { + // Ignore silently + } finally { + loadingCurrentCustomer = false; } } function goToGeneratePayment() { if (!isGenerateValid) return; - tillAmount = Number(generateAmount); - tillAction = 'create'; - tillGiftCardId = undefined; - showTillPayment = true; + generateStep = 'payment'; } - async function generateGiftCard() { - if (!isGenerateValid) return; + async function searchGenerateCustomers() { + if (!generateUserQuery.trim()) return; + generateLoadingUsers = true; + try { + const res = await fetch( + `/api/admin/users?page=1&per_page=5&q=${encodeURIComponent(generateUserQuery)}`, + { headers: { Authorization: `Bearer ${authStore.currentToken}` } } + ); + if (res.ok) { + const data = await res.json(); + const excludedRoles = ['admin', 'guest', 'affiliate']; + generateUsers = (data.users || []).filter( + (u: { account_role: string }) => !excludedRoles.includes(u.account_role) + ); + } + } catch { + toast.error('Failed to search users'); + } finally { + generateLoadingUsers = false; + } + } + + async function generateInventoryCard() { creating = true; try { const res = await fetch('/api/admin/gift-cards', { @@ -137,20 +379,23 @@ 'Content-Type': 'application/json', Authorization: `Bearer ${authStore.currentToken}` }, - body: JSON.stringify({ amount: Number(generateAmount) }) + body: JSON.stringify({ + amount: 0, + is_inventory: true + }) }); if (res.ok) { const data = await res.json(); - toast.success(`Gift card ${formatCardCode(data.id)} generated successfully!`); + toast.success(`Inventory card ${formatCardCode(data.id)} created`); showGenerateModal = false; resetGenerateModal(); await fetchGiftCards(); } else { const errText = await res.text(); - toast.error(errText || 'Failed to generate gift card'); + toast.error(errText || 'Failed to create inventory card'); } } catch (err) { - toast.error('Network error generating gift card'); + toast.error('Network error'); } finally { creating = false; } @@ -158,37 +403,12 @@ function goToTopUpPayment() { if (!isTopUpValid) return; - tillAmount = Number(topUpAmount); - tillAction = 'topup'; - tillGiftCardId = selectedCardId ?? undefined; - showTillPayment = true; - } - - async function topUpCard() { - if (!isTopUpValid || !selectedCardId) return; - toppingUp = true; - try { - const res = await fetch(`/api/admin/gift-cards/${selectedCardId}/topup`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${authStore.currentToken}` - }, - body: JSON.stringify({ amount: Number(topUpAmount) }) - }); - if (res.ok) { - toast.success('Gift card topped up successfully'); - showTopUpModal = false; - resetTopUpModal(); - await fetchGiftCards(); - } else { - const errText = await res.text(); - toast.error(errText || 'Failed to top up gift card'); + if (topUpMode === 'giveaway') { + if (selectedCardId) { + handleEmbeddedGiveawayTopUp(selectedCardId); } - } catch (err) { - toast.error('Network error topping up gift card'); - } finally { - toppingUp = false; + } else { + topUpStep = 'payment'; } } @@ -225,39 +445,282 @@ } function resetGenerateModal() { - generateStep = 'choice'; - generateMode = 'giveaway'; + generateStep = 'type'; + generateType = 'code'; + generateCustomerTab = 'current'; + currentCustomerInfo = null; + selectedCustomer = null; + isGuestSelected = false; generateAmount = ''; + generateEmail = ''; + generateUserQuery = ''; + generateUsers = []; + tillGuestEmail = undefined; } function resetTopUpModal() { topUpStep = 'choice'; topUpMode = 'giveaway'; topUpAmount = ''; + + // Reset payment + cashAmount = ''; + cashTendered = 0; + extraAsTip = false; + ephemeralCardNumber = ''; + ephemeralCardExpiry = ''; + ephemeralCardCVC = ''; + ephemeralCardError = ''; + paymentError = ''; + paymentResult = null; + cardMachineItemID = null; + checkoutId = null; + idempotencyKey = ''; } - async function handleTillPaymentComplete(result: { - id: string; - item_id?: string; - total_amount: number; - payment_method: string; - status: string; - }) { - // The till sale endpoint already created/topped-up the gift card atomically. - // All we need to do here is show confirmation and refresh the list. - if (tillAction === 'create') { - const code = result.item_id ? formatCardCode(result.item_id) : 'unknown'; - toast.success(`Gift card ${code} generated successfully!`); - showTillPayment = false; - showGenerateModal = false; - resetGenerateModal(); + // =============== Embedded Payment Handlers =============== + + let isEphemeralCardValid = $derived( + ephemeralCardNumber.replace(/\s/g, '').length >= 13 && + ephemeralCardExpiry.includes('/') && + ephemeralCardExpiry.length === 5 && + ephemeralCardCVC.length >= 3 + ); + + function handleEphemeralCardNumberInput(e: Event) { + const target = e.currentTarget; + const clean = target.value.replace(/\D/g, ''); + const formatted = clean.match(/.{1,4}/g)?.join(' ') || clean; + ephemeralCardNumber = formatted.slice(0, 19); + } + + function handleEphemeralExpiryInput(e: Event) { + const target = e.currentTarget; + const clean = target.value.replace(/\D/g, ''); + if (clean.length > 2) { + ephemeralCardExpiry = clean.slice(0, 2) + '/' + clean.slice(2, 4); } else { - toast.success('Gift card topped up successfully'); - showTillPayment = false; - showTopUpModal = false; - resetTopUpModal(); + ephemeralCardExpiry = clean; + } + } + + function handleEphemeralCvcInput(e: Event) { + const target = e.currentTarget; + ephemeralCardCVC = target.value.replace(/\D/g, '').slice(0, 4); + } + + function setModalStep( + actionType: 'create' | 'topup', + step: 'processing' | 'success' | 'error' | 'payment' | 'cash_entry' | 'card_details' + ) { + if (actionType === 'create') { + generateStep = step; + } else { + topUpStep = step; + } + } + + async function handleEmbeddedCashPayment(actionType: 'create' | 'topup', gcId?: string) { + setModalStep(actionType, 'processing'); + processingMessage = 'Processing cash payment...'; + try { + const amt = actionType === 'create' ? Number(generateAmount) : Number(topUpAmount); + const body: Record = { + item_type: 'gift_card', + action: actionType, + amount: amt, + payment_method: 'cash', + idempotency_key: getIdempotencyKey() + }; + if (gcId) body.gift_card_id = gcId; + if (selectedCustomer) body.user_id = selectedCustomer.id; + if (actionType === 'create' && generateType === 'account' && selectedCustomer) body.redeem_to_user_id = selectedCustomer.id; + + const res = await fetch('/api/admin/till/sale', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authStore.currentToken}` + }, + body: JSON.stringify(body) + }); + if (res.ok) { + const data = await res.json(); + paymentResult = { ...data }; + setModalStep(actionType, 'success'); + await fetchGiftCards(); + } else { + paymentError = await res.text(); + setModalStep(actionType, 'error'); + } + } catch { + paymentError = 'Network error processing cash payment'; + setModalStep(actionType, 'error'); + } + } + + async function handleEmbeddedCardMachinePayment(actionType: 'create' | 'topup', gcId?: string) { + setModalStep(actionType, 'processing'); + processingMessage = 'Initiating card machine...'; + try { + const amt = actionType === 'create' ? Number(generateAmount) : Number(topUpAmount); + const body: Record = { + item_type: 'gift_card', + action: actionType, + amount: amt, + payment_method: 'card_machine', + idempotency_key: getIdempotencyKey() + }; + if (gcId) body.gift_card_id = gcId; + if (selectedCustomer) body.user_id = selectedCustomer.id; + if (actionType === 'create' && generateType === 'account' && selectedCustomer) body.redeem_to_user_id = selectedCustomer.id; + + const res = await fetch('/api/admin/till/sale', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authStore.currentToken}` + }, + body: JSON.stringify(body) + }); + if (res.ok) { + const data = await res.json(); + cardMachineItemID = data.item_id || null; + if (data.status === 'pending' && data.checkout_id) { + checkoutId = data.checkout_id; + setModalStep(actionType, 'processing'); + pollEmbeddedCheckout(data.checkout_id, amt, actionType); + } else { + paymentResult = { ...data }; + setModalStep(actionType, 'success'); + await fetchGiftCards(); + } + } else { + paymentError = await res.text(); + setModalStep(actionType, 'error'); + } + } catch { + paymentError = 'Network error initiating card machine payment'; + setModalStep(actionType, 'error'); + } + } + + async function pollEmbeddedCheckout(ckId: string, amt: number, actionType: 'create' | 'topup') { + const maxAttempts = 60; + let attempts = 0; + while (attempts < maxAttempts) { + if (!showGenerateModal && !showTopUpModal) { + return; + } + await new Promise((r) => setTimeout(r, 2000)); + attempts++; + try { + const res = await fetch(`/api/admin/till/sale/checkout/${ckId}/status`, { + headers: { Authorization: `Bearer ${authStore.currentToken}` } + }); + if (res.ok) { + const data = await res.json(); + if (data.status === 'COMPLETED') { + paymentResult = { + id: data.payment_id || ckId, + item_id: cardMachineItemID ?? undefined, + giftcard_code: data.giftcard_code || undefined + }; + setModalStep(actionType, 'success'); + await fetchGiftCards(); + return; + } + } + } catch { + // Continue polling + } + } + paymentError = 'Card machine payment timed out. Please check Square dashboard.'; + setModalStep(actionType, 'error'); + } + + async function handleEmbeddedEphemeralCardPayment(actionType: 'create' | 'topup', gcId?: string) { + const cardNum = ephemeralCardNumber.replace(/\s/g, ''); + const [monthStr, yearStr] = ephemeralCardExpiry.split('/'); + const expMonth = parseInt(monthStr, 10); + const expYear = 2000 + parseInt(yearStr, 10); + + setModalStep(actionType, 'processing'); + processingMessage = 'Processing card payment...'; + try { + const amt = actionType === 'create' ? Number(generateAmount) : Number(topUpAmount); + const body: Record = { + item_type: 'gift_card', + action: actionType, + amount: amt, + payment_method: 'online_square', + idempotency_key: getIdempotencyKey(), + card_number: cardNum, + card_exp_month: expMonth, + card_exp_year: expYear, + card_cvc: ephemeralCardCVC + }; + if (gcId) body.gift_card_id = gcId; + if (selectedCustomer) body.user_id = selectedCustomer.id; + if (actionType === 'create' && generateType === 'account' && selectedCustomer) body.redeem_to_user_id = selectedCustomer.id; + + const res = await fetch('/api/admin/till/sale', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authStore.currentToken}` + }, + body: JSON.stringify(body) + }); + if (res.ok) { + const data = await res.json(); + paymentResult = { ...data }; + setModalStep(actionType, 'success'); + await fetchGiftCards(); + } else { + paymentError = await res.text(); + setModalStep(actionType, 'error'); + } + } catch { + paymentError = 'Network error processing card payment'; + setModalStep(actionType, 'error'); + } + } + + async function handleEmbeddedGiveawayTopUp(gcId: string) { + topUpStep = 'processing'; + processingMessage = 'Processing on-the-house top-up...'; + try { + const body: Record = { + item_type: 'gift_card', + action: 'topup', + amount: Number(topUpAmount), + payment_method: 'on_the_house', + gift_card_id: gcId, + idempotency_key: 'till-on-the-house-' + gcId + '-' + Date.now() + }; + + const res = await fetch('/api/admin/till/sale', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${authStore.currentToken}` + }, + body: JSON.stringify(body) + }); + if (res.ok) { + const data = await res.json(); + paymentResult = { ...data }; + topUpStep = 'success'; + await fetchGiftCards(); + } else { + paymentError = await res.text(); + topUpStep = 'error'; + } + } catch { + paymentError = 'Network error processing giveaway top-up'; + topUpStep = 'error'; } - await fetchGiftCards(); } function handleCodeInput(e: Event) { @@ -288,6 +751,18 @@ }); } + function getExpiryDate(lastUsedAt?: string): Date | null { + if (!lastUsedAt) return null; + const date = new Date(lastUsedAt); + date.setMonth(date.getMonth() + 24); + return date; + } + + function isExpired(lastUsedAt?: string): boolean { + const expiry = getExpiryDate(lastUsedAt); + return expiry !== null && expiry < new Date(); + } + // =============== Sorting =============== type SortKey = | 'code' @@ -322,10 +797,16 @@ } let sortedCards = $derived.by(() => { - const cards = [...summary.gift_cards]; + let filtered = [...cards]; + if (activeSection === 'cards') { + filtered = cards.filter(gc => !isExpired(gc.last_used_at)); + } else if (activeSection === 'expired_cards') { + filtered = cards.filter(gc => isExpired(gc.last_used_at)); + } + const sorted = [...filtered]; const { key, dir } = cardSort; const mul = dir === 'asc' ? 1 : -1; - cards.sort((a, b) => { + sorted.sort((a, b) => { switch (key) { case 'code': return a.id.localeCompare(b.id) * mul; @@ -344,11 +825,14 @@ return 0; } }); - return cards; + return sorted; }); + let activeCardsCount = $derived(cards.filter(gc => !isExpired(gc.last_used_at)).length); + let expiredCardsCount = $derived(cards.filter(gc => isExpired(gc.last_used_at)).length); + let sortedBalances = $derived.by(() => { - const bals = [...summary.user_balances]; + const bals = [...balances]; const { key, dir } = balanceSort; const mul = dir === 'asc' ? 1 : -1; bals.sort((a, b) => { @@ -424,21 +908,52 @@ : 'border-transparent text-muted-foreground hover:text-foreground'}" onclick={() => (activeSection = 'cards')} > - Physical Gift Cards ({summary.gift_cards.length}) + Active Gift Cards ({activeCardsCount}) + + - {#if activeSection === 'cards'} + {#if activeSection === 'cards' || activeSection === 'expired_cards'} +
+ { if (e.key === 'Enter') searchCards(); }} + /> + +
+