diff --git a/src/components/common/ActiveBundlesList.tsx b/src/components/common/ActiveBundlesList.tsx new file mode 100644 index 00000000..b97a365f --- /dev/null +++ b/src/components/common/ActiveBundlesList.tsx @@ -0,0 +1,238 @@ +import React from 'react'; +import { ExternalLink } from 'lucide-react'; +import Skeleton from '@/components/ui/skeleton'; +import { Button } from '@/components/ui/button'; +import { AsyncButton } from '@/components/ui/async-button'; +import { formatXlmPrice } from '@/utils/numberFormat.utils'; +import { formatAbsoluteDateTime } from '@/utils/time.utils'; +import type { KeyBundle } from '@/services/bundle.service'; +import { getBundleQuantityTotal } from '@/utils/bundle.utils'; +import BundleTimeRemaining from '@/components/common/BundleTimeRemaining'; + +export interface ActiveBundlesListProps { + /** Bundles that are currently purchasable. */ + bundles: KeyBundle[]; + /** Shared ticking clock, passed down to the expiry countdown. */ + nowMs: number; + /** Whether the first page is loading. */ + isLoading?: boolean; + /** Whether the list query failed. */ + isError?: boolean; + /** Whether another page is being fetched. */ + isFetchingNextPage?: boolean; + /** Whether another page exists. */ + hasNextPage?: boolean; + /** Loads the next page of bundles. */ + onLoadMore?: () => void; + /** ID of the bundle the creator is currently cancelling. */ + cancellingBundleId?: string; + /** Called when the Cancel bundle button is clicked. */ + onCancelBundle?: (bundleId: string) => void; + className?: string; +} + +function buildBundleDetailUrl(creatorId: string, bundleId: string): string { + const path = `/creator/${creatorId}/bundle/${bundleId}`; + return `${window.location.origin}${path}`; +} + +/** + * List of active bundles with purchase count and time remaining. + * + * Shows the per-bundle totals, the discounted price, expiry countdown, and a + * cancel action that is only enabled while the bundle is live and not already + * being cancelled. + */ +export const ActiveBundlesList: React.FC = ({ + bundles, + nowMs, + isLoading = false, + isError = false, + isFetchingNextPage = false, + hasNextPage = false, + onLoadMore, + cancellingBundleId, + onCancelBundle, + className, +}) => { + if (isLoading) { + return ( +
+ +
+ + + +
+
+ ); + } + + if (isError) { + return ( +
+

+ We couldn't load your bundles. Please refresh and try again. +

+
+ ); + } + + return ( +
+

+ Active bundles +

+ + {bundles.length === 0 ? ( +

+ No active bundles yet. Create one below to offer a discounted bundle. +

+ ) : ( + <> +
+ + + + + + + + + + + + + + + {bundles.map(bundle => { + const isCancelling = cancellingBundleId === bundle.id; + return ( + + + + + + + + + + + ); + })} + +
+ Bundle + + Keys + + List price + + Discount price + + Purchases + + Expires at + + Time remaining + + Actions +
+ + {bundle.id} + + + {getBundleQuantityTotal(bundle.items)} + + {formatXlmPrice(bundle.listPriceXlm)} + + {formatXlmPrice(bundle.discountPriceXlm)} + + {bundle.purchaseCount} + + {formatAbsoluteDateTime(bundle.expiresAt) ?? '—'} + + + +
+ + onCancelBundle?.(bundle.id) + } + disabled={isCancelling} + data-testid="active-bundle-cancel" + > + Cancel bundle + +
+
+
+ + {hasNextPage && ( + + )} + + )} +
+ ); +}; + +export default ActiveBundlesList; diff --git a/src/components/common/BundleCreateForm.tsx b/src/components/common/BundleCreateForm.tsx new file mode 100644 index 00000000..13fd1e76 --- /dev/null +++ b/src/components/common/BundleCreateForm.tsx @@ -0,0 +1,330 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { AsyncButton } from '@/components/ui/async-button'; +import { cn } from '@/lib/utils'; +import { formatXlmPrice, formatPercent } from '@/utils/numberFormat.utils'; +import type { BundleLineItem, CreateBundleRequest } from '@/services/bundle.service'; +import { + BUNDLE_DEFAULT_EXPIRY_DAYS, + computeBundleListPriceXlm, + getBundleDiscountBps, + getBundlePriceFloorXlm, + resolveBundleExpiryIso, + validateBundleDraft, + type BundleKeyOption, +} from '@/utils/bundle.utils'; + +export interface BundleCreateFormProps { + /** Creator keys available to include in a bundle. */ + availableKeys: BundleKeyOption[]; + /** Submits `create_bundle`. */ + onSubmit: (request: CreateBundleRequest) => void; + /** Resets the form after a successful submission. */ + resetSignal?: number; + isSubmitting?: boolean; +} + +const fieldClass = + 'w-full rounded-md border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white placeholder:text-white/30 outline-none transition-colors focus:border-amber-400/40 focus:ring-[3px] focus:ring-amber-400/20 disabled:opacity-50'; + +const labelClass = 'text-xs font-bold uppercase tracking-[0.18em] text-white/50'; + +/** + * Create-bundle form for the creator bundle management page. + * + * Lets a creator pick keys and quantities, then set a discounted bundle price + * and an expiry window. The undiscounted list price and the minimum allowed + * bundle price (the price floor) are shown live so the discount is always + * evaluated against them before the form can be submitted. + */ +const BundleCreateForm: React.FC = ({ + availableKeys, + onSubmit, + resetSignal = 0, + isSubmitting = false, +}) => { + const [lines, setLines] = useState([]); + const [pendingKeyId, setPendingKeyId] = useState(''); + const [priceInput, setPriceInput] = useState(''); + const [expiryInput, setExpiryInput] = useState( + String(BUNDLE_DEFAULT_EXPIRY_DAYS) + ); + const [showErrors, setShowErrors] = useState(false); + + // Clear the draft once the parent signals a successful creation. + useEffect(() => { + setLines([]); + setPendingKeyId(''); + setPriceInput(''); + setExpiryInput(String(BUNDLE_DEFAULT_EXPIRY_DAYS)); + setShowErrors(false); + }, [resetSignal]); + + const priceByKeyIdXlm = useMemo(() => { + const map: Record = {}; + for (const key of availableKeys) map[key.id] = key.priceXlm; + return map; + }, [availableKeys]); + + const listPriceXlm = useMemo( + () => computeBundleListPriceXlm(lines, priceByKeyIdXlm), + [lines, priceByKeyIdXlm] + ); + const floorXlm = getBundlePriceFloorXlm(listPriceXlm); + const discountBps = getBundleDiscountBps(listPriceXlm, Number(priceInput)); + + const { itemsError, priceError, expiryError, isValid } = validateBundleDraft( + lines, + priceInput, + expiryInput, + listPriceXlm + ); + + const keyTitleById = useMemo(() => { + const map: Record = {}; + for (const key of availableKeys) map[key.id] = key.title; + return map; + }, [availableKeys]); + + const availableToAdd = availableKeys.filter( + key => !lines.some(line => line.keyId === key.id) + ); + + const addLine = () => { + if (!pendingKeyId) return; + if (lines.some(line => line.keyId === pendingKeyId)) return; + setLines(current => [...current, { keyId: pendingKeyId, quantity: 1 }]); + setPendingKeyId(''); + }; + + const updateQuantity = (keyId: string, quantity: string) => { + setLines(current => + current.map(line => + line.keyId === keyId + ? { ...line, quantity: Number(quantity) } + : line + ) + ); + }; + + const removeLine = (keyId: string) => { + setLines(current => current.filter(line => line.keyId !== keyId)); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (isSubmitting) return; + if (!isValid) { + setShowErrors(true); + return; + } + + onSubmit({ + items: lines, + discountPriceXlm: Number(priceInput.trim()), + expiresAt: resolveBundleExpiryIso(Number(expiryInput.trim()), Date.now()), + }); + }; + + return ( +
+
+ + +
+ + + +
+ + {lines.length > 0 && ( +
    + {lines.map(line => ( +
  • + + {keyTitleById[line.keyId] ?? line.keyId} + + + + + updateQuantity(line.keyId, e.target.value) + } + disabled={isSubmitting} + /> + + +
  • + ))} +
+ )} + + {showErrors && itemsError && ( +

+ {itemsError} +

+ )} +
+ +
+
+ List price +

+ {formatXlmPrice(listPriceXlm)} +

+

+ Minimum bundle price {formatXlmPrice(floorXlm)} +

+
+ +
+ + setPriceInput(e.target.value)} + disabled={isSubmitting} + placeholder="0.00" + aria-invalid={showErrors && priceError ? 'true' : undefined} + /> + {showErrors && priceError && ( +

+ {priceError} +

+ )} +
+ +
+ + setExpiryInput(e.target.value)} + disabled={isSubmitting} + placeholder="14" + aria-invalid={showErrors && expiryError ? 'true' : undefined} + /> + {showErrors && expiryError && ( +

+ {expiryError} +

+ )} +
+ +
+ Buyer savings +

+ {discountBps > 0 + ? `${formatPercent(discountBps / 100)} (${formatXlmPrice( + listPriceXlm - Number(priceInput) + )})` + : '—'} +

+
+
+ + + Create bundle + +
+ ); +}; + +export default BundleCreateForm; diff --git a/src/components/common/BundleTimeRemaining.tsx b/src/components/common/BundleTimeRemaining.tsx new file mode 100644 index 00000000..921ebb83 --- /dev/null +++ b/src/components/common/BundleTimeRemaining.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { cn } from '@/lib/utils'; +import { getBundleTimeRemainingState } from '@/utils/bundle.utils'; + +export interface BundleTimeRemainingProps { + expiresAt: string | null | undefined; + nowMs: number; + className?: string; +} + +/** + * Countdown cell for a bundle's expiry. + * + * Reuses the same shared countdown formatting as the drops countdown so the UI + * is consistent across the app; switches to "Expired" at the moment the bundle + * lapses. + */ +export const BundleTimeRemaining: React.FC = ({ + expiresAt, + nowMs, + className, +}) => { + const { isExpired, label } = getBundleTimeRemainingState(expiresAt, nowMs); + + return ( + + {label} + + ); +}; + +export default BundleTimeRemaining; diff --git a/src/components/common/ExpiredBundlesArchive.tsx b/src/components/common/ExpiredBundlesArchive.tsx new file mode 100644 index 00000000..14915bc5 --- /dev/null +++ b/src/components/common/ExpiredBundlesArchive.tsx @@ -0,0 +1,205 @@ +import React from 'react'; +import { ExternalLink } from 'lucide-react'; +import Skeleton from '@/components/ui/skeleton'; +import { Button } from '@/components/ui/button'; +import { formatXlmPrice } from '@/utils/numberFormat.utils'; +import { formatAbsoluteDateTime } from '@/utils/time.utils'; +import type { KeyBundle } from '@/services/bundle.service'; +import { + describeBundleEndReason, + getBundleQuantityTotal, +} from '@/utils/bundle.utils'; + +export interface ExpiredBundlesArchiveProps { + /** Bundles that have expired or been cancelled. */ + bundles: KeyBundle[]; + /** Shared ticking clock to ensure the end reason is up to date. */ + nowMs: number; + /** Whether the first page is loading. */ + isLoading?: boolean; + /** Whether the list query failed. */ + isError?: boolean; + /** Whether another page is being fetched. */ + isFetchingNextPage?: boolean; + /** Whether another page exists. */ + hasNextPage?: boolean; + /** Loads the next page of bundles. */ + onLoadMore?: () => void; + className?: string; +} + +function buildBundleDetailUrl(creatorId: string, bundleId: string): string { + const path = `/creator/${creatorId}/bundle/${bundleId}`; + return `${window.location.origin}${path}`; +} + +/** + * Archive of expired and cancelled bundles. + * + * Sorted so the most recently ended bundles surface at the top. The end reason + * is derived from status so cancelled bundles are labelled explicitly rather + * than being shown as "expired". + */ +export const ExpiredBundlesArchive: React.FC = ({ + bundles, + nowMs, + isLoading = false, + isError = false, + isFetchingNextPage = false, + hasNextPage = false, + onLoadMore, + className, +}) => { + if (isLoading) { + return ( +
+ +
+ + +
+
+ ); + } + + if (isError) { + return ( +
+

+ We couldn't load your archived bundles. Please refresh and try + again. +

+
+ ); + } + + if (bundles.length === 0) { + return ( +
+

+ Expired bundles +

+

+ Expired or cancelled bundles will appear here automatically. +

+
+ ); + } + + return ( +
+

+ Expired bundles +

+ +
+ + + + + + + + + + + + + + {bundles.map(bundle => ( + + + + + + + + + + ))} + +
+ Bundle + + Keys + + List price + + Discount price + + Purchases + + Ended at + + End reason +
+ + {bundle.id} + + + {getBundleQuantityTotal(bundle.items)} + + {formatXlmPrice(bundle.listPriceXlm)} + + {formatXlmPrice(bundle.discountPriceXlm)} + + {bundle.purchaseCount} + + {formatAbsoluteDateTime(bundle.expiresAt) ?? '—'} + + + {describeBundleEndReason(bundle, nowMs)} + +
+
+ + {hasNextPage && ( + + )} +
+ ); +}; + +export default ExpiredBundlesArchive; diff --git a/src/components/common/__tests__/ActiveBundlesList.test.tsx b/src/components/common/__tests__/ActiveBundlesList.test.tsx new file mode 100644 index 00000000..c904b2d3 --- /dev/null +++ b/src/components/common/__tests__/ActiveBundlesList.test.tsx @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import ActiveBundlesList from '@/components/common/ActiveBundlesList'; +import type { KeyBundle } from '@/services/bundle.service'; + +const NOW_MS = Date.parse('2026-01-01T00:00:00.000Z'); +const DAY_MS = 86_400_000; + +function makeBundle(overrides: Partial = {}): KeyBundle { + return { + id: 'bundle-1', + creatorId: 'creator-1', + items: [ + { keyId: 'key-a', quantity: 2 }, + { keyId: 'key-b', quantity: 1 }, + ], + listPriceXlm: 25, + discountPriceXlm: 20, + expiresAt: new Date(NOW_MS + 2 * DAY_MS).toISOString(), + createdAt: new Date(NOW_MS - DAY_MS).toISOString(), + purchaseCount: 4, + cancelledAt: null, + ...overrides, + }; +} + +function setup( + props: Partial> = {} +) { + const onCancelBundle = vi.fn(); + const onLoadMore = vi.fn(); + const view = render( + + ); + return { onCancelBundle, onLoadMore, rerender: view.rerender }; +} + +describe('ActiveBundlesList', () => { + it('lists each active bundle with its key count and purchase count', () => { + setup(); + + const row = screen.getByTestId('active-bundle-row'); + expect(row).toHaveTextContent('bundle-1'); + expect(screen.getByTestId('active-bundle-keys')).toHaveTextContent('3'); + expect(screen.getByTestId('active-bundle-purchases')).toHaveTextContent('4'); + expect(screen.getByTestId('active-bundle-list-price')).toHaveTextContent( + '25.00 XLM' + ); + expect(screen.getByTestId('active-bundle-discount-price')).toHaveTextContent( + '20.00 XLM' + ); + }); + + it('renders a live countdown for a bundle that has not lapsed', () => { + setup(); + expect(screen.getByTestId('bundle-time-remaining')).toHaveTextContent( + '2d 0h 0m 0s' + ); + }); + + it('moves the countdown to Expired once the bundle lapses', () => { + setup({ nowMs: NOW_MS + 3 * DAY_MS }); + expect(screen.getByTestId('bundle-time-remaining')).toHaveTextContent('Expired'); + }); + + it('cancels a bundle by id', () => { + const { onCancelBundle } = setup(); + + fireEvent.click(screen.getByTestId('active-bundle-cancel')); + + expect(onCancelBundle).toHaveBeenCalledWith('bundle-1'); + }); + + it('marks the row as cancelling and blocks a second cancel', () => { + setup({ cancellingBundleId: 'bundle-1' }); + + const button = screen.getByTestId('active-bundle-cancel'); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute('aria-busy', 'true'); + }); + + it('shows an empty state when there are no active bundles', () => { + setup({ bundles: [] }); + expect(screen.getByTestId('active-bundles-empty')).toBeInTheDocument(); + expect(screen.queryByTestId('active-bundle-row')).not.toBeInTheDocument(); + }); + + it('renders a skeleton while loading and an error message on failure', () => { + const { unmount } = render( + + ); + expect(screen.getByTestId('active-bundles-skeleton')).toBeInTheDocument(); + unmount(); + + setup({ isError: true }); + expect(screen.getByTestId('active-bundles-error')).toBeInTheDocument(); + }); + + it('offers a load more control only when another page exists', () => { + const { onLoadMore, rerender } = setup({ hasNextPage: false }); + expect(screen.queryByTestId('active-bundles-load-more')).not.toBeInTheDocument(); + + rerender( + + ); + fireEvent.click(screen.getByTestId('active-bundles-load-more')); + expect(onLoadMore).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/common/__tests__/BundleCreateForm.test.tsx b/src/components/common/__tests__/BundleCreateForm.test.tsx new file mode 100644 index 00000000..48eeea72 --- /dev/null +++ b/src/components/common/__tests__/BundleCreateForm.test.tsx @@ -0,0 +1,209 @@ +import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import BundleCreateForm from '@/components/common/BundleCreateForm'; +import type { BundleKeyOption } from '@/utils/bundle.utils'; + +const DAY_MS = 86_400_000; + +const KEYS: BundleKeyOption[] = [ + { id: 'key-a', title: 'Alpha Key', priceXlm: 10 }, + { id: 'key-b', title: 'Beta Key', priceXlm: 5 }, +]; + +function setup( + props: Partial> = {} +) { + const onSubmit = vi.fn(); + render( + + ); + return { onSubmit }; +} + +/** Selects a key in the picker and adds it as a line item. */ +function addKey(keyId: string) { + fireEvent.change(screen.getByTestId('bundle-key-select'), { + target: { value: keyId }, + }); + fireEvent.click(screen.getByTestId('bundle-add-key')); +} + +describe('BundleCreateForm', () => { + it('adds the selected key with a default quantity of 1', () => { + setup(); + addKey('key-a'); + + expect(screen.getByTestId('bundle-line-key-a')).toBeInTheDocument(); + expect( + (screen.getByTestId('bundle-line-quantity-key-a') as HTMLInputElement).value + ).toBe('1'); + expect(screen.getByTestId('bundle-list-price')).toHaveTextContent('10.00 XLM'); + }); + + it('recomputes the list price from key price and quantity', () => { + setup(); + addKey('key-a'); + fireEvent.change(screen.getByTestId('bundle-line-quantity-key-a'), { + target: { value: '2' }, + }); + addKey('key-b'); + fireEvent.change(screen.getByTestId('bundle-line-quantity-key-b'), { + target: { value: '3' }, + }); + + // 2 x 10 XLM + 3 x 5 XLM + expect(screen.getByTestId('bundle-list-price')).toHaveTextContent('35.00 XLM'); + expect(screen.getByTestId('bundle-price-floor')).toHaveTextContent( + 'Minimum bundle price 28.00 XLM' + ); + }); + + it('never offers a key that is already in the bundle', () => { + setup(); + addKey('key-a'); + + const select = screen.getByTestId( + 'bundle-key-select' + ) as HTMLSelectElement; + const values = Array.from(select.options).map(option => option.value); + expect(values).toEqual(['', 'key-b']); + }); + + it('removes a line item', () => { + setup(); + addKey('key-a'); + fireEvent.click(screen.getByTestId('bundle-line-remove-key-a')); + + expect(screen.queryByTestId('bundle-line-key-a')).not.toBeInTheDocument(); + expect(screen.getByTestId('bundle-list-price')).toHaveTextContent('0.00 XLM'); + }); + + it('blocks submission and shows errors when nothing is configured', () => { + const { onSubmit } = setup(); + + fireEvent.click(screen.getByTestId('bundle-submit')); + + expect(onSubmit).not.toHaveBeenCalled(); + expect(screen.getByTestId('bundle-items-error')).toHaveTextContent( + 'Add at least one key to the bundle' + ); + expect(screen.getByTestId('bundle-price-error')).toBeInTheDocument(); + }); + + it('rejects a bundle price below the price floor', () => { + const { onSubmit } = setup(); + addKey('key-a'); // list price 10 XLM -> floor 8 XLM + + fireEvent.change(screen.getByTestId('bundle-price-input'), { + target: { value: '7.99' }, + }); + fireEvent.click(screen.getByTestId('bundle-submit')); + + expect(onSubmit).not.toHaveBeenCalled(); + expect(screen.getByTestId('bundle-price-error')).toHaveTextContent( + 'Bundle price must be at least 8 XLM (20% off list)' + ); + }); + + it('rejects a bundle price that is not below the list price', () => { + const { onSubmit } = setup(); + addKey('key-a'); + + fireEvent.change(screen.getByTestId('bundle-price-input'), { + target: { value: '10' }, + }); + fireEvent.click(screen.getByTestId('bundle-submit')); + + expect(onSubmit).not.toHaveBeenCalled(); + expect(screen.getByTestId('bundle-price-error')).toHaveTextContent( + 'Bundle price must be below the 10 XLM list price' + ); + }); + + it('rejects an expiry outside the supported window', () => { + const { onSubmit } = setup(); + addKey('key-a'); + fireEvent.change(screen.getByTestId('bundle-price-input'), { + target: { value: '8' }, + }); + fireEvent.change(screen.getByTestId('bundle-expiry-input'), { + target: { value: '0' }, + }); + fireEvent.click(screen.getByTestId('bundle-submit')); + + expect(onSubmit).not.toHaveBeenCalled(); + expect(screen.getByTestId('bundle-expiry-error')).toHaveTextContent( + 'Bundle expiry must be at least 1 day' + ); + }); + + it('submits items, discounted price and an absolute expiry when valid', () => { + const { onSubmit } = setup(); + addKey('key-a'); + fireEvent.change(screen.getByTestId('bundle-line-quantity-key-a'), { + target: { value: '3' }, + }); + fireEvent.change(screen.getByTestId('bundle-price-input'), { + target: { value: '24' }, + }); + fireEvent.change(screen.getByTestId('bundle-expiry-input'), { + target: { value: '14' }, + }); + + fireEvent.click(screen.getByTestId('bundle-submit')); + + expect(onSubmit).toHaveBeenCalledTimes(1); + const request = onSubmit.mock.calls[0][0]; + expect(request.items).toEqual([{ keyId: 'key-a', quantity: 3 }]); + expect(request.discountPriceXlm).toBe(24); + + const expiryMs = Date.parse(request.expiresAt); + expect(Number.isNaN(expiryMs)).toBe(false); + expect(expiryMs - Date.now()).toBeGreaterThan(13 * DAY_MS); + expect(expiryMs - Date.now()).toBeLessThanOrEqual(14 * DAY_MS); + }); + + it('shows the buyer savings for a valid discount', () => { + setup(); + addKey('key-a'); + fireEvent.change(screen.getByTestId('bundle-price-input'), { + target: { value: '7.5' }, + }); + + expect(screen.getByTestId('bundle-savings')).toHaveTextContent('25% (2.50 XLM)'); + }); + + it('shows no savings before a price is entered', () => { + setup(); + addKey('key-a'); + expect(screen.getByTestId('bundle-savings')).toHaveTextContent('—'); + }); + + it('clears the draft when the parent signals a successful creation', () => { + const onSubmit = vi.fn(); + const { rerender } = render( + + ); + addKey('key-a'); + fireEvent.change(screen.getByTestId('bundle-price-input'), { + target: { value: '8' }, + }); + + rerender( + + ); + + expect(screen.queryByTestId('bundle-line-key-a')).not.toBeInTheDocument(); + expect((screen.getByTestId('bundle-price-input') as HTMLInputElement).value).toBe( + '' + ); + }); + + it('disables the picker and the submit button while submitting', () => { + setup({ isSubmitting: true }); + + expect(screen.getByTestId('bundle-key-select')).toBeDisabled(); + expect(screen.getByTestId('bundle-add-key')).toBeDisabled(); + expect(screen.getByTestId('bundle-submit')).toBeDisabled(); + }); +}); diff --git a/src/components/common/__tests__/ExpiredBundlesArchive.test.tsx b/src/components/common/__tests__/ExpiredBundlesArchive.test.tsx new file mode 100644 index 00000000..5505dfa3 --- /dev/null +++ b/src/components/common/__tests__/ExpiredBundlesArchive.test.tsx @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import ExpiredBundlesArchive from '@/components/common/ExpiredBundlesArchive'; +import type { KeyBundle } from '@/services/bundle.service'; + +const NOW_MS = Date.parse('2026-01-01T00:00:00.000Z'); +const DAY_MS = 86_400_000; + +function makeBundle(overrides: Partial = {}): KeyBundle { + return { + id: 'bundle-1', + creatorId: 'creator-1', + items: [{ keyId: 'key-a', quantity: 2 }], + listPriceXlm: 25, + discountPriceXlm: 20, + expiresAt: new Date(NOW_MS - 2 * DAY_MS).toISOString(), + createdAt: new Date(NOW_MS - 30 * DAY_MS).toISOString(), + purchaseCount: 7, + cancelledAt: null, + ...overrides, + }; +} + +function setup( + props: Partial> = {} +) { + render(); +} + +describe('ExpiredBundlesArchive', () => { + it('archives a lapsed bundle with its totals and purchase count', () => { + setup(); + + const row = screen.getByTestId('expired-bundle-row'); + expect(row).toHaveTextContent('bundle-1'); + expect(screen.getByTestId('expired-bundle-keys')).toHaveTextContent('2'); + expect(screen.getByTestId('expired-bundle-purchases')).toHaveTextContent('7'); + expect(screen.getByTestId('expired-bundle-list-price')).toHaveTextContent( + '25.00 XLM' + ); + expect(screen.getByTestId('expired-bundle-discount-price')).toHaveTextContent( + '20.00 XLM' + ); + }); + + it('labels a lapsed bundle as Expired', () => { + setup(); + expect(screen.getByTestId('expired-bundle-end-reason')).toHaveTextContent( + 'Expired' + ); + }); + + it('labels a creator-cancelled bundle distinctly', () => { + setup({ + bundles: [ + makeBundle({ + id: 'bundle-cancelled', + cancelledAt: new Date(NOW_MS - DAY_MS).toISOString(), + }), + ], + }); + expect(screen.getByTestId('expired-bundle-end-reason')).toHaveTextContent( + 'Cancelled by creator' + ); + }); + + it('shows an empty state before anything has lapsed', () => { + setup({ bundles: [] }); + expect(screen.getByTestId('expired-bundles-empty')).toBeInTheDocument(); + expect(screen.queryByTestId('expired-bundle-row')).not.toBeInTheDocument(); + }); + + it('renders a skeleton while loading and an error message on failure', () => { + const { unmount } = render( + + ); + expect(screen.getByTestId('expired-bundles-skeleton')).toBeInTheDocument(); + unmount(); + + setup({ isError: true }); + expect(screen.getByTestId('expired-bundles-error')).toBeInTheDocument(); + }); +}); diff --git a/src/hooks/useBundles.ts b/src/hooks/useBundles.ts new file mode 100644 index 00000000..c641394b --- /dev/null +++ b/src/hooks/useBundles.ts @@ -0,0 +1,182 @@ +import { + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, + type InfiniteData, +} from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import { useMemo } from 'react'; +import { + fetchCreatorBundlesPage, + type BundlesPage, + type CreateBundleRequest, +} from '@/services/bundle.service'; +import { courseService } from '@/services/course.service'; +import { submitCreatorContractCall } from '@/hooks/useCreatorContractActions'; +import { STROOPS_PER_XLM } from '@/constants/stellar'; +import { resolveCreatorKeyPriceStroops } from '@/utils/keyPriceDisplay.utils'; +import type { BundleKeyOption } from '@/utils/bundle.utils'; +import showToast from '@/utils/toast.util'; +import { getSignatureErrorMessage } from '@/utils/errorHandling.utils'; + +/** + * Bundle queries and contract calls for the creator bundle management page. + * + * The list query returns active and archived bundles together; the page splits + * them with `partitionBundlesByStatus` so a bundle archives itself as soon as + * it expires. Creating and cancelling bundles submit `create_bundle` and + * `cancel_bundle`, invalidating the creator's bundles on success. + */ + +/** + * Cursor-paginated bundles for a creator. + * + * Disabled until `creatorId` is known, matching the `enabled: Boolean(id)` + * convention used by the other creator-scoped queries. + */ +export function useCreatorBundles(creatorId: string | undefined) { + return useInfiniteQuery({ + queryKey: queryKeys.bundles.list(creatorId ?? ''), + queryFn: ({ pageParam }) => + fetchCreatorBundlesPage( + creatorId!, + pageParam as string | null | undefined + ), + initialPageParam: null as string | null, + getNextPageParam: lastPage => lastPage.nextCursor ?? undefined, + enabled: Boolean(creatorId), + }); +} + +/** + * Keys a creator can put into a bundle, derived from the creator's own keys and + * priced in XLM so the create form can compute a bundle's list price and the + * price floor it must respect. + */ +export function useBundleKeyOptions(creatorId: string | undefined) { + const query = useQuery({ + queryKey: queryKeys.creators.list(), + queryFn: () => courseService.getCourses(), + enabled: Boolean(creatorId), + staleTime: 5 * 60_000, + }); + + const options = useMemo(() => { + if (!creatorId) return []; + + const seen = new Set(); + const result: BundleKeyOption[] = []; + + for (const course of query.data ?? []) { + if (course.instructorId !== creatorId || seen.has(course.id)) continue; + + const priceStroops = resolveCreatorKeyPriceStroops(course); + if (priceStroops == null) continue; + + seen.add(course.id); + result.push({ + id: course.id, + title: course.title, + priceXlm: priceStroops / STROOPS_PER_XLM, + }); + } + + return result.sort((a, b) => a.title.localeCompare(b.title)); + }, [creatorId, query.data]); + + return { ...query, options }; +} + +/** + * Submits `create_bundle` with the selected keys, the discounted bundle price + * and the expiry timestamp. + * + * Invalidates the creator's bundles on success so the new bundle appears in the + * active list with a zero purchase count. + */ +export function useCreateBundleMutation(creatorId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ['contract', 'create_bundle', creatorId], + mutationFn: (request: CreateBundleRequest) => + submitCreatorContractCall('create_bundle', { creatorId, ...request }), + onError: error => { + showToast.error(getSignatureErrorMessage(error)); + }, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: queryKeys.bundles.all(creatorId), + }); + showToast.success('Bundle created'); + }, + }); +} + +/** + * Stamps a cancellation onto the cached bundle across every loaded page. + * Exported for testing the optimistic cache patch in isolation. + */ +export function markBundleCancelled( + data: InfiniteData | undefined, + bundleId: string, + cancelledAt: string +): InfiniteData | undefined { + if (!data) return data; + + let changed = false; + const pages = data.pages.map(page => { + let pageChanged = false; + const bundles = page.bundles.map(bundle => { + if (bundle.id !== bundleId) return bundle; + pageChanged = true; + return { ...bundle, cancelledAt }; + }); + if (pageChanged) changed = true; + return pageChanged ? { ...page, bundles } : page; + }); + + return changed ? { ...data, pages } : data; +} + +/** + * Submits `cancel_bundle` for a single bundle. + * + * The cached bundle is stamped as cancelled before the call resolves so it + * leaves the active list immediately, rolling back if the call fails. The + * query is then invalidated to pick up the authoritative state. + */ +export function useCancelBundleMutation(creatorId: string) { + const queryClient = useQueryClient(); + const listKey = queryKeys.bundles.list(creatorId); + + return useMutation({ + mutationKey: ['contract', 'cancel_bundle', creatorId], + mutationFn: (bundleId: string) => + submitCreatorContractCall('cancel_bundle', { creatorId, bundleId }), + onMutate: async bundleId => { + await queryClient.cancelQueries({ queryKey: listKey }); + const previous = queryClient.getQueryData>( + listKey + ); + queryClient.setQueryData>( + listKey, + markBundleCancelled(previous, bundleId, new Date().toISOString()) + ); + return { previous }; + }, + onError: (error, _bundleId, context) => { + if (context?.previous) { + queryClient.setQueryData(listKey, context.previous); + } + showToast.error(getSignatureErrorMessage(error)); + }, + onSuccess: () => { + showToast.success('Bundle cancelled'); + }, + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: listKey }); + }, + }); +} diff --git a/src/hooks/useCreatorContractActions.ts b/src/hooks/useCreatorContractActions.ts index 9d5bc30b..85ab3d40 100644 --- a/src/hooks/useCreatorContractActions.ts +++ b/src/hooks/useCreatorContractActions.ts @@ -12,15 +12,21 @@ import type { GraduatedCurveMilestone } from '@/components/common/GraduatedCurve * `set_launch_penalty`, `set_max_buy_quantity`, `set_quorum_bps` — #828, * `set_buy_cooldown` — #889). * - * The on-chain wiring is not in the client yet, so each mutation simulates - * signing latency and resolves. On success the creator detail query is - * invalidated so the dashboard reflects the new state, and a toast confirms - * the call. + * Each mutation simulates signing latency and resolves. On success the creator + * detail query is invalidated so the dashboard reflects the new state, and a + * toast confirms the call. */ const SIGN_LATENCY_MS = 1200; -async function submitContractCall(fn: string, args: unknown) { +/** + * Signs and submits a creator contract call. + * + * The on-chain wiring is not in the client yet, so this simulates the signing + * latency and resolves. Exported so creator-facing mutations that live in their + * own hooks (e.g. key bundles) submit calls the same way. + */ +export async function submitCreatorContractCall(fn: string, args: unknown) { // In production this signs and submits `fn` with `args` via the wallet. void fn; void args; @@ -30,6 +36,8 @@ async function submitContractCall(fn: string, args: unknown) { return { success: true as const }; } +const submitContractCall = submitCreatorContractCall; + export interface AuctionConfigInput { price: number; supply: number; diff --git a/src/hooks/useNowMs.ts b/src/hooks/useNowMs.ts new file mode 100644 index 00000000..706ae347 --- /dev/null +++ b/src/hooks/useNowMs.ts @@ -0,0 +1,19 @@ +import { useEffect, useState } from 'react'; + +/** + * Shared ticking clock used by views that render a live countdown. + * + * Owning a single interval at the page level and passing `nowMs` down keeps + * expiry-derived state (countdown labels, automatic archiving) in sync across + * every row, and lets components stay pure and deterministic in tests. + */ +export function useNowMs(intervalMs = 1000): number { + const [nowMs, setNowMs] = useState(() => Date.now()); + + useEffect(() => { + const id = window.setInterval(() => setNowMs(Date.now()), intervalMs); + return () => window.clearInterval(id); + }, [intervalMs]); + + return nowMs; +} diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index 684e54ea..09600b96 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -95,4 +95,8 @@ export const queryKeys = { summary: (wallet: string) => ['referrals', wallet, 'summary'] as const, wallets: (wallet: string) => ['referrals', wallet, 'wallets'] as const, }, + bundles: { + all: (creatorId: string) => ['bundles', creatorId] as const, + list: (creatorId: string) => ['bundles', creatorId, 'list'] as const, + }, } as const; diff --git a/src/pages/BundleManagementPage.tsx b/src/pages/BundleManagementPage.tsx new file mode 100644 index 00000000..66e85ca5 --- /dev/null +++ b/src/pages/BundleManagementPage.tsx @@ -0,0 +1,156 @@ +import { useMemo, useState } from 'react'; +import { useParams } from 'react-router'; +import { useCreatorDetail } from '@/hooks/useCreators'; +import { useDocumentTitle } from '@/hooks/useDocumentTitle'; +import { useNowMs } from '@/hooks/useNowMs'; +import { + useBundleKeyOptions, + useCancelBundleMutation, + useCreateBundleMutation, + useCreatorBundles, +} from '@/hooks/useBundles'; +import { CreatorDashboardSkeleton } from '@/components/common/CreatorSkeleton'; +import BundleCreateForm from '@/components/common/BundleCreateForm'; +import ActiveBundlesList from '@/components/common/ActiveBundlesList'; +import ExpiredBundlesArchive from '@/components/common/ExpiredBundlesArchive'; +import { partitionBundlesByStatus } from '@/utils/bundle.utils'; + +const CARD_CLASS = + 'rounded-[2rem] border border-white/10 bg-white/[0.02] p-6 shadow-2xl backdrop-blur-md md:p-8'; + +/** + * Creator bundle management page. + * + * Lets a creator define a bundle of their keys at a discounted price with an + * expiry window, then track the active bundles (purchase count and time + * remaining) and review the archive of bundles that expired or were cancelled. + * + * Expiry is evaluated against a shared ticking clock, so a bundle leaves the + * active list and lands in the archive on its own once it lapses. + */ +export default function BundleManagementPage() { + const { id = '' } = useParams<{ id: string }>(); + const nowMs = useNowMs(); + + const { data: creator, isLoading, isError } = useCreatorDetail(id); + const { + data: bundlePages, + isError: isBundlesError, + isLoading: isBundlesLoading, + } = useCreatorBundles(id); + const { options: keyOptions } = useBundleKeyOptions(id); + + const createBundle = useCreateBundleMutation(id); + const cancelBundle = useCancelBundleMutation(id); + + // Bumped on a successful creation so the create form clears its draft. + const [resetSignal, setResetSignal] = useState(0); + + const bundles = useMemo( + () => bundlePages?.pages.flatMap(page => page.bundles) ?? [], + [bundlePages] + ); + const { active, archived } = useMemo( + () => partitionBundlesByStatus(bundles, nowMs), + [bundles, nowMs] + ); + + // A first page that has not resolved yet means the active list is still + // loading; an empty result is a real empty state, not a loading one. + const isLoadingBundles = isBundlesLoading && !bundlePages; + + useDocumentTitle( + creator ? `${creator.title} · Bundles — AccessLayer` : 'Bundles — AccessLayer' + ); + + if (isLoading) { + return ( +
+
+ +
+
+ ); + } + + if (isError || !creator) { + return ( +
+
+

Key bundles

+

+ We couldn't load this creator's bundles. Try again + shortly. +

+
+
+ ); + } + + return ( +
+
+
+

+ {creator.title} · Key bundles +

+

+ Bundle your keys at a discounted price, set an expiry, and track + how each bundle sells. +

+
+ +
+

+ Create a bundle +

+

+ Pick the keys and quantities to include, then set a discounted + bundle price and how long it stays available. +

+ + {keyOptions.length === 0 ? ( +

+ You need at least one of your own keys with a published price + before you can create a bundle. +

+ ) : ( + + createBundle.mutate(request, { + onSuccess: () => setResetSignal(current => current + 1), + }) + } + /> + )} +
+ +
+ cancelBundle.mutate(bundleId)} + /> +
+ +
+ +
+
+
+ ); +} diff --git a/src/pages/CreatorDashboardPage.tsx b/src/pages/CreatorDashboardPage.tsx index ad2307cf..61ca56fd 100644 --- a/src/pages/CreatorDashboardPage.tsx +++ b/src/pages/CreatorDashboardPage.tsx @@ -1,4 +1,4 @@ -import { useParams, useSearchParams } from 'react-router'; +import { Link, useParams, useSearchParams } from 'react-router'; import { useAccount } from 'wagmi'; import { useCreatorDetail } from '@/hooks/useCreators'; import { CreatorDashboardSkeleton } from '@/components/common/CreatorSkeleton'; @@ -134,6 +134,13 @@ export default function CreatorDashboardPage() {

Manage your key profile and auction configuration.

+ + Manage key bundles + {creator.deprecated && ( diff --git a/src/pages/__tests__/BundleManagementPage.integration.test.tsx b/src/pages/__tests__/BundleManagementPage.integration.test.tsx new file mode 100644 index 00000000..091e4a65 --- /dev/null +++ b/src/pages/__tests__/BundleManagementPage.integration.test.tsx @@ -0,0 +1,269 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter, Route, Routes } from 'react-router'; +import BundleManagementPage from '@/pages/BundleManagementPage'; +import { courseService, type Course } from '@/services/course.service'; +import { submitCreatorContractCall } from '@/hooks/useCreatorContractActions'; +import showToast from '@/utils/toast.util'; +import type { BundlesPage, KeyBundle } from '@/services/bundle.service'; + +const { getCreatorBundles } = vi.hoisted(() => ({ + getCreatorBundles: vi.fn(), +})); + +vi.mock('@/services/course.service', () => ({ + courseService: { getCourse: vi.fn(), getCourses: vi.fn() }, +})); + +vi.mock('@/services/bundle.service', () => ({ + bundleService: { getCreatorBundles }, + fetchCreatorBundlesPage: (creatorId: string, cursor: string | null) => + getCreatorBundles(creatorId, cursor), +})); + +// The bundle page reuses the shared contract-call seam, so mocking it here +// also covers the creator dashboard's mutations. +vi.mock('@/hooks/useCreatorContractActions', () => ({ + submitCreatorContractCall: vi.fn(), +})); + +vi.mock('@/utils/toast.util', () => ({ + default: { + message: vi.fn(), + success: vi.fn(), + error: vi.fn(), + loading: vi.fn(), + transactionSuccess: vi.fn(), + }, +})); + +const CREATOR_ID = 'creator-1'; +const DAY_MS = 86_400_000; +const KEY_PRICE_XLM = 10; + +const mockGetCourse = vi.mocked(courseService.getCourse); +const mockGetCourses = vi.mocked(courseService.getCourses); +const mockSubmitCall = vi.mocked(submitCreatorContractCall); +const mockToastSuccess = vi.mocked(showToast.success); +const mockToastError = vi.mocked(showToast.error); + +function makeCourse(): Course { + return { + id: 'key-a', + title: 'Alpha Key', + description: '', + price: KEY_PRICE_XLM, + priceStroops: KEY_PRICE_XLM * 10_000_000, + instructorId: CREATOR_ID, + category: 'Test', + level: 'BEGINNER', + }; +} + +function makeBundle(overrides: Partial = {}): KeyBundle { + return { + id: 'bundle-1', + creatorId: CREATOR_ID, + items: [{ keyId: 'key-a', quantity: 2 }], + listPriceXlm: 20, + discountPriceXlm: 16, + expiresAt: new Date(Date.now() + 3 * DAY_MS).toISOString(), + createdAt: new Date().toISOString(), + purchaseCount: 2, + cancelledAt: null, + ...overrides, + }; +} + +/** Stands in for the bundle endpoint, persisting cancellations between fetches. */ +function stubBundleEndpoint(initial: KeyBundle[]) { + const store: KeyBundle[] = initial.map(bundle => ({ ...bundle })); + getCreatorBundles.mockImplementation( + async (): Promise => ({ + bundles: store.map(bundle => ({ ...bundle })), + nextCursor: null, + }) + ); + return store; +} + +function makeQueryClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); +} + +function renderPage() { + return render( + + + + } + /> + + + + ); +} + +/** Fills the create form with a valid draft: 2 x Alpha Key at 16 XLM for 7 days. */ +function fillValidBundleDraft() { + fireEvent.change(screen.getByTestId('bundle-key-select'), { + target: { value: 'key-a' }, + }); + fireEvent.click(screen.getByTestId('bundle-add-key')); + fireEvent.change(screen.getByTestId('bundle-line-quantity-key-a'), { + target: { value: '2' }, + }); + fireEvent.change(screen.getByTestId('bundle-price-input'), { + target: { value: '16' }, + }); + fireEvent.change(screen.getByTestId('bundle-expiry-input'), { + target: { value: '7' }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockGetCourse.mockResolvedValue(makeCourse()); + mockGetCourses.mockResolvedValue([makeCourse()]); + stubBundleEndpoint([makeBundle()]); + mockSubmitCall.mockResolvedValue({ success: true }); +}); + +afterEach(cleanup); + +describe('BundleManagementPage', () => { + it('shows an error state when the creator cannot be loaded', async () => { + mockGetCourse.mockRejectedValue(new Error('nope')); + renderPage(); + + expect(await screen.findByTestId('bundle-management-error')).toBeInTheDocument(); + }); + + it('blocks creating a bundle when the discount is under the price floor', async () => { + renderPage(); + + await screen.findByTestId('bundle-create-form'); + fireEvent.change(screen.getByTestId('bundle-key-select'), { + target: { value: 'key-a' }, + }); + fireEvent.click(screen.getByTestId('bundle-add-key')); + // 2 keys x 10 XLM = 20 XLM list price, so the floor is 16 XLM. + fireEvent.change(screen.getByTestId('bundle-line-quantity-key-a'), { + target: { value: '2' }, + }); + fireEvent.change(screen.getByTestId('bundle-price-input'), { + target: { value: '15' }, + }); + fireEvent.click(screen.getByTestId('bundle-submit')); + + expect(mockSubmitCall).not.toHaveBeenCalled(); + expect(screen.getByTestId('bundle-price-error')).toHaveTextContent( + 'Bundle price must be at least 16 XLM (20% off list)' + ); + }); + + it('submits create_bundle with the selected keys, price and expiry', async () => { + renderPage(); + + await screen.findByTestId('bundle-create-form'); + fillValidBundleDraft(); + fireEvent.click(screen.getByTestId('bundle-submit')); + + await waitFor(() => expect(mockSubmitCall).toHaveBeenCalledTimes(1)); + expect(mockSubmitCall).toHaveBeenCalledWith( + 'create_bundle', + expect.objectContaining({ + creatorId: CREATOR_ID, + items: [{ keyId: 'key-a', quantity: 2 }], + discountPriceXlm: 16, + expiresAt: expect.any(String), + }) + ); + expect(mockToastSuccess).toHaveBeenCalledWith('Bundle created'); + }); + + it('surfaces a rejected signature without losing the page', async () => { + mockSubmitCall.mockRejectedValue({ code: 4001 }); + renderPage(); + + await screen.findByTestId('bundle-create-form'); + fillValidBundleDraft(); + fireEvent.click(screen.getByTestId('bundle-submit')); + + await waitFor(() => expect(mockToastError).toHaveBeenCalled()); + expect(screen.getByTestId('bundle-create-form')).toBeInTheDocument(); + }); + + it('lists active bundles with purchase count and a countdown', async () => { + renderPage(); + + expect(await screen.findByTestId('active-bundle-row')).toHaveTextContent( + 'bundle-1' + ); + expect(screen.getByTestId('active-bundle-purchases')).toHaveTextContent('2'); + expect(screen.getByTestId('bundle-time-remaining')).toHaveTextContent('2d'); + }); + + it('archives a bundle that has already expired', async () => { + stubBundleEndpoint([ + makeBundle({ + id: 'bundle-expired', + expiresAt: new Date(Date.now() - DAY_MS).toISOString(), + }), + ]); + renderPage(); + + expect(await screen.findByTestId('expired-bundle-row')).toHaveTextContent( + 'bundle-expired' + ); + expect(screen.queryByTestId('active-bundle-row')).not.toBeInTheDocument(); + expect(screen.getByTestId('expired-bundle-end-reason')).toHaveTextContent( + 'Expired' + ); + }); + + it('removes a cancelled bundle from the active list and archives it', async () => { + const store = stubBundleEndpoint([makeBundle()]); + // Mirror the endpoint recording the cancellation. + mockSubmitCall.mockImplementation(async (fn, args) => { + if (fn === 'cancel_bundle') { + const { bundleId } = args as { bundleId: string }; + const target = store.find(bundle => bundle.id === bundleId); + if (target) target.cancelledAt = new Date().toISOString(); + } + return { success: true }; + }); + + renderPage(); + await screen.findByTestId('active-bundle-row'); + fireEvent.click(screen.getByTestId('active-bundle-cancel')); + + await waitFor(() => + expect(screen.queryByTestId('active-bundle-row')).not.toBeInTheDocument() + ); + expect(mockSubmitCall).toHaveBeenCalledWith('cancel_bundle', { + creatorId: CREATOR_ID, + bundleId: 'bundle-1', + }); + expect(mockToastSuccess).toHaveBeenCalledWith('Bundle cancelled'); + expect(await screen.findByTestId('expired-bundle-row')).toHaveTextContent( + 'bundle-1' + ); + }); + + it('keeps the bundle listed when the cancel call fails', async () => { + mockSubmitCall.mockRejectedValue(new Error('network down')); + renderPage(); + + await screen.findByTestId('active-bundle-row'); + fireEvent.click(screen.getByTestId('active-bundle-cancel')); + + await waitFor(() => expect(mockToastError).toHaveBeenCalled()); + expect(screen.getByTestId('active-bundle-row')).toBeInTheDocument(); + }); +}); diff --git a/src/routes.tsx b/src/routes.tsx index 3fcf6ce1..377e5641 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -15,6 +15,7 @@ import GovernancePage from './pages/GovernancePage'; import ProposalDetailPage from './pages/ProposalDetailPage'; import ReferralDashboardPage from './pages/ReferralDashboardPage'; import CreateCreatorKeyPage from './pages/CreateCreatorKeyPage'; +import BundleManagementPage from './pages/BundleManagementPage'; export const routes = [ { @@ -61,6 +62,14 @@ export const routes = [ path: '/creators/:id/dashboard', element: , }, + { + path: '/creator/:id/bundles', + element: , + }, + { + path: '/creators/:id/bundles', + element: , + }, { path: '/notifications', element: , diff --git a/src/services/bundle.service.ts b/src/services/bundle.service.ts new file mode 100644 index 00000000..bf752335 --- /dev/null +++ b/src/services/bundle.service.ts @@ -0,0 +1,99 @@ +// src/services/bundle.service.ts +import { BaseApiService, type APIResponse } from './api.service'; + +/** A single key + quantity pair inside a bundle. */ +export interface BundleLineItem { + /** Creator key id included in the bundle. */ + keyId: string; + /** How many units of that key the bundle contains. */ + quantity: number; +} + +/** + * Lifecycle status of a bundle. + * + * `active` and `expired` are both derived from `expiresAt`; `cancelled` is set + * by the creator before expiry. Everything except `active` is archived. + */ +export type BundleStatus = 'active' | 'expired' | 'cancelled'; + +/** A creator-defined bundle of keys sold together at a discount. */ +export interface KeyBundle { + /** Bundle id. */ + id: string; + /** Creator key the bundle is listed under. */ + creatorId: string; + /** Per-key line items making up the bundle. */ + items: BundleLineItem[]; + /** Undiscounted total of every line item at list price, in XLM. */ + listPriceXlm: number; + /** Price a buyer pays for the whole bundle, in XLM. */ + discountPriceXlm: number; + /** ISO timestamp after which the bundle can no longer be purchased. */ + expiresAt: string; + /** ISO timestamp of when the bundle was created. */ + createdAt: string; + /** Number of completed bundle purchases. */ + purchaseCount: number; + /** ISO timestamp of when the creator cancelled the bundle, when cancelled. */ + cancelledAt?: string | null; +} + +/** Cursor-paginated envelope for the bundle list. */ +export interface BundlesPage { + bundles: KeyBundle[]; + nextCursor: string | null; +} + +/** Payload for `create_bundle`. */ +export interface CreateBundleRequest { + /** Keys and quantities that make up the bundle. */ + items: BundleLineItem[]; + /** Discounted bundle price, in XLM. */ + discountPriceXlm: number; + /** ISO timestamp after which the bundle expires. */ + expiresAt: string; +} + +class BundleService extends BaseApiService { + /** + * One cursor-paginated page of a creator's bundles - + * GET /creators/:creatorId/bundles. + * + * The API returns active and archived bundles together; the client splits + * them by comparing `expiresAt` against the current time so bundles archive + * the moment they expire, without waiting for a refetch. + */ + async getCreatorBundles( + creatorId: string, + cursor?: string | null + ): Promise { + try { + const response = await this.api.get>( + `/creators/${creatorId}/bundles`, + { params: cursor ? { cursor } : undefined } + ); + + const data = response.data.data; + return { + bundles: Array.isArray(data?.bundles) ? data.bundles : [], + nextCursor: data?.nextCursor ?? null, + }; + } catch (error) { + throw this.handleError(error); + } + } +} + +export const bundleService = new BundleService(); + +/** + * Convenience wrapper for fetching a page of a creator's bundles. + * Exposed as a plain function to facilitate test spying. + */ +export async function fetchCreatorBundlesPage( + creatorId: string, + cursor: string | null | undefined +): Promise { + return bundleService.getCreatorBundles(creatorId, cursor); +} diff --git a/src/utils/__tests__/bundle.utils.test.ts b/src/utils/__tests__/bundle.utils.test.ts new file mode 100644 index 00000000..75c6c4a4 --- /dev/null +++ b/src/utils/__tests__/bundle.utils.test.ts @@ -0,0 +1,335 @@ +import { describe, expect, it } from 'vitest'; +import { + canCancelBundle, + computeBundleListPriceXlm, + describeBundleEndReason, + getBundleDiscountBps, + getBundlePriceFloorXlm, + getBundleQuantityTotal, + getBundleStatus, + getBundleTimeRemainingState, + isBundleExpired, + partitionBundlesByStatus, + resolveBundleExpiryIso, + validateBundleDraft, + BUNDLE_MAX_EXPIRY_DAYS, + BUNDLE_MIN_DISCOUNT_BPS, + type BundleKeyOption, +} from '@/utils/bundle.utils'; +import type { KeyBundle } from '@/services/bundle.service'; + +const NOW_MS = Date.parse('2026-01-01T00:00:00.000Z'); +const DAY_MS = 86_400_000; + +function makeBundle(overrides: Partial = {}): KeyBundle { + return { + id: 'bundle-1', + creatorId: 'creator-1', + items: [{ keyId: 'key-a', quantity: 2 }], + listPriceXlm: 20, + discountPriceXlm: 16, + expiresAt: new Date(NOW_MS + 2 * DAY_MS).toISOString(), + createdAt: new Date(NOW_MS - DAY_MS).toISOString(), + purchaseCount: 0, + cancelledAt: null, + ...overrides, + }; +} + +describe('bundle quantity + list price', () => { + it('sums quantities across every line item', () => { + expect( + getBundleQuantityTotal([ + { keyId: 'key-a', quantity: 2 }, + { keyId: 'key-b', quantity: 5 }, + ]) + ).toBe(7); + }); + + it('ignores non-finite quantities instead of returning NaN', () => { + expect( + getBundleQuantityTotal([{ keyId: 'key-a', quantity: Number.NaN }]) + ).toBe(0); + }); + + it('multiplies each line by its key list price', () => { + expect( + computeBundleListPriceXlm( + [ + { keyId: 'key-a', quantity: 2 }, + { keyId: 'key-b', quantity: 1 }, + ], + { 'key-a': 10, 'key-b': 4 } + ) + ).toBe(24); + }); + + it('skips keys with a missing price rather than producing NaN', () => { + expect( + computeBundleListPriceXlm( + [ + { keyId: 'key-a', quantity: 2 }, + { keyId: 'key-missing', quantity: 3 }, + ], + { 'key-a': 10 } + ) + ).toBe(20); + }); +}); + +describe('bundle price floor', () => { + it('allows a 20% discount off the list price', () => { + expect(BUNDLE_MIN_DISCOUNT_BPS).toBe(2_000); + expect(getBundlePriceFloorXlm(100)).toBe(80); + }); + + it('returns 0 when the list price is unknown', () => { + expect(getBundlePriceFloorXlm(0)).toBe(0); + expect(getBundlePriceFloorXlm(Number.NaN)).toBe(0); + }); + + it('reports the discount in basis points', () => { + expect(getBundleDiscountBps(100, 80)).toBe(2_000); + expect(getBundleDiscountBps(100, 75)).toBe(2_500); + }); + + it('reports no discount when the price is not below list', () => { + expect(getBundleDiscountBps(100, 100)).toBe(0); + expect(getBundleDiscountBps(100, 120)).toBe(0); + expect(getBundleDiscountBps(0, 5)).toBe(0); + }); +}); + +describe('validateBundleDraft', () => { + const items = [{ keyId: 'key-a', quantity: 1 }]; + + it('accepts a price exactly at the floor', () => { + const result = validateBundleDraft(items, '80', '14', 100); + expect(result.isValid).toBe(true); + expect(result.priceError).toBeNull(); + }); + + it('rejects a price below the floor', () => { + const result = validateBundleDraft(items, '79.99', '14', 100); + expect(result.isValid).toBe(false); + expect(result.priceError).toBe('Bundle price must be at least 80 XLM (20% off list)'); + }); + + it('rejects a price that is not actually a discount', () => { + const atList = validateBundleDraft(items, '100', '14', 100); + expect(atList.priceError).toBe( + 'Bundle price must be below the 100 XLM list price' + ); + + const aboveList = validateBundleDraft(items, '120', '14', 100); + expect(aboveList.priceError).toBe( + 'Bundle price must be below the 100 XLM list price' + ); + }); + + it.each(['', ' ', '0', '-5', 'abc'])( + 'rejects %o as a bundle price', + raw => { + const result = validateBundleDraft(items, raw, '14', 100); + expect(result.isValid).toBe(false); + expect(result.priceError).toBe('Enter a bundle price greater than 0'); + } + ); + + it('requires at least one key with a whole quantity', () => { + expect(validateBundleDraft([], '80', '14', 100).itemsError).toBe( + 'Add at least one key to the bundle' + ); + + expect( + validateBundleDraft([{ keyId: 'key-a', quantity: 0 }], '80', '14', 100) + .itemsError + ).toBe('Quantities must be whole numbers greater than 0'); + + expect( + validateBundleDraft([{ keyId: 'key-a', quantity: 1.5 }], '80', '14', 100) + .itemsError + ).toBe('Quantities must be whole numbers greater than 0'); + }); + + it('rejects the same key added twice', () => { + const result = validateBundleDraft( + [ + { keyId: 'key-a', quantity: 1 }, + { keyId: 'key-a', quantity: 2 }, + ], + '80', + '14', + 100 + ); + expect(result.itemsError).toBe('A key can only be added once'); + }); + + it('rejects an expiry outside the supported window', () => { + expect(validateBundleDraft(items, '80', '0', 100).expiryError).toBe( + 'Bundle expiry must be at least 1 day' + ); + expect( + validateBundleDraft(items, '80', String(BUNDLE_MAX_EXPIRY_DAYS + 1), 100) + .expiryError + ).toBe(`Bundle expiry cannot exceed ${BUNDLE_MAX_EXPIRY_DAYS} days`); + expect(validateBundleDraft(items, '80', '1.5', 100).expiryError).toBe( + 'Enter a whole number of days' + ); + expect(validateBundleDraft(items, '80', '', 100).expiryError).toBe( + 'Enter a bundle expiry in days' + ); + expect(validateBundleDraft(items, '80', '14', 100).expiryError).toBeNull(); + }); + + it('reports every field error at once', () => { + const result = validateBundleDraft([], '1', '0', 0); + expect(result.itemsError).not.toBeNull(); + expect(result.expiryError).not.toBeNull(); + expect(result.priceError).not.toBeNull(); + expect(result.isValid).toBe(false); + }); +}); + +describe('bundle expiry + status', () => { + it('treats a bundle as expired once the clock reaches its expiry', () => { + const bundle = makeBundle({ + expiresAt: new Date(NOW_MS).toISOString(), + }); + expect(isBundleExpired(bundle, NOW_MS - 1)).toBe(false); + expect(isBundleExpired(bundle, NOW_MS)).toBe(true); + expect(getBundleStatus(bundle, NOW_MS)).toBe('expired'); + }); + + it('keeps a future bundle active and cancellable', () => { + const bundle = makeBundle(); + expect(getBundleStatus(bundle, NOW_MS)).toBe('active'); + expect(canCancelBundle(bundle, NOW_MS)).toBe(true); + }); + + it('never reports an unparseable expiry as expired', () => { + const bundle = makeBundle({ expiresAt: 'not-a-date' }); + expect(isBundleExpired(bundle, NOW_MS)).toBe(false); + expect(getBundleStatus(bundle, NOW_MS)).toBe('active'); + }); + + it('lets a cancellation win over expiry', () => { + const bundle = makeBundle({ + expiresAt: new Date(NOW_MS - DAY_MS).toISOString(), + cancelledAt: new Date(NOW_MS - 2 * DAY_MS).toISOString(), + }); + expect(getBundleStatus(bundle, NOW_MS)).toBe('cancelled'); + expect(canCancelBundle(bundle, NOW_MS)).toBe(false); + expect(describeBundleEndReason(bundle, NOW_MS)).toBe('Cancelled by creator'); + }); + + it('describes why a bundle ended', () => { + expect( + describeBundleEndReason( + makeBundle({ expiresAt: new Date(NOW_MS - 1).toISOString() }), + NOW_MS + ) + ).toBe('Expired'); + expect(describeBundleEndReason(makeBundle(), NOW_MS)).toBe('Active'); + }); +}); + +describe('getBundleTimeRemainingState', () => { + it('reports a live countdown for a future expiry', () => { + const expiresAt = new Date(NOW_MS + 2 * DAY_MS).toISOString(); + expect(getBundleTimeRemainingState(expiresAt, NOW_MS)).toEqual({ + isExpired: false, + label: '2d 0h 0m 0s', + }); + }); + + it('reports the bundle as expired at and after the expiry', () => { + const expiresAt = new Date(NOW_MS).toISOString(); + expect(getBundleTimeRemainingState(expiresAt, NOW_MS)).toEqual({ + isExpired: true, + label: 'Expired', + }); + expect( + getBundleTimeRemainingState(expiresAt, NOW_MS + DAY_MS) + ).toMatchObject({ isExpired: true, label: 'Expired' }); + }); + + it('falls back to a placeholder for a missing expiry', () => { + expect(getBundleTimeRemainingState(null, NOW_MS)).toEqual({ + isExpired: false, + label: '—', + }); + expect(getBundleTimeRemainingState('nope', NOW_MS)).toEqual({ + isExpired: false, + label: '—', + }); + }); +}); + +describe('partitionBundlesByStatus', () => { + const live = makeBundle({ + id: 'live', + expiresAt: new Date(NOW_MS + DAY_MS).toISOString(), + }); + const liveSoon = makeBundle({ + id: 'live-soon', + expiresAt: new Date(NOW_MS + 1_000).toISOString(), + }); + const expired = makeBundle({ + id: 'expired', + expiresAt: new Date(NOW_MS - 2 * DAY_MS).toISOString(), + }); + const cancelled = makeBundle({ + id: 'cancelled', + expiresAt: new Date(NOW_MS - DAY_MS).toISOString(), + cancelledAt: new Date(NOW_MS - DAY_MS).toISOString(), + }); + + it('splits live bundles from expired and cancelled ones', () => { + const { active, archived } = partitionBundlesByStatus( + [live, expired, cancelled], + NOW_MS + ); + expect(active.map(b => b.id)).toEqual(['live']); + expect(archived.map(b => b.id).sort()).toEqual(['cancelled', 'expired']); + }); + + it('moves a bundle to the archive as soon as it lapses', () => { + const before = partitionBundlesByStatus([liveSoon], NOW_MS - 2_000); + expect(before.active).toHaveLength(1); + expect(before.archived).toHaveLength(0); + + const after = partitionBundlesByStatus([liveSoon], NOW_MS + 2_000); + expect(after.active).toHaveLength(0); + expect(after.archived.map(b => b.id)).toEqual(['live-soon']); + }); + + it('orders the active list soonest-expiry first and the archive newest-end first', () => { + const { active, archived } = partitionBundlesByStatus( + [live, liveSoon, expired, cancelled], + NOW_MS + ); + expect(active.map(b => b.id)).toEqual(['live-soon', 'live']); + expect(archived.map(b => b.id)).toEqual(['cancelled', 'expired']); + }); +}); + +describe('resolveBundleExpiryIso', () => { + it('turns a day count into an absolute ISO expiry', () => { + expect(resolveBundleExpiryIso(14, NOW_MS)).toBe('2026-01-15T00:00:00.000Z'); + expect(resolveBundleExpiryIso(1, NOW_MS)).toBe('2026-01-02T00:00:00.000Z'); + }); +}); + +describe('BundleKeyOption', () => { + it('carries the id, title and list price the create form needs', () => { + const option: BundleKeyOption = { + id: 'key-a', + title: 'Alpha Key', + priceXlm: 10, + }; + expect(computeBundleListPriceXlm([{ keyId: option.id, quantity: 2 }], { + [option.id]: option.priceXlm, + })).toBe(20); + }); +}); diff --git a/src/utils/bundle.utils.ts b/src/utils/bundle.utils.ts new file mode 100644 index 00000000..4d93da24 --- /dev/null +++ b/src/utils/bundle.utils.ts @@ -0,0 +1,289 @@ +/** + * Key bundle helpers for the creator bundle management page. + * + * A bundle is a creator-defined set of keys sold together at a discount for a + * limited window. Everything here is pure so the create form, the active list + * and the archive all derive their state from the same rules: + * + * - the **price floor** is the cheapest a bundle may be priced, derived from + * the list price of its line items, + * - **expiry** is compared against an injected `nowMs` so a bundle archives + * itself the moment it lapses, with no refetch. + */ + +import type { BundleLineItem, BundleStatus, KeyBundle } from '@/services/bundle.service'; +// Reuses the shared duration formatter; the countdown shape it produces +// ("2d 4h 13m 5s") is what bundle rows render. +import { formatDropTimeRemaining } from '@/utils/dropCountdown.utils'; + +const MS_PER_DAY = 86_400_000; +const BPS_DENOMINATOR = 10_000; + +/** Minimum discount a bundle must offer, in basis points (2,000 = 20% off). */ +export const BUNDLE_MIN_DISCOUNT_BPS = 2_000; + +/** Shortest expiry a creator may configure, in days. */ +export const BUNDLE_MIN_EXPIRY_DAYS = 1; + +/** Longest expiry a creator may configure, in days. */ +export const BUNDLE_MAX_EXPIRY_DAYS = 90; + +/** Default expiry offered by the create form, in days. */ +export const BUNDLE_DEFAULT_EXPIRY_DAYS = 14; + +/** A key the creator can add to a bundle, with its list price in XLM. */ +export interface BundleKeyOption { + id: string; + title: string; + /** Undiscounted price of a single unit, in XLM. */ + priceXlm: number; +} + +/** Total number of keys across every line item. */ +export function getBundleQuantityTotal(items: BundleLineItem[]): number { + return items.reduce( + (total, item) => total + (Number.isFinite(item.quantity) ? item.quantity : 0), + 0 + ); +} + +/** + * Undiscounted total of a bundle's line items, given the list price of each + * key. Quantities and prices that are missing or non-finite contribute zero so + * a partially-loaded price map can never produce `NaN`. + */ +export function computeBundleListPriceXlm( + items: BundleLineItem[], + priceByKeyIdXlm: Record +): number { + return items.reduce((total, item) => { + const unitPrice = priceByKeyIdXlm[item.keyId]; + const quantity = Number(item.quantity); + if (!Number.isFinite(unitPrice) || !Number.isFinite(quantity)) return total; + return total + (unitPrice as number) * quantity; + }, 0); +} + +/** + * Cheapest price a bundle may be listed at: the list price minus the minimum + * required discount. Returns 0 when the list price is unknown. + */ +export function getBundlePriceFloorXlm(listPriceXlm: number): number { + if (!Number.isFinite(listPriceXlm) || listPriceXlm <= 0) return 0; + return (listPriceXlm * (BPS_DENOMINATOR - BUNDLE_MIN_DISCOUNT_BPS)) / BPS_DENOMINATOR; +} + +/** Discount a bundle price represents, in basis points (0 when not discounted). */ +export function getBundleDiscountBps( + listPriceXlm: number, + discountPriceXlm: number +): number { + if (!Number.isFinite(listPriceXlm) || listPriceXlm <= 0) return 0; + if (!Number.isFinite(discountPriceXlm) || discountPriceXlm <= 0) return 0; + if (discountPriceXlm >= listPriceXlm) return 0; + return Math.round( + ((listPriceXlm - discountPriceXlm) / listPriceXlm) * BPS_DENOMINATOR + ); +} + +/** Resolves a bundle's `expiresAt` to epoch milliseconds, or null if unparseable. */ +export function resolveBundleExpiryMs(bundle: Pick): number | null { + const parsed = new Date(bundle.expiresAt).getTime(); + return Number.isNaN(parsed) ? null : parsed; +} + +/** A bundle is expired once the current time reaches its expiry. */ +export function isBundleExpired( + bundle: Pick, + nowMs: number +): boolean { + const expiryMs = resolveBundleExpiryMs(bundle); + if (expiryMs == null) return false; + return nowMs >= expiryMs; +} + +/** + * Lifecycle status of a bundle. A creator cancellation wins over expiry so a + * cancelled bundle never reappears as merely expired. + */ +export function getBundleStatus( + bundle: Pick, + nowMs: number +): BundleStatus { + if (bundle.cancelledAt) return 'cancelled'; + return isBundleExpired(bundle, nowMs) ? 'expired' : 'active'; +} + +/** + * Only live bundles can be cancelled: a bundle that has already expired or been + * cancelled is read-only. + */ +export function canCancelBundle( + bundle: Pick, + nowMs: number +): boolean { + return getBundleStatus(bundle, nowMs) === 'active'; +} + +/** Human-readable reason a bundle is no longer active. */ +export function describeBundleEndReason( + bundle: Pick, + nowMs: number +): string { + switch (getBundleStatus(bundle, nowMs)) { + case 'cancelled': + return 'Cancelled by creator'; + case 'expired': + return 'Expired'; + case 'active': + return 'Active'; + } +} + +/** Countdown state for a bundle's expiry timestamp. */ +export function getBundleTimeRemainingState( + expiresAt: string | null | undefined, + nowMs: number +): { isExpired: boolean; label: string } { + const parsed = expiresAt == null ? Number.NaN : new Date(expiresAt).getTime(); + if (Number.isNaN(parsed)) return { isExpired: false, label: '—' }; + + const remainingMs = parsed - nowMs; + if (remainingMs <= 0) return { isExpired: true, label: 'Expired' }; + + return { isExpired: false, label: formatDropTimeRemaining(remainingMs) }; +} + +export interface PartitionedBundles { + /** Bundles still purchasable, soonest to expire first. */ + active: KeyBundle[]; + /** Expired and cancelled bundles, most recently ended first. */ + archived: KeyBundle[]; +} + +/** + * Splits a creator's bundles into the active list and the archive. + * + * Both sides are sorted by expiry so the countdown column reads in a sensible + * order: the bundles about to lapse lead the active list, and the most + * recently ended bundles lead the archive. + */ +export function partitionBundlesByStatus( + bundles: KeyBundle[], + nowMs: number +): PartitionedBundles { + const active: KeyBundle[] = []; + const archived: KeyBundle[] = []; + + for (const bundle of bundles) { + if (getBundleStatus(bundle, nowMs) === 'active') active.push(bundle); + else archived.push(bundle); + } + + const byExpiry = (a: KeyBundle, b: KeyBundle) => + (resolveBundleExpiryMs(a) ?? 0) - (resolveBundleExpiryMs(b) ?? 0); + + active.sort(byExpiry); + archived.sort((a, b) => byExpiry(b, a)); + + return { active, archived }; +} + +/** Converts a day count into the ISO expiry timestamp sent to the contract. */ +export function resolveBundleExpiryIso(expiryDays: number, nowMs: number): string { + return new Date(nowMs + expiryDays * MS_PER_DAY).toISOString(); +} + +export interface BundleDraftValidation { + itemsError: string | null; + priceError: string | null; + expiryError: string | null; + isValid: boolean; +} + +/** + * Validates the raw create-bundle form values. + * + * The bundle price is checked against the list price of the selected line + * items: it must be a real number, strictly below list (otherwise the bundle is + * not a discount), and at or above the price floor. The expiry must be a whole + * number of days inside the supported window. + */ +export function validateBundleDraft( + items: BundleLineItem[], + discountPriceRaw: string, + expiryDaysRaw: string, + listPriceXlm: number +): BundleDraftValidation { + const itemsError = validateBundleItems(items); + const priceError = validateBundlePrice(discountPriceRaw, listPriceXlm); + const expiryError = validateBundleExpiryDays(expiryDaysRaw); + + return { + itemsError, + priceError, + expiryError, + isValid: !itemsError && !priceError && !expiryError, + }; +} + +function validateBundleItems(items: BundleLineItem[]): string | null { + if (items.length === 0) return 'Add at least one key to the bundle'; + + const seen = new Set(); + for (const item of items) { + if (!item.keyId) return 'Every line needs a key'; + if (seen.has(item.keyId)) return 'A key can only be added once'; + seen.add(item.keyId); + + const quantity = Number(item.quantity); + if (!Number.isInteger(quantity) || quantity <= 0) { + return 'Quantities must be whole numbers greater than 0'; + } + } + + return null; +} + +function validateBundlePrice( + discountPriceRaw: string, + listPriceXlm: number +): string | null { + const trimmed = discountPriceRaw.trim(); + const price = Number(trimmed); + + if (trimmed === '' || !Number.isFinite(price) || price <= 0) { + return 'Enter a bundle price greater than 0'; + } + + if (!Number.isFinite(listPriceXlm) || listPriceXlm <= 0) { + return 'The list price for this bundle is not known yet'; + } + + if (price >= listPriceXlm) { + return `Bundle price must be below the ${listPriceXlm} XLM list price`; + } + + const floorXlm = getBundlePriceFloorXlm(listPriceXlm); + if (price < floorXlm) { + return `Bundle price must be at least ${floorXlm} XLM (20% off list)`; + } + + return null; +} + +function validateBundleExpiryDays(expiryDaysRaw: string): string | null { + const trimmed = expiryDaysRaw.trim(); + if (trimmed === '') return 'Enter a bundle expiry in days'; + + const days = Number(trimmed); + if (!Number.isInteger(days)) return 'Enter a whole number of days'; + if (days < BUNDLE_MIN_EXPIRY_DAYS) { + return `Bundle expiry must be at least ${BUNDLE_MIN_EXPIRY_DAYS} day`; + } + if (days > BUNDLE_MAX_EXPIRY_DAYS) { + return `Bundle expiry cannot exceed ${BUNDLE_MAX_EXPIRY_DAYS} days`; + } + + return null; +}