diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx new file mode 100644 index 0000000..e69f834 --- /dev/null +++ b/app/admin/layout.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from "next" + +export const metadata: Metadata = { + title: "Admin - SplitSimple", + alternates: { + canonical: "/admin", + }, + robots: { + index: false, + follow: false, + }, +} + +export default function AdminLayout({ children }: { children: React.ReactNode }) { + return children +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx index cd3c4d2..794018e 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { useRouter } from 'next/navigation' import { Shield, @@ -141,14 +141,28 @@ export default function AdminPage() { const [showBillDialog, setShowBillDialog] = useState(false) const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [billToDelete, setBillToDelete] = useState(null) + const fetchDebounceRef = useRef(null) + const fetchAbortRef = useRef(null) useEffect(() => { checkAuth() }, []) useEffect(() => { - if (isAuthenticated) { + if (!isAuthenticated) return + + if (fetchDebounceRef.current) { + window.clearTimeout(fetchDebounceRef.current) + } + + fetchDebounceRef.current = window.setTimeout(() => { fetchBills() + }, 250) + + return () => { + if (fetchDebounceRef.current) { + window.clearTimeout(fetchDebounceRef.current) + } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isAuthenticated, currentPage, searchQuery, statusFilter, sortBy, sortOrder]) @@ -212,6 +226,12 @@ export default function AdminPage() { const fetchBills = async () => { try { + if (fetchAbortRef.current) { + fetchAbortRef.current.abort() + } + const controller = new AbortController() + fetchAbortRef.current = controller + const params = new URLSearchParams({ page: currentPage.toString(), limit: '20', @@ -221,7 +241,9 @@ export default function AdminPage() { sortOrder }) - const response = await fetch(`/api/admin/bills?${params}`) + const response = await fetch(`/api/admin/bills?${params}`, { + signal: controller.signal + }) if (response.ok) { const data = await response.json() @@ -230,6 +252,9 @@ export default function AdminPage() { setTotalPages(data.pagination.totalPages) } } catch (error) { + if (error instanceof DOMException && error.name === 'AbortError') { + return + } console.error('Error fetching bills:', error) toast({ title: 'Error', @@ -357,10 +382,10 @@ export default function AdminPage() { if (isLoading) { return ( -
+
-

Loading...

+

Loading…

) @@ -368,7 +393,7 @@ export default function AdminPage() { if (!isAuthenticated) { return ( -
+
@@ -389,12 +414,14 @@ export default function AdminPage() { type="password" placeholder="Enter admin password" value={password} + name="admin-password" + autoComplete="current-password" onChange={(e) => setPassword(e.target.value)} required />
@@ -405,7 +432,7 @@ export default function AdminPage() { } return ( -
+
{/* Clean, Minimal Header */}
@@ -536,7 +563,7 @@ export default function AdminPage() {
@@ -549,7 +576,7 @@ export default function AdminPage() {
@@ -674,7 +701,7 @@ export default function AdminPage() {
-
@@ -769,10 +796,13 @@ export default function AdminPage() { setSearchQuery(e.target.value)} - 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-all" + 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" />
@@ -780,7 +810,8 @@ export default function AdminPage() { setSortBy(e.target.value)} - 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-all" + 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" > @@ -872,6 +904,7 @@ export default function AdminPage() { setShowBillDialog(true) }} title="View details" + aria-label="View bill details" > @@ -880,6 +913,7 @@ export default function AdminPage() { size="icon" onClick={() => window.open(bill.shareUrl, '_blank')} title="Open in new tab" + aria-label="Open share link in new tab" > @@ -888,6 +922,7 @@ export default function AdminPage() { size="icon" onClick={() => copyToClipboard(bill.shareUrl)} title="Copy share link" + aria-label="Copy share link" > @@ -896,6 +931,7 @@ export default function AdminPage() { size="icon" onClick={() => handleExtendBill(bill.id)} title="Extend expiration" + aria-label="Extend bill expiration" > @@ -907,6 +943,7 @@ export default function AdminPage() { setShowDeleteDialog(true) }} title="Delete bill" + aria-label="Delete bill" > @@ -996,6 +1033,7 @@ export default function AdminPage() { variant="outline" size="icon" onClick={() => copyToClipboard(selectedBill.shareUrl)} + aria-label="Copy share URL" > @@ -1119,4 +1157,4 @@ export default function AdminPage() {
) -} \ No newline at end of file +} diff --git a/app/globals.css b/app/globals.css index 1c62062..b1f571e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -361,13 +361,13 @@ } .proportion-bar-fill { - @apply h-full transition-all duration-300; + @apply h-full transition-[width] duration-250 ease-out; background: currentColor; } .proportion-bar-segment { @apply h-full; - transition: width 0.3s var(--ease-smooth); + transition: width 0.25s var(--ease-out-cubic); } /* Simple block-based bars for inline display */ @@ -456,7 +456,7 @@ /* ===== Interactive Elements ===== */ .receipt-button { - @apply inline-flex items-center gap-2 px-3 py-2 border-2 border-border bg-card text-foreground hover:bg-muted/50 font-medium transition-all; + @apply inline-flex items-center gap-2 px-3 py-2 border-2 border-border bg-card text-foreground hover:bg-muted/50 font-medium transition-[transform,box-shadow,background-color,border-color,color]; box-shadow: var(--receipt-shadow); } @@ -471,7 +471,7 @@ } .receipt-input { - @apply border-2 border-border bg-card px-3 py-2 font-mono tabular-nums transition-all; + @apply border-2 border-border bg-card px-3 py-2 font-mono tabular-nums transition-[border-color,box-shadow,background-color]; font-family: var(--font-numbers); } @@ -502,7 +502,7 @@ /* ===== Clickable Element Enhancements ===== */ .clickable-badge { - @apply transition-all duration-150 ease-out cursor-pointer; + @apply transition-[transform,box-shadow] duration-150 ease-out cursor-pointer; } .clickable-badge:hover { @@ -520,7 +520,10 @@ select:focus { @apply outline-none ring-2 ring-primary ring-offset-2; background-color: rgba(var(--primary), 0.02); - transition: all 0.2s var(--ease-out-cubic); + transition: + box-shadow 0.2s var(--ease-out-cubic), + border-color 0.2s var(--ease-out-cubic), + background-color 0.2s var(--ease-out-cubic); } input::placeholder { @@ -536,11 +539,11 @@ /* Button press effect */ .btn-press { - @apply transition-transform duration-100; + @apply transition-transform duration-100 ease-out; } .btn-press:active { - transform: scale(0.98); + transform: scale(0.97); } /* Number change flash */ @@ -554,7 +557,7 @@ } .number-flash { - animation: number-flash 0.4s ease-out; + animation: number-flash 0.2s ease-out; } /* Success ripple effect */ @@ -568,7 +571,7 @@ } .success-ripple { - animation: success-ripple 0.6s ease-out; + animation: success-ripple 0.25s ease-out; } /* Delete slide-out */ @@ -600,7 +603,7 @@ } .add-slide-in { - animation: add-slide-in 0.3s var(--ease-spring); + animation: add-slide-in 0.25s var(--ease-spring); } /* Pulse animation for new inputs */ @@ -614,7 +617,7 @@ } .input-focus-hint { - animation: pulse-border 2s ease-in-out 3; + animation: pulse-border 0.3s ease-in-out 3; } /* Stagger slide-in animations */ @@ -630,11 +633,11 @@ } .animate-slide-in-1 { - animation: slide-in-up 0.4s ease-out 0.15s both; + animation: slide-in-up 0.25s ease-out 0.15s both; } .animate-slide-in-2 { - animation: slide-in-up 0.4s ease-out 0.3s both; + animation: slide-in-up 0.25s ease-out 0.3s both; } /* Person cell assignment animations */ @@ -714,7 +717,7 @@ } .ledger-row { - @apply border-b border-border transition-all duration-200; + @apply border-b border-border transition-[transform,box-shadow,background-color,border-color] duration-200; border-left: 3px solid transparent; transform-origin: center; } @@ -779,7 +782,7 @@ } .ledger-grid-row { - @apply border-b border-border hover:bg-muted/20 transition-colors duration-150; + @apply border-b border-border hover:bg-muted/20 transition-colors duration-150 ease-out; } /* Mini inline proportion bars for table cells */ @@ -801,7 +804,7 @@ /* Clickable person cell states */ .person-cell-assigned { - @apply transition-all duration-150; + @apply transition-[filter] duration-150 ease-out; } .person-cell-assigned:hover { @@ -809,7 +812,7 @@ } .person-cell-unassigned { - @apply bg-muted/40 transition-all duration-150; + @apply bg-muted/40 transition-colors duration-150 ease-out; } .person-cell-unassigned:hover { @@ -872,7 +875,7 @@ } .dock-item { - @apply rounded-lg p-2 transition-all duration-200 border-2 border-transparent; + @apply rounded-lg p-2 transition-[transform,background-color,border-color] duration-200 border-2 border-transparent; background: transparent; } @@ -890,7 +893,7 @@ /* ===== Animation Classes ===== */ .animate-receipt-print { - animation: receipt-print 0.4s ease-out; + animation: receipt-print 0.25s ease-out; } @keyframes receipt-print { @@ -905,7 +908,7 @@ } .success-pulse { - animation: success-pulse 0.6s var(--ease-smooth); + animation: success-pulse 0.25s var(--ease-smooth); } @keyframes success-pulse { @@ -915,7 +918,7 @@ } .error-shake { - animation: error-shake 0.5s ease-in-out; + animation: error-shake 0.25s ease-in-out; } @keyframes error-shake { @@ -932,7 +935,7 @@ } .long-press-active { - animation: long-press-pulse 0.5s ease-out; + animation: long-press-pulse 0.25s ease-out; } /* Touch-specific improvements */ @@ -1031,14 +1034,15 @@ /* ===== Pro SaaS Design System ===== */ .pro-app-shell { - @apply h-screen w-full overflow-hidden relative; + @apply h-dvh w-full overflow-hidden relative; background: #F8FAFC; /* slate-50 */ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; } .pro-header { @apply fixed top-0 left-0 right-0 flex items-center justify-between px-6; - height: 64px; + height: calc(64px + env(safe-area-inset-top)); + padding-top: env(safe-area-inset-top); z-index: 20; background: rgba(255, 255, 255, 0.8); backdrop-filter: blur(8px); @@ -1048,7 +1052,8 @@ .pro-footer { @apply fixed bottom-0 left-0 right-0 flex items-center justify-between px-6; - height: 56px; + height: calc(56px + env(safe-area-inset-bottom)); + padding-bottom: env(safe-area-inset-bottom); z-index: 40; background: #FFFFFF; border-top: 1px solid #E2E8F0; @@ -1057,8 +1062,8 @@ .pro-main { @apply absolute inset-0 overflow-hidden; - padding-top: 64px; - padding-bottom: 56px; + padding-top: calc(64px + env(safe-area-inset-top)); + padding-bottom: calc(56px + env(safe-area-inset-bottom)); z-index: 10; } @@ -1090,13 +1095,13 @@ } .pro-tile-inactive { - @apply flex items-center justify-center transition-all duration-100; + @apply flex items-center justify-center transition-colors duration-100; color: #94A3B8; /* slate-400 */ background: transparent; } .pro-tile-active { - @apply flex items-center justify-center rounded-md transition-all duration-100; + @apply flex items-center justify-center rounded-md transition-[transform,filter,background-color] duration-100; color: white; } @@ -1140,3 +1145,25 @@ scrollbar-color: #CBD5E1 transparent; } } + +@media (prefers-reduced-motion: reduce) { + * { + animation: none !important; + transition-property: opacity, color, background-color, border-color, box-shadow !important; + transition-duration: 150ms !important; + transition-timing-function: var(--ease-out-cubic) !important; + scroll-behavior: auto !important; + } + + .ledger-row:hover, + .receipt-button:hover, + .clickable-badge:hover, + .dock-item:hover, + .btn-press:active, + .clickable-badge:active, + button:active, + a:active, + [role="button"]:active { + transform: none !important; + } +} diff --git a/app/layout.tsx b/app/layout.tsx index ba3dd07..b388dbe 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -8,9 +8,56 @@ import { Analytics } from "@vercel/analytics/react" import { PostHogProvider } from "@/components/PostHogProvider" export const metadata: Metadata = { + metadataBase: new URL("https://splitsimple.anuragd.me"), title: "SplitSimple - Easy Expense Splitting", description: "Split expenses with friends and colleagues effortlessly", generator: "v0.app", + alternates: { + canonical: "/", + }, + openGraph: { + title: "SplitSimple - Easy Expense Splitting", + description: "Split expenses with friends and colleagues effortlessly", + url: "https://splitsimple.anuragd.me", + siteName: "SplitSimple", + locale: "en_US", + type: "website", + images: [ + { + url: "/og-image.png", + width: 1200, + height: 630, + alt: "SplitSimple bill splitting summary preview", + type: "image/png", + }, + ], + }, + twitter: { + card: "summary_large_image", + title: "SplitSimple - Easy Expense Splitting", + description: "Split expenses with friends and colleagues effortlessly", + images: [ + { + url: "/og-image.png", + width: 1200, + height: 630, + alt: "SplitSimple bill splitting summary preview", + }, + ], + }, + icons: { + icon: "/icon.svg", + apple: "/icon.svg", + }, + other: { + "theme-color": "#1E40AF", + "msapplication-TileColor": "#1E40AF", + }, + appleWebApp: { + title: "SplitSimple", + statusBarStyle: "black-translucent", + capable: true, + }, } export default function RootLayout({ @@ -21,6 +68,12 @@ export default function RootLayout({ return ( + + Skip to content + @@ -33,4 +86,4 @@ export default function RootLayout({ ) -} \ No newline at end of file +} diff --git a/app/og-image/page.tsx b/app/og-image/page.tsx new file mode 100644 index 0000000..9cf37be --- /dev/null +++ b/app/og-image/page.tsx @@ -0,0 +1,93 @@ +import type { Metadata } from "next" + +export const metadata: Metadata = { + title: "OG Image - SplitSimple", + alternates: { + canonical: "/og-image", + }, + robots: { + index: false, + follow: false, + }, +} + +export default function OgImagePage() { + return ( +
+ + +
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+ SplitSimple +
+
+ +
+

+ Split bills in seconds. +

+

+ Split expenses with friends and colleagues effortlessly. Assign items, tips, and taxes with a clean + spreadsheet workflow. +

+
+ +
+ + Real-time totals + + + Receipt scan + + + Shareable links + +
+ +
+ splitsimple.anuragd.me +
+
+
+
+
+ ) +} diff --git a/app/page.tsx b/app/page.tsx index c91c92f..ae26598 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,7 +1,12 @@ "use client" +import { Suspense } from "react" import { ProBillSplitter } from "@/components/ProBillSplitter" export default function HomePage() { - return + return ( + + + + ) } diff --git a/components/AddPersonForm.tsx b/components/AddPersonForm.tsx index 0799471..1552bff 100644 --- a/components/AddPersonForm.tsx +++ b/components/AddPersonForm.tsx @@ -107,12 +107,13 @@ export const AddPersonForm = forwardRef(fu @@ -121,7 +122,7 @@ export const AddPersonForm = forwardRef(fu size="sm" onClick={handleAddPerson} disabled={!newPersonName.trim()} - className={`h-10 px-4 rounded-xl btn-float transition-all duration-300 font-medium ${showSuccess ? 'bg-success hover:bg-success/90 success-pulse' : 'bg-gradient-to-br from-primary to-primary/90 hover:from-primary-600 hover:to-primary/80 text-white'}`} + className={`h-10 px-4 rounded-xl btn-float transition-[background-color,box-shadow,transform,color] duration-300 ease-out motion-reduce:transition-none font-medium ${showSuccess ? 'bg-success hover:bg-success/90 success-pulse' : 'bg-gradient-to-br from-primary to-primary/90 hover:from-primary-600 hover:to-primary/80 text-white'}`} > {showSuccess ? '✓' : 'Add'} diff --git a/components/AnimatedNumber.tsx b/components/AnimatedNumber.tsx index 3350faa..a0f7809 100644 --- a/components/AnimatedNumber.tsx +++ b/components/AnimatedNumber.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, memo } from "react" import { formatNumber } from "@/lib/utils" +import { useReducedMotion } from "@/hooks/use-reduced-motion" interface AnimatedNumberProps { value: number @@ -16,16 +17,23 @@ export const AnimatedNumber = memo(function AnimatedNumber({ value, className = "", formatFn = formatNumber, // Use smart number formatting by default - duration = 300, + duration = 250, prefix = "", suffix = "" }: AnimatedNumberProps) { const [displayValue, setDisplayValue] = useState(value) const [isAnimating, setIsAnimating] = useState(false) const [shouldPulse, setShouldPulse] = useState(false) + const prefersReducedMotion = useReducedMotion() useEffect(() => { if (value === displayValue) return + if (prefersReducedMotion) { + setDisplayValue(value) + setIsAnimating(false) + setShouldPulse(false) + return + } const difference = Math.abs(value - displayValue) const percentChange = displayValue !== 0 ? (difference / Math.abs(displayValue)) : Infinity @@ -36,7 +44,7 @@ export const AnimatedNumber = memo(function AnimatedNumber({ setDisplayValue(value) // Still trigger pulse for visual feedback setShouldPulse(true) - setTimeout(() => setShouldPulse(false), 600) + setTimeout(() => setShouldPulse(false), 250) return } @@ -47,7 +55,7 @@ export const AnimatedNumber = memo(function AnimatedNumber({ // Trigger pulse for significant changes if (Math.abs(difference) > Math.abs(startValue) * 0.1) { setShouldPulse(true) - setTimeout(() => setShouldPulse(false), 600) + setTimeout(() => setShouldPulse(false), 250) } const animate = () => { @@ -72,11 +80,11 @@ export const AnimatedNumber = memo(function AnimatedNumber({ } requestAnimationFrame(animate) - }, [value, displayValue, duration]) + }, [value, displayValue, duration, prefersReducedMotion]) return (
+ {error && (

{error}

@@ -178,7 +184,7 @@ export function BillLookup({ mode = "auto" }: BillLookupProps) { {isLoading ? ( <> - Loading... + Loading… ) : ( <> @@ -199,12 +205,15 @@ export function BillLookup({ mode = "auto" }: BillLookupProps) {
{secondaryAction.label} @@ -105,7 +105,7 @@ export function QuickStartCard({ return (
diff --git a/components/LedgerItemsTable.tsx b/components/LedgerItemsTable.tsx index 3fc7123..19f3d7e 100644 --- a/components/LedgerItemsTable.tsx +++ b/components/LedgerItemsTable.tsx @@ -329,7 +329,7 @@ export function LedgerItemsTable() { placeholder="Search items by name, price, or person..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} - className="pl-10 pr-10 h-9 text-sm border-border/50 bg-background/50 focus:bg-background focus:border-primary/50 transition-all" + className="pl-10 pr-10 h-9 text-sm border-border/50 bg-background/50 focus:bg-background focus:border-primary/50 transition-[background-color,border-color,color,box-shadow]" /> {searchQuery && ( diff --git a/components/MobileActionButton.tsx b/components/MobileActionButton.tsx index 63734d6..041264c 100644 --- a/components/MobileActionButton.tsx +++ b/components/MobileActionButton.tsx @@ -47,7 +47,7 @@ export function MobileActionButton({ @@ -392,7 +393,7 @@ export function MobileLedgerView() { diff --git a/components/PersonChip.tsx b/components/PersonChip.tsx index b2a562d..231e6e2 100644 --- a/components/PersonChip.tsx +++ b/components/PersonChip.tsx @@ -55,7 +55,7 @@ export const PersonChip = memo(function PersonChip({ } } - const baseClasses = "flex items-center gap-1.5 cursor-pointer transition-all border" + const baseClasses = "flex items-center gap-1.5 cursor-pointer transition-[background-color,border-color,color,box-shadow,transform] border" const selectedClasses = "bg-primary text-primary-foreground hover:bg-primary/90" const unselectedClasses = "bg-muted hover:bg-muted/80 text-muted-foreground border-dashed" diff --git a/components/PostHogProvider.tsx b/components/PostHogProvider.tsx index 619c3d6..8bab7c6 100644 --- a/components/PostHogProvider.tsx +++ b/components/PostHogProvider.tsx @@ -1,57 +1,97 @@ "use client" -import posthog from "posthog-js" import { PostHogProvider as PHProvider } from "posthog-js/react" -import { useEffect } from "react" +import { useEffect, useState } from "react" export function PostHogProvider({ children }: { children: React.ReactNode }) { + const [client, setClient] = useState(null) + useEffect(() => { - posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, { - api_host: "/ingest", - ui_host: "https://us.posthog.com", - defaults: '2025-05-24', - capture_exceptions: true, // This enables capturing exceptions using Error Tracking - debug: process.env.NODE_ENV === "development", // Debug only in development - disable_session_recording: false, // Ensure session recording works - capture_pageview: true, - capture_pageleave: true, - session_recording: { - maskAllInputs: true, // Mask sensitive input data - maskTextSelector: '.receipt-title', // Mask bill titles for privacy - }, - loaded: (posthog) => { - // PostHog loaded successfully + let cancelled = false + let idleCallbackId: number | null = null + let idleTimeoutId: ReturnType | null = null + + const init = async () => { + const { default: posthog } = await import("posthog-js") + if (cancelled) return + + posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, { + api_host: "/ingest", + ui_host: "https://us.posthog.com", + defaults: "2025-05-24", + capture_exceptions: true, // This enables capturing exceptions using Error Tracking + debug: process.env.NODE_ENV === "development", // Debug only in development + disable_session_recording: false, // Ensure session recording works + capture_pageview: true, + capture_pageleave: true, + session_recording: { + maskAllInputs: true, // Mask sensitive input data + maskTextSelector: ".receipt-title", // Mask bill titles for privacy + }, + loaded: () => { + // PostHog loaded successfully + }, + }) + + // Generate a unique user ID for analytics (privacy-friendly) + const getOrCreateUserId = () => { + let userId = localStorage.getItem("splitsimple_user_id") + if (!userId) { + userId = `user_${Date.now()}_${Math.random().toString(36).substring(2, 9)}` + localStorage.setItem("splitsimple_user_id", userId) + } + return userId } - }) - - // Generate a unique user ID for analytics (privacy-friendly) - const getOrCreateUserId = () => { - let userId = localStorage.getItem('splitsimple_user_id') - if (!userId) { - userId = `user_${Date.now()}_${Math.random().toString(36).substring(2, 9)}` - localStorage.setItem('splitsimple_user_id', userId) + + // Identify the user with PostHog + const userId = getOrCreateUserId() + posthog.identify(userId, { + app_version: "1.0.0", + platform: "web", + user_type: "anonymous", + }) + + // Set user properties for better analytics + posthog.people.set({ + first_seen: new Date().toISOString(), + browser: navigator.userAgent, + screen_resolution: `${window.screen.width}x${window.screen.height}`, + }) + + setClient(posthog) + } + + if (process.env.NODE_ENV === "production") { + if (typeof window !== "undefined" && "requestIdleCallback" in window) { + idleCallbackId = (window as Window & { + requestIdleCallback?: (cb: () => void) => number + }).requestIdleCallback?.(() => { + init() + }) ?? null + } else { + idleTimeoutId = setTimeout(() => { + init() + }, 0) } - return userId + } else { + init() } - // Identify the user with PostHog - const userId = getOrCreateUserId() - posthog.identify(userId, { - app_version: '1.0.0', - platform: 'web', - user_type: 'anonymous', - }) - - // Set user properties for better analytics - posthog.people.set({ - first_seen: new Date().toISOString(), - browser: navigator.userAgent, - screen_resolution: `${window.screen.width}x${window.screen.height}`, - }) + return () => { + cancelled = true + if (idleCallbackId !== null && typeof window !== "undefined" && "cancelIdleCallback" in window) { + ;(window as Window & { cancelIdleCallback?: (id: number) => void }).cancelIdleCallback?.(idleCallbackId) + } + if (idleTimeoutId !== null) { + clearTimeout(idleTimeoutId) + } + } }, []) + if (!client) return <>{children} + return ( - + {children} ) diff --git a/components/ProBillBreakdownView.tsx b/components/ProBillBreakdownView.tsx new file mode 100644 index 0000000..87faf27 --- /dev/null +++ b/components/ProBillBreakdownView.tsx @@ -0,0 +1,180 @@ +"use client" + +import React from 'react' +import { cn } from '@/lib/utils' +import type { Item, Person } from '@/contexts/BillContext' + +interface ColorToken { + id: string + bg: string + solid: string + text: string + textSolid: string + hex: string +} + +interface PersonShare { + subtotal: number + tax: number + tip: number + discount: number + total: number + ratio: number + items: Array +} + +interface ProBillBreakdownViewProps { + calculatedItems: Array + colors: ColorToken[] + formatCurrency: (amount: number) => string + grandTotal: number + people: Person[] + personFinalShares: Record + subtotal: number + taxAmount: number + tipAmount: number + title: string +} + +export default function ProBillBreakdownView({ + calculatedItems, + colors, + formatCurrency, + grandTotal, + people, + personFinalShares, + subtotal, + taxAmount, + tipAmount, + title, +}: ProBillBreakdownViewProps) { + return ( +
+
+
+ {/* LEFT: Bill Summary Receipt */} +
+
+
+ +
+

{title}

+

Bill Summary

+
+ +
+ {calculatedItems.map((item) => ( +
+ {item.name} + {formatCurrency(item.totalItemPrice)} +
+ ))} +
+ +
+
+ Subtotal + {formatCurrency(subtotal)} +
+
+ Tax + {formatCurrency(taxAmount)} +
+ {tipAmount > 0 && ( +
+ Tip + {formatCurrency(tipAmount)} +
+ )} +
+ Grand Total + {formatCurrency(grandTotal)} +
+
+
+
+ + {/* RIGHT: Individual Breakdowns */} +
+ {people.map((person) => { + const stats = personFinalShares[person.id] + const colorObj = colors[person.colorIdx || 0] + const initials = person.name.split(' ').map((part) => part[0]).join('').toUpperCase().slice(0, 2) + + return ( +
+
+
+
+ {initials} +
+
{person.name}
+
+
+ {formatCurrency(stats?.total || 0)} +
+
+
+ {/* Share Graph */} +
+
+ Share + {stats?.ratio.toFixed(1) || 0}% +
+
+ {[...Array(10)].map((_, index) => { + const filled = index < Math.round((stats?.ratio || 0) / 10) + return ( +
+ ) + })} +
+
+ +
+ {stats?.items.map((item) => ( +
+ +
{item.name} +
+ + {formatCurrency(item.pricePerPerson)} + +
+ ))} +
+ +
+ + Sub: {formatCurrency(stats?.subtotal || 0)} + + + Tax: {formatCurrency(stats?.tax || 0)} + + {stats?.tip > 0 && ( + + Tip: {formatCurrency(stats.tip)} + + )} +
+
+
+ ) + })} +
+
+
+
+ ) +} diff --git a/components/ProBillSplitter.tsx b/components/ProBillSplitter.tsx index 98224ff..1a109b3 100644 --- a/components/ProBillSplitter.tsx +++ b/components/ProBillSplitter.tsx @@ -1,6 +1,7 @@ "use client" import React, { useState, useMemo, useEffect, useLayoutEffect, useRef, useCallback } from 'react' +import { usePathname, useRouter, useSearchParams } from 'next/navigation' import { Plus, Search, @@ -19,13 +20,12 @@ import { Percent, Calculator, ChevronDown, - Code, - Camera + Camera, + Pencil } from 'lucide-react' import { useBill } from '@/contexts/BillContext' import type { Item, Person } from '@/contexts/BillContext' -import { formatCurrency } from '@/lib/utils' -import { getBillSummary, calculateItemSplits } from '@/lib/calculations' +import { cn } from '@/lib/utils' import { generateSummaryText, copyToClipboard } from '@/lib/export' import { useToast } from '@/hooks/use-toast' import { ShareBill } from '@/components/ShareBill' @@ -37,14 +37,36 @@ import { migrateBillSchema } from '@/lib/validation' import { useIsMobile } from '@/hooks/use-mobile' import { MobileSpreadsheetView } from '@/components/MobileSpreadsheetView' -import { ReceiptScanner } from '@/components/ReceiptScanner' +import dynamic from 'next/dynamic' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from '@/components/ui/context-menu' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' export type SplitMethod = "even" | "shares" | "percent" | "exact" @@ -80,6 +102,23 @@ const formatCurrencySimple = (amount: number) => { return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount || 0) } +const ReceiptScanner = dynamic( + () => import('@/components/ReceiptScanner').then((mod) => mod.ReceiptScanner), + { ssr: false } +) + +const ProBillBreakdownView = dynamic( + () => import('@/components/ProBillBreakdownView'), + { + ssr: false, + loading: () => ( +
+
Loading breakdown…
+
+ ), + } +) + // --- Grid Cell Component (moved outside to prevent re-creation on every render) --- const GridCell = React.memo(({ row, @@ -113,7 +152,7 @@ const GridCell = React.memo(({ const inputType = 'text' const inputMode = isNumericField ? 'decimal' : undefined const placeholder = - field === 'name' ? 'Type item...' : + field === 'name' ? 'Type item…' : field === 'price' ? '0.00' : field === 'qty' ? '1' : '' @@ -125,32 +164,53 @@ const GridCell = React.memo(({ type={inputType} inputMode={inputMode} value={value} + name={`${field}-${itemId}`} + autoComplete="off" + aria-label={`Edit ${field}`} onChange={e => onCellEdit(itemId, field, e.target.value)} onClick={(e) => e.stopPropagation()} - className={`w-full h-full px-4 py-3 text-sm border-2 border-indigo-500 focus:outline-none ${className}`} + className={cn( + "w-full h-full px-4 py-3 text-sm border-2 border-indigo-500 focus:outline-none", + className + )} />
) } return ( -
onCellClick(row, col)} - className={` - w-full h-full px-4 py-3 flex items-center cursor-text relative - ${isSelected ? 'ring-inset ring-2 ring-indigo-500 z-10' : ''} - ${className} - `} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onCellClick(row, col) + } + }} + className={cn( + "w-full h-full px-4 py-3 flex items-center cursor-text relative text-left", + isSelected && "ring-inset ring-2 ring-indigo-500 z-10", + className + )} > - + {value ? (field === 'price' ? `$${value}` : value) : placeholder} -
+ ) }) GridCell.displayName = 'GridCell' + + + + // --- Split Method Options (constant) --- const splitMethodOptions = [ { value: 'even' as SplitMethod, label: 'Even Split', icon: Users }, @@ -163,20 +223,70 @@ function DesktopBillSplitter() { const { state, dispatch, canUndo, canRedo } = useBill() const { toast } = useToast() const analytics = useBillAnalytics() - const [activeView, setActiveView] = useState<'ledger' | 'breakdown'>('ledger') + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + const viewParam = searchParams.get('view') + const normalizedView = viewParam === 'breakdown' ? 'breakdown' : 'ledger' + const [activeView, setActiveView] = useState<'ledger' | 'breakdown'>(normalizedView) const [billId, setBillId] = useState('') + const [loadBillError, setLoadBillError] = useState(null) + const [copyError, setCopyError] = useState(null) const [selectedCell, setSelectedCell] = useState<{ row: number; col: string }>({ row: 0, col: 'name' }) const [editing, setEditing] = useState(false) const [editingPerson, setEditingPerson] = useState(null) + const [expandedPeople, setExpandedPeople] = useState>(new Set()) const [hoveredColumn, setHoveredColumn] = useState(null) - const [contextMenu, setContextMenu] = useState<{ x: number; y: number; itemId: string; personId?: string } | null>(null) const [isLoadingBill, setIsLoadingBill] = useState(false) const [newLoadDropdownOpen, setNewLoadDropdownOpen] = useState(false) const [hideStarter, setHideStarter] = useState(false) + const [isNewBillDialogOpen, setIsNewBillDialogOpen] = useState(false) + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) + const [pendingDeleteItem, setPendingDeleteItem] = useState(null) + const [isRemovePersonDialogOpen, setIsRemovePersonDialogOpen] = useState(false) + const [pendingRemovePerson, setPendingRemovePerson] = useState(null) + + const focusRingClass = + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/40 focus-visible:ring-offset-2 focus-visible:ring-offset-white" + + const setView = useCallback((nextView: 'ledger' | 'breakdown') => { + setActiveView(nextView) + const params = new URLSearchParams(searchParams.toString()) + if (nextView === 'ledger') { + params.delete('view') + } else { + params.set('view', 'breakdown') + } + const query = params.toString() + router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false }) + }, [pathname, router, searchParams]) const editInputRef = useRef(null) const loadBillRequestRef = useRef(null) // Track current load request to prevent race conditions const previousItemsLengthRef = useRef(0) + const newBillSourceRef = useRef<'button' | 'shortcut'>('button') + const hotkeyStateRef = useRef({ + activeView: 'ledger' as 'ledger' | 'breakdown', + editing: false, + selectedCell: { row: 0, col: 'name' }, + items: [] as Item[], + people: [] as Person[], + editingPerson: null as Person | null, + historyIndex: 0, + }) + const hotkeyActionsRef = useRef({ + addItem: () => {}, + addPerson: () => {}, + copyBreakdown: () => {}, + toggleAssignment: (_itemId: string, _personId: string) => {}, + updateItem: (_id: string, _updates: Partial) => {}, + dispatchUndo: () => {}, + dispatchRedo: () => {}, + toastUndo: () => {}, + toastRedo: () => {}, + closeEditingPerson: () => {}, + stopEditing: () => {}, + }) const people = state.currentBill.people const items = state.currentBill.items @@ -188,7 +298,6 @@ function DesktopBillSplitter() { } // --- Derived Data --- - const summary = getBillSummary(state.currentBill) const hasMeaningfulItems = useMemo(() => { return items.some(i => (i.name || '').trim() !== '' || (i.price || '').trim() !== '' || (i.quantity || 1) !== 1) }, [items]) @@ -256,9 +365,21 @@ function DesktopBillSplitter() { return shares }, [calculatedItems, people, subtotal, taxAmount, tipAmount, discountAmount]) + const itemsById = useMemo(() => { + return new Map(items.map((item) => [item.id, item])) + }, [items]) + + const peopleById = useMemo(() => { + return new Map(people.map((person) => [person.id, person])) + }, [people]) + + useEffect(() => { + setExpandedPeople(new Set()) + }, [people.length]) + // --- Actions --- const toggleAssignment = useCallback((itemId: string, personId: string) => { - const item = items.find(i => i.id === itemId) + const item = itemsById.get(itemId) if (!item) return const isAssigned = item.splitWith.includes(personId) @@ -270,10 +391,10 @@ function DesktopBillSplitter() { type: 'UPDATE_ITEM', payload: { ...item, splitWith: newSplitWith } }) - }, [items, dispatch]) + }, [itemsById, dispatch]) const toggleAllAssignments = useCallback((itemId: string) => { - const item = items.find(i => i.id === itemId) + const item = itemsById.get(itemId) if (!item) return const allAssigned = people.every(p => item.splitWith.includes(p.id)) @@ -283,25 +404,37 @@ function DesktopBillSplitter() { type: 'UPDATE_ITEM', payload: { ...item, splitWith: newSplitWith } }) - }, [items, people, dispatch]) + }, [itemsById, people, dispatch]) + + const togglePersonExpansion = useCallback((personId: string) => { + setExpandedPeople(prev => { + const next = new Set(prev) + if (next.has(personId)) { + next.delete(personId) + } else { + next.add(personId) + } + return next + }) + }, []) const clearRowAssignments = useCallback((itemId: string) => { - const item = items.find(i => i.id === itemId) + const item = itemsById.get(itemId) if (!item) return dispatch({ type: 'UPDATE_ITEM', payload: { ...item, splitWith: [] } }) - }, [items, dispatch]) + }, [itemsById, dispatch]) const updateItem = useCallback((id: string, updates: Partial) => { - const item = items.find(i => i.id === id) + const item = itemsById.get(id) if (!item) return dispatch({ type: 'UPDATE_ITEM', payload: { ...item, ...updates } }) - }, [items, dispatch]) + }, [itemsById, dispatch]) const addItem = useCallback(() => { const newItem: Omit = { @@ -316,13 +449,36 @@ function DesktopBillSplitter() { }, [people, dispatch, analytics]) const deleteItem = useCallback((id: string) => { - const item = items.find(i => i.id === id) + const item = itemsById.get(id) dispatch({ type: 'REMOVE_ITEM', payload: id }) if (item) { analytics.trackItemRemoved(item.method) toast({ title: "Item deleted", duration: TIMING.TOAST_SHORT }) } - }, [items, dispatch, analytics, toast]) + }, [itemsById, dispatch, analytics, toast]) + + const confirmNewBill = useCallback(() => { + dispatch({ type: 'NEW_BILL' }) + toast({ title: "New bill created" }) + analytics.trackBillCreated() + analytics.trackFeatureUsed( + newBillSourceRef.current === "shortcut" ? "keyboard_shortcut_new_bill" : "new_bill" + ) + newBillSourceRef.current = "button" + setIsNewBillDialogOpen(false) + }, [dispatch, toast, analytics]) + + const openDeleteDialog = useCallback((item: Item) => { + setPendingDeleteItem(item) + setIsDeleteDialogOpen(true) + }, []) + + const confirmDeleteItem = useCallback(() => { + if (!pendingDeleteItem) return + deleteItem(pendingDeleteItem.id) + setPendingDeleteItem(null) + setIsDeleteDialogOpen(false) + }, [pendingDeleteItem, deleteItem]) const duplicateItem = useCallback((item: Item) => { const duplicated: Omit = { @@ -377,7 +533,7 @@ function DesktopBillSplitter() { }, [dispatch, toast, analytics]) const removePerson = useCallback((personId: string) => { - const person = people.find(p => p.id === personId) + const person = peopleById.get(personId) const hadItems = items.some(i => i.splitWith.includes(personId)) dispatch({ type: 'REMOVE_PERSON', payload: personId }) if (person) { @@ -385,7 +541,36 @@ function DesktopBillSplitter() { toast({ title: "Person removed", description: person.name }) } setEditingPerson(null) - }, [people, items, dispatch, analytics, toast]) + }, [peopleById, items, dispatch, analytics, toast]) + + const openRemovePersonDialog = useCallback((person: Person) => { + setPendingRemovePerson(person) + setIsRemovePersonDialogOpen(true) + }, []) + + const confirmRemovePerson = useCallback(() => { + if (!pendingRemovePerson) return + removePerson(pendingRemovePerson.id) + setPendingRemovePerson(null) + setIsRemovePersonDialogOpen(false) + }, [pendingRemovePerson, removePerson]) + + function handleUndo(): void { + dispatch({ type: 'UNDO' }) + toast({ title: "Undone", duration: TIMING.TOAST_SHORT }) + analytics.trackUndoRedoUsed("undo", state.historyIndex) + } + + function handleRedo(): void { + dispatch({ type: 'REDO' }) + toast({ title: "Redone", duration: TIMING.TOAST_SHORT }) + analytics.trackUndoRedoUsed("redo", state.historyIndex) + } + + function openNewBillDialog(): void { + newBillSourceRef.current = "button" + setIsNewBillDialogOpen(true) + } // --- Split Method Management --- const getSplitMethodIcon = (method: SplitMethod) => { @@ -394,7 +579,7 @@ function DesktopBillSplitter() { } const changeSplitMethod = useCallback((itemId: string, newMethod: SplitMethod) => { - const item = items.find(i => i.id === itemId) + const item = itemsById.get(itemId) if (!item) return const oldMethod = item.method @@ -405,17 +590,13 @@ function DesktopBillSplitter() { description: `Changed to ${splitMethodOptions.find(o => o.value === newMethod)?.label}`, duration: TIMING.TOAST_SHORT }) - }, [items, updateItem, analytics, toast]) + }, [itemsById, updateItem, analytics, toast]) // --- Bill ID Loading --- const handleLoadBill = useCallback(async () => { const trimmedId = billId.trim() if (!trimmedId) { - toast({ - title: "Enter Bill ID", - description: "Please enter a bill ID to load", - variant: "destructive" - }) + setLoadBillError("Enter a bill ID to load.") return } @@ -424,6 +605,7 @@ function DesktopBillSplitter() { loadBillRequestRef.current = requestId setIsLoadingBill(true) + setLoadBillError(null) analytics.trackFeatureUsed("load_bill_by_id", { bill_id: trimmedId }) try { @@ -435,11 +617,7 @@ function DesktopBillSplitter() { } if (result.error || !result.bill) { - toast({ - title: "Bill not found", - description: result.error || "Could not find bill with that ID", - variant: "destructive" - }) + setLoadBillError(result.error || "Could not find bill with that ID.") analytics.trackError("load_bill_failed", result.error || "Bill not found") return } @@ -452,14 +630,11 @@ function DesktopBillSplitter() { }) analytics.trackSharedBillLoaded("cloud") setBillId('') // Clear input after successful load + setLoadBillError(null) } catch (error) { // Only show error if this request is still current if (loadBillRequestRef.current === requestId) { - toast({ - title: "Load failed", - description: error instanceof Error ? error.message : "Unknown error", - variant: "destructive" - }) + setLoadBillError(error instanceof Error ? error.message : "Load failed. Try again.") analytics.trackError("load_bill_failed", error instanceof Error ? error.message : "Unknown error") } } finally { @@ -473,11 +648,7 @@ function DesktopBillSplitter() { // --- Copy Breakdown --- const copyBreakdown = useCallback(async () => { if (people.length === 0) { - toast({ - title: "No data to copy", - description: "Add people and items to generate a summary", - variant: "destructive" - }) + setCopyError("Add people and items to copy a summary.") analytics.trackError("copy_summary_failed", "No data to copy") return } @@ -485,6 +656,7 @@ function DesktopBillSplitter() { const text = generateSummaryText(state.currentBill) const success = await copyToClipboard(text) if (success) { + setCopyError(null) toast({ title: "Copied!", description: "Bill summary copied to clipboard" @@ -492,11 +664,7 @@ function DesktopBillSplitter() { analytics.trackBillSummaryCopied() analytics.trackFeatureUsed("copy_summary") } else { - toast({ - title: "Copy failed", - description: "Unable to copy to clipboard. Please try again.", - variant: "destructive" - }) + setCopyError("Unable to copy. Please try again.") analytics.trackError("copy_summary_failed", "Clipboard API failed") } }, [people, state.currentBill, toast, analytics]) @@ -525,48 +693,15 @@ function DesktopBillSplitter() { setEditing(true) }, [people, items, toggleAssignment]) - // --- Context Menu --- - const handleContextMenu = useCallback((e: React.MouseEvent, itemId: string, personId?: string) => { - e.preventDefault() - - // Context menu dimensions - const menuWidth = 192 // 48 * 4 = w-48 - const menuHeight = 200 // approximate height - - // Calculate position with boundary detection - let x = e.clientX - let y = e.clientY - - // Keep menu within viewport bounds - if (x + menuWidth > window.innerWidth) { - x = window.innerWidth - menuWidth - 10 - } - if (y + menuHeight > window.innerHeight) { - y = window.innerHeight - menuHeight - 10 - } - - setContextMenu({ - x, - y, - itemId, - personId - }) - }, []) - - useEffect(() => { - const handleClick = () => { - setContextMenu(null) - } - window.addEventListener('click', handleClick) - return () => window.removeEventListener('click', handleClick) - }, []) - // --- Global Keyboard Shortcuts & Grid Navigation --- const handleGlobalKeyDown = useCallback((e: KeyboardEvent) => { // Check if we're in an input field - comprehensive check const target = e.target as HTMLElement const activeElement = document.activeElement as HTMLElement + const hotkeyState = hotkeyStateRef.current + const hotkeyActions = hotkeyActionsRef.current + const isInInput = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || @@ -581,24 +716,24 @@ function DesktopBillSplitter() { (target instanceof Element && target.closest('input, textarea, select, [contenteditable="true"]') !== null) const editableCols = ['name', 'price', 'qty'] - const isEditableCell = editableCols.includes(selectedCell.col) + const isEditableCell = editableCols.includes(hotkeyState.selectedCell.col) - const colOrder = ['name', 'price', 'qty', ...people.map(p => p.id)] - const currentColIdx = colOrder.indexOf(selectedCell.col) - const currentRowIdx = selectedCell.row + const colOrder = ['name', 'price', 'qty', ...hotkeyState.people.map(p => p.id)] + const currentColIdx = colOrder.indexOf(hotkeyState.selectedCell.col) + const currentRowIdx = hotkeyState.selectedCell.row const commitAndMove = (direction: 'down' | 'up' | 'right' | 'left') => { let nextRow = currentRowIdx let nextColIdx = currentColIdx if (direction === 'down') { - if (currentRowIdx < items.length - 1) nextRow += 1 + if (currentRowIdx < hotkeyState.items.length - 1) nextRow += 1 } else if (direction === 'up') { if (currentRowIdx > 0) nextRow -= 1 } else if (direction === 'right') { if (currentColIdx < colOrder.length - 1) { nextColIdx += 1 - } else if (currentRowIdx < items.length - 1) { + } else if (currentRowIdx < hotkeyState.items.length - 1) { nextRow += 1 nextColIdx = 0 } @@ -612,18 +747,18 @@ function DesktopBillSplitter() { } setSelectedCell({ row: nextRow, col: colOrder[nextColIdx] }) - setEditing(false) + hotkeyActions.stopEditing() } const startTypingEdit = (initialValue: string) => { if (!isEditableCell) return - const item = items[currentRowIdx] + const item = hotkeyState.items[currentRowIdx] if (!item) return - if (selectedCell.col === 'name') updateItem(item.id, { name: initialValue }) - if (selectedCell.col === 'price') updateItem(item.id, { price: initialValue }) - if (selectedCell.col === 'qty') { + if (hotkeyState.selectedCell.col === 'name') hotkeyActions.updateItem(item.id, { name: initialValue }) + if (hotkeyState.selectedCell.col === 'price') hotkeyActions.updateItem(item.id, { price: initialValue }) + if (hotkeyState.selectedCell.col === 'qty') { const parsed = parseInt(initialValue, 10) - updateItem(item.id, { quantity: initialValue === '' ? 0 : (isNaN(parsed) ? 0 : parsed) }) + hotkeyActions.updateItem(item.id, { quantity: initialValue === '' ? 0 : (isNaN(parsed) ? 0 : parsed) }) } setEditing(true) } @@ -632,34 +767,29 @@ function DesktopBillSplitter() { // Escape key - close modals, menus, and exit edit mode if (e.key === 'Escape') { - if (editingPerson) { - setEditingPerson(null) + if (hotkeyState.editingPerson) { + hotkeyActions.closeEditingPerson() e.preventDefault() return } - if (contextMenu) { - setContextMenu(null) - e.preventDefault() - return - } - if (editing) { - setEditing(false) + if (hotkeyState.editing) { + hotkeyActions.stopEditing() e.preventDefault() return } } // If currently editing a cell input, let typing happen but keep spreadsheet commits - if (editing && isInInput) { + if (hotkeyState.editing && isInInput) { if (e.key === 'Enter') { e.preventDefault() if (e.shiftKey) { commitAndMove('up') } else { // At last row, add a new one and move - if (currentRowIdx >= items.length - 1) { - addItem() - setSelectedCell({ row: items.length, col: selectedCell.col }) + if (currentRowIdx >= hotkeyState.items.length - 1) { + hotkeyActions.addItem() + setSelectedCell({ row: hotkeyState.items.length, col: hotkeyState.selectedCell.col }) } else { commitAndMove('down') } @@ -683,36 +813,30 @@ function DesktopBillSplitter() { if (!isInInput) { if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) { e.preventDefault() - dispatch({ type: 'UNDO' }) - toast({ title: "Undo", duration: TIMING.TOAST_SHORT }) - analytics.trackUndoRedoUsed("undo", state.historyIndex) + hotkeyActions.dispatchUndo() + hotkeyActions.toastUndo() return } if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'z') { e.preventDefault() - dispatch({ type: 'REDO' }) - toast({ title: "Redo", duration: TIMING.TOAST_SHORT }) - analytics.trackUndoRedoUsed("redo", state.historyIndex) + hotkeyActions.dispatchRedo() + hotkeyActions.toastRedo() return } // Cmd+N: New bill if ((e.metaKey || e.ctrlKey) && e.key === 'n') { e.preventDefault() - if (confirm('Start a new bill? Current bill will be lost if not shared.')) { - dispatch({ type: 'NEW_BILL' }) - toast({ title: "New bill created" }) - analytics.trackBillCreated() - analytics.trackFeatureUsed("keyboard_shortcut_new_bill") - } + newBillSourceRef.current = "shortcut" + setIsNewBillDialogOpen(true) return } // Cmd+Shift+N: Add new item if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'N') { e.preventDefault() - addItem() + hotkeyActions.addItem() analytics.trackFeatureUsed("keyboard_shortcut_add_item") return } @@ -720,7 +844,7 @@ function DesktopBillSplitter() { // Cmd+Shift+P: Add person if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'P') { e.preventDefault() - addPerson() + hotkeyActions.addPerson() analytics.trackFeatureUsed("keyboard_shortcut_add_person") return } @@ -728,7 +852,7 @@ function DesktopBillSplitter() { // Cmd+Shift+C: Copy summary if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'C') { e.preventDefault() - copyBreakdown() + hotkeyActions.copyBreakdown() analytics.trackFeatureUsed("keyboard_shortcut_copy") return } @@ -744,10 +868,10 @@ function DesktopBillSplitter() { } // Grid navigation - Excel-like behavior - if (activeView !== 'ledger') return + if (hotkeyState.activeView !== 'ledger') return // Type-to-edit from selection - if (!editing && isEditableCell && (isPrintableKey || e.key === 'Backspace' || e.key === 'Delete')) { + if (!hotkeyState.editing && isEditableCell && (isPrintableKey || e.key === 'Backspace' || e.key === 'Delete')) { e.preventDefault() const seed = (e.key === 'Backspace' || e.key === 'Delete') ? '' : e.key startTypingEdit(seed) @@ -769,9 +893,9 @@ function DesktopBillSplitter() { if (e.shiftKey) { commitAndMove('up') } else { - if (currentRowIdx >= items.length - 1) { - addItem() - setSelectedCell({ row: items.length, col: selectedCell.col }) + if (currentRowIdx >= hotkeyState.items.length - 1) { + hotkeyActions.addItem() + setSelectedCell({ row: hotkeyState.items.length, col: hotkeyState.selectedCell.col }) } else { commitAndMove('down') } @@ -786,33 +910,81 @@ function DesktopBillSplitter() { if (e.key === 'ArrowRight' && currentColIdx < colOrder.length - 1) newColIdx++ if (e.key === 'ArrowLeft' && currentColIdx > 0) newColIdx-- - if (e.key === 'ArrowDown' && currentRowIdx < items.length - 1) newRowIdx++ + if (e.key === 'ArrowDown' && currentRowIdx < hotkeyState.items.length - 1) newRowIdx++ if (e.key === 'ArrowUp' && currentRowIdx > 0) newRowIdx-- setSelectedCell({ row: newRowIdx, col: colOrder[newColIdx] }) - setEditing(false) + hotkeyActions.stopEditing() return } // Toggle assignment with space when on person cells - if (e.key === ' ' && people.some(p => p.id === selectedCell.col)) { + if (e.key === ' ' && hotkeyState.people.some(p => p.id === hotkeyState.selectedCell.col)) { e.preventDefault() - const item = items[selectedCell.row] - if (item) toggleAssignment(item.id, selectedCell.col) + const item = hotkeyState.items[hotkeyState.selectedCell.row] + if (item) hotkeyActions.toggleAssignment(item.id, hotkeyState.selectedCell.col) return } - }, [activeView, editing, selectedCell, items, people, addItem, toggleAssignment, addPerson, copyBreakdown, dispatch, toast, analytics, state.historyIndex, editingPerson, contextMenu, updateItem]) + }, [analytics]) useEffect(() => { window.addEventListener('keydown', handleGlobalKeyDown) return () => window.removeEventListener('keydown', handleGlobalKeyDown) }, [handleGlobalKeyDown]) + useEffect(() => { + hotkeyStateRef.current = { + activeView, + editing, + selectedCell, + items, + people, + editingPerson, + historyIndex: state.historyIndex, + } + }, [activeView, editing, selectedCell, items, people, editingPerson, state.historyIndex]) + + useEffect(() => { + hotkeyActionsRef.current = { + addItem, + addPerson, + copyBreakdown, + toggleAssignment, + updateItem, + dispatchUndo: () => { + dispatch({ type: 'UNDO' }) + analytics.trackUndoRedoUsed("undo", state.historyIndex) + }, + dispatchRedo: () => { + dispatch({ type: 'REDO' }) + analytics.trackUndoRedoUsed("redo", state.historyIndex) + }, + toastUndo: () => toast({ title: "Undo", duration: TIMING.TOAST_SHORT }), + toastRedo: () => toast({ title: "Redo", duration: TIMING.TOAST_SHORT }), + closeEditingPerson: () => setEditingPerson(null), + stopEditing: () => setEditing(false), + } + }, [ + addItem, + addPerson, + copyBreakdown, + toggleAssignment, + updateItem, + dispatch, + toast, + analytics, + state.historyIndex, + ]) + useEffect(() => { const saved = window.localStorage.getItem('splitsimple_hide_starter') if (saved === '1') setHideStarter(true) }, []) + useEffect(() => { + setActiveView(normalizedView) + }, [normalizedView]) + // Seed a first row when empty so the grid is immediately ready to type. useLayoutEffect(() => { if (activeView !== 'ledger') return @@ -848,8 +1020,8 @@ function DesktopBillSplitter() { {/* --- Header --- */}
- {/* Left cluster: Brand + Title + Undo/Redo */} -
+ {/* Left cluster: Brand + Title + Sync */} +
@@ -862,77 +1034,122 @@ function DesktopBillSplitter() { style={{ width: `${Math.min(Math.max((title || '').length || 7, 7), 26)}ch`, }} - className="block text-sm font-bold bg-transparent border-none p-0 focus:ring-0 text-slate-900 w-auto min-w-[7ch] max-w-[26ch] hover:text-indigo-600 transition-colors font-inter" - placeholder="Project Name" + className={cn( + "block text-sm font-bold bg-transparent border-none p-0 focus:ring-0 text-slate-900 w-auto min-w-[7ch] max-w-[26ch] hover:text-indigo-600 transition-colors font-inter", + focusRingClass + )} + placeholder="Project name…" + aria-label="Bill title" + name="bill-title" + autoComplete="off" /> -
SPLIT SIMPLE
+
SPLIT SIMPLE
+
+ + {/* Center: View switcher */} +
+ + +
+ + {/* Right cluster: History + Primary actions */} +
-
- {/* Right cluster: New/Load + Sync/Scan/Share */} -
+
- - - - - -
e.stopPropagation()}> - -
- + { + setNewLoadDropdownOpen(open) + if (!open) setLoadBillError(null) + }} + > + + + + +
e.stopPropagation()}> + +
+ setBillId(e.target.value)} + name="bill-id" + autoComplete="off" + onChange={(e) => { + setBillId(e.target.value) + if (loadBillError) setLoadBillError(null) + }} onKeyDown={(e) => { if (e.key === 'Enter' && billId.trim()) { handleLoadBill() @@ -943,488 +1160,650 @@ function DesktopBillSplitter() { } }} onClick={(e) => e.stopPropagation()} - placeholder="ABC123..." + placeholder="ABC123…" disabled={isLoadingBill} - className="w-full h-8 pl-7 pr-2 bg-slate-50 border border-slate-200 rounded-md text-xs placeholder:text-slate-400 focus:border-indigo-500 focus:bg-white transition-colors disabled:opacity-50 font-mono" - autoFocus - /> -
-
- - -
+ />
-
-
-
+
+ + +
+ {loadBillError && ( +

+ {loadBillError} +

+ )} +
+
+
+
-
- -
- - - Scan Receipt - + + + Scan Receipt + + )} + /> +
+ +
+ + {copyError && ( + + {copyError} + + )}
+
+
+ +
+
{/* --- Main Workspace --- */} -
+
{/* LEDGER VIEW */} {activeView === 'ledger' && ( -
-
-
- {/* Sticky toolbar */} -
-
- -
- - Split - - - People - -
-
-
- Tab/Enter to commit - Esc to exit -
-
- - {/* Starter banner (shown until the bill has meaningful items) */} - {!hideStarter && !hasMeaningfulItems && ( -
-
-
-
Start splitting in 3 quick steps
-
- Click a cell and type, then press Tab/Enter to move like Sheets. -
- -
-
-
-
1
-
-
Add people
-
⌘⇧P
-
-
- -
- -
-
-
2
-
-
Add items
-
⌘⇧N
-
-
- -
- -
-
-
3
-
-
Scan receipt
-
Optional
-
-
- - - Scan - - )} - /> -
-
-
- -
-
- )} - {/* Live Roster */} -
-
- Live Breakdown -
-
- {people.length === 0 ? ( -
- - Click + above to add people or press ⌘⇧P -
- ) : ( - people.map(p => { - const stats = personFinalShares[p.id] - const colorObj = COLORS[p.colorIdx || 0] - const percent = stats ? (stats.total / (grandTotal || 1)) * 100 : 0 - return ( -
-
- {p.name.split(' ')[0]} - - {formatCurrencySimple(stats?.total || 0)} - - - ({percent.toFixed(0)}%) - -
- ) - }) - )} -
+
+
+
+
+
+
+
+

Items & split

+

+ Add items, set prices, and assign people to split each line. +

+
- {/* Sticky Header */} -
-
#
-
Item Description
-
Price
-
Qty
- - {people.map(p => { - const colorObj = COLORS[p.colorIdx || 0] - const initials = p.name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2) - return ( -
setHoveredColumn(p.id)} - onMouseLeave={() => setHoveredColumn(null)} - onClick={() => setEditingPerson(p)} - > -
- {initials} + {/* Sticky toolbar */} +
+
+ +
+ + Split + + + People + +
+
+
+ Tab/Enter to commit + Esc to exit
- - {p.name.split(' ')[0]} -
- ) - })} -
- -
-
- Total -
-
+
+
+ {/* Sticky Header */} +
+
#
+
Item Description
+
Price
+
Qty
+ + {people.map(p => { + const colorObj = COLORS[p.colorIdx || 0] + const initials = p.name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2) + return ( + + ) + })} + + +
+ Total +
+
- {/* Body */} -
- {calculatedItems.length === 0 && ( -
-
- -
-

No items yet

-

- Add your first item to start splitting the bill. Press ⌘⇧N or click the button below. -

- -
- )} + {/* Body */} +
+ {calculatedItems.length === 0 && ( +
+
+ +
+

No items yet

+

+ Add your first item to start splitting the bill. Press ⌘⇧N or click the button below. +

+ +
+ )} - {calculatedItems.map((item, rIdx) => ( -
handleContextMenu(e, item.id)} - > - {/* Index / "Equal" Button */} -
- {String(rIdx + 1).padStart(2, '0')} - -
+ {calculatedItems.map((item, rIdx) => ( + + +
+ {/* Index / "Equal" Button */} +
+ {String(rIdx + 1).padStart(2, '0')} + +
+ + {/* Name + Split Method Selector */} +
+
+ +
+
+ + + + + + {splitMethodOptions.map(option => ( + { + e.preventDefault() + changeSplitMethod(item.id, option.value) + }} + className={cn( + "text-xs flex items-center gap-2 font-inter", + item.method === option.value ? "bg-indigo-50 text-indigo-700 font-bold" : "text-slate-600" + )} + > + {React.createElement(option.icon, { size: 12 })} + {option.label} + + ))} + + +
+
+ + {/* Price */} +
+ +
+ + {/* Qty */} +
+ +
+ + {/* Person Cells (The "Cards") */} + {people.map(p => { + const isAssigned = item.splitWith.includes(p.id) + const isSelected = selectedCell.row === rIdx && selectedCell.col === p.id + const color = COLORS[p.colorIdx || 0] + + return ( + + ) + })} + + {/* Inline actions */} +
+ + +
+ + {/* Row Total */} +
+ ${(item.totalItemPrice || 0).toFixed(2)} +
+
+
+ + duplicateItem(item)} + > + Duplicate item + + toggleAllAssignments(item.id)} + > + Split with everyone + + clearRowAssignments(item.id)} + > + Clear row + + + openDeleteDialog(item)} + > + Delete item + + +
+ ))} - {/* Name + Split Method Selector */} -
-
- -
-
- - + {/* Add Row Button */} + {calculatedItems.length > 0 && ( - - - {splitMethodOptions.map(option => ( - { - e.preventDefault() - changeSplitMethod(item.id, option.value) - }} - className={`text-xs flex items-center gap-2 font-inter ${item.method === option.value ? 'bg-indigo-50 text-indigo-700 font-bold' : 'text-slate-600'}`} - > - {React.createElement(option.icon, { size: 12 })} - {option.label} - - ))} - - + )} +
- - {/* Price */} -
- -
- - {/* Qty */} -
- -
- - {/* Person Cells (The "Cards") */} - {people.map(p => { - const isAssigned = item.splitWith.includes(p.id) - const isSelected = selectedCell.row === rIdx && selectedCell.col === p.id - const color = COLORS[p.colorIdx || 0] - - return ( -
{ - e.stopPropagation() - handleContextMenu(e, item.id, p.id) +
+
+ +
+
+ )} - {/* Inline actions */} -
- +
+
+
+

People

+

Track who is splitting the bill.

+
- {/* Row Total */} -
- ${(item.totalItemPrice || 0).toFixed(2)} -
-
- ))} - - {/* Add Row Button */} - {calculatedItems.length > 0 && ( - - )} - - {/* Summary Rows Section */} -
- {/* Subtotal Row */} -
-
-
- Subtotal -
-
- ${subtotal.toFixed(2)} -
-
- - {people.map(p => { - const stats = personFinalShares[p.id] - return ( -
- ${(stats?.subtotal || 0).toFixed(2)} +
+ {people.length === 0 ? ( +
+ No people yet. Add someone to start splitting.
- ) - })} - -
-
- ${subtotal.toFixed(2)} + ) : ( + people.map(p => { + const stats = personFinalShares[p.id] + const colorObj = COLORS[p.colorIdx || 0] + const percent = stats ? (stats.total / (grandTotal || 1)) * 100 : 0 + const isExpanded = expandedPeople.has(p.id) + return ( +
+
togglePersonExpansion(p.id)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault() + togglePersonExpansion(p.id) + } + }} + role="button" + tabIndex={0} + className="w-full flex items-center justify-between px-3 py-2 text-left cursor-pointer" + aria-expanded={isExpanded} + aria-label={`Toggle ${p.name} details`} + > +
+
+ {p.name} +
+
+
+
+ {formatCurrencySimple(stats?.total || 0)} +
+
+ {percent.toFixed(0)}% +
+
+ + +
+
+
+
+
+
+
+ Subtotal + {formatCurrencySimple(stats?.subtotal || 0)} +
+
+ Tax + {formatCurrencySimple(stats?.tax || 0)} +
+ {stats?.tip ? ( +
+ Tip + {formatCurrencySimple(stats.tip)} +
+ ) : null} + {stats?.discount ? ( +
+ Discount + -{formatCurrencySimple(stats.discount)} +
+ ) : null} +
+ +
+ {stats?.items.length ? ( + stats.items.map(item => ( +
+ + {item.qty > 1 + ? `${item.name || 'Item'} ×${item.qty}` + : (item.name || 'Item')} + + + {formatCurrencySimple(item.pricePerPerson)} + +
+ )) + ) : ( +
No items assigned.
+ )} +
+
+
+
+
+ ) + }) + )}
- {/* Tax Row */} -
-
-
- Tax +
+
+
+

Bill totals

+

Adjust tax, tip, and discounts.

+
-
- { - dispatch({ type: 'SET_TAX', payload: e.target.value }) - analytics.trackTaxTipDiscountUsed("tax", e.target.value, state.currentBill.taxTipAllocation) - }} - className="w-full bg-white rounded px-2 py-1.5 border border-slate-200 shadow-inner focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200 focus:bg-indigo-50/30 transition-all text-xs font-space-mono text-slate-700 text-right" - placeholder="0.00" - /> -
-
- {people.map(p => { - const stats = personFinalShares[p.id] - return ( -
- ${(stats?.tax || 0).toFixed(2)} -
- ) - })} - -
-
- ${taxAmount.toFixed(2)} -
-
- - {/* Tip Row */} -
-
-
- Tip -
-
- { - dispatch({ type: 'SET_TIP', payload: e.target.value }) - analytics.trackTaxTipDiscountUsed("tip", e.target.value, state.currentBill.taxTipAllocation) - }} - className="w-full bg-white rounded px-2 py-1.5 border border-slate-200 shadow-inner focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200 focus:bg-indigo-50/30 transition-all text-xs font-space-mono text-slate-700 text-right" - placeholder="0.00" - /> -
-
- - {people.map(p => { - const stats = personFinalShares[p.id] - return ( -
- ${(stats?.tip || 0).toFixed(2)} -
- ) - })} - -
-
- ${tipAmount.toFixed(2)} -
-
- - {/* Discount Row */} -
-
-
- Discount -
-
- { - dispatch({ type: 'SET_DISCOUNT', payload: e.target.value }) - analytics.trackTaxTipDiscountUsed("discount", e.target.value, state.currentBill.taxTipAllocation) - }} - className="w-full bg-white rounded px-2 py-1.5 border border-slate-200 shadow-inner focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200 focus:bg-indigo-50/30 transition-all text-xs font-space-mono text-slate-700 text-right" - placeholder="0.00" - /> -
-
- - {people.map(p => { - const stats = personFinalShares[p.id] - return ( -
- ${(stats?.discount || 0).toFixed(2)} +
+
+ + { + dispatch({ type: 'SET_TAX', payload: e.target.value }) + analytics.trackTaxTipDiscountUsed("tax", e.target.value, state.currentBill.taxTipAllocation) + }} + className="w-full h-9 rounded-md border border-slate-200 bg-white px-2 text-right font-space-mono text-slate-700 tabular-nums focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200" + placeholder="0.00" + /> +
+
+ + { + dispatch({ type: 'SET_TIP', payload: e.target.value }) + analytics.trackTaxTipDiscountUsed("tip", e.target.value, state.currentBill.taxTipAllocation) + }} + className="w-full h-9 rounded-md border border-slate-200 bg-white px-2 text-right font-space-mono text-slate-700 tabular-nums focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200" + placeholder="0.00" + /> +
+
+ + { + dispatch({ type: 'SET_DISCOUNT', payload: e.target.value }) + analytics.trackTaxTipDiscountUsed("discount", e.target.value, state.currentBill.taxTipAllocation) + }} + className="w-full h-9 rounded-md border border-slate-200 bg-white px-2 text-right font-space-mono text-slate-700 tabular-nums focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200" + placeholder="0.00" + /> +
+
+ +
+ {formatCurrencySimple(subtotal)}
- ) - })} - -
-
- -${discountAmount.toFixed(2)} -
-
- - {/* Grand Total Row */} -
-
-
- Grand Total +
-
-
- - {people.map(p => { - const stats = personFinalShares[p.id] - const colorObj = COLORS[p.colorIdx || 0] - return ( -
-
- ${(stats?.total || 0).toFixed(2)} -
-
- ) - })} -
-
- ${grandTotal.toFixed(2)} -
-
- -
-
-
-
-
- )} - - {/* BREAKDOWN VIEW */} - {activeView === 'breakdown' && ( -
-
-
- {/* LEFT: Bill Summary Receipt */} -
-
-
- -
-

{title}

-

Bill Summary

-
- -
- {calculatedItems.map(item => ( -
- {item.name} - {formatCurrencySimple(item.totalItemPrice)} +
+
+ Tax + {formatCurrencySimple(taxAmount)}
- ))} -
- -
-
- Subtotal - {formatCurrencySimple(subtotal)} -
-
- Tax - {formatCurrencySimple(taxAmount)} -
- {tipAmount > 0 && ( -
+
Tip - {formatCurrencySimple(tipAmount)} + {formatCurrencySimple(tipAmount)}
- )} -
- Grand Total - {formatCurrencySimple(grandTotal)} -
-
-
-
- - {/* RIGHT: Individual Breakdowns */} -
- {people.map(p => { - const stats = personFinalShares[p.id] - const colorObj = COLORS[p.colorIdx || 0] - const initials = p.name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2) - - return ( -
-
-
-
- {initials} -
-
{p.name}
-
-
- {formatCurrencySimple(stats?.total || 0)} -
+
+ Discount + -{formatCurrencySimple(discountAmount)}
-
- {/* Share Graph */} -
-
- Share - {stats?.ratio.toFixed(1) || 0}% -
-
- {[...Array(10)].map((_, i) => { - const filled = i < Math.round((stats?.ratio || 0) / 10) - return ( -
- ) - })} -
-
- -
- {stats?.items.map(item => ( -
- -
{item.name} -
- - {formatCurrencySimple(item.pricePerPerson)} - -
- ))} -
- -
- - Sub: {formatCurrencySimple(stats?.subtotal || 0)} - - - Tax: {formatCurrencySimple(stats?.tax || 0)} - - {stats?.tip > 0 && ( - - Tip: {formatCurrencySimple(stats.tip)} - - )} -
+
+ Grand total + {formatCurrencySimple(grandTotal)}
- ) - })} +
+
)} + + {/* BREAKDOWN VIEW */} + {activeView === 'breakdown' && ( + + )}
+ + + + Start a new bill? + + Your current bill will be cleared unless you share it first. + + + + Cancel + Start new bill + + + + + { + setIsDeleteDialogOpen(open) + if (!open) setPendingDeleteItem(null) + }} + > + + + Delete this item? + + This action can’t be undone. + + + + setPendingDeleteItem(null)}> + Cancel + + Delete item + + + + + { + setIsRemovePersonDialogOpen(open) + if (!open) setPendingRemovePerson(null) + }} + > + + + Remove this person? + + {pendingRemovePerson?.name || "This person"} will be removed from the bill and their splits cleared. + + + + setPendingRemovePerson(null)}> + Cancel + + Remove person + + + + {/* --- Footer --- */}
- {items.length} + {items.length} items - {people.length} + {people.length} people
-
+
- -
@@ -1768,91 +2050,13 @@ function DesktopBillSplitter() {
- {/* --- Context Menu --- */} - {contextMenu && ( -
e.stopPropagation()} - > -
- Actions -
- {contextMenu.personId ? ( - - ) : ( - <> - - - - - )} -
- -
- )} - {/* --- Person Editor Modal --- */} - {editingPerson && ( -
setEditingPerson(null)} - > -
e.stopPropagation()} - > -
-

Edit Member

- -
- + !open && setEditingPerson(null)}> + + + Edit Member + + {editingPerson && (
@@ -1883,22 +2092,22 @@ function DesktopBillSplitter() {
-
-
- )} + )} + +
) } diff --git a/components/ReceiptScanner.tsx b/components/ReceiptScanner.tsx index 51ebfbc..f52a815 100644 --- a/components/ReceiptScanner.tsx +++ b/components/ReceiptScanner.tsx @@ -28,6 +28,17 @@ import { useToast } from "@/hooks/use-toast" type ScannerState = 'idle' | 'uploading' | 'processing' | 'reviewing' +interface ScanError { + title: string + description: string +} + +interface OCRClientError extends Error { + code?: string + status?: number + retryAfter?: number +} + interface ReceiptScannerProps { onImport: (items: Omit[]) => void trigger?: React.ReactNode @@ -40,7 +51,7 @@ export function ReceiptScanner({ onImport, trigger }: ReceiptScannerProps) { const [scannedItems, setScannedItems] = useState([]) const [zoom, setZoom] = useState(1) const [rotation, setRotate] = useState(0) - const [activeTab, setActiveTab] = useState('image') + const [error, setError] = useState(null) const { toast } = useToast() const handleReset = useCallback(() => { @@ -49,6 +60,7 @@ export function ReceiptScanner({ onImport, trigger }: ReceiptScannerProps) { setScannedItems([]) setZoom(1) setRotate(0) + setError(null) }, []) const handleOpenChange = (open: boolean) => { @@ -58,7 +70,35 @@ export function ReceiptScanner({ onImport, trigger }: ReceiptScannerProps) { } } + const validateFile = (file: File) => { + const maxSize = 5 * 1024 * 1024 + if (!file.type.startsWith('image/')) { + return { + title: "Unsupported File", + description: "Please upload an image file (JPG, PNG, HEIC)." + } + } + if (file.size > maxSize) { + return { + title: "File Too Large", + description: "Please upload an image under 5MB." + } + } + return null + } + const processImage = async (file: File) => { + const validationError = validateFile(file) + if (validationError) { + setError(validationError) + toast({ + title: validationError.title, + description: validationError.description, + variant: "destructive" + }) + return + } + setState('processing') try { @@ -79,36 +119,36 @@ export function ReceiptScanner({ onImport, trigger }: ReceiptScannerProps) { } catch (error) { console.error('Receipt scanning error:', error) const errorMessage = error instanceof Error ? error.message : "Unknown error" + const errorCode = (error as OCRClientError).code // Provide more specific error messages let title = "Scan Failed" let description = "Could not process receipt. Please try again." - if (errorMessage.includes("No items detected")) { + if (errorCode === "RATE_LIMIT_ERROR") { + title = "OCR Temporarily Unavailable" + description = "There are issues with the OCR model due to rate limits. Please try again later." + } else if (errorCode === "NO_ITEMS_DETECTED" || errorMessage.includes("No items detected")) { title = "No Items Found" description = "We couldn't detect any items in this receipt. Try a clearer image or add items manually." + } else if (errorCode === "INVALID_FILE" || errorCode === "FILE_TOO_LARGE") { + title = "Invalid File" + description = errorMessage } else if (errorMessage.includes("HEIC") || errorMessage.includes("conversion")) { title = "Image Format Issue" description = "Could not convert HEIC image. Try uploading a JPG or PNG instead." - } else if (errorMessage.includes("API_KEY")) { + } else if (errorCode === "API_KEY_MISSING") { title = "Configuration Error" description = "Receipt scanning is not configured. Please check your environment variables." - } else if (errorMessage.includes("API")) { + } else if (errorCode === "API_ERROR" || errorCode === "OCR_API_ERROR") { title = "Service Unavailable" description = "The receipt scanning service is temporarily unavailable. Please try again later or add items manually." - } else if (errorMessage.includes("file") || errorMessage.includes("size")) { - title = "Invalid File" - description = errorMessage } else { - // Show actual error message for debugging - description = `${errorMessage}\n\nCheck browser console for details.` + description = "Something went wrong while scanning. Please try again or add items manually." } - toast({ - title, - description, - variant: "destructive" - }) + setError({ title, description }) + toast({ title, description, variant: "destructive" }) setState('idle') } } @@ -130,6 +170,14 @@ export function ReceiptScanner({ onImport, trigger }: ReceiptScannerProps) { } const handleImport = () => { + if (scannedItems.length === 0) { + toast({ + title: "No items to import", + description: "Add at least one item before importing.", + variant: "destructive" + }) + return + } onImport(scannedItems) setIsOpen(false) toast({ @@ -148,15 +196,20 @@ export function ReceiptScanner({ onImport, trigger }: ReceiptScannerProps) { )} {state === 'idle' && ( - + setError(null)} + /> )} {state === 'processing' && ( - + )} {state === 'reviewing' && ( @@ -179,7 +232,17 @@ export function ReceiptScanner({ onImport, trigger }: ReceiptScannerProps) { // --- Sub-Components --- -function UploadView({ onUpload, onPaste }: { onUpload: (file: File) => void, onPaste: (text: string) => void }) { +function UploadView({ + onUpload, + onPaste, + error, + onDismissError +}: { + onUpload: (file: File) => void + onPaste: (text: string) => void + error: ScanError | null + onDismissError: () => void +}) { const fileInputRef = useRef(null) const [dragActive, setDragActive] = useState(false) const [pasteText, setPasteText] = useState("") @@ -218,9 +281,23 @@ function UploadView({ onUpload, onPaste }: { onUpload: (file: File) => void, onP
-
+
+
+
{error.title}
+

{error.description}

+
+ +
+
+ )} +

Click to upload or drag & drop

Supports JPG, PNG, HEIC (Max 5MB) • Preview unavailable for HEIC

-
+