diff --git a/.gitignore b/.gitignore index d647a8c..69ed6ef 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,8 @@ public/mockServiceWorker.js .env.local +.claude/settings.local.json +.gstack/ + +# Local tooling artifacts +.playwright-cli/ diff --git a/app/admin/__tests__/page.test.tsx b/app/admin/__tests__/page.test.tsx new file mode 100644 index 0000000..532eb8d --- /dev/null +++ b/app/admin/__tests__/page.test.tsx @@ -0,0 +1,176 @@ +import userEvent from '@testing-library/user-event' +import { render, screen, waitFor } from '@/tests/utils/test-utils' +import AdminPage from '@/app/admin/page' +import type { AdminBillMetadata, AdminStats, Bill } from '@/lib/bill-types' + +const mockToast = jest.fn() +const mockPush = jest.fn() + +jest.mock('@/hooks/use-toast', () => ({ + useToast: () => ({ + toast: mockToast, + }), +})) + +jest.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mockPush, + replace: jest.fn(), + prefetch: jest.fn(), + back: jest.fn(), + forward: jest.fn(), + refresh: jest.fn(), + }), +})) + +function createBill(overrides: Partial = {}): Bill { + return { + id: 'bill-1', + title: 'Weekly Dinner', + status: 'active', + tax: '0', + tip: '0', + discount: '0', + taxTipAllocation: 'proportional', + notes: '', + people: [], + items: [], + ...overrides, + } +} + +function createAdminStats(overrides: Partial = {}): AdminStats { + return { + totalBills: 1, + activeBills: 1, + draftBills: 0, + closedBills: 0, + totalItems: 2, + totalPeople: 3, + totalStorageSize: 1024, + averageBillSize: 1024, + totalMoneyProcessed: 24, + averageBillValue: 24, + medianBillValue: 24, + largestBill: 24, + smallestBill: 24, + subtotalRevenue: 24, + taxRevenue: 0, + tipRevenue: 0, + totalTaxCollected: 0, + totalTipsProcessed: 0, + totalDiscountsApplied: 0, + billsWithTax: 0, + billsWithTips: 0, + billsWithDiscounts: 0, + billsCreatedToday: 1, + billsCreatedThisWeek: 1, + billsCreatedThisMonth: 1, + billsCreatedLastWeek: 0, + billsCreatedLastMonth: 0, + weeklyGrowth: 100, + monthlyGrowth: 100, + completionRate: 100, + shareRate: 100, + averageAccessCount: 1, + sharedBills: 1, + completedBills: 1, + averageItemsPerBill: 2, + averagePeoplePerBill: 3, + complexBills: 0, + largeBills: 0, + popularSplitMethods: [{ method: 'even', count: 1, percentage: 100 }], + ...overrides, + } +} + +function createAdminBill(overrides: Partial = {}): AdminBillMetadata { + return { + id: 'bill-1', + bill: createBill(), + createdAt: '2026-05-29T00:00:00.000Z', + lastModified: '2026-05-29T00:00:00.000Z', + expiresAt: '2027-05-29T00:00:00.000Z', + accessCount: 1, + size: 512, + shareUrl: 'https://splitsimple.example/b/bill-1', + totalAmount: 24, + lastAccessed: '2026-05-29T00:00:00.000Z', + ...overrides, + } +} + +function createBillsResponse(overrides: Partial<{ bills: AdminBillMetadata[]; stats: AdminStats; pagination: { totalPages: number } }> = {}) { + return { + bills: [createAdminBill()], + stats: createAdminStats(), + pagination: { totalPages: 1 }, + ...overrides, + } +} + +function createFetchResponse(status: number, body?: unknown) { + return { + ok: status >= 200 && status < 300, + status, + json: jest.fn().mockResolvedValue(body), + blob: jest.fn().mockResolvedValue(new Blob()), + } as unknown as Response +} + +describe('AdminPage', () => { + beforeEach(() => { + mockToast.mockReset() + mockPush.mockReset() + global.fetch = jest.fn() + }) + + it('returns to the login screen when the admin session expires during bill fetch', async () => { + jest.mocked(global.fetch) + .mockResolvedValueOnce(createFetchResponse(200)) + .mockResolvedValueOnce(createFetchResponse(401, { error: 'Unauthorized' })) + + render() + + expect(await screen.findByText('Admin Access')).toBeInTheDocument() + expect(mockToast).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Session expired', + }) + ) + }) + + it('shows an empty state instead of a blank table when no bills are returned', async () => { + jest.mocked(global.fetch) + .mockResolvedValueOnce(createFetchResponse(200)) + .mockResolvedValueOnce( + createFetchResponse(200, createBillsResponse({ bills: [], stats: createAdminStats({ totalBills: 0, activeBills: 0, sharedBills: 0, totalItems: 0, totalPeople: 0, totalMoneyProcessed: 0, averageBillValue: 0, medianBillValue: 0, largestBill: 0, smallestBill: 0, subtotalRevenue: 0, averageBillSize: 0, averageAccessCount: 0, completionRate: 0, shareRate: 0, averageItemsPerBill: 0, averagePeoplePerBill: 0, completedBills: 0 }), pagination: { totalPages: 0 } })) + ) + + render() + + expect(await screen.findByText(/no bills found/i)).toBeInTheDocument() + expect(screen.getByText(/try changing your search or filters/i)).toBeInTheDocument() + expect(screen.getByText('Page 1 of 1')).toBeInTheDocument() + }) + + it('requests a supported backend sort key when sorting by total amount', async () => { + const user = userEvent.setup() + + jest.mocked(global.fetch) + .mockResolvedValueOnce(createFetchResponse(200)) + .mockResolvedValueOnce(createFetchResponse(200, createBillsResponse())) + .mockResolvedValueOnce(createFetchResponse(200, createBillsResponse())) + + render() + + await screen.findByText('Bills Command Center') + + await user.selectOptions(screen.getByLabelText(/sort by/i), 'total') + + await waitFor(() => { + const lastCallUrl = jest.mocked(global.fetch).mock.calls.at(-1)?.[0] + expect(lastCallUrl).toEqual(expect.stringContaining('sortBy=total')) + }) + }) +}) diff --git a/app/admin/page.tsx b/app/admin/page.tsx index c534125..deb8658 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -87,11 +87,16 @@ interface AdminBillsResponse { } } +interface ApiErrorResponse { + error?: string +} + export default function AdminPage() { const router = useRouter() const { toast } = useToast() const [isAuthenticated, setIsAuthenticated] = useState(false) const [isLoading, setIsLoading] = useState(true) + const [isFetching, setIsFetching] = useState(false) const [password, setPassword] = useState('') const [bills, setBills] = useState([]) const [stats, setStats] = useState(null) @@ -108,6 +113,37 @@ export default function AdminPage() { const fetchDebounceRef = useRef(null) const fetchAbortRef = useRef(null) + const readErrorMessage = async (response: Response, fallback: string) => { + try { + const data = await response.json() as ApiErrorResponse + return data.error || fallback + } catch { + return fallback + } + } + + const handleUnauthorized = () => { + if (!isAuthenticated) return + + if (fetchAbortRef.current) { + fetchAbortRef.current.abort() + fetchAbortRef.current = null + } + + setIsAuthenticated(false) + setBills([]) + setStats(null) + setSelectedBill(null) + setShowBillDialog(false) + setShowDeleteDialog(false) + setBillToDelete(null) + toast({ + title: 'Session expired', + description: 'Please log in again to continue managing bills.', + variant: 'destructive' + }) + } + useEffect(() => { checkAuth() }, []) @@ -131,6 +167,17 @@ export default function AdminPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isAuthenticated, currentPage, searchQuery, statusFilter, sortBy, sortOrder]) + useEffect(() => { + return () => { + if (fetchAbortRef.current) { + fetchAbortRef.current.abort() + } + if (fetchDebounceRef.current) { + window.clearTimeout(fetchDebounceRef.current) + } + } + }, []) + const checkAuth = async () => { try { const response = await fetch('/api/admin/bills?limit=1') @@ -190,6 +237,7 @@ export default function AdminPage() { const fetchBills = async () => { try { + setIsFetching(true) if (fetchAbortRef.current) { fetchAbortRef.current.abort() } @@ -209,12 +257,27 @@ export default function AdminPage() { signal: controller.signal }) - if (response.ok) { - const data = await response.json() as AdminBillsResponse - setBills(data.bills) - setStats(data.stats) - setTotalPages(data.pagination.totalPages) + if (response.status === 401) { + handleUnauthorized() + return + } + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Failed to fetch bills')) + } + + const data = await response.json() as AdminBillsResponse + const safeTotalPages = Math.max(1, data.pagination.totalPages || 1) + + if (currentPage > safeTotalPages) { + setTotalPages(safeTotalPages) + setCurrentPage(safeTotalPages) + return } + + setBills(data.bills) + setStats(data.stats) + setTotalPages(safeTotalPages) } catch (error) { if (error instanceof DOMException && error.name === 'AbortError') { return @@ -222,9 +285,12 @@ export default function AdminPage() { console.error('Error fetching bills:', error) toast({ title: 'Error', - description: 'Failed to fetch bills', + description: error instanceof Error ? error.message : 'Failed to fetch bills', variant: 'destructive' }) + } finally { + setIsFetching(false) + fetchAbortRef.current = null } } @@ -234,25 +300,30 @@ export default function AdminPage() { method: 'DELETE' }) - if (response.ok) { - toast({ - title: 'Success', - description: 'Bill deleted successfully' - }) - fetchBills() - } else { - throw new Error('Failed to delete bill') + if (response.status === 401) { + handleUnauthorized() + return } + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Failed to delete bill')) + } + + toast({ + title: 'Success', + description: 'Bill deleted successfully' + }) + await fetchBills() } catch (error) { toast({ title: 'Error', - description: 'Failed to delete bill', + description: error instanceof Error ? error.message : 'Failed to delete bill', variant: 'destructive' }) + } finally { + setShowDeleteDialog(false) + setBillToDelete(null) } - - setShowDeleteDialog(false) - setBillToDelete(null) } const handleExtendBill = async (billId: string, days: number = 30) => { @@ -263,19 +334,24 @@ export default function AdminPage() { body: JSON.stringify({ days }) }) - if (response.ok) { - toast({ - title: 'Success', - description: `Bill expiration extended by ${days} days` - }) - fetchBills() - } else { - throw new Error('Failed to extend bill') + if (response.status === 401) { + handleUnauthorized() + return } + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Failed to extend bill expiration')) + } + + toast({ + title: 'Success', + description: `Bill expiration extended by ${days} days` + }) + await fetchBills() } catch (error) { toast({ title: 'Error', - description: 'Failed to extend bill expiration', + description: error instanceof Error ? error.message : 'Failed to extend bill expiration', variant: 'destructive' }) } @@ -285,39 +361,52 @@ export default function AdminPage() { try { const response = await fetch(`/api/admin/export?format=${format}`) - if (response.ok) { - const blob = await response.blob() - const url = window.URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = `bills_export_${new Date().toISOString()}.${format}` - document.body.appendChild(a) - a.click() - window.URL.revokeObjectURL(url) - document.body.removeChild(a) + if (response.status === 401) { + handleUnauthorized() + return + } - toast({ - title: 'Success', - description: 'Bills exported successfully' - }) - } else { - throw new Error('Failed to export bills') + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Failed to export bills')) } + + const blob = await response.blob() + const url = window.URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `bills_export_${new Date().toISOString()}.${format}` + document.body.appendChild(a) + a.click() + window.URL.revokeObjectURL(url) + document.body.removeChild(a) + + toast({ + title: 'Success', + description: 'Bills exported successfully' + }) } catch (error) { toast({ title: 'Error', - description: 'Failed to export bills', + description: error instanceof Error ? error.message : 'Failed to export bills', variant: 'destructive' }) } } - const copyToClipboard = (text: string) => { - navigator.clipboard.writeText(text) - toast({ - title: 'Copied', - description: 'Share URL copied to clipboard' - }) + const copyToClipboard = async (text: string) => { + try { + await navigator.clipboard.writeText(text) + toast({ + title: 'Copied', + description: 'Share URL copied to clipboard' + }) + } catch { + toast({ + title: 'Clipboard blocked', + description: 'Could not copy the share URL. Please copy it manually.', + variant: 'destructive' + }) + } } const formatBytes = (bytes: number) => { @@ -327,7 +416,8 @@ export default function AdminPage() { } const formatDate = (dateString: string) => { - return new Date(dateString).toLocaleString() + const date = new Date(dateString) + return Number.isNaN(date.getTime()) ? 'Unknown' : date.toLocaleString() } const getStatusBadge = (status: string) => { @@ -344,6 +434,10 @@ export default function AdminPage() { ) } + const safeBillsTotal = Math.max(stats?.totalBills || 0, 1) + const activeBillsShare = stats ? Math.min(100, (stats.activeBills / safeBillsTotal) * 100) : 0 + const sharedBillsShare = stats?.totalBills ? Math.round((stats.sharedBills / stats.totalBills) * 100) : 0 + if (isLoading) { return (
@@ -666,7 +760,7 @@ export default function AdminPage() {
of {stats.totalBills} total @@ -691,7 +785,7 @@ export default function AdminPage() {
{stats.sharedBills}

- {Math.round((stats.sharedBills / stats.totalBills) * 100)}% collaboration rate + {sharedBillsShare}% collaboration rate

@@ -726,10 +820,11 @@ export default function AdminPage() { onClick={() => fetchBills()} variant="outline" size="sm" + disabled={isFetching} className="gap-1 btn-smooth border-slate-200 hover:border-slate-300" > - - Sync + + {isFetching ? 'Syncing…' : 'Sync'}
@@ -764,7 +859,10 @@ export default function AdminPage() { value={searchQuery} name="search-bills" autoComplete="off" - onChange={(e) => setSearchQuery(e.target.value)} + onChange={(e) => { + setSearchQuery(e.target.value) + setCurrentPage(1) + }} aria-label="Search bills" className="w-full pl-10 pr-4 py-2 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" /> @@ -773,7 +871,10 @@ export default function AdminPage() {
setSortBy(e.target.value)} + onChange={(e) => { + setSortBy(e.target.value) + setCurrentPage(1) + }} aria-label="Sort by" className="px-3 py-2 bg-white border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition-colors" > - - + + + + + )} - > - - {config.label} ) } diff --git a/components/BillStartOptions.tsx b/components/BillStartOptions.tsx new file mode 100644 index 0000000..a8e6632 --- /dev/null +++ b/components/BillStartOptions.tsx @@ -0,0 +1,612 @@ +"use client" + +import { useMemo, useState } from "react" +import { Camera, FileText, PencilLine, ArrowRight, Calculator, Percent, Scale, DollarSign, Plus, X } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { ReceiptScanner } from "@/components/ReceiptScanner" +import { buildManualItemizedBill, buildQuickSplitBill, type QuickSplitMode } from "@/lib/bill-start" +import { useBill } from "@/contexts/BillContext" +import type { Item, ReceiptLineItem } from "@/lib/bill-types" +import { useToast } from "@/hooks/use-toast" +import { useBillAnalytics } from "@/hooks/use-analytics" +import { cn } from "@/lib/utils" + +interface BillStartOptionsProps { + className?: string + compact?: boolean + layout?: "sidebar" | "grid" +} + +interface QuickSplitParticipantDraft { + id: string + name: string + shares: string + exactAmount: string +} + +function createParticipantDraft(index: number): QuickSplitParticipantDraft { + return { + id: `participant-${Date.now()}-${index}-${Math.random().toString(36).slice(2, 6)}`, + name: "", + shares: "1", + exactAmount: "", + } +} + +function parseCurrencyValue(value: string) { + const trimmed = value.trim() + if (!trimmed) return 0 + const parsed = Number.parseFloat(trimmed) + return Number.isFinite(parsed) ? parsed : NaN +} + +export function BillStartOptions({ className, compact = false, layout = "sidebar" }: BillStartOptionsProps) { + const { state, dispatch } = useBill() + const { toast } = useToast() + const analytics = useBillAnalytics() + const [manualOpen, setManualOpen] = useState(false) + const [manualMode, setManualMode] = useState<"menu" | "quick">("menu") + const [manualTitle, setManualTitle] = useState("Manual Split") + const [quickTitle, setQuickTitle] = useState("Quick Split") + const [quickAmount, setQuickAmount] = useState("") + const [quickTax, setQuickTax] = useState("") + const [quickTip, setQuickTip] = useState("") + const [quickDiscount, setQuickDiscount] = useState("") + const [quickSplitMode, setQuickSplitMode] = useState("even") + const [participants, setParticipants] = useState([ + createParticipantDraft(0), + createParticipantDraft(1), + ]) + + const handleScanImport = (items: ReceiptLineItem[]) => { + items.forEach((item) => { + const newItem: Omit = { + ...item, + splitWith: state.currentBill.people.map((person) => person.id), + method: "even", + } + dispatch({ type: "ADD_ITEM", payload: newItem }) + }) + + analytics.trackFeatureUsed("start_flow_receipt_import", { count: items.length }) + } + + const resetManualFlow = () => { + setManualMode("menu") + setManualTitle("Manual Split") + setQuickTitle("Quick Split") + setQuickAmount("") + setQuickTax("") + setQuickTip("") + setQuickDiscount("") + setQuickSplitMode("even") + setParticipants([createParticipantDraft(0), createParticipantDraft(1)]) + } + + const namedParticipants = useMemo( + () => participants.filter((participant) => participant.name.trim().length > 0), + [participants] + ) + + const quickAmountValue = parseCurrencyValue(quickAmount) + const quickTaxValue = parseCurrencyValue(quickTax) + const quickTipValue = parseCurrencyValue(quickTip) + const quickDiscountValue = parseCurrencyValue(quickDiscount) + + const exactMismatch = useMemo(() => { + if (quickSplitMode !== "exact" || !Number.isFinite(quickAmountValue)) { + return null + } + + const exactTotal = namedParticipants.reduce((sum, participant) => { + const amount = parseCurrencyValue(participant.exactAmount) + return sum + (Number.isFinite(amount) ? amount : 0) + }, 0) + + const difference = Math.round((exactTotal - quickAmountValue) * 100) / 100 + return Math.abs(difference) <= 0.01 ? null : difference + }, [namedParticipants, quickAmountValue, quickSplitMode]) + + const quickSplitError = useMemo(() => { + if (namedParticipants.length < 2) { + return "Add at least 2 people for a quick split." + } + + if (!quickAmount.trim()) { + return "Enter the amount you want to split." + } + + if (!Number.isFinite(quickAmountValue) || quickAmountValue <= 0) { + return "Enter a valid amount greater than 0." + } + + if ([quickTaxValue, quickTipValue, quickDiscountValue].some((value) => Number.isNaN(value))) { + return "Use valid numbers for tax, tip, and discount." + } + + if (quickSplitMode === "shares") { + const hasInvalidShare = namedParticipants.some((participant) => { + const shareValue = parseCurrencyValue(participant.shares) + return !Number.isFinite(shareValue) || shareValue <= 0 + }) + + if (hasInvalidShare) { + return "Every person needs a positive share value." + } + } + + if (quickSplitMode === "exact") { + const hasInvalidAmount = namedParticipants.some((participant) => { + const exactAmount = parseCurrencyValue(participant.exactAmount) + return !Number.isFinite(exactAmount) || exactAmount < 0 + }) + + if (hasInvalidAmount) { + return "Every person needs a valid exact amount." + } + + if (exactMismatch !== null) { + return "Exact amounts must add up to the amount being split." + } + } + + return null + }, [ + exactMismatch, + namedParticipants, + quickAmount, + quickAmountValue, + quickDiscountValue, + quickSplitMode, + quickTaxValue, + quickTipValue, + ]) + + const startManualItemized = () => { + dispatch({ + type: "LOAD_BILL", + payload: { + bill: buildManualItemizedBill(manualTitle), + source: "draft", + }, + }) + analytics.trackFeatureUsed("start_manual_itemized") + toast({ + title: "Manual bill ready", + description: "We added a starter person and 3 blank rows so you can type right away.", + variant: "success", + }) + setManualOpen(false) + resetManualFlow() + } + + const startQuickSplit = () => { + if (quickSplitError) return + + dispatch({ + type: "LOAD_BILL", + payload: { + bill: buildQuickSplitBill({ + title: quickTitle, + amount: quickAmountValue, + tax: quickTaxValue, + tip: quickTipValue, + discount: quickDiscountValue, + splitMode: quickSplitMode, + allocation: "proportional", + participants: namedParticipants.map((participant) => ({ + name: participant.name, + shares: parseCurrencyValue(participant.shares), + exactAmount: parseCurrencyValue(participant.exactAmount), + })), + }), + source: "draft", + }, + }) + + analytics.trackFeatureUsed("start_quick_split", { + split_mode: quickSplitMode, + people_count: namedParticipants.length, + has_adjustments: Boolean(quickTax.trim() || quickTip.trim() || quickDiscount.trim()), + }) + toast({ + title: "Quick split ready", + description: "You can review the totals immediately or itemize later if the group wants more precision.", + variant: "success", + }) + setManualOpen(false) + resetManualFlow() + } + + const updateParticipant = (id: string, field: keyof QuickSplitParticipantDraft, value: string) => { + setParticipants((current) => + current.map((participant) => (participant.id === id ? { ...participant, [field]: value } : participant)) + ) + } + + const addParticipant = () => { + setParticipants((current) => [...current, createParticipantDraft(current.length)]) + } + + const removeParticipant = (id: string) => { + setParticipants((current) => (current.length > 2 ? current.filter((participant) => participant.id !== id) : current)) + } + + return ( + <> +
+

+ No receipt needed. Start with a photo, copied order text, or a manual split. +

+ +
+ + + + )} + /> + + + + + )} + /> + + +
+
+ + { + setManualOpen(open) + if (!open) { + resetManualFlow() + } + }} + > + + {manualMode === "menu" ? ( + <> + + Enter Manually + + Choose the fastest path for the information you have right now. + + + +
+ + +
+
+ +
+
+
+
Itemized bill
+

+ Start with blank rows if you want fairness by item from the beginning. +

+
+ +
+ + setManualTitle(event.target.value)} + placeholder="Manual Split" + /> +
+ + +
+
+
+ + ) : ( + <> + + Quick Split Total + + This creates one shared line item now, and you can itemize it later if the group wants more detail. + + + +
+
+
+ + setQuickTitle(event.target.value)} + placeholder="Quick Split" + /> +
+ +
+ + setQuickAmount(event.target.value)} + placeholder="86.42" + /> +

+ If this already includes tax and tip, leave the fields below blank. +

+
+
+ +
+
+ + setQuickTax(event.target.value)} + placeholder="0.00" + /> +
+
+ + setQuickTip(event.target.value)} + placeholder="0.00" + /> +
+
+ + setQuickDiscount(event.target.value)} + placeholder="0.00" + /> +
+
+ +
+ + +
+ +
+
+
+

People

+

+ Add everyone involved in the split. +

+
+ +
+ +
+ {participants.map((participant, index) => ( +
+
+
+ + updateParticipant(participant.id, "name", event.target.value)} + placeholder={`Person ${index + 1}`} + /> +
+ + {participants.length > 2 && ( + + )} +
+ + {quickSplitMode === "shares" && ( +
+ + updateParticipant(participant.id, "shares", event.target.value)} + placeholder="1" + /> +
+ )} + + {quickSplitMode === "exact" && ( +
+ + updateParticipant(participant.id, "exactAmount", event.target.value)} + placeholder="0.00" + /> +
+ )} +
+ ))} +
+
+ + {quickSplitError && ( +
+ {quickSplitError} +
+ )} + +
+ + +
+
+ + )} +
+
+ + ) +} + +function StartOptionButton({ + icon: Icon, + title, + description, + layout, +}: { + icon: typeof Camera + title: string + description: string + layout: "sidebar" | "grid" +}) { + const isSidebar = layout === "sidebar" + + return ( + + + + + + {isSidebar ? ( + + + {title} + {description} + + + ) : ( + <> + {title} + {description} + + )} + + ) +} diff --git a/components/ProBillSplitter.tsx b/components/ProBillSplitter.tsx index 73c51f8..b329c5f 100644 --- a/components/ProBillSplitter.tsx +++ b/components/ProBillSplitter.tsx @@ -38,6 +38,7 @@ import { useIsMobile } from '@/hooks/use-mobile' import { MobileSpreadsheetView } from '@/components/MobileSpreadsheetView' import { AnimatedNumber } from '@/components/AnimatedNumber' import { SplitSimpleIcon } from '@/components/SplitSimpleIcon' +import { BillStartOptions } from '@/components/BillStartOptions' import { ToastAction } from '@/components/ui/toast' import { getSplitMethodOption, splitMethodOptions } from '@/components/split-method-options' @@ -68,6 +69,7 @@ import { import { Dialog, DialogContent, + DialogDescription, DialogHeader, DialogTitle, } from '@/components/ui/dialog' @@ -835,6 +837,14 @@ function DesktopBillSplitter() { } } + // A modal/dialog (Edit Member, New Bill, Share, Delete…) owns the keyboard while open. + // Its inputs live in a portal; when focus sits on the dialog container or a button + // inside it, isInInput is briefly false. Without this guard a printable key falls + // through to type-to-edit and silently edits the selected ledger cell behind the modal. + if (document.querySelector('[role="dialog"][data-state="open"], [role="alertdialog"][data-state="open"]')) { + return + } + // If currently editing a cell input, let typing happen but keep spreadsheet commits if (hotkeyState.editing && isInInput) { if (e.key === 'Enter') { @@ -865,62 +875,65 @@ function DesktopBillSplitter() { return } + // Ignore spreadsheet hotkeys while focus is in other form controls like the bill title. + if (isInInput) { + return + } + // Global shortcuts (only when not typing in other inputs) - if (!isInInput) { - if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) { - e.preventDefault() - hotkeyActions.dispatchUndo() - hotkeyActions.toastUndo() - return - } + if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) { + e.preventDefault() + hotkeyActions.dispatchUndo() + hotkeyActions.toastUndo() + return + } - if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'z') { - e.preventDefault() - hotkeyActions.dispatchRedo() - hotkeyActions.toastRedo() - return - } + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'z') { + e.preventDefault() + hotkeyActions.dispatchRedo() + hotkeyActions.toastRedo() + return + } - // Cmd+N: New bill - if ((e.metaKey || e.ctrlKey) && e.key === 'n') { - e.preventDefault() - newBillSourceRef.current = "shortcut" - setIsNewBillDialogOpen(true) - return - } + // Cmd+N: New bill + if ((e.metaKey || e.ctrlKey) && e.key === 'n') { + e.preventDefault() + newBillSourceRef.current = "shortcut" + setIsNewBillDialogOpen(true) + return + } - // Cmd+Shift+N: Add new item - if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'N') { - e.preventDefault() - hotkeyActions.addItem() - analytics.trackFeatureUsed("keyboard_shortcut_add_item") - return - } + // Cmd+Shift+N: Add new item + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'N') { + e.preventDefault() + hotkeyActions.addItem() + analytics.trackFeatureUsed("keyboard_shortcut_add_item") + return + } - // Cmd+Shift+P: Add person - if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'P') { - e.preventDefault() - hotkeyActions.addPerson() - analytics.trackFeatureUsed("keyboard_shortcut_add_person") - return - } + // Cmd+Shift+P: Add person + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'P') { + e.preventDefault() + hotkeyActions.addPerson() + analytics.trackFeatureUsed("keyboard_shortcut_add_person") + return + } - // Cmd+Shift+C: Copy summary - if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'C') { - e.preventDefault() - hotkeyActions.copyBreakdown() - analytics.trackFeatureUsed("keyboard_shortcut_copy") - return - } + // Cmd+Shift+C: Copy summary + if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'C') { + e.preventDefault() + hotkeyActions.copyBreakdown() + analytics.trackFeatureUsed("keyboard_shortcut_copy") + return + } - // Cmd+S: Share - if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key === 's') { - e.preventDefault() - const shareButton = document.querySelector('[data-share-trigger]') as HTMLButtonElement - if (shareButton) shareButton.click() - analytics.trackFeatureUsed("keyboard_shortcut_share") - return - } + // Cmd+S: Share + if ((e.metaKey || e.ctrlKey) && !e.shiftKey && e.key === 's') { + e.preventDefault() + const shareButton = document.querySelector('[data-share-trigger]') as HTMLButtonElement + if (shareButton) shareButton.click() + analytics.trackFeatureUsed("keyboard_shortcut_share") + return } // Grid navigation - Excel-like behavior @@ -1054,11 +1067,11 @@ function DesktopBillSplitter() { previousItemsLengthRef.current = items.length if (activeView !== 'ledger') return - if (prevLen === 0 && items.length === 1) { + if (prevLen === 0 && items.length === 1 && !hasMeaningfulItems) { setSelectedCell({ row: 0, col: 'name' }) setEditing(true) } - }, [activeView, items.length]) + }, [activeView, hasMeaningfulItems, items.length]) useEffect(() => { if (editing && editInputRef.current) { @@ -1087,6 +1100,9 @@ function DesktopBillSplitter() { dispatch({ type: 'SET_BILL_TITLE', payload: e.target.value }) analytics.trackTitleChanged(e.target.value) }} + onKeyDown={(e) => { + e.stopPropagation() + }} style={{ width: `${Math.min(Math.max((title || '').length || 7, 7), 26)}ch`, }} @@ -1713,62 +1729,42 @@ function DesktopBillSplitter() {
-
- {people.length === 0 ? ( - - ) : ( - - )} - {people.length === 0 ? ( - - ) : ( +
+ + +
+ {people.length === 0 ? ( + + ) : ( + + )} - )} - - Scan receipt to import items - - )} - /> +
)} @@ -2185,6 +2181,7 @@ function DesktopBillSplitter() { Edit Member + Update this person's display name and color. {editingPerson && (
diff --git a/components/ReceiptScanner.tsx b/components/ReceiptScanner.tsx index 80f7707..a3b06e3 100644 --- a/components/ReceiptScanner.tsx +++ b/components/ReceiptScanner.tsx @@ -36,9 +36,10 @@ interface ScanError { interface ReceiptScannerProps { onImport: (items: ReceiptLineItem[]) => void trigger?: React.ReactNode + initialTab?: "image" | "text" } -export function ReceiptScanner({ onImport, trigger }: ReceiptScannerProps) { +export function ReceiptScanner({ onImport, trigger, initialTab = "image" }: ReceiptScannerProps) { const [isOpen, setIsOpen] = useState(false) const [state, setState] = useState('idle') const [receiptImage, setReceiptImage] = useState(null) @@ -46,6 +47,7 @@ export function ReceiptScanner({ onImport, trigger }: ReceiptScannerProps) { const [zoom, setZoom] = useState(1) const [rotation, setRotate] = useState(0) const [error, setError] = useState(null) + const [activeTab, setActiveTab] = useState<"image" | "text">(initialTab) const { toast } = useToast() const handleReset = useCallback(() => { @@ -55,10 +57,14 @@ export function ReceiptScanner({ onImport, trigger }: ReceiptScannerProps) { setZoom(1) setRotate(0) setError(null) - }, []) + setActiveTab(initialTab) + }, [initialTab]) const handleOpenChange = (open: boolean) => { setIsOpen(open) + if (open) { + setActiveTab(initialTab) + } if (!open) { setTimeout(handleReset, 300) // Reset after animation } @@ -195,10 +201,12 @@ export function ReceiptScanner({ onImport, trigger }: ReceiptScannerProps) { )}> {state === 'idle' && ( setError(null)} + onTabChange={setActiveTab} /> )} @@ -227,15 +235,19 @@ export function ReceiptScanner({ onImport, trigger }: ReceiptScannerProps) { // --- Sub-Components --- function UploadView({ + activeTab, onUpload, onPaste, error, - onDismissError + onDismissError, + onTabChange, }: { + activeTab: "image" | "text" onUpload: (file: File) => void onPaste: (text: string) => void error: ScanError | null onDismissError: () => void + onTabChange: (value: "image" | "text") => void }) { const fileInputRef = useRef(null) const [dragActive, setDragActive] = useState(false) @@ -262,11 +274,11 @@ function UploadView({ return (
- - Add Receipt - + + Add Items + - + onTabChange(value as "image" | "text")} className="flex-1 flex flex-col">
Upload Image diff --git a/components/ShareBill.tsx b/components/ShareBill.tsx index 6f360cf..9cd65b6 100644 --- a/components/ShareBill.tsx +++ b/components/ShareBill.tsx @@ -30,6 +30,8 @@ export function ShareBill({ variant = "outline", size = "sm", showText = true, i const [isStoring, setIsStoring] = useState(false) const [shareUrl, setShareUrl] = useState("") const [storeError, setStoreError] = useState(null) + const isSharedCopy = state.billSource === "shared_copy" + const shareTriggerLabel = isSharedCopy ? "Share updated copy" : "Share" // Generate share URL when dialog opens const handleOpenDialog = async (open: boolean) => { @@ -158,20 +160,23 @@ export function ShareBill({ variant = "outline", size = "sm", showText = true, i id={id ?? "share-bill-trigger"} // Allow external triggering via document.getElementById() variant={variant} size={size} + aria-label={shareTriggerLabel} className={showText ? "flex items-center gap-1.5 btn-smooth" : "dock-item p-0 h-auto bg-transparent border-0 hover:bg-primary/10"} > - {showText && Share} + {showText && {shareTriggerLabel}} - Share & Export "{state.currentBill.title}" + {isSharedCopy ? `Share updated copy "${state.currentBill.title}"` : `Share & Export "${state.currentBill.title}"`} - Share a link with anyone or export your bill data. Bills are stored securely and expire after 6 months. + {isSharedCopy + ? "This creates a new link for your edited copy. The original shared bill stays unchanged." + : "Share a link with anyone or export your bill data. Bills are stored securely and expire after 1 year."} @@ -245,7 +250,7 @@ export function ShareBill({ variant = "outline", size = "sm", showText = true, i - ✅ Universal sharing: This link works for anyone and auto-deletes after 6 months. + ✅ Universal sharing: This link works for anyone and auto-deletes after 1 year. Bills are stored securely in the cloud. diff --git a/components/__tests__/ProBillSplitterTitleInput.test.tsx b/components/__tests__/ProBillSplitterTitleInput.test.tsx new file mode 100644 index 0000000..1c77b79 --- /dev/null +++ b/components/__tests__/ProBillSplitterTitleInput.test.tsx @@ -0,0 +1,64 @@ +import userEvent from '@testing-library/user-event' +import { render, screen, waitFor } from '@/tests/utils/test-utils' +import { ProBillSplitter } from '@/components/ProBillSplitter' +import { createMockBill, createMockItem } from '@/tests/utils/test-utils' +import { useBill } from '@/contexts/BillContext' + +function BillStateProbe() { + const { state } = useBill() + + return ( +
+ {state.currentBill.title} + {state.currentBill.items[0]?.name ?? ''} +
+ ) +} + +describe('ProBillSplitter bill title input', () => { + it('does not start editing the selected ledger cell while typing in the bill title', async () => { + const user = userEvent.setup() + + const bill = createMockBill({ + id: 'bill-title-bug', + title: 'Dinner', + items: [ + createMockItem({ + id: 'item-1', + name: 'Pad Thai', + }), + ], + }) + + window.localStorage.getItem = jest.fn((key: string) => { + if (key === 'splitSimple_currentBill') { + return JSON.stringify(bill) + } + + return null + }) + + render( + <> + + + + ) + + const titleInput = await screen.findByRole('textbox', { name: /bill title/i }) + + await waitFor(() => { + expect(titleInput).toHaveValue('Dinner') + expect(screen.getByTestId('probe-title')).toHaveTextContent('Dinner') + expect(screen.getByTestId('probe-first-item-name')).toHaveTextContent('Pad Thai') + }) + + await user.click(titleInput) + await user.type(titleInput, 'X') + + expect(titleInput).toHaveValue('DinnerX') + expect(screen.getByTestId('probe-title')).toHaveTextContent('DinnerX') + expect(screen.getByTestId('probe-first-item-name')).toHaveTextContent('Pad Thai') + expect(screen.queryByLabelText(/edit name/i)).not.toBeInTheDocument() + }) +}) diff --git a/components/__tests__/SharedBillUi.test.tsx b/components/__tests__/SharedBillUi.test.tsx new file mode 100644 index 0000000..1ac31e2 --- /dev/null +++ b/components/__tests__/SharedBillUi.test.tsx @@ -0,0 +1,140 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { BillSourceIndicator } from '@/components/BillSourceIndicator' +import { ShareBill } from '@/components/ShareBill' +import { createMockBill } from '@/tests/utils/test-utils' +import { useBill } from '@/contexts/BillContext' +import { useRouter } from 'next/navigation' +import { storeBillInCloud, generateCloudShareUrl } from '@/lib/sharing' + +jest.mock('@/contexts/BillContext', () => ({ + useBill: jest.fn(), +})) + +const mockPush = jest.fn() + +jest.mock('next/navigation', () => ({ + useRouter: jest.fn(), +})) + +jest.mock('@/hooks/use-toast', () => ({ + useToast: () => ({ + toast: jest.fn(), + }), +})) + +jest.mock('@/hooks/use-analytics', () => ({ + useBillAnalytics: () => ({ + trackShareBillClicked: jest.fn(), + trackFeatureUsed: jest.fn(), + trackError: jest.fn(), + }), +})) + +jest.mock('@/lib/sharing', () => { + const actual = jest.requireActual('@/lib/sharing') + + return { + ...actual, + storeBillInCloud: jest.fn(), + generateCloudShareUrl: jest.fn(), + } +}) + +const mockedUseBill = jest.mocked(useBill) +const mockedUseRouter = jest.mocked(useRouter) +const mockedStoreBillInCloud = jest.mocked(storeBillInCloud) +const mockedGenerateCloudShareUrl = jest.mocked(generateCloudShareUrl) + +function createMockRouter() { + return { + push: mockPush, + back: jest.fn(), + forward: jest.fn(), + refresh: jest.fn(), + replace: jest.fn(), + prefetch: jest.fn(), + } +} + +function mockBillState(overrides: Partial> = {}, billSource: 'draft' | 'shared' | 'shared_copy' = 'draft', sharedOriginBillId: string | null = null) { + mockedUseBill.mockReturnValue({ + state: { + currentBill: createMockBill(overrides), + billSource, + sharedOriginBillId, + syncStatus: 'never_synced', + lastSyncTime: null, + history: [], + historyIndex: -1, + maxHistorySize: 50, + }, + dispatch: jest.fn(), + canUndo: false, + canRedo: false, + syncToCloud: jest.fn(), + }) +} + +describe('BillSourceIndicator', () => { + beforeEach(() => { + mockPush.mockReset() + mockedUseRouter.mockReturnValue(createMockRouter() as unknown as ReturnType) + }) + + it('shows a forked shared-bill label and reload action for local copies', () => { + mockBillState({ id: 'forked-bill-id' }, 'shared_copy', 'shared-bill-id') + + render() + + expect(screen.getByText('Forked from shared bill')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /reload original/i })).toBeInTheDocument() + }) + + it('reopens the original shared bill from a forked copy', async () => { + const user = userEvent.setup() + mockBillState({ id: 'forked-bill-id' }, 'shared_copy', 'shared-bill-id') + + render() + + await user.click(screen.getByRole('button', { name: /reload original/i })) + + expect(mockPush).toHaveBeenCalledWith('/b/shared-bill-id', { scroll: false }) + }) + + it('keeps the shared bill indicator simple when viewing the original', () => { + mockBillState({ id: 'shared-bill-id' }, 'shared', 'shared-bill-id') + + render() + + expect(screen.getByText('Viewing shared bill')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /reload original/i })).not.toBeInTheDocument() + }) +}) + +describe('ShareBill', () => { + beforeEach(() => { + mockedUseRouter.mockReturnValue(createMockRouter() as unknown as ReturnType) + mockedStoreBillInCloud.mockResolvedValue({ success: true }) + mockedGenerateCloudShareUrl.mockImplementation((billId: string) => `https://splitsimple.app/b/${billId}`) + }) + + it('updates the trigger label for shared copies', () => { + mockBillState({ id: 'forked-bill-id', title: 'Edited Bill' }, 'shared_copy', 'shared-bill-id') + + render() + + expect(screen.getByRole('button', { name: /share updated copy/i })).toBeInTheDocument() + }) + + it('explains that sharing a fork keeps the original untouched', async () => { + const user = userEvent.setup() + mockBillState({ id: 'forked-bill-id', title: 'Edited Bill' }, 'shared_copy', 'shared-bill-id') + + render() + + await user.click(screen.getByRole('button', { name: /share updated copy/i })) + + expect(await screen.findByText(/original shared bill stays unchanged/i)).toBeInTheDocument() + }) +}) diff --git a/components/mobile/MobileCardView.tsx b/components/mobile/MobileCardView.tsx index eaca57f..52b1c3b 100644 --- a/components/mobile/MobileCardView.tsx +++ b/components/mobile/MobileCardView.tsx @@ -27,7 +27,7 @@ import { Edit2 } from "lucide-react" import { useBill } from "@/contexts/BillContext" -import type { Item, Person, ReceiptLineItem } from "@/lib/bill-types" +import type { Item, Person } from "@/lib/bill-types" import { calculateItemSplits, getBillSummary } from "@/lib/calculations" import { PersonSelector } from "@/components/PersonSelector" import { SplitMethodSelector } from "@/components/SplitMethodSelector" @@ -40,7 +40,7 @@ import { useBillAnalytics } from "@/hooks/use-analytics" import { SplitSimpleIcon } from "@/components/SplitSimpleIcon" import { BillLookup } from "@/components/BillLookup" import { ShareBill } from "@/components/ShareBill" -import { ReceiptScanner } from "@/components/ReceiptScanner" +import { BillStartOptions } from "@/components/BillStartOptions" import { cn } from "@/lib/utils" // Color palette for people @@ -114,19 +114,6 @@ export function MobileCardView() { } } - const handleScanImport = (scannedItems: ReceiptLineItem[]) => { - scannedItems.forEach((item) => { - const newItem: Omit = { - ...item, - splitWith: people.map((p) => p.id), - method: "even", - } - dispatch({ type: "ADD_ITEM", payload: newItem }) - }) - analytics.trackFeatureUsed("scan_receipt_import", { count: scannedItems.length }) - toast({ title: "Items added from scan" }) - } - const handleNewBill = () => { if (confirm("Start a new bill? Current bill will be lost if not shared.")) { dispatch({ type: "NEW_BILL" }) @@ -279,16 +266,13 @@ export function MobileCardView() { {items.length === 0 ? ( - +
📝

No items yet

-

Add your first item to start splitting

+

Start with a receipt, pasted text, or a manual split.

- +
) : ( diff --git a/lib/__tests__/bill-start.test.ts b/lib/__tests__/bill-start.test.ts new file mode 100644 index 0000000..bb8ce64 --- /dev/null +++ b/lib/__tests__/bill-start.test.ts @@ -0,0 +1,57 @@ +import { getBillSummary } from "@/lib/calculations" +import { buildManualItemizedBill, buildQuickSplitBill } from "@/lib/bill-start" + +describe("bill-start helpers", () => { + it("builds an even quick split bill that reconciles", () => { + const bill = buildQuickSplitBill({ + title: "Bar tab", + amount: 80, + tax: 6.4, + tip: 13.6, + participants: [ + { name: "Anurag" }, + { name: "Sunil" }, + { name: "Praks" }, + { name: "Shambhavi" }, + ], + }) + + const summary = getBillSummary(bill) + + expect(bill.items).toHaveLength(1) + expect(summary.subtotal).toBe(80) + expect(summary.tax).toBe(6.4) + expect(summary.tip).toBe(13.6) + expect(summary.total).toBe(100) + expect(summary.personTotals).toHaveLength(4) + expect(summary.personTotals.every((person) => person.total === 25)).toBe(true) + }) + + it("builds an exact quick split bill with person amounts", () => { + const bill = buildQuickSplitBill({ + title: "Taxi", + amount: 42, + splitMode: "exact", + participants: [ + { name: "Anurag", exactAmount: 12 }, + { name: "Sunil", exactAmount: 10 }, + { name: "Praks", exactAmount: 20 }, + ], + }) + + const summary = getBillSummary(bill) + + expect(summary.total).toBe(42) + expect(summary.personTotals.map((person) => person.total)).toEqual([12, 10, 20]) + }) + + it("builds a manual itemized draft with starter rows", () => { + const bill = buildManualItemizedBill("Groceries") + + expect(bill.title).toBe("Groceries") + expect(bill.people).toHaveLength(1) + expect(bill.items).toHaveLength(3) + expect(bill.items.every((item) => item.quantity === 1 && item.method === "even")).toBe(true) + expect(bill.items.every((item) => item.splitWith.length === 1)).toBe(true) + }) +}) diff --git a/lib/__tests__/constants.test.ts b/lib/__tests__/constants.test.ts new file mode 100644 index 0000000..d9e91c2 --- /dev/null +++ b/lib/__tests__/constants.test.ts @@ -0,0 +1,7 @@ +import { STORAGE } from '@/lib/constants' + +describe('shared bill storage constants', () => { + it('keeps shared bills available for one year by default', () => { + expect(STORAGE.BILL_TTL_SECONDS).toBe(365 * 24 * 60 * 60) + }) +}) diff --git a/lib/bill-start.ts b/lib/bill-start.ts new file mode 100644 index 0000000..06cf29e --- /dev/null +++ b/lib/bill-start.ts @@ -0,0 +1,127 @@ +import type { Bill, Item, Person } from "@/lib/bill-types" + +// Mirrors the canonical `COLORS` palette in ProBillSplitter (hex values, same order). +// `color` and `colorIdx` are both derived from this single source so the stored hex +// always matches the swatch the Pro view renders via COLORS[colorIdx]. +const PERSON_COLORS = [ + "#4F46E5", // indigo + "#F97316", // orange + "#F43F5E", // rose + "#10B981", // emerald + "#3B82F6", // blue + "#F59E0B", // amber +] + +function createId() { + return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}` +} + +function sanitizeAmount(value: number) { + return Math.max(0, Math.round(value * 100) / 100) +} + +function formatAmount(value: number) { + return sanitizeAmount(value).toFixed(2) +} + +export type QuickSplitMode = "even" | "shares" | "exact" + +export interface QuickSplitParticipantInput { + name: string + shares?: number + exactAmount?: number +} + +export interface QuickSplitDraftInput { + title: string + amount: number + tax?: number + tip?: number + discount?: number + allocation?: "proportional" | "even" + splitMode?: QuickSplitMode + participants: QuickSplitParticipantInput[] +} + +export function buildQuickSplitBill(input: QuickSplitDraftInput): Bill { + const splitMode = input.splitMode ?? "even" + const amount = sanitizeAmount(input.amount) + const tax = sanitizeAmount(input.tax ?? 0) + const tip = sanitizeAmount(input.tip ?? 0) + const discount = sanitizeAmount(input.discount ?? 0) + + const people: Person[] = input.participants.map((participant, index) => { + const colorIdx = index % PERSON_COLORS.length + return { + id: createId(), + name: participant.name.trim(), + color: PERSON_COLORS[colorIdx], + colorIdx, + } + }) + + const item: Item = { + id: createId(), + name: "Shared total", + price: formatAmount(amount), + quantity: 1, + splitWith: people.map((person) => person.id), + method: splitMode, + } + + if (splitMode === "shares") { + item.customSplits = Object.fromEntries( + people.map((person, index) => [person.id, input.participants[index]?.shares ?? 1]) + ) + } + + if (splitMode === "exact") { + item.customSplits = Object.fromEntries( + people.map((person, index) => [person.id, sanitizeAmount(input.participants[index]?.exactAmount ?? 0)]) + ) + } + + return { + id: createId(), + title: input.title.trim() || "Quick Split", + status: "active", + tax: tax > 0 ? formatAmount(tax) : "", + tip: tip > 0 ? formatAmount(tip) : "", + discount: discount > 0 ? formatAmount(discount) : "", + taxTipAllocation: input.allocation ?? "proportional", + notes: "", + people, + items: [item], + } +} + +export function buildManualItemizedBill(title = "Manual Split"): Bill { + const firstPerson: Person = { + id: createId(), + name: "Person 1", + color: PERSON_COLORS[0], + colorIdx: 0, + } + + const starterItems: Item[] = Array.from({ length: 3 }, () => ({ + id: createId(), + name: "", + price: "", + quantity: 1, + splitWith: [firstPerson.id], + method: "even" as const, + })) + + return { + id: createId(), + title: title.trim() || "Manual Split", + status: "active", + tax: "", + tip: "", + discount: "", + taxTipAllocation: "proportional", + notes: "", + people: [firstPerson], + items: starterItems, + } +} diff --git a/lib/constants.ts b/lib/constants.ts index f8dc4f9..6580bd8 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -23,5 +23,5 @@ export const TIMING = { // Shared bill storage constants export const STORAGE = { - BILL_TTL_SECONDS: 15552000, // ~6 months (6 * 30 days) + BILL_TTL_SECONDS: 31536000, // 1 year (365 days) } as const diff --git a/lib/sharing.ts b/lib/sharing.ts index 6e64a58..2c87cf2 100644 --- a/lib/sharing.ts +++ b/lib/sharing.ts @@ -147,7 +147,7 @@ export async function getBillFromCloud(billId: string): Promise if (!response.ok) { if (response.status === 404) { - return { error: "We couldn't find that bill. Bills expire after 6 months — ask the owner for a fresh link, or double-check the ID." } + return { error: "We couldn't find that bill. Bills expire after 1 year — ask the owner for a fresh link, or double-check the ID." } } let errorMessage = 'Something went wrong loading this bill. Please try again.' diff --git a/next.config.mjs b/next.config.mjs index 0a34819..8ed2ca0 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,5 +1,6 @@ /** @type {import('next').NextConfig} */ const nextConfig = { + allowedDevOrigins: ["127.0.0.1"], images: { unoptimized: true, },