diff --git a/.env.example b/.env.example index 6b0efe0..a83e3f7 100644 --- a/.env.example +++ b/.env.example @@ -8,4 +8,31 @@ ADMIN_PASSWORD=your_secure_password_here # Optional: PostHog configuration for analytics # NEXT_PUBLIC_POSTHOG_KEY= -# NEXT_PUBLIC_POSTHOG_HOST= \ No newline at end of file +# NEXT_PUBLIC_POSTHOG_HOST= + +# ============================================ +# Receipt Scanning Configuration (Optional) +# ============================================ +# Receipt scanning will use mock data if not configured +# Choose one provider and set its API key + +# OCR Provider - Choose one: "google" (default), "openai", or "anthropic" +# OCR_PROVIDER=google + +# Google Gemini API Key (if using Google as provider) +# Get your key from: https://aistudio.google.com/app/apikey +# GOOGLE_GENERATIVE_AI_API_KEY=your_gemini_api_key_here + +# OpenAI API Key (if using OpenAI as provider) +# Get your key from: https://platform.openai.com/api-keys +# OPENAI_API_KEY=your_openai_api_key_here + +# Anthropic API Key (if using Anthropic as provider) +# Get your key from: https://console.anthropic.com/settings/keys +# ANTHROPIC_API_KEY=your_anthropic_api_key_here + +# Optional: Override the default model for your chosen provider +# Google default: gemini-2.0-flash +# OpenAI default: gpt-4o +# Anthropic default: claude-sonnet-4-20250514 +# OCR_MODEL=gemini-2.0-flash \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 710e150..1f7344d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,6 +14,13 @@ permissions: jobs: test: runs-on: ubuntu-latest + env: + REDIS_URL: redis://localhost:6379 + NEXT_PUBLIC_POSTHOG_KEY: test-key + NEXT_PUBLIC_POSTHOG_HOST: https://app.posthog.com + OCR_PROVIDER: google + GOOGLE_API_KEY: placeholder + NODE_ENV: test strategy: matrix: @@ -143,4 +150,4 @@ jobs: uses: github/codeql-action/upload-sarif@v3 if: always() && hashFiles('snyk.sarif') != '' with: - sarif_file: snyk.sarif \ No newline at end of file + sarif_file: snyk.sarif diff --git a/INFRASTRUCTURE.md b/INFRASTRUCTURE.md index 68edf99..80f4e84 100644 --- a/INFRASTRUCTURE.md +++ b/INFRASTRUCTURE.md @@ -410,7 +410,7 @@ export const trackPerformance = (metric: string, value: number) => { ```typescript // Redis caching configuration export const CACHE_CONFIG = { - // Bill data cache (30 days) + // Bill data cache (~6 months) BILL_TTL: 30 * 24 * 60 * 60, // Admin session cache (24 hours) @@ -496,7 +496,7 @@ const securityHeaders = [ ### Data Privacy - **No personal data collection**: Anonymous user IDs only -- **Temporary storage**: 30-day automatic expiration +- **Temporary storage**: auto-deletes after ~6 months - **Input masking**: Sensitive data masked in session recordings - **GDPR compliant**: No persistent user tracking diff --git a/README.md b/README.md index 335c6a7..b82fe51 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A modern, collaborative bill-splitting app with real-time sync and universal sha * **Shareable Links**: Generate unique URLs that work for anyone, anywhere * **Real-time Collaboration**: Multiple people can edit the same bill simultaneously * **Cloud Sync**: Automatic syncing with visual status indicators -* **No Accounts Required**: Anonymous, temporary bill storage (30-day expiration) +* **No Accounts Required**: Anonymous, temporary bill storage (auto-deletes after ~6 months) ### πŸ’° **Smart Bill Management** * **Intelligent Status System**: Draft β†’ Active β†’ Closed workflow with contextual actions diff --git a/README.simple.md b/README.simple.md new file mode 100644 index 0000000..7580d4a --- /dev/null +++ b/README.simple.md @@ -0,0 +1,65 @@ +# SplitSimple β€” Quick Start + +SplitSimple is a modern billsplitting tool built with Next.js, TypeScript, Tailwind, and Redis. It keeps everyone in sync while you divide receipts line-by-line. + +## Features + +- Per-item splitting (even, shares, percent, exact) with penny-safe math +- Tax/tip/discount allocation (proportional or even) +- Auto-save to local storage + optional cloud share links (Redis) +- Undo/redo history, keyboard shortcuts, and CSV/export summaries +- Responsive UI with a dedicated mobile workflow + +## Requirements + +- Node.js 18+ +- pnpm 9+ +- Redis URL (for sharing) stored in `.env.local` as `REDIS_URL` + +## Develop + +```bash +pnpm install +pnpm dev +# open http://localhost:3000 +``` + +Helpful scripts: + +- `pnpm lint` – ESLint/Next checks +- `pnpm typecheck` – TypeScript +- `pnpm test` – Jest suite (`pnpm test:coverage` for coverage) +- `pnpm dev:clean` – Clear `.next` cache before starting dev + +## Environment + +```ini +REDIS_URL="redis://..." +NEXT_PUBLIC_POSTHOG_KEY="optional analytics" +NEXT_PUBLIC_POSTHOG_HOST="https://app.posthog.com" +OCR_PROVIDER="google" # or openai/anthropic +``` + +If OCR keys are missing the app falls back to mock data. + +## Deploy + +1. Provision Redis (Vercel KV or any managed Redis) +2. Set env vars above +3. `pnpm build && pnpm start` (or deploy via Vercel/GitHub Actions) + +## Project Structure + +- `components/` – UI (desktop + mobile-specific views) +- `contexts/` – `BillContext` reducer/history, sync helpers +- `lib/` – calculations, validation, sharing/export helpers +- `app/api/` – Next.js route handlers for sharing +- `tests/` – Jest helpers + MSW mocks + +## CI + +`.github/workflows/test.yml` runs lint, typecheck, unit tests, and Codecov upload; integration tests spin up Redis and run targeted suites. + +## License + +Β© SplitSimple team β€” redistribute under the repository’s LICENSE. diff --git a/RECEIPT_API_PLAN.md b/RECEIPT_API_PLAN.md new file mode 100644 index 0000000..3693963 --- /dev/null +++ b/RECEIPT_API_PLAN.md @@ -0,0 +1,198 @@ +# Backend API Integration Plan: Gemini Vision OCR + +## Overview +Replace the mock OCR service with a real backend API call to Google Gemini Vision API (gemini-3 model) for receipt scanning. + +## Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ ReceiptScanner β”‚ (Client Component) +β”‚ Component β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ POST /api/receipt/scan + β”‚ (multipart/form-data) + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ /api/receipt/ β”‚ (Next.js API Route) +β”‚ scan β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”œβ”€β–Ί Validate file (size, type) + β”œβ”€β–Ί Convert to base64 + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Gemini Vision β”‚ (Google AI SDK) +β”‚ API β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”œβ”€β–Ί Send image + prompt + β”œβ”€β–Ί Receive JSON response + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Response β”‚ +β”‚ Parser β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”œβ”€β–Ί Extract items (name, price, qty) + β”œβ”€β–Ί Validate & sanitize + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Return Items β”‚ +β”‚ to Client β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## Implementation Steps + +### 1. Environment Setup +- **File**: `.env.local` (add to `.env.example`) +- **Variable**: `GEMINI_API_KEY` +- **Validation**: Update `lib/env-validation.ts` to check for this key + +### 2. Install Dependencies +```bash +npm install @google/generative-ai +``` + +### 3. Create API Route +- **File**: `app/api/receipt/scan/route.ts` +- **Method**: POST +- **Input**: multipart/form-data with `file` field +- **Output**: JSON with `items` array + +### 4. Gemini Integration Service +- **File**: `lib/gemini-ocr.ts` +- **Functions**: + - `scanReceiptImage(imageBase64: string): Promise` + - `parseGeminiResponse(response: string): OCRResult['items']` + - `validateAndSanitizeItems(items: any[]): OCRResult['items']` + +### 5. Update Client Code +- **File**: `lib/mock-ocr.ts` +- **Change**: Replace `simulateOCR` with real API call +- **File**: `components/ReceiptScanner.tsx` +- **Change**: Update `processImage` to call new API endpoint + +### 6. Error Handling +- Network failures β†’ Show retry option +- API errors β†’ Fallback to mock (development) or show error +- Invalid responses β†’ Graceful degradation +- Rate limiting β†’ User-friendly message + +### 7. Testing Strategy +- Unit tests for response parsing +- Integration tests for API route +- Mock Gemini responses for development +- Error scenario testing + +## API Route Specification + +### Endpoint +`POST /api/receipt/scan` + +### Request +- **Content-Type**: `multipart/form-data` +- **Body**: + - `file`: Image file (JPG, PNG, HEIC) + - Max size: 5MB + +### Response (Success) +```json +{ + "success": true, + "items": [ + { + "name": "Garlic Naan", + "price": "4.50", + "quantity": 1 + }, + { + "name": "Butter Chicken", + "price": "16.00", + "quantity": 2 + } + ], + "confidence": "high" +} +``` + +### Response (Error) +```json +{ + "success": false, + "error": "Invalid file format", + "code": "INVALID_FILE" +} +``` + +## Gemini Prompt Engineering + +### System Prompt +``` +You are a receipt OCR system. Extract all line items from this receipt image. + +For each item, identify: +1. Item name (clean, no special characters) +2. Price (numeric value only, as string) +3. Quantity (default to 1 if not specified) + +Return ONLY a valid JSON array in this exact format: +[ + {"name": "Item Name", "price": "12.99", "quantity": 1}, + {"name": "Another Item", "price": "5.50", "quantity": 2} +] + +Do not include: +- Tax lines +- Tip lines +- Subtotal/total lines +- Store information +- Dates/times + +If you cannot identify items clearly, return an empty array []. +``` + +## Error Codes + +- `INVALID_FILE`: File type not supported +- `FILE_TOO_LARGE`: File exceeds 5MB +- `GEMINI_API_ERROR`: Gemini API returned an error +- `PARSE_ERROR`: Could not parse Gemini response +- `NO_ITEMS_FOUND`: No items detected in receipt +- `NETWORK_ERROR`: Network request failed + +## Fallback Strategy + +1. **Development Mode**: If `GEMINI_API_KEY` not set, use mock data +2. **API Failure**: Show error with "Try Again" button +3. **Empty Results**: Suggest manual entry or text paste +4. **Rate Limiting**: Queue requests or show "Please wait" message + +## Security Considerations + +- Validate file types server-side +- Enforce file size limits +- Sanitize API responses +- Never expose API key to client +- Rate limiting (future enhancement) + +## Performance Optimizations + +- Compress images before sending (if > 1MB) +- Cache common receipt formats (future) +- Stream responses for large receipts (future) +- Optimize Gemini prompt for faster responses + +## Future Enhancements + +- Batch processing multiple receipts +- Receipt format learning/adaptation +- Confidence scores per item +- Support for multiple currencies +- Receipt metadata extraction (date, store name) + + diff --git a/RECEIPT_SCANNING_SETUP.md b/RECEIPT_SCANNING_SETUP.md new file mode 100644 index 0000000..288b288 --- /dev/null +++ b/RECEIPT_SCANNING_SETUP.md @@ -0,0 +1,129 @@ +# Receipt Scanning Setup + +## Overview + +The receipt scanning feature uses the [Vercel AI SDK](https://ai-sdk.dev/) to support multiple AI providers. You can easily switch between providers by changing environment variables - no code changes needed! + +## Supported Providers + +- **Google (Gemini)** - Default, fast and cost-effective +- **OpenAI (GPT-4o)** - High accuracy +- **Anthropic (Claude)** - Excellent for complex receipts + +## Environment Variables + +### Quick Start (One API Key Only!) + +You only need to add **one API key** for the provider you want to use: + +**Option 1: Use Google Gemini (Recommended - Fast & Free)** +```bash +# Default provider, no OCR_PROVIDER needed +GOOGLE_GENERATIVE_AI_API_KEY=your_gemini_key_here +``` + +**Option 2: Use OpenAI** +```bash +OCR_PROVIDER=openai +OPENAI_API_KEY=your_openai_key_here +``` + +**Option 3: Use Anthropic Claude** +```bash +OCR_PROVIDER=anthropic +ANTHROPIC_API_KEY=your_anthropic_key_here +``` + +### Advanced Configuration + +```bash +# Choose your provider (default: google) +OCR_PROVIDER=google # or "openai" or "anthropic" + +# Optional: Override default model +OCR_MODEL=gemini-1.5-pro # Provider-specific model name +``` + +## Getting API Keys + +### Google (Gemini) - Recommended +1. Go to [Google AI Studio](https://makersuite.google.com/app/apikey) +2. Sign in with your Google account +3. Click "Create API Key" +4. Copy the key and add to `.env.local` + +### OpenAI +1. Go to [OpenAI Platform](https://platform.openai.com/api-keys) +2. Sign in and create a new API key +3. Add to `.env.local` + +### Anthropic +1. Go to [Anthropic Console](https://console.anthropic.com/) +2. Create an API key +3. Add to `.env.local` + +## Example Configuration + +**Minimal setup (Google Gemini - default):** +```bash +GOOGLE_GENERATIVE_AI_API_KEY=your_gemini_key +``` + +**Switch to OpenAI:** +```bash +OCR_PROVIDER=openai +OPENAI_API_KEY=your_openai_key +``` + +**Switch to Anthropic:** +```bash +OCR_PROVIDER=anthropic +ANTHROPIC_API_KEY=your_anthropic_key +``` + +**With custom model:** +```bash +OCR_PROVIDER=google +GOOGLE_GENERATIVE_AI_API_KEY=your_key +OCR_MODEL=gemini-1.5-pro +``` + +## Supported Models + +### Google +- `gemini-2.0-flash` (default, fastest) +- `gemini-1.5-flash` (fast) +- `gemini-1.5-pro` (more accurate) +- `gemini-2.0-flash-exp` (experimental) + +### OpenAI +- `gpt-4o` (default, best for images) +- `gpt-4o-mini` (faster, cheaper) +- `gpt-4-turbo` (alternative) + +### Anthropic +- `claude-sonnet-4-20250514` (default, best balance) +- `claude-3-opus-20240229` (most accurate) +- `claude-3-5-haiku-20241022` (fastest) + +## Fallback Behavior + +- If no API key is set, the app will use mock data (development mode) +- If the API call fails, the app will show an error message to the user +- The "Paste Text" option always works and doesn't require an API key + +## Testing + +1. **Without API key**: The app will use mock data automatically +2. **With API key**: Upload a receipt image to test real OCR functionality +3. **Switch providers**: Change `OCR_PROVIDER` and restart to test different models +4. **Error cases**: Try uploading invalid files or very large images to test error handling + +## Benefits of AI SDK + +- **Unified API**: Same code works with all providers +- **Easy Switching**: Change providers via environment variables +- **Type Safety**: Structured outputs with Zod schemas +- **No Parsing**: AI SDK handles JSON parsing automatically +- **Future-Proof**: Easy to add new providers as they become available + diff --git a/app/api/admin/bills/[id]/route.ts b/app/api/admin/bills/[id]/route.ts index c13847f..b303d91 100644 --- a/app/api/admin/bills/[id]/route.ts +++ b/app/api/admin/bills/[id]/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import { adminAuthMiddleware } from '@/lib/admin-auth' import { executeRedisOperation } from '@/lib/redis-pool' import { validateEnvironment } from '@/lib/env-validation' -import type { Bill } from '@/contexts/BillContext' +import { STORAGE } from '@/lib/constants' async function getBillHandler( req: NextRequest, @@ -67,7 +67,7 @@ async function updateBillHandler( updatedBill.lastModified = new Date().toISOString() await executeRedisOperation(async (redis) => { - await redis.setEx(key, 30 * 24 * 60 * 60, JSON.stringify(updatedBill)) + await redis.setEx(key, STORAGE.BILL_TTL_SECONDS, JSON.stringify(updatedBill)) }) return NextResponse.json({ @@ -150,4 +150,4 @@ async function extendBillHandler( export const GET = adminAuthMiddleware(getBillHandler) export const PUT = adminAuthMiddleware(updateBillHandler) export const DELETE = adminAuthMiddleware(deleteBillHandler) -export const PATCH = adminAuthMiddleware(extendBillHandler) \ No newline at end of file +export const PATCH = adminAuthMiddleware(extendBillHandler) diff --git a/app/api/admin/bills/route.ts b/app/api/admin/bills/route.ts index 9a4b73a..39c99ca 100644 --- a/app/api/admin/bills/route.ts +++ b/app/api/admin/bills/route.ts @@ -4,6 +4,7 @@ import { executeRedisOperation } from '@/lib/redis-pool' import { validateEnvironment } from '@/lib/env-validation' import { getBillSummary } from '@/lib/calculations' import type { Bill } from '@/contexts/BillContext' +import { STORAGE } from '@/lib/constants' interface BillMetadata { id: string @@ -68,7 +69,7 @@ async function getAllBillsHandler(req: NextRequest) { const billSummary = getBillSummary(billData) // For bills without timestamps, estimate based on Redis key order or use a reasonable fallback const estimatedCreatedAt = billData.createdAt || - (ttl > 0 ? new Date(Date.now() - (30 * 24 * 60 * 60 * 1000 - ttl * 1000)).toISOString() : + (ttl > 0 ? new Date(Date.now() - (STORAGE.BILL_TTL_SECONDS * 1000 - ttl * 1000)).toISOString() : new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString()) // Default to 1 week ago const metadata: BillMetadata = { @@ -358,4 +359,4 @@ async function getAllBillsHandler(req: NextRequest) { } } -export const GET = adminAuthMiddleware(getAllBillsHandler) \ No newline at end of file +export const GET = adminAuthMiddleware(getAllBillsHandler) diff --git a/app/api/bills/[id]/route.ts b/app/api/bills/[id]/route.ts index 4edd31f..e01fffa 100644 --- a/app/api/bills/[id]/route.ts +++ b/app/api/bills/[id]/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server" import type { Bill } from "@/contexts/BillContext" import { executeRedisOperation } from "@/lib/redis-pool" import { validateEnvironment } from "@/lib/env-validation" +import { STORAGE } from "@/lib/constants" // GET /api/bills/[id] - Retrieve a shared bill export async function GET( @@ -44,7 +45,7 @@ export async function GET( // Update the bill with incremented access count await client.setEx( `bill:${billId}`, - 30 * 24 * 60 * 60, // Maintain original TTL + STORAGE.BILL_TTL_SECONDS, JSON.stringify(updatedBill) ) @@ -100,8 +101,18 @@ export async function POST( ) } - const body = await request.json() - const bill: Bill = body.bill + let body: any + try { + body = await request.json() + } catch (parseError) { + console.error("Invalid JSON body received:", parseError) + return NextResponse.json( + { error: "Invalid JSON body" }, + { status: 400 } + ) + } + + const bill: Bill = body?.bill if (!bill || typeof bill !== 'object') { return NextResponse.json( @@ -121,10 +132,9 @@ export async function POST( // Use connection pool for Redis operation await executeRedisOperation(async (client) => { - // Store bill in Redis with 30-day expiration (2,592,000 seconds) await client.setEx( `bill:${billId}`, - 30 * 24 * 60 * 60, // 30 days in seconds + STORAGE.BILL_TTL_SECONDS, JSON.stringify(billWithMetadata) ) }) diff --git a/app/api/bills/__tests__/route.test.ts b/app/api/bills/__tests__/route.test.ts index 39f9e24..b900d32 100644 --- a/app/api/bills/__tests__/route.test.ts +++ b/app/api/bills/__tests__/route.test.ts @@ -4,6 +4,7 @@ import { NextRequest } from 'next/server' import { GET, POST } from '../[id]/route' import { createMockBill } from '../../../../tests/utils/test-utils' +import { STORAGE } from '@/lib/constants' // Mock Redis client const mockRedisClient = { @@ -143,7 +144,7 @@ describe('/api/bills/[id] route', () => { expect(mockRedisClient.connect).toHaveBeenCalled() expect(mockRedisClient.setEx).toHaveBeenCalledWith( 'bill:test-bill-id', - 30 * 24 * 60 * 60, // 30 days + STORAGE.BILL_TTL_SECONDS, JSON.stringify(testBill) ) expect(mockRedisClient.disconnect).toHaveBeenCalled() @@ -286,7 +287,7 @@ describe('/api/bills/[id] route', () => { expect(mockRedisClient.setEx).toHaveBeenCalledWith( 'bill:test-id', - 2592000, // 30 days in seconds + STORAGE.BILL_TTL_SECONDS, expect.any(String) ) }) @@ -325,4 +326,4 @@ describe('/api/bills/[id] route', () => { expect(mockRedisClient.disconnect).toHaveBeenCalled() }) }) -}) \ No newline at end of file +}) diff --git a/app/api/receipt/scan/route.ts b/app/api/receipt/scan/route.ts new file mode 100644 index 0000000..f6569ca --- /dev/null +++ b/app/api/receipt/scan/route.ts @@ -0,0 +1,230 @@ +import { NextRequest, NextResponse } from "next/server" +import { scanReceiptImage, type OCRProvider } from "@/lib/receipt-ocr" +import sharp from "sharp" + +const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB +const ALLOWED_MIME_TYPES = [ + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/heic', + 'image/heif', + 'image/webp' +] + +export async function POST(request: NextRequest) { + try { + // Get provider configuration from environment + const provider = (process.env.OCR_PROVIDER as OCRProvider) || "google" + const model = process.env.OCR_MODEL + + // Check if API key exists for the provider + const apiKey = getApiKeyForProvider(provider) + if (!apiKey) { + return NextResponse.json( + { + success: false, + error: `${provider} API key not configured`, + code: "API_KEY_MISSING", + provider + }, + { status: 500 } + ) + } + + // Parse multipart form data + const formData = await request.formData() + const file = formData.get('file') as File | null + + if (!file) { + return NextResponse.json( + { + success: false, + error: "No file provided", + code: "INVALID_FILE" + }, + { status: 400 } + ) + } + + // Validate file type + if (!ALLOWED_MIME_TYPES.includes(file.type)) { + return NextResponse.json( + { + success: false, + error: `Invalid file type. Allowed: ${ALLOWED_MIME_TYPES.join(', ')}`, + code: "INVALID_FILE" + }, + { status: 400 } + ) + } + + // Validate file size + if (file.size > MAX_FILE_SIZE) { + return NextResponse.json( + { + success: false, + error: `File too large. Maximum size: ${MAX_FILE_SIZE / 1024 / 1024}MB`, + code: "FILE_TOO_LARGE" + }, + { status: 400 } + ) + } + + // Convert file to base64 and handle preview generation + let imageBase64: string + let previewBase64: string | undefined + let mimeType: string + + try { + const arrayBuffer = await file.arrayBuffer() + const buffer: Buffer = Buffer.from(arrayBuffer) + + // Check if it's HEIC/HEIF + const isHeic = file.type === 'image/heic' || + file.type === 'image/heif' || + file.name.toLowerCase().endsWith('.heic') || + file.name.toLowerCase().endsWith('.heif') + + if (isHeic) { + // HEIC files: Skip preview generation, but send to AI (they support HEIC natively) + console.log('HEIC detected - skipping preview, sending directly to AI OCR...') + mimeType = file.type || 'image/heic' + imageBase64 = buffer.toString('base64') + previewBase64 = undefined // No preview for HEIC + } else { + // Non-HEIC files: Process normally with preview + mimeType = file.type || 'image/jpeg' + imageBase64 = buffer.toString('base64') + + // Create a smaller preview image (max 1200px width) + try { + const previewBuffer = await sharp(buffer) + .resize(1200, null, { + fit: 'inside', + withoutEnlargement: true + }) + .jpeg({ quality: 85 }) + .toBuffer() + + previewBase64 = previewBuffer.toString('base64') + console.log('Preview image created successfully') + } catch (previewError) { + console.error('Preview creation error:', previewError) + // If preview creation fails, use original image + previewBase64 = imageBase64 + } + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + console.error('Image processing error:', errorMsg, error) + return NextResponse.json( + { + success: false, + error: `Failed to process image file: ${errorMsg}`, + code: "FILE_PROCESSING_ERROR" + }, + { status: 400 } + ) + } + + // Call OCR API (provider-agnostic) + try { + const result = await scanReceiptImage(imageBase64, mimeType, { + provider, + model + }) + + if (result.items.length === 0) { + return NextResponse.json( + { + success: true, + items: [], + confidence: "low", + warning: "No items detected in receipt", + provider, + preview: previewBase64 ? `data:image/jpeg;base64,${previewBase64}` : undefined + }, + { status: 200 } + ) + } + + return NextResponse.json( + { + success: true, + items: result.items, + confidence: "high", + provider, + preview: previewBase64 ? `data:image/jpeg;base64,${previewBase64}` : undefined + }, + { status: 200 } + ) + } catch (error) { + console.error("OCR API error:", error) + + const errorMessage = error instanceof Error ? error.message : "Unknown error" + + // Check for specific error types + if (errorMessage.includes("API key")) { + return NextResponse.json( + { + success: false, + error: "Invalid API key", + code: "API_ERROR", + provider + }, + { status: 401 } + ) + } + + if (errorMessage.includes("quota") || errorMessage.includes("rate")) { + return NextResponse.json( + { + success: false, + error: "API rate limit exceeded. Please try again later.", + code: "RATE_LIMIT_ERROR", + provider + }, + { status: 429 } + ) + } + + return NextResponse.json( + { + success: false, + error: "Failed to process receipt", + code: "OCR_API_ERROR", + details: errorMessage, + provider + }, + { status: 500 } + ) + } + } catch (error) { + console.error("Unexpected error in receipt scan API:", error) + return NextResponse.json( + { + success: false, + error: "Internal server error", + code: "INTERNAL_ERROR" + }, + { status: 500 } + ) + } +} + +/** + * Get API key for the specified provider + */ +function getApiKeyForProvider(provider: OCRProvider): string | undefined { + switch (provider) { + case "google": + return process.env.GOOGLE_GENERATIVE_AI_API_KEY || process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY + case "openai": + return process.env.OPENAI_API_KEY + case "anthropic": + return process.env.ANTHROPIC_API_KEY + default: + return undefined + } +} diff --git a/app/globals.css b/app/globals.css index 8777d1b..1c62062 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,5 +1,6 @@ @import "tailwindcss"; @import "tw-animate-css"; +@import url('https://fonts.googleapis.com/css2?family=Space+Mono:ital,wght@0,400;0,700;1,400&family=Inter:wght@400;500;600;700&display=swap'); @custom-variant dark (&:is(.dark *)); @@ -1027,4 +1028,115 @@ .dark * { scrollbar-color: #44403C #1A1918; } + + /* ===== Pro SaaS Design System ===== */ + .pro-app-shell { + @apply h-screen 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; + z-index: 20; + background: rgba(255, 255, 255, 0.8); + backdrop-filter: blur(8px); + border-bottom: 1px solid #E2E8F0; /* slate-200 */ + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); + } + + .pro-footer { + @apply fixed bottom-0 left-0 right-0 flex items-center justify-between px-6; + height: 56px; + z-index: 40; + background: #FFFFFF; + border-top: 1px solid #E2E8F0; + box-shadow: 0 -4px 6px -1px rgba(0, 0, 0, 0.05); + } + + .pro-main { + @apply absolute inset-0 overflow-hidden; + padding-top: 64px; + padding-bottom: 56px; + z-index: 10; + } + + .pro-grid { + display: grid; + grid-template-columns: + 48px + minmax(240px, 2fr) + 100px + 80px + repeat(var(--person-count, 3), minmax(100px, 1fr)) + 100px; + } + + .pro-sticky-left { + @apply sticky left-0 bg-white z-20; + box-shadow: 2px 0 5px -2px rgba(0, 0, 0, 0.05); + } + + .pro-sticky-right { + @apply sticky right-0 bg-white z-20; + box-shadow: -2px 0 5px -2px rgba(0, 0, 0, 0.05); + } + + .pro-grid-header { + @apply sticky top-0 bg-white border-b border-slate-200; + z-index: 30; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); + } + + .pro-tile-inactive { + @apply flex items-center justify-center transition-all duration-100; + color: #94A3B8; /* slate-400 */ + background: transparent; + } + + .pro-tile-active { + @apply flex items-center justify-center rounded-md transition-all duration-100; + color: white; + } + + .pro-tile-active:hover { + filter: brightness(0.95); + } + + .pro-tile-active:active { + transform: scale(0.95); + } + + .font-space-mono { + font-family: 'Space Mono', monospace; + } + + .font-inter { + font-family: 'Inter', sans-serif; + } + + /* Minimal scrollbar for pro design */ + .pro-scrollbar::-webkit-scrollbar { + width: 6px; + height: 6px; + } + + .pro-scrollbar::-webkit-scrollbar-track { + background: transparent; + } + + .pro-scrollbar::-webkit-scrollbar-thumb { + background: #CBD5E1; /* slate-300 */ + border-radius: 3px; + } + + .pro-scrollbar::-webkit-scrollbar-thumb:hover { + background: #94A3B8; /* slate-400 */ + } + + .pro-scrollbar { + scrollbar-width: thin; + scrollbar-color: #CBD5E1 transparent; + } } diff --git a/app/page.tsx b/app/page.tsx index fe9415a..c91c92f 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,633 +1,7 @@ "use client" -import React from "react" -import dynamic from "next/dynamic" -import { LedgerItemsTable } from "@/components/LedgerItemsTable" -import { MobileLedgerView } from "@/components/MobileLedgerView" -import { PeopleBreakdownTable } from "@/components/PeopleBreakdownTable" -import { TaxTipSection } from "@/components/TaxTipSection" -import { TotalsPanel } from "@/components/TotalsPanel" -import { MobileTotalsBar } from "@/components/MobileTotalsBar" -import { MobileFirstUI } from "@/components/MobileFirstUI" -import { MobileActionButton, MobileActionSpacer } from "@/components/MobileActionButton" -import { BillLookup } from "@/components/BillLookup" -import { Input } from "@/components/ui/input" -import { Button } from "@/components/ui/button" -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" -import { Receipt, Plus, Copy, Share2, Users, Download } from "lucide-react" -import { getBillFromCloud } from "@/lib/sharing" -import { useBill } from "@/contexts/BillContext" -import { useToast } from "@/hooks/use-toast" -import { generateSummaryText, copyToClipboard } from "@/lib/export" -import { migrateBillSchema } from "@/lib/validation" -import { useState, useEffect, useRef } from "react" -import { useBillAnalytics } from "@/hooks/use-analytics" -import { useIsMobile } from "@/hooks/use-mobile" -import { BillStatusIndicator } from "@/components/BillStatusIndicator" -import { SyncStatusIndicator } from "@/components/SyncStatusIndicator" -import { TIMING } from "@/lib/constants" - -// Lazy load heavy components -const ShareBill = dynamic(() => import("@/components/ShareBill").then(mod => ({ default: mod.ShareBill })), { - loading: () => -}) - -const KeyboardShortcutsHelp = dynamic(() => import("@/components/KeyboardShortcutsHelp").then(mod => ({ default: mod.KeyboardShortcutsHelp })), { - loading: () => null -}) +import { ProBillSplitter } from "@/components/ProBillSplitter" export default function HomePage() { - const { state, dispatch } = useBill() - const { toast } = useToast() - const analytics = useBillAnalytics() - const [isAddingPerson, setIsAddingPerson] = useState(false) - const [showShortcutsHelp, setShowShortcutsHelp] = useState(false) - const [showLoadBillDialog, setShowLoadBillDialog] = useState(false) - const [loadBillId, setLoadBillId] = useState("") - const personInputRef = useRef(null) - const titleInputRef = useRef(null) - const isMobile = useIsMobile() - const [isInitialLoad, setIsInitialLoad] = useState(true) - const [previousTitle, setPreviousTitle] = useState(state.currentBill.title) - - const isNewBillFlow = - isInitialLoad && state.currentBill.title === "New Bill" && state.currentBill.people.length === 0 - - useEffect(() => { - if (isNewBillFlow) { - titleInputRef.current?.focus() - titleInputRef.current?.select() - setIsInitialLoad(false) - } - }, [isNewBillFlow]) - - useEffect(() => { - if (isAddingPerson) { - setTimeout(() => personInputRef.current?.focus(), 0) - } - }, [isAddingPerson]) - - // Listen for bill load events from BillContext - useEffect(() => { - const handleBillLoaded = (event: Event) => { - const customEvent = event as CustomEvent - const { title, people, items } = customEvent.detail - toast({ - title: "Bill loaded!", - description: `"${title}" with ${people} people and ${items} items`, - }) - analytics.trackFeatureUsed("load_shared_bill_success") - } - - const handleBillLoadFailed = (event: Event) => { - const customEvent = event as CustomEvent - const { billId, error } = customEvent.detail - toast({ - title: "Failed to load bill", - description: error || `Bill ${billId.slice(0, 8)}... not found or expired`, - variant: "destructive", - }) - analytics.trackError("load_shared_bill_failed", error || "Bill not found") - } - - window.addEventListener('bill-loaded-success', handleBillLoaded) - window.addEventListener('bill-load-failed', handleBillLoadFailed) - - return () => { - window.removeEventListener('bill-loaded-success', handleBillLoaded) - window.removeEventListener('bill-load-failed', handleBillLoadFailed) - } - }, [toast, analytics]) - - const handleTitleChange = (e: React.ChangeEvent) => { - const newTitle = e.target.value - dispatch({ type: "SET_BILL_TITLE", payload: newTitle }) - - if (newTitle !== previousTitle) { - setPreviousTitle(newTitle) - } - } - - useEffect(() => { - if (previousTitle !== state.currentBill.title && previousTitle !== "New Bill") { - const timeoutId = setTimeout(() => { - analytics.trackTitleChanged(state.currentBill.title) - }, 1000) - - return () => clearTimeout(timeoutId) - } - return undefined - }, [state.currentBill.title, previousTitle, analytics]) - - const handleTitleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault() - setIsAddingPerson(true) - } - } - - const handleNewBill = React.useCallback(() => { - dispatch({ type: "NEW_BILL" }) - analytics.trackBillCreated() - analytics.trackFeatureUsed("new_bill") - }, [dispatch, analytics]) - - const handleCopySummary = React.useCallback(async () => { - if (state.currentBill.people.length === 0) { - toast({ - title: "No data to copy", - description: "Add people and items to generate a summary", - variant: "destructive", - }) - analytics.trackError("copy_summary_failed", "No data to copy") - return - } - - const summaryText = generateSummaryText(state.currentBill) - const success = await copyToClipboard(summaryText) - - if (success) { - toast({ - title: "Summary copied!", - description: "Bill summary has been copied to your clipboard", - }) - analytics.trackBillSummaryCopied() - analytics.trackFeatureUsed("copy_summary") - } else { - toast({ - title: "Copy failed", - description: "Unable to copy to clipboard. Please try again.", - variant: "destructive", - }) - analytics.trackError("copy_summary_failed", "Clipboard API failed") - } - }, [state.currentBill, toast, analytics]) - - const handleAddPerson = () => { - setIsAddingPerson(true) - analytics.trackFeatureUsed("mobile_add_person") - } - - const handleAddItem = () => { - analytics.trackFeatureUsed("mobile_add_item") - } - - const handleLoadBill = async () => { - if (!loadBillId.trim()) { - toast({ - title: "Enter a bill ID", - description: "Please enter a valid bill ID to load", - variant: "destructive", - }) - return - } - - try { - const result = await getBillFromCloud(loadBillId.trim()) - - if (result.bill) { - // Migration: Add missing fields - const migratedBill = migrateBillSchema(result.bill) - - dispatch({ type: "LOAD_BILL", payload: migratedBill }) - setShowLoadBillDialog(false) - setLoadBillId("") - - toast({ - title: "Bill loaded!", - description: `"${result.bill.title}" with ${result.bill.people.length} people and ${result.bill.items.length} items`, - }) - analytics.trackFeatureUsed("manual_load_bill") - } else { - toast({ - title: "Bill not found", - description: result.error || "The bill ID may be invalid or expired", - variant: "destructive", - }) - analytics.trackError("manual_load_bill_failed", result.error || "Bill not found") - } - } catch (error) { - toast({ - title: "Error loading bill", - description: error instanceof Error ? error.message : "Unknown error occurred", - variant: "destructive", - }) - analytics.trackError("manual_load_bill_error", error instanceof Error ? error.message : "Unknown error") - } - } - - // Global keyboard shortcuts - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - // Only trigger if not in an input field - const target = e.target as HTMLElement - if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.contentEditable === 'true') { - return - } - - // N: Add new item - if (e.key === 'n' || e.key === 'N') { - e.preventDefault() - const newItem = { - name: "", - price: "", - quantity: 1, - splitWith: state.currentBill.people.map((p) => p.id), - method: "even" as const, - } - dispatch({ type: "ADD_ITEM", payload: newItem }) - analytics.trackFeatureUsed("keyboard_shortcut_add_item") - } - - // P: Add person - if (e.key === 'p' || e.key === 'P') { - e.preventDefault() - setIsAddingPerson(true) - analytics.trackFeatureUsed("keyboard_shortcut_add_person") - } - - // C: Copy summary - if (e.key === 'c' || e.key === 'C') { - e.preventDefault() - handleCopySummary() - analytics.trackFeatureUsed("keyboard_shortcut_copy") - } - - // S: Share - if (e.key === 's' || e.key === 'S') { - e.preventDefault() - analytics.trackFeatureUsed("keyboard_shortcut_share") - document.getElementById('share-bill-trigger')?.click() - } - - // Cmd/Ctrl + N: New bill - if ((e.metaKey || e.ctrlKey) && e.key === 'n') { - e.preventDefault() - handleNewBill() - analytics.trackFeatureUsed("keyboard_shortcut_new_bill") - } - - // Cmd/Ctrl + Z: Undo - if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) { - e.preventDefault() - dispatch({ type: 'UNDO' }) - toast({ - title: "Undo", - description: "Previous action has been undone", - duration: TIMING.TOAST_SHORT, - }) - analytics.trackFeatureUsed("keyboard_shortcut_undo") - } - - // Cmd/Ctrl + Shift + Z: Redo - if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'z') { - e.preventDefault() - dispatch({ type: 'REDO' }) - toast({ - title: "Redo", - description: "Action has been restored", - duration: TIMING.TOAST_SHORT, - }) - analytics.trackFeatureUsed("keyboard_shortcut_redo") - } - - // ?: Show keyboard shortcuts help - if (e.key === '?') { - e.preventDefault() - setShowShortcutsHelp(true) - analytics.trackFeatureUsed("keyboard_shortcut_help") - } - } - - document.addEventListener('keydown', handleKeyDown) - return () => document.removeEventListener('keydown', handleKeyDown) - }, [dispatch, analytics, handleCopySummary, handleNewBill, setIsAddingPerson, state.currentBill.people]) - - return ( -
- {/* Receipt-Style Header */} -
-
-
- {/* Left: App branding & Receipt ID */} -
-
-
- - - {/* Center: Bill Title */} -
- -
- - {/* Right: Status & Sync */} -
- - -
-
-
-
- - {/* Main Content - Receipt Container */} -
- {/* Vertical Stack Layout */} -
- {/* Mobile First UI */} - {isMobile && state.currentBill.people.length === 0 ? ( - - ) : null} - - {/* Desktop Layout - Always show on desktop */} - {!isMobile && ( - <> - {/* Section 1: People Breakdown - Always visible */} - - - {/* Section 2: Items Ledger - Only when people exist - Staggered animation */} - {state.currentBill.people.length > 0 && ( -
- -
- )} - - {/* Section 3: Payment Summary - Only when people exist - Staggered animation */} - {state.currentBill.people.length > 0 && ( -
- -
- )} - - )} - - {/* Mobile Ledger - Only when people exist */} - {isMobile && state.currentBill.people.length > 0 && ( - - )} -
- - {/* Mobile Action Button */} - {state.currentBill.people.length > 0 && ( - - )} - - -
- - - - {/* KEYBOARD SHORTCUTS BAR - Desktop Only */} - {!isMobile && state.currentBill.people.length > 0 && ( -
-
-
- - - - - - - -
- - {/* Hidden ShareBill trigger with id */} -
- -
-
- -
- - {/* Load Bill Dialog */} - - - - - - - Load Bill by ID - - Enter a bill ID to load a shared bill. You can find the bill ID in the share URL (after ?bill=) - - -
-
- setLoadBillId(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { - handleLoadBill() - } - }} - className="font-mono text-sm" - autoFocus - /> -

- Example: If the URL is ?bill=1763442653885-vlpkbu4,
- enter 1763442653885-vlpkbu4 -

-
-
- - -
-
-
-
- -
- - - - -
-
-
- )} - - {/* FOOTER */} - -
- ) -} \ No newline at end of file + return +} diff --git a/components/BillLookup.tsx b/components/BillLookup.tsx index 5a5a3e1..ccfbd59 100644 --- a/components/BillLookup.tsx +++ b/components/BillLookup.tsx @@ -13,7 +13,11 @@ import { useIsMobile } from "@/hooks/use-mobile" import { useBillAnalytics } from "@/hooks/use-analytics" import { cn } from "@/lib/utils" -export function BillLookup() { +interface BillLookupProps { + mode?: "auto" | "inline" +} + +export function BillLookup({ mode = "auto" }: BillLookupProps) { const { dispatch } = useBill() const { toast } = useToast() const analytics = useBillAnalytics() @@ -125,8 +129,10 @@ export function BillLookup() { if (error) setError(null) } + const shouldRenderSheet = mode === "auto" && isMobile + // Mobile: Sheet/Bottom drawer - if (isMobile) { + if (shouldRenderSheet) { return ( @@ -187,7 +193,7 @@ export function BillLookup() { ) } - // Desktop: Inline input + // Desktop or forced inline input return (
diff --git a/components/MobileSpreadsheetView.tsx b/components/MobileSpreadsheetView.tsx new file mode 100644 index 0000000..d863418 --- /dev/null +++ b/components/MobileSpreadsheetView.tsx @@ -0,0 +1,343 @@ +"use client" + +import { useMemo, useState, useEffect } from "react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet" +import { Card, CardContent } from "@/components/ui/card" +import { ChevronLeft, ChevronRight, Copy, Plus, Undo2, Redo2, FilePlus2, Search, Calculator } from "lucide-react" +import { useBill } from "@/contexts/BillContext" +import type { Item } from "@/contexts/BillContext" +import { calculateItemSplits, getBillSummary } from "@/lib/calculations" +import { PersonSelector } from "@/components/PersonSelector" +import { SplitMethodSelector } from "@/components/SplitMethodSelector" +import { SplitMethodInput } from "@/components/SplitMethodInput" +import { TaxTipSection } from "@/components/TaxTipSection" +import { formatCurrency } from "@/lib/utils" +import { copyToClipboard, generateSummaryText } from "@/lib/export" +import { useToast } from "@/hooks/use-toast" +import { useBillAnalytics } from "@/hooks/use-analytics" +import { SplitSimpleIcon } from "@/components/ProBillSplitter" +import { BillLookup } from "@/components/BillLookup" +import { ShareBill } from "@/components/ShareBill" +import { ReceiptScanner } from "@/components/ReceiptScanner" + +export function MobileSpreadsheetView() { + const { state, dispatch, canUndo, canRedo } = useBill() + const { toast } = useToast() + const analytics = useBillAnalytics() + const summary = useMemo(() => getBillSummary(state.currentBill), [state.currentBill]) + const [focusedIndex, setFocusedIndex] = useState(0) + const [totalsOpen, setTotalsOpen] = useState(false) + const [loadOpen, setLoadOpen] = useState(false) + const items = state.currentBill.items + const people = state.currentBill.people + const currentItem = items[focusedIndex] + + useEffect(() => { + if (focusedIndex >= items.length) { + setFocusedIndex(Math.max(0, items.length - 1)) + } + }, [items.length, focusedIndex]) + + const handleAddItem = () => { + dispatch({ + type: "ADD_ITEM", + payload: { + name: "", + price: "", + quantity: 1, + splitWith: people.map((p) => p.id), + method: "even", + }, + }) + setFocusedIndex(items.length) + analytics.trackFeatureUsed("mobile_add_item") + } + + const handleUpdateItem = (updates: Partial) => { + if (!currentItem) return + dispatch({ + type: "UPDATE_ITEM", + payload: { ...currentItem, ...updates }, + }) + } + + const handleCopySummary = async () => { + const summaryText = generateSummaryText(state.currentBill) + const success = await copyToClipboard(summaryText) + if (success) { + toast({ title: "Summary copied" }) + analytics.trackFeatureUsed("copy_summary_mobile") + } else { + toast({ title: "Copy failed", description: "Please try again", variant: "destructive" }) + } + } + + const handleScanImport = (scannedItems: Omit[]) => { + 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 personSplits = useMemo(() => { + if (!currentItem) return {} + return calculateItemSplits(currentItem, people) + }, [currentItem, people]) + + const handleNewBill = () => { + if (confirm("Start a new bill? Current bill will be lost if not shared.")) { + dispatch({ type: "NEW_BILL" }) + toast({ title: "New bill created" }) + setFocusedIndex(0) + analytics.trackFeatureUsed("mobile_new_bill") + } + } + + const renderPersonAmounts = () => { + if (!currentItem || people.length === 0) { + return null + } + + return ( +
+
+ Person shares + {currentItem.splitWith.length} selected +
+
+ {people.map((person) => { + if (!currentItem.splitWith.includes(person.id)) return null + const amount = personSplits[person.id] || 0 + return ( +
+
+ + {person.name} +
+ {formatCurrency(amount)} +
+ ) + })} +
+
+ ) + } + + const renderItemForm = () => { + if (!currentItem) { + return ( + + +

No items yet. Add your first item to start splitting.

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

Item

+

#{focusedIndex + 1} of {items.length}

+
+
+ + +
+
+ +
+
+ + handleUpdateItem({ name: e.target.value })} + placeholder="e.g., Wheelchair" + className="h-11 text-base" + /> +
+
+
+ + handleUpdateItem({ quantity: Math.max(1, Number(e.target.value) || 1) })} + className="h-11" + /> +
+
+ + handleUpdateItem({ price: e.target.value })} + className="h-11" + placeholder="0.00" + /> +
+
+
+ Total + + {formatCurrency((parseFloat(currentItem.price || "0") || 0) * (currentItem.quantity || 1))} + +
+
+ + handleUpdateItem({ splitWith: ids })} + size="md" + /> + + handleUpdateItem({ method })} + itemId={currentItem.id} + peopleCount={people.length} + assignedPeopleCount={currentItem.splitWith.length} + /> + + handleUpdateItem({ customSplits: splits })} + /> + + {renderPersonAmounts()} +
+
+ ) + } + + return ( +
+
+ +
+ dispatch({ type: "SET_BILL_TITLE", payload: e.target.value })} + className="h-9 text-base font-semibold border-none px-0 focus-visible:ring-0 bg-transparent" + /> +

SplitSimple

+
+
+ + +
+
+ +
+ + +
+
+

Grand total

+

{formatCurrency(summary.total)}

+
+
+
{people.length} people
+
{items.length} items
+
+
+ + + + + + + Totals & People + +
+ +
+
+
+
+
+ + {renderItemForm()} + +
+ +
+
+ + +
+
+ + + + + + + + + + Load Bill + + + + + +
+
+ +
+
+
+ ) +} diff --git a/components/ProBillSplitter.tsx b/components/ProBillSplitter.tsx new file mode 100644 index 0000000..eea1d78 --- /dev/null +++ b/components/ProBillSplitter.tsx @@ -0,0 +1,1641 @@ +"use client" + +import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react' +import { + Plus, + Search, + Trash2, + Grid as GridIcon, + X, + Equal, + FileText, + ClipboardCopy, + Eraser, + RotateCcw, + RotateCw, + FileQuestion, + Users, + Scale, + Percent, + Calculator, + ChevronDown, + Code, + Camera +} 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 { generateSummaryText, copyToClipboard } from '@/lib/export' +import { useToast } from '@/hooks/use-toast' +import { ShareBill } from '@/components/ShareBill' +import { SyncStatusIndicator } from '@/components/SyncStatusIndicator' +import { useBillAnalytics } from '@/hooks/use-analytics' +import { TIMING } from '@/lib/constants' +import { getBillFromCloud } from '@/lib/sharing' +import { migrateBillSchema } from '@/lib/validation' +import { useIsMobile } from '@/hooks/use-mobile' +import { MobileSpreadsheetView } from '@/components/MobileSpreadsheetView' + +import { ReceiptScanner } from '@/components/ReceiptScanner' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' + +export type SplitMethod = "even" | "shares" | "percent" | "exact" + +// --- DESIGN TOKENS --- +const COLORS = [ + { id: 'indigo', bg: 'bg-indigo-100', solid: 'bg-indigo-600', text: 'text-indigo-700', textSolid: 'text-white', hex: '#4F46E5' }, + { id: 'orange', bg: 'bg-orange-100', solid: 'bg-orange-500', text: 'text-orange-700', textSolid: 'text-white', hex: '#F97316' }, + { id: 'rose', bg: 'bg-rose-100', solid: 'bg-rose-500', text: 'text-rose-700', textSolid: 'text-white', hex: '#F43F5E' }, + { id: 'emerald', bg: 'bg-emerald-100', solid: 'bg-emerald-500', text: 'text-emerald-700', textSolid: 'text-white', hex: '#10B981' }, + { id: 'blue', bg: 'bg-blue-100', solid: 'bg-blue-500', text: 'text-blue-700', textSolid: 'text-white', hex: '#3B82F6' }, + { id: 'amber', bg: 'bg-amber-100', solid: 'bg-amber-500', text: 'text-amber-700', textSolid: 'text-white', hex: '#F59E0B' }, +] + +export const SplitSimpleIcon = () => ( +
+ +
+) + +const formatCurrencySimple = (amount: number) => { + return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount || 0) +} + +// --- Grid Cell Component (moved outside to prevent re-creation on every render) --- +const GridCell = React.memo(({ + row, + col, + value, + type = 'text', + className = '', + isSelected, + isEditing, + itemId, + field, + onCellEdit, + onCellClick, + editInputRef +}: { + row: number + col: string + value: string | number + type?: string + className?: string + isSelected: boolean + isEditing: boolean + itemId: string + field: 'name' | 'price' | 'qty' + onCellEdit: (itemId: string, field: 'name' | 'price' | 'qty', value: string) => void + onCellClick: (row: number, col: string) => void + editInputRef: React.RefObject +}) => { + if (isEditing) { + return ( +
+ 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}`} + /> +
+ ) + } + + 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} + `} + > + {value} +
+ ) +}) + +GridCell.displayName = 'GridCell' + +// --- Split Method Options (constant) --- +const splitMethodOptions = [ + { value: 'even' as SplitMethod, label: 'Even Split', icon: Users }, + { value: 'shares' as SplitMethod, label: 'By Shares', icon: Scale }, + { value: 'percent' as SplitMethod, label: 'By Percent', icon: Percent }, + { value: 'exact' as SplitMethod, label: 'Exact Amount', icon: Calculator }, +] + +function DesktopBillSplitter() { + const { state, dispatch, canUndo, canRedo } = useBill() + const { toast } = useToast() + const analytics = useBillAnalytics() + const [activeView, setActiveView] = useState<'ledger' | 'breakdown'>('ledger') + const [billId, setBillId] = useState('') + const [selectedCell, setSelectedCell] = useState<{ row: number; col: string }>({ row: 0, col: 'name' }) + const [editing, setEditing] = useState(false) + const [editingPerson, setEditingPerson] = useState(null) + 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 [lastClickTime, setLastClickTime] = useState<{ row: number; col: string; time: number } | null>(null) + const [newLoadDropdownOpen, setNewLoadDropdownOpen] = useState(false) + + const editInputRef = useRef(null) + const loadBillRequestRef = useRef(null) // Track current load request to prevent race conditions + + const people = state.currentBill.people + const items = state.currentBill.items + const title = state.currentBill.title + + // Detect if device is touch-based (mobile/tablet) + const isTouchDevice = () => { + return 'ontouchstart' in window || navigator.maxTouchPoints > 0 + } + + // --- Derived Data --- + const summary = getBillSummary(state.currentBill) + + const calculatedItems = useMemo(() => items.map(item => { + const price = parseFloat(item.price || '0') + const qty = item.quantity || 1 + const totalItemPrice = price * qty + const splitCount = item.splitWith.length + const pricePerPerson = splitCount > 0 ? totalItemPrice / splitCount : 0 + return { ...item, totalItemPrice, pricePerPerson, price, qty } + }), [items]) + + const { subtotal, taxAmount, tipAmount, discountAmount, grandTotal } = useMemo(() => { + const sub = calculatedItems.reduce((acc, item) => acc + item.totalItemPrice, 0) + const tax = parseFloat(state.currentBill.tax || '0') + const tip = parseFloat(state.currentBill.tip || '0') + const disc = parseFloat(state.currentBill.discount || '0') + return { + subtotal: sub, + taxAmount: tax, + tipAmount: tip, + discountAmount: disc, + grandTotal: sub + tax + tip - disc + } + }, [calculatedItems, state.currentBill.tax, state.currentBill.tip, state.currentBill.discount]) + + const personFinalShares = useMemo(() => { + const shares: Record = {} + + const totalWeight = subtotal > 0 ? subtotal : 1 + + people.forEach(p => { + let personSub = 0 + calculatedItems.forEach(item => { + if (item.splitWith.includes(p.id)) { + personSub += item.pricePerPerson + } + }) + + const ratio = totalWeight > 0 ? personSub / totalWeight : 0 + const tax = taxAmount * ratio + const tip = tipAmount * ratio + const disc = discountAmount * ratio + + shares[p.id] = { + subtotal: personSub, + tax, + tip, + discount: disc, + total: personSub + tax + tip - disc, + ratio: ratio * 100, + items: calculatedItems.filter(i => i.splitWith.includes(p.id)) + } + }) + + return shares + }, [calculatedItems, people, subtotal, taxAmount, tipAmount, discountAmount]) + + // --- Actions --- + const toggleAssignment = useCallback((itemId: string, personId: string) => { + const item = items.find(i => i.id === itemId) + if (!item) return + + const isAssigned = item.splitWith.includes(personId) + const newSplitWith = isAssigned + ? item.splitWith.filter(id => id !== personId) + : [...item.splitWith, personId] + + dispatch({ + type: 'UPDATE_ITEM', + payload: { ...item, splitWith: newSplitWith } + }) + }, [items, dispatch]) + + const toggleAllAssignments = useCallback((itemId: string) => { + const item = items.find(i => i.id === itemId) + if (!item) return + + const allAssigned = people.every(p => item.splitWith.includes(p.id)) + const newSplitWith = allAssigned ? [] : people.map(p => p.id) + + dispatch({ + type: 'UPDATE_ITEM', + payload: { ...item, splitWith: newSplitWith } + }) + }, [items, people, dispatch]) + + const clearRowAssignments = useCallback((itemId: string) => { + const item = items.find(i => i.id === itemId) + if (!item) return + dispatch({ + type: 'UPDATE_ITEM', + payload: { ...item, splitWith: [] } + }) + }, [items, dispatch]) + + const updateItem = useCallback((id: string, updates: Partial) => { + const item = items.find(i => i.id === id) + if (!item) return + dispatch({ + type: 'UPDATE_ITEM', + payload: { ...item, ...updates } + }) + }, [items, dispatch]) + + const addItem = useCallback(() => { + const newItem: Omit = { + name: '', + price: '0', + quantity: 1, + splitWith: people.map(p => p.id), + method: 'even' + } + dispatch({ type: 'ADD_ITEM', payload: newItem }) + analytics.trackItemAdded('0', 'even', people.length) + }, [people, dispatch, analytics]) + + const deleteItem = useCallback((id: string) => { + const item = items.find(i => i.id === 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]) + + const duplicateItem = useCallback((item: Item) => { + const duplicated: Omit = { + name: `${item.name} (copy)`, + price: item.price, + quantity: item.quantity, + splitWith: [...item.splitWith], + method: item.method, + customSplits: item.customSplits ? { ...item.customSplits } : undefined + } + dispatch({ type: 'ADD_ITEM', payload: duplicated }) + analytics.trackFeatureUsed("duplicate_item") + toast({ title: "Item duplicated" }) + }, [dispatch, analytics, toast]) + + const handleScanImport = useCallback((scannedItems: Omit[]) => { + scannedItems.forEach(item => { + const newItem: Omit = { + ...item, + splitWith: people.map(p => p.id), // Default split with everyone + method: 'even' + } + dispatch({ type: 'ADD_ITEM', payload: newItem }) + }) + analytics.trackFeatureUsed("scan_receipt_import", { count: scannedItems.length }) + }, [people, dispatch, analytics]) + + const addPerson = useCallback(() => { + const newName = `Person ${people.length + 1}` + dispatch({ + type: 'ADD_PERSON', + payload: { + name: newName, + color: COLORS[people.length % COLORS.length].hex + } + }) + analytics.trackPersonAdded("manual") + toast({ title: "Person added", description: newName }) + }, [people, dispatch, analytics, toast]) + + const updatePerson = useCallback((updatedPerson: Person) => { + dispatch({ + type: 'UPDATE_PERSON', + payload: updatedPerson + }) + toast({ + title: "Person updated", + description: `${updatedPerson.name}'s details have been updated` + }) + analytics.trackFeatureUsed("update_person") + setEditingPerson(null) + }, [dispatch, toast, analytics]) + + const removePerson = useCallback((personId: string) => { + const person = people.find(p => p.id === personId) + const hadItems = items.some(i => i.splitWith.includes(personId)) + dispatch({ type: 'REMOVE_PERSON', payload: personId }) + if (person) { + analytics.trackPersonRemoved(hadItems) + toast({ title: "Person removed", description: person.name }) + } + setEditingPerson(null) + }, [people, items, dispatch, analytics, toast]) + + // --- Split Method Management --- + const getSplitMethodIcon = (method: SplitMethod) => { + const option = splitMethodOptions.find(o => o.value === method) + return option?.icon || Users + } + + const changeSplitMethod = useCallback((itemId: string, newMethod: SplitMethod) => { + const item = items.find(i => i.id === itemId) + if (!item) return + + const oldMethod = item.method + updateItem(itemId, { method: newMethod }) + analytics.trackSplitMethodChanged(itemId, oldMethod, newMethod, item.splitWith.length) + toast({ + title: "Split method changed", + description: `Changed to ${splitMethodOptions.find(o => o.value === newMethod)?.label}`, + duration: TIMING.TOAST_SHORT + }) + }, [items, 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" + }) + return + } + + // Create unique request ID to prevent race conditions + const requestId = `${Date.now()}-${Math.random()}` + loadBillRequestRef.current = requestId + + setIsLoadingBill(true) + analytics.trackFeatureUsed("load_bill_by_id", { bill_id: trimmedId }) + + try { + const result = await getBillFromCloud(trimmedId) + + // Check if this request is still current + if (loadBillRequestRef.current !== requestId) { + console.log('Bill load cancelled - newer request started') + return + } + + if (result.error || !result.bill) { + toast({ + title: "Bill not found", + description: result.error || "Could not find bill with that ID", + variant: "destructive" + }) + analytics.trackError("load_bill_failed", result.error || "Bill not found") + return + } + + const migratedBill = migrateBillSchema(result.bill) + dispatch({ type: 'LOAD_BILL', payload: migratedBill }) + toast({ + title: "Bill loaded!", + description: `Loaded "${migratedBill.title}"` + }) + analytics.trackSharedBillLoaded("cloud") + setBillId('') // Clear input after successful load + } 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" + }) + analytics.trackError("load_bill_failed", error instanceof Error ? error.message : "Unknown error") + } + } finally { + // Only clear loading state if this request is still current + if (loadBillRequestRef.current === requestId) { + setIsLoadingBill(false) + } + } + }, [billId, dispatch, toast, analytics]) + + // --- 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" + }) + analytics.trackError("copy_summary_failed", "No data to copy") + return + } + + const text = generateSummaryText(state.currentBill) + const success = await copyToClipboard(text) + if (success) { + toast({ + title: "Copied!", + description: "Bill summary copied to clipboard" + }) + analytics.trackBillSummaryCopied() + analytics.trackFeatureUsed("copy_summary") + } else { + toast({ + title: "Copy failed", + description: "Unable to copy to clipboard. Please try again.", + variant: "destructive" + }) + analytics.trackError("copy_summary_failed", "Clipboard API failed") + } + }, [people, state.currentBill, toast, analytics]) + + // --- Stable Grid Cell Callbacks (for performance) --- + const handleCellEdit = useCallback((itemId: string, field: 'name' | 'price' | 'qty', value: string) => { + if (field === 'name') { + updateItem(itemId, { name: value }) + } else if (field === 'price') { + updateItem(itemId, { price: value }) + } else if (field === 'qty') { + updateItem(itemId, { quantity: parseInt(value) || 1 }) + } + }, [updateItem]) + + const handleCellClick = useCallback((row: number, col: string) => { + const now = Date.now() + const isDoubleClick = lastClickTime && + lastClickTime.row === row && + lastClickTime.col === col && + now - lastClickTime.time < 300 + + // Special case: person assignment cells always single-click toggle + if (people.some(p => p.id === col)) { + const item = items[row] + if (item) toggleAssignment(item.id, col) + return + } + + // Touch devices: single tap enters edit mode + // Desktop: double-click enters edit mode + if (isTouchDevice() || isDoubleClick) { + setSelectedCell({ row, col }) + setEditing(true) + setLastClickTime(null) + } else { + // Single-click: select only + setSelectedCell({ row, col }) + setEditing(false) + setLastClickTime({ row, col, time: now }) + } + }, [lastClickTime, people, items, toggleAssignment]) + + // --- Context Menu --- + const handleContextMenu = useCallback((e: React.MouseEvent, itemId: string, personId?: string) => { + e.preventDefault() + setContextMenu({ + x: e.clientX, + y: e.clientY, + itemId, + personId + }) + }, []) + + useEffect(() => { + const handleClick = () => { + setContextMenu(null) + } + window.addEventListener('click', handleClick) + return () => window.removeEventListener('click', handleClick) + }, []) + + // --- Global Keyboard Shortcuts --- + const handleGlobalKeyDown = useCallback((e: KeyboardEvent) => { + // Check if we're in an input field + const target = e.target as HTMLElement + const isInInput = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.contentEditable === 'true' + + // Escape key - close modals, menus, and exit edit mode + if (e.key === 'Escape') { + if (editingPerson) { + setEditingPerson(null) + e.preventDefault() + return + } + if (contextMenu) { + setContextMenu(null) + e.preventDefault() + return + } + if (editing) { + setEditing(false) + e.preventDefault() + return + } + } + + // Global shortcuts that work even in inputs + 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) + 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) + return + } + + 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") + } + return + } + + // Shortcuts that don't work in inputs + if (!isInInput) { + // N: Add new item + if (e.key === 'n' || e.key === 'N') { + e.preventDefault() + addItem() + analytics.trackFeatureUsed("keyboard_shortcut_add_item") + return + } + + // P: Add person + if (e.key === 'p' || e.key === 'P') { + e.preventDefault() + addPerson() + analytics.trackFeatureUsed("keyboard_shortcut_add_person") + return + } + + // C: Copy summary + if (e.key === 'c' || e.key === 'C') { + e.preventDefault() + copyBreakdown() + analytics.trackFeatureUsed("keyboard_shortcut_copy") + return + } + + // S: Share (trigger click on share button) + if (e.key === 's' || e.key === 'S') { + e.preventDefault() + // Find and click the share button + const shareButton = document.querySelector('[data-share-trigger]') as HTMLButtonElement + if (shareButton) shareButton.click() + analytics.trackFeatureUsed("keyboard_shortcut_share") + return + } + } + + // Grid navigation - Excel-like behavior + if (activeView !== 'ledger') return + + // Define column order for navigation + const colOrder = ['name', 'price', 'qty', ...people.map(p => p.id)] + const currentColIdx = colOrder.indexOf(selectedCell.col) + const currentRowIdx = selectedCell.row + + // Tab key - move to next cell (right), Shift+Tab - move to previous cell (left) + if (e.key === 'Tab') { + e.preventDefault() + setEditing(false) + + if (e.shiftKey) { + // Shift+Tab: Move left + if (currentColIdx > 0) { + setSelectedCell({ row: currentRowIdx, col: colOrder[currentColIdx - 1] }) + } else if (currentRowIdx > 0) { + // Wrap to end of previous row + setSelectedCell({ row: currentRowIdx - 1, col: colOrder[colOrder.length - 1] }) + } + } else { + // Tab: Move right + if (currentColIdx < colOrder.length - 1) { + setSelectedCell({ row: currentRowIdx, col: colOrder[currentColIdx + 1] }) + } else if (currentRowIdx < items.length - 1) { + // Wrap to beginning of next row + setSelectedCell({ row: currentRowIdx + 1, col: colOrder[0] }) + } + } + return + } + + // Enter key - move down to next row (same column), Shift+Enter - move up + if (e.key === 'Enter') { + e.preventDefault() + setEditing(false) + + if (e.shiftKey) { + // Shift+Enter: Move up + if (currentRowIdx > 0) { + setSelectedCell(prev => ({ ...prev, row: prev.row - 1 })) + } + } else { + // Enter: Move down + if (currentRowIdx < items.length - 1) { + setSelectedCell(prev => ({ ...prev, row: prev.row + 1 })) + } else { + // At last row, add new item and move to it + addItem() + // New item will be at items.length + setSelectedCell(prev => ({ ...prev, row: items.length })) + } + } + return + } + + // Arrow keys - navigate grid OR move cursor in edit mode + if (e.key.startsWith('Arrow')) { + const target = e.target as HTMLElement + const isInTextInput = target.tagName === 'INPUT' && target.getAttribute('type') === 'text' + + // If editing text, let arrow keys move cursor (don't intercept) + if (editing && isInTextInput) { + return + } + + // Not editing or not in text input: navigate grid + e.preventDefault() + setEditing(false) + + let newColIdx = currentColIdx + let newRowIdx = currentRowIdx + + 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 === 'ArrowUp' && currentRowIdx > 0) newRowIdx-- + + setSelectedCell({ row: newRowIdx, col: colOrder[newColIdx] }) + return + } + + // Space or Enter when not editing - toggle assignment or start editing + if (!editing) { + if (e.key === 'Enter') { + e.preventDefault() + if (people.some(p => p.id === selectedCell.col)) { + const item = items[selectedCell.row] + if (item) toggleAssignment(item.id, selectedCell.col) + } else { + setEditing(true) + } + } else if (e.key === ' ') { + e.preventDefault() + if (people.some(p => p.id === selectedCell.col)) { + const item = items[selectedCell.row] + if (item) toggleAssignment(item.id, selectedCell.col) + } + } + // Note: Auto-typing disabled - user must double-click or press Enter to edit + // This provides more intentional, Excel-like behavior + } + }, [activeView, editing, selectedCell, items, people, addItem, toggleAssignment, addPerson, copyBreakdown, dispatch, toast, analytics, state.historyIndex, editingPerson, contextMenu, updateItem]) + + useEffect(() => { + window.addEventListener('keydown', handleGlobalKeyDown) + return () => window.removeEventListener('keydown', handleGlobalKeyDown) + }, [handleGlobalKeyDown]) + + useEffect(() => { + if (editing && editInputRef.current) { + editInputRef.current.focus() + } + }, [editing]) + + return ( +
+ {/* --- Header --- */} +
+
+ +
+ { + dispatch({ type: 'SET_BILL_TITLE', payload: e.target.value }) + analytics.trackTitleChanged(e.target.value) + }} + className="block text-sm font-bold bg-transparent border-none p-0 focus:ring-0 text-slate-900 w-48 hover:text-indigo-600 transition-colors truncate font-inter" + placeholder="Project Name" + /> +
SPLIT SIMPLE
+
+ + {/* New and Load buttons */} +
+ + + + + + + +
e.stopPropagation()}> + +
+ + setBillId(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && billId.trim()) { + handleLoadBill() + setNewLoadDropdownOpen(false) + } + if (e.key === 'Escape') { + setNewLoadDropdownOpen(false) + } + }} + onClick={(e) => e.stopPropagation()} + 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 + /> +
+
+ + +
+
+
+
+
+
+ +
+ {/* Undo/Redo */} +
+ + +
+ + {/* Scan Receipt Button */} + + + {/* Share Button */} + +
+
+ + {/* --- Main Workspace --- */} +
+ {/* LEDGER VIEW */} + {activeView === 'ledger' && ( +
+
+
+ {/* Live Roster */} +
+
+ Live Breakdown +
+
+ {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)}%) + +
+ ) + })} +
+ + {/* 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} +
+ + {p.name.split(' ')[0]} + +
+ ) + })} + +
+ +
+
+ Total +
+
+ + {/* Body */} +
+ {calculatedItems.map((item, rIdx) => ( +
handleContextMenu(e, item.id)} + > + {/* Index / "Equal" Button */} +
+ {String(rIdx + 1).padStart(2, '0')} + +
+ + {/* Name + Split Method Selector */} +
+
+ +
+
+ + + + + + {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) + }} + onClick={() => { + setSelectedCell({ row: rIdx, col: p.id }) + toggleAssignment(item.id, p.id) + }} + onMouseEnter={() => setHoveredColumn(p.id)} + onMouseLeave={() => setHoveredColumn(null)} + className={` + w-28 border-r border-slate-100 relative cursor-pointer flex items-center justify-center transition-all duration-100 select-none + ${isSelected ? `ring-inset ring-2 ring-indigo-600 z-10` : ''} + ${hoveredColumn === p.id && !isAssigned ? 'bg-slate-50' : ''} + `} + > + {isAssigned ? ( +
+ + {(item.pricePerPerson || 0).toFixed(2)} + +
+ ) : ( + + - + + )} +
+ ) + })} + + {/* Empty Column */} +
+ +
+ + {/* Row Total */} +
+ {(item.totalItemPrice || 0).toFixed(2)} +
+
+ ))} + + {/* Add Row Button */} + + + {/* Summary Rows Section */} +
+ {/* Subtotal Row */} +
+
+
+ Subtotal +
+
+ {subtotal.toFixed(2)} +
+
+ + {people.map(p => { + const stats = personFinalShares[p.id] + return ( +
+ {(stats?.subtotal || 0).toFixed(2)} +
+ ) + })} + +
+
+ {subtotal.toFixed(2)} +
+
+ + {/* Tax Row */} +
+
+
+ Tax + +
+
+ { + 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 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition-colors 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 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition-colors 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 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 transition-colors 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)} +
+ ) + })} + +
+
+ -{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)} +
+ ))} +
+ +
+
+ Subtotal + {formatCurrencySimple(subtotal)} +
+
+ Tax + {formatCurrencySimple(taxAmount)} +
+ {tipAmount > 0 && ( +
+ Tip + {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)} +
+
+
+ {/* 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)} + + )} +
+
+
+ ) + })} +
+
+
+
+ )} +
+ + {/* --- Footer --- */} +
+ {/* Left Section: GitHub Link */} + + + {/* Center Section: View Switcher */} +
+
+ + +
+ + +
+ + {/* Right Section: Creator Credit */} + +
+ + {/* --- Context Menu --- */} + {contextMenu && ( +
e.stopPropagation()} + > +
+ Actions +
+ {contextMenu.personId ? ( + + ) : ( + <> + + + + + )} +
+ +
+ )} + + {/* --- Person Editor Modal --- */} + {editingPerson && ( +
setEditingPerson(null)} + > +
e.stopPropagation()} + > +
+

Edit Member

+ +
+ +
+
+ + setEditingPerson({ ...editingPerson, name: e.target.value })} + className="w-full bg-slate-50 border border-slate-200 rounded-lg px-3 py-2.5 text-sm text-slate-900 font-medium focus:ring-2 focus:ring-indigo-500 focus:border-transparent font-inter" + /> +
+ +
+ +
+ {COLORS.map((c, idx) => ( +
+
+ +
+ + +
+
+
+
+ )} +
+ ) +} + +export function ProBillSplitter() { + const isMobileView = useIsMobile() + if (isMobileView) { + return + } + return +} diff --git a/components/ReceiptScanner.tsx b/components/ReceiptScanner.tsx new file mode 100644 index 0000000..2e48cec --- /dev/null +++ b/components/ReceiptScanner.tsx @@ -0,0 +1,441 @@ +"use client" + +import React, { useState, useCallback, useRef } from "react" +import { + Camera, + Upload, + X, + Check, + Loader2, + Image as ImageIcon, + FileText, + ScanLine, + ZoomIn, + ZoomOut, + RotateCw, + Trash2, + Plus, + Minus +} from "lucide-react" +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { cn } from "@/lib/utils" +import { scanReceiptImage, parseReceiptText, OCRResult } from "@/lib/mock-ocr" +import { Item } from "@/contexts/BillContext" +import { useToast } from "@/hooks/use-toast" + +type ScannerState = 'idle' | 'uploading' | 'processing' | 'reviewing' + +interface ReceiptScannerProps { + onImport: (items: Omit[]) => void + trigger?: React.ReactNode +} + +export function ReceiptScanner({ onImport, trigger }: ReceiptScannerProps) { + const [isOpen, setIsOpen] = useState(false) + const [state, setState] = useState('idle') + const [receiptImage, setReceiptImage] = useState(null) + const [scannedItems, setScannedItems] = useState([]) + const [zoom, setZoom] = useState(1) + const [rotation, setRotate] = useState(0) + const [activeTab, setActiveTab] = useState('image') + const { toast } = useToast() + + const handleReset = useCallback(() => { + setState('idle') + setReceiptImage(null) + setScannedItems([]) + setZoom(1) + setRotate(0) + }, []) + + const handleOpenChange = (open: boolean) => { + setIsOpen(open) + if (!open) { + setTimeout(handleReset, 300) // Reset after animation + } + } + + const processImage = async (file: File) => { + setState('processing') + + try { + // Call the API - it will handle preview generation (skips for HEIC) + const result = await scanReceiptImage(file) + setScannedItems(result.items) + + // Use the preview from API if available + if (result.preview) { + setReceiptImage(result.preview) + } else { + // No preview available (e.g., HEIC files) + // Set to null so ReviewView shows only items list + setReceiptImage(null) + } + + setState('reviewing') + } catch (error) { + console.error('Receipt scanning error:', error) + const errorMessage = error instanceof Error ? error.message : "Unknown error" + + // Provide more specific error messages + let title = "Scan Failed" + let description = "Could not process receipt. Please try again." + + if (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 (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")) { + title = "Configuration Error" + description = "Receipt scanning is not configured. Please check your environment variables." + } else if (errorMessage.includes("API")) { + 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.` + } + + toast({ + title, + description, + variant: "destructive" + }) + setState('idle') + } + } + + const handlePasteText = (text: string) => { + if (!text.trim()) return + const items = parseReceiptText(text) + if (items.length > 0) { + setScannedItems(items) + setState('reviewing') + setReceiptImage(null) // No image in text mode + } else { + toast({ + title: "No items found", + description: "Try pasting text with prices (e.g., 'Burger 12.00')", + variant: "destructive" + }) + } + } + + const handleImport = () => { + onImport(scannedItems) + setIsOpen(false) + toast({ + title: "Items Imported", + description: `Successfully added ${scannedItems.length} items from receipt.` + }) + } + + return ( + + + {trigger || ( + + )} + + + {state === 'idle' && ( + + )} + + {state === 'processing' && ( + + )} + + {state === 'reviewing' && ( + + )} + + + ) +} + +// --- Sub-Components --- + +function UploadView({ onUpload, onPaste }: { onUpload: (file: File) => void, onPaste: (text: string) => void }) { + const fileInputRef = useRef(null) + const [dragActive, setDragActive] = useState(false) + const [pasteText, setPasteText] = useState("") + + const handleDrag = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + if (e.type === "dragenter" || e.type === "dragover") { + setDragActive(true) + } else if (e.type === "dragleave") { + setDragActive(false) + } + } + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setDragActive(false) + if (e.dataTransfer.files && e.dataTransfer.files[0]) { + onUpload(e.dataTransfer.files[0]) + } + } + + return ( +
+ + Add Receipt + + + +
+ + Upload Image + Paste Text + +
+ + +
fileInputRef.current?.click()} + > + e.target.files?.[0] && onUpload(e.target.files[0])} + /> +
+ +
+

Click to upload or drag & drop

+

Supports JPG, PNG, HEIC (Max 5MB) β€’ Preview unavailable for HEIC

+
+
+ + +