Claude/debug site loading i9mb4 #3950
20 changed files+2338−187
ModifiedCLAUDE.md+268−119View fileUnifiedSplit
@@ -2,16 +2,41 @@
22
33> Master reference for all Claude sessions working on this project.
44> Read this FIRST before making any changes.
5> **THIS IS THE MOST ADVANCED DICTATION PLATFORM ON THE INTERNET. TREAT EVERY LINE OF CODE ACCORDINGLY.**
6
7---
8
9## ABSOLUTE REQUIREMENTS — NON-NEGOTIABLE
10
111. **ZERO HTML** — No raw HTML anywhere in the codebase. All UI must use React/Next.js components with Tailwind CSS. No dangerouslySetInnerHTML except for service worker registration. No HTML strings. Components only.
122. **MOST ADVANCED STACK** — Always use the latest, most advanced approach available. If there's a better way, use it. No shortcuts, no legacy patterns, no "good enough."
133. **COURTROOM-GRADE SECURITY** — This product will be used in courtrooms, depositions, client meetings, and privileged legal communications. All data must be encrypted in transit (TLS). Sessions must be secure (HttpOnly, Secure, SameSite cookies). No sensitive data in localStorage. Audit logging for all actions. HIPAA and SOC 2 awareness in all design decisions.
144. **UNIVERSAL DEVICE SUPPORT** — Must work flawlessly on: iOS (Safari + PWA), Android (Chrome + PWA), Windows (Chrome, Edge, Firefox), Mac (Safari, Chrome), Linux, iPad, tablets. Capacitor wrappers for App Store and Google Play.
155. **UNIVERSAL LANGUAGE SUPPORT** — 23+ languages with real legal and accounting terminology per locale. Not placeholders — actual jurisdiction-specific legal terms.
166. **BEST GRAMMAR ON THE MARKET** — Context-aware grammar correction that understands legal citations (Bluebook, OSCOLA, AGLC, McGill), accounting standards (GAAP, IFRS), court filing conventions, and profession-specific jargon. Must exceed Grammarly for legal/accounting writing.
177. **INTEGRATION-READY** — Architecture must support integration with any external system: email, calendar, document management, legal research, accounting platforms, messenger, CRM.
518
619---
720
821## PROJECT OVERVIEW
922
10**AlecRae Voice** is an AI-powered professional dictation platform built specifically for attorneys, lawyers, and accountants. It is NOT a general-purpose dictation tool — it understands how legal and accounting professionals work, how they structure documents, and the specialised terminology they use.
23**AlecRae Voice** is the most advanced AI-powered professional dictation platform on the Internet. Built for attorneys, lawyers, accountants, and consumers. It understands how legal and accounting professionals work, how they structure documents, and the specialised terminology they use — in 23+ languages.
24
25**This is the first of its kind.** No other product combines:
26- 22 document modes (12 professional + 10 consumer)
27- 23-language legal/accounting terminology
28- Courtroom-grade security
29- Real-time AI streaming enhancement
30- Cross-platform universal access
31- Advanced grammar engine with citation style awareness
32- Document templates with fillable fields
33- Batch transcription
34- Multi-user enterprise with RBAC, SSO, Stripe billing
35- White-label for law firms
1136
1237**Deployed at:** AlecRae.app (Vercel)
13**GitHub repo:** alecrae-voice
14**Status:** V1 built April 2026, deploying to production
38**GitHub repo:** ccantynz-alt/AlecRae.app
39**Status:** V2 — Full platform built April 2026
1540
1641---
1742
@@ -36,33 +61,43 @@ Long-term, this tool may be rebranded as **MarcoReid Voice** when the brand arch
3661- **BlabbyAI** — Whisper-based, Windows desktop. Has custom legal modes. Newer entrant.
3762- **Dictation Daddy** — Browser-based, legal terminology support. Simpler feature set.
3863- **Philips SpeechLive** — Enterprise dictation with legal workflow integration. Higher price.
64- **Grammarly** — Grammar correction leader, but zero legal/accounting specialisation. No dictation. No document formatting.
3965
4066### Our Competitive Advantages (what NONE of them do)
411. **Document-type formatting** — 12 specialised modes that format dictation into properly structured legal letters, court filings, memos, tax advisories etc. Competitors just clean up text.
671. **22 document-type modes** — 12 professional + 10 consumer modes that format dictation into properly structured legal letters, court filings, memos, tax advisories, medical notes, real estate docs, etc.
42682. **Streaming Claude AI** — enhancement appears in real-time, not batch processing.
433. **Legal AND accounting** — every competitor focuses on legal only. We serve both professions.
444. **Custom AI instructions** — users can tell the AI their firm name, spelling preferences, date formats.
455. **AlecRae ecosystem integration** (planned) — will connect to AlecRae's legal research, accounting, messenger, and email systems.
466. **No subscription for admin** — password-gated, zero recurring cost for the operator.
47
48### Target: 80-90% ahead of competition
49- Must exceed WisprFlow on legal/accounting specialisation
50- Must exceed Dragon on AI intelligence and cross-platform support
51- Must exceed all on document formatting capabilities
693. **Legal AND accounting AND consumer** — every competitor focuses on one. We serve all.
704. **23-language legal terminology** — real jurisdiction-specific terms, not translations.
715. **Advanced grammar engine** — citation style awareness (Bluebook, OSCOLA, AGLC, McGill), profession-specific rules, configurable formality.
726. **Custom AI instructions** — users can tell the AI their firm name, spelling preferences, date formats.
737. **Document templates** — fillable field templates for common legal/accounting documents.
748. **Batch transcription** — upload multiple audio files at once.
759. **Multi-user enterprise** — RBAC, SSO (Google/Microsoft), Stripe billing, admin dashboard, white-label.
7610. **Universal platform** — Web, iOS, Android, Windows, Mac, Linux. PWA + Capacitor native apps.
7711. **AlecRae ecosystem integration** — connects to legal research, accounting, messenger, and email systems.
78
79### Target: THE MOST ADVANCED ON THE INTERNET
80- Must exceed ALL competitors on every dimension
81- Must be the product that sets the standard for the industry
82- 110% quality — nothing ships that isn't production-perfect
5283
5384---
5485
5586## TECH STACK
5687
57- **Framework:** Next.js 14 (App Router)
58- **Language:** TypeScript
59- **Styling:** Tailwind CSS
60- **Transcription:** OpenAI Whisper API (whisper-1)
61- **AI Enhancement:** Claude API (claude-sonnet-4-20250514) with streaming
62- **Auth:** JWT sessions via jose library, admin password gated
88- **Framework:** Next.js 14 (App Router) — server components, streaming, edge runtime
89- **Language:** TypeScript (strict mode)
90- **Styling:** Tailwind CSS — NO raw HTML, NO CSS modules, components only
91- **Transcription:** OpenAI Whisper API (whisper-1) with 23-language support
92- **AI Enhancement:** Claude API (claude-sonnet-4-20250514) with streaming SSE
93- **Grammar Engine:** Custom context-aware system with citation style support
94- **Auth:** Multi-user JWT sessions via jose, PBKDF2 password hashing (Web Crypto), SSO (Google/Microsoft OAuth2)
95- **Billing:** Stripe (checkout, portal, webhooks)
6396- **Export:** docx library for Word document generation
64- **Database:** Neon PostgreSQL (PENDING — currently localStorage)
65- **Hosting:** Vercel
97- **Database:** Neon PostgreSQL (serverless driver)
98- **Native Apps:** Capacitor (iOS + Android)
99- **Offline:** Service worker with cache-first static, network-first API, background sync
100- **Hosting:** Vercel (edge functions, auto-deploy)
66101- **Domain:** AlecRae.app
67102
68103---
@@ -71,146 +106,242 @@ Long-term, this tool may be rebranded as **MarcoReid Voice** when the brand arch
71106
72107```
73108app/
74 page.tsx — Login page (public)
75 layout.tsx — Root layout, PWA metadata
76 globals.css — Global styles, animations
109 page.tsx — Login page (public)
110 layout.tsx — Root layout, PWA metadata, service worker
111 globals.css — Global styles, animations
77112 app/
78 page.tsx — Main dictation interface (protected)
79 layout.tsx — App layout wrapper
113 page.tsx — Main dictation interface (protected)
114 layout.tsx — App layout wrapper
115 admin/page.tsx — Admin dashboard (owner/admin only)
116 billing/page.tsx — Plans & billing (Stripe)
80117 api/
81 auth/route.ts — POST login, DELETE logout
82 transcribe/route.ts — Whisper transcription with vocab hints
83 enhance/route.ts — Claude streaming enhancement
118 auth/route.ts — POST login (password), DELETE logout
119 auth/login/route.ts — POST email+password login
120 auth/register/route.ts — POST user registration
121 auth/sso/[provider]/route.ts — GET SSO redirect (Google/Microsoft)
122 auth/sso/[provider]/callback/ — GET OAuth callback
123 transcribe/route.ts — Whisper transcription (23 languages)
124 transcribe-stream/route.ts — Real-time streaming transcription (SSE)
125 transcribe-batch/route.ts — Batch multi-file transcription
126 enhance/route.ts — Claude streaming enhancement (22 modes)
127 dictations/route.ts — GET/POST dictation history
128 dictations/[id]/route.ts — GET/DELETE single dictation
129 vocabulary/route.ts — GET/POST/DELETE custom terms
130 settings/route.ts — GET/PUT user settings
131 analytics/route.ts — POST log / GET stats
132 firms/route.ts — GET/POST/PUT firm profiles
133 firms/[id]/route.ts — GET/DELETE firm
134 billing/checkout/route.ts — POST Stripe checkout session
135 billing/portal/route.ts — POST Stripe portal session
136 billing/webhook/route.ts — POST Stripe webhooks
137 users/route.ts — GET list / POST invite
138 users/[id]/route.ts — GET/PUT/DELETE user
139 admin/stats/route.ts — GET dashboard analytics
140 admin/users/route.ts — GET paginated user search
141 whitelabel/route.ts — GET/PUT white-label config
142 audio/route.ts — POST/GET audio storage
143 db/init/route.ts — POST initialize database schema
84144lib/
85 auth.ts — JWT session creation/verification
86 templates.ts — 12 document mode system prompts, vocabulary
87middleware.ts — Route protection (checks JWT cookie)
145 auth.ts — JWT session creation/verification (original)
146 auth-multi.ts — Multi-user auth with RBAC
147 db.ts — Neon PostgreSQL connection
148 db-schema.ts — Database schema initialisation
149 get-user.ts — Get/create user from session
150 templates.ts — 12 professional document mode prompts
151 consumer-modes.ts — 10 consumer document mode prompts
152 templates-fillable.ts — Document templates with fillable fields
153 languages.ts — 23 languages with legal/accounting terms
154 grammar-engine.ts — Advanced grammar config + prompt builder
155 auto-detect.ts — Auto-detect document type from content
156 firm-profiles.ts — Firm profile management
157 firm-store.ts — Firm storage (in-memory, pending DB)
158 stripe.ts — Stripe billing integration
159 sso.ts — SSO (Google/Microsoft OAuth2)
160 whitelabel.ts — White-label configuration
161 platform.ts — Cross-platform detection + audio config
162 shortcuts.ts — Platform-aware keyboard shortcuts
163 register-sw.ts — Service worker registration
164middleware.ts — Route protection (JWT + role checks)
165capacitor.config.ts — Capacitor native app config
88166public/
89 manifest.json — PWA manifest
167 manifest.json — Full PWA manifest with shortcuts, share target
168 sw.js — Service worker (offline, background sync)
90169```
91170
92### Auth Flow
931. User visits AlecRae.app → sees login page
942. Enters admin password → POST /api/auth → JWT cookie set (30 day expiry)
953. Redirected to /app → middleware checks JWT on every request
964. All /api/transcribe and /api/enhance routes also protected
171### Security Architecture
172- All API routes validate JWT session before processing
173- Passwords hashed with PBKDF2 (100,000 iterations, SHA-256) via Web Crypto API
174- HttpOnly, Secure, SameSite=Lax session cookies
175- Stripe webhooks verified by signature
176- SSO uses OAuth2 authorization code flow
177- Role-based access control (owner > admin > user > viewer)
178- Privacy mode — dictations can be excluded from history
179- No sensitive data stored client-side
180- Audit logging for all significant actions
181- CORS and Content Security Policy headers
182
183### Auth Flow (Multi-user)
1841. User visits AlecRae.app → login page
1852. Login via email+password, SSO (Google/Microsoft), or admin password
1863. JWT cookie set with embedded user data (id, email, role, firmId, tier)
1874. Middleware checks JWT + role on every protected route
1885. API routes extract user from JWT for data scoping
97189
98190### Dictation Flow
991. User taps record → browser MediaRecorder captures audio (webm/opus preferred)
1002. Audio blob sent to /api/transcribe → Whisper processes with legal vocab prompt
1013. Raw text returned → voice commands processed client-side (new paragraph, period, etc.)
1024. User selects document mode → taps Enhance
1035. /api/enhance streams Claude response via SSE → text appears in real-time
1046. User can copy, export as .docx, or save to history
1911. User taps record → browser MediaRecorder captures audio (format auto-detected per platform)
1922. Audio sent to /api/transcribe with language parameter → Whisper processes with locale-specific vocab
1933. Raw text returned → voice commands processed client-side
1944. User selects document mode (22 modes) or auto-detect suggests one
1955. /api/enhance streams Claude response with grammar config → text appears in real-time
1966. User can copy, export .docx, save to DB history, or use template
105197
106198---
107199
108## DOCUMENT MODES (12 total)
109
110### General
111- General cleanup — grammar, punctuation, formatting
112- Client email — professional correspondence
113- Meeting notes — structured minutes with action items
200## DOCUMENT MODES (22 total)
114201
115### Legal
202### Professional — Legal (7)
116203- Legal letter — formal letter with proper structure
117204- Legal memorandum — TO/FROM/RE header, Issue/Analysis/Conclusion
118205- Court filing — numbered paragraphs, formal court language
119206- Demand letter — pre-litigation correspondence
120207- Deposition summary — organised by topic with testimony highlights
121208- Engagement letter — client terms, scope, fees
209- General cleanup — grammar, punctuation, formatting
122210
123### Accounting
211### Professional — Accounting (3)
124212- Accounting report — GAAP/IFRS terminology, findings, recommendations
125213- Tax advisory — IRC references, tax positions, circular 230 disclaimers
126214- Audit opinion — AICPA standards format, scope, opinion paragraphs
127215
216### Professional — General (2)
217- Client email — professional correspondence
218- Meeting notes — structured minutes with action items
219
220### Consumer (10)
221- Personal letter — warm personal correspondence
222- Business email — professional business communication
223- Resume / Cover letter — job application formatting
224- Social media post — concise, engaging content
225- Blog post — structured article with headers
226- Academic paper — formal academic writing
227- Medical notes — SOAP format clinical notes
228- Real estate — property docs, listings, leases
229- Insurance claim — claim filing documentation
230- Complaint letter — formal complaint with resolution request
231
128232---
129233
130## FEATURES BUILT (V1)
234## LANGUAGES (23)
235
236English (US, UK, AU, NZ), Spanish, French, German, Italian, Portuguese (BR, PT), Dutch, Japanese, Korean, Mandarin Chinese, Arabic, Hindi, Russian, Turkish, Polish, Swedish, Thai, Vietnamese, Indonesian
131237
132- [x] Admin password auth with JWT sessions
133- [x] Whisper transcription with legal/accounting vocabulary hints
134- [x] Custom vocabulary (user-added terms fed to Whisper)
135- [x] 12 document mode AI enhancement with specialised system prompts
238Each language includes real legal terminology and accounting terms specific to that jurisdiction's legal system.
239
240---
241
242## FEATURES BUILT (V2)
243
244### Core Dictation
245- [x] Whisper transcription with 23-language support
246- [x] Custom vocabulary per user (fed to Whisper)
247- [x] 22 document mode AI enhancement (12 professional + 10 consumer)
136248- [x] Streaming Claude response (real-time text appearance)
137249- [x] Voice commands (new paragraph, period, comma, delete that, etc.)
138- [x] Customisable hotkeys (rebind all shortcuts)
139- [x] Custom AI instructions per user
140- [x] Privacy mode (disable history for sensitive dictations)
141- [x] Dictation history (last 50, stored in localStorage)
250- [x] Auto-detect document type from content
251- [x] Real-time streaming transcription endpoint
252- [x] Batch file transcription (up to 20 files)
253
254### Grammar & Language
255- [x] Advanced grammar engine with profession-specific rules
256- [x] Citation style support (Bluebook, OSCOLA, AGLC, McGill)
257- [x] Configurable spelling (US/UK/AU/NZ), dates, numbers
258- [x] Formality levels (standard, formal, court)
259- [x] 23-language legal/accounting terminology
260
261### Templates & Documents
262- [x] 6 built-in document templates with fillable fields
142263- [x] Export to .docx
143264- [x] Copy to clipboard
265
266### User Experience
267- [x] Customisable hotkeys (26 shortcuts, platform-aware)
268- [x] Custom AI instructions per user
269- [x] Privacy mode (disable history for sensitive dictations)
270- [x] Recording mode toggle (tap or hold)
271- [x] Audio playback support
144272- [x] Word count
145- [x] PWA manifest (installable on all devices)
273- [x] Dark theme with gold accent (professional, not playful)
274
275### Platform
276- [x] PWA with full manifest, shortcuts, share target, file handlers
277- [x] Service worker (offline caching, background sync, push notifications)
278- [x] Platform detection with optimal audio config per device
279- [x] Capacitor config for iOS + Android native apps
280- [x] Cross-platform meta tags (iOS, Android, Windows)
146281- [x] Responsive design (desktop, tablet, mobile)
147- [x] Recording mode toggle (tap or hold)
148282
149## FEATURES PENDING
150
151### Phase 2 — Database (Neon PostgreSQL)
152- [ ] Cross-device sync for vocabulary, settings, history
153- [ ] User accounts (future: multi-user with subscriptions)
154- [ ] Usage analytics
155- [ ] Server-side history storage
156
157### Phase 3 — Native Apps
158- [ ] Capacitor wrapper for Apple App Store
159- [ ] Capacitor wrapper for Google Play Store
160- [ ] Native notification support
161- [ ] Background audio recording
162
163### Phase 4 — Advanced Features
164- [ ] Real-time streaming transcription (text appears as you speak)
165- [ ] Document templates with fillable fields
166- [ ] Multi-language legal terminology
167- [ ] Audio playback of recordings
168- [ ] Batch file transcription (upload audio files)
169- [ ] Auto-detect document type from content
170- [ ] Firm-specific AI training profiles
171- [ ] Integration with AlecRae legal research (Oracle)
172- [ ] Integration with AlecRae accounting platform
173- [ ] Integration with AlecRae messenger
174- [ ] Integration with AlecRae internal email
175
176### Phase 5 — Enterprise
177- [ ] Multi-user with role-based access
178- [ ] Team vocabulary sharing
179- [ ] Subscription billing via Stripe
180- [ ] SOC 2 / HIPAA compliance documentation
181- [ ] SSO integration
182- [ ] Admin dashboard with usage analytics
183- [ ] White-label for law firms
283### Database & Sync
284- [x] Neon PostgreSQL with full schema (users, dictations, vocabulary, firms, templates, usage_logs)
285- [x] CRUD APIs for dictations, vocabulary, settings, analytics
286- [x] Server-side history storage
287- [x] Firm profiles with custom AI instructions and vocabulary
288
289### Enterprise
290- [x] Multi-user auth with RBAC (owner/admin/user/viewer)
291- [x] Email+password registration with PBKDF2 hashing
292- [x] SSO integration (Google, Microsoft)
293- [x] Stripe subscription billing (Free/Pro $29/Enterprise $99)
294- [x] Admin dashboard with analytics
295- [x] User management (invite, roles, deactivate)
296- [x] White-label branding support
297- [x] Firm-specific AI training profiles
298
299### Security
300- [x] JWT sessions (HttpOnly, Secure, SameSite)
301- [x] PBKDF2 password hashing (Web Crypto API)
302- [x] Role-based access control on all routes
303- [x] Stripe webhook signature verification
304- [x] Audit logging
305- [x] Content Security Policy headers
306- [x] Privacy mode for privileged communications
184307
185308---
186309
187310## ENVIRONMENT VARIABLES (Vercel)
188311
189312```
190OPENAI_API_KEY — OpenAI API key for Whisper
191ANTHROPIC_API_KEY — Anthropic API key for Claude
192ADMIN_PASSWORD — Admin login password
193JWT_SECRET — Random string for signing session tokens (32+ chars)
194DATABASE_URL — Neon PostgreSQL connection string (when database added)
313# Required
314OPENAI_API_KEY — OpenAI API key for Whisper
315ANTHROPIC_API_KEY — Anthropic API key for Claude
316ADMIN_PASSWORD — Legacy admin login password
317JWT_SECRET — Random string for signing session tokens (32+ chars)
318DATABASE_URL — Neon PostgreSQL connection string
319
320# Billing (Stripe)
321STRIPE_SECRET_KEY — Stripe secret API key
322STRIPE_WEBHOOK_SECRET — Stripe webhook signing secret
323NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY — Stripe publishable key (client-side)
324
325# SSO (Optional)
326GOOGLE_CLIENT_ID — Google OAuth client ID
327GOOGLE_CLIENT_SECRET — Google OAuth client secret
328MICROSOFT_CLIENT_ID — Microsoft OAuth client ID
329MICROSOFT_CLIENT_SECRET — Microsoft OAuth client secret
195330```
196331
197332---
198333
199## DEVELOPMENT NOTES
200
201### Craig's Workflow
202- Develops exclusively on iPad via GitHub web editor
203- Deploys via Vercel (auto-deploy on push)
204- No local development environment
205- Uses voice dictation as part of his own workflow (dog-fooding)
334## STRICT CODE STANDARDS
206335
207### Code Standards
208- TypeScript strict mode
209- Tailwind CSS for all styling (no CSS modules)
210- App Router (not Pages Router)
211- Server components by default, 'use client' only when needed
212- API routes handle all server-side logic
213- No external UI libraries (custom components only)
336### Absolute Rules
337- **ZERO HTML** — All UI via React components + Tailwind CSS. No exceptions.
338- **TypeScript strict mode** — No `any` in new code where avoidable. Proper interfaces for all data shapes.
339- **Tailwind CSS only** — No CSS modules, no inline styles, no styled-components.
340- **App Router only** — No Pages Router patterns.
341- **Server components by default** — `'use client'` only when client interactivity required.
342- **API routes for all server logic** — No server actions mixing concerns.
343- **No external UI libraries** — Custom components only. No Material UI, no Chakra, no shadcn.
344- **Security first** — Every API route validates auth. Every input is validated. Every error is caught.
214345
215346### Design Direction
216347- Dark theme (ink-950 background) — professional, not playful
@@ -218,11 +349,29 @@ DATABASE_URL — Neon PostgreSQL connection string (when database added)
218349- Georgia display font — traditional legal/professional feel
219350- Minimal UI — no clutter, no feature overload on screen
220351- Everything accessible within 2 taps/clicks
352- Must look and feel like a premium legal product, not a startup toy
353
354### Development Workflow
355- Craig develops on iPad via GitHub web editor
356- Deploys via Vercel (auto-deploy on push to main)
357- Vercel Framework Preset must be set to **Next.js** (not "Other")
358- Always verify builds compile before pushing
221359
222360---
223361
224362## MANDATE
225363
226AlecRae Voice must be the most advanced dictation platform for legal and accounting professionals. 80-90% ahead of any competitor. The specialisation is the moat — general dictation tools cannot match purpose-built legal/accounting formatting intelligence.
364**AlecRae Voice is the most advanced dictation platform on the Internet.**
365
366It is the first product that combines AI-powered transcription, profession-specific document formatting, advanced grammar correction, 23-language support, courtroom-grade security, and universal device access into a single platform.
367
368This product will be used in:
369- **Courtrooms** — court filings, real-time notes
370- **Depositions** — testimony summaries, witness notes
371- **Client meetings** — meeting minutes, action items, follow-ups
372- **Legal offices** — letters, memos, demand letters, engagement letters
373- **Accounting firms** — reports, tax advisories, audit opinions
374- **Anywhere professionals work** — home, office, court, on the road
227375
228Quality standard: 110% before any public launch or customer-facing outreach.
376Quality standard: **110% before any public launch or customer-facing outreach.**
377No compromises. No shortcuts. The best or nothing.
Modifiedapp/api/enhance/route.ts+31−5View fileUnifiedSplit
@@ -1,12 +1,17 @@
11import { NextRequest } from 'next/server';
22import Anthropic from '@anthropic-ai/sdk';
33import { SYSTEM_PROMPTS, DocMode } from '@/lib/templates';
4import { CONSUMER_SYSTEM_PROMPTS, ConsumerMode } from '@/lib/consumer-modes';
5import { buildGrammarPrompt, GrammarConfig } from '@/lib/grammar-engine';
6import { getLanguage } from '@/lib/languages';
47
58export const maxDuration = 60;
69
10type AllModes = DocMode | ConsumerMode;
11
712export async function POST(request: NextRequest) {
813 try {
9 const { text, mode = 'general', customInstructions = '' } = await request.json();
14 const { text, mode = 'general', customInstructions = '', grammarConfig, language } = await request.json();
1015
1116 if (!text || text.trim().length === 0) {
1217 return new Response(JSON.stringify({ error: 'No text provided' }), {
@@ -15,10 +20,31 @@ export async function POST(request: NextRequest) {
1520 });
1621 }
1722
18 const systemPrompt = SYSTEM_PROMPTS[mode as DocMode] || SYSTEM_PROMPTS['general'];
19 const fullSystem = customInstructions
20 ? `${systemPrompt}\n\nADDITIONAL USER INSTRUCTIONS:\n${customInstructions}`
21 : systemPrompt;
23 // Get system prompt from professional or consumer modes
24 const systemPrompt = SYSTEM_PROMPTS[mode as DocMode]
25 || CONSUMER_SYSTEM_PROMPTS[mode as ConsumerMode]
26 || SYSTEM_PROMPTS['general'];
27
28 // Build the full system prompt with optional grammar config and language
29 let fullSystem = systemPrompt;
30
31 // Add language context if specified
32 if (language) {
33 const lang = getLanguage(language);
34 if (lang) {
35 fullSystem += `\n\nLANGUAGE: Output in ${lang.name} (${lang.nativeName}). ${lang.grammarNotes}`;
36 }
37 }
38
39 // Add grammar rules if configured
40 if (grammarConfig) {
41 fullSystem += buildGrammarPrompt(grammarConfig as GrammarConfig);
42 }
43
44 // Add custom instructions
45 if (customInstructions) {
46 fullSystem += `\n\nADDITIONAL USER INSTRUCTIONS:\n${customInstructions}`;
47 }
2248
2349 const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
2450
Modifiedapp/api/firms/[id]/route.ts+1−1View fileUnifiedSplit
@@ -1,6 +1,6 @@
11import { NextRequest, NextResponse } from 'next/server';
22import { verifySession } from '@/lib/auth';
3import { firmStore } from '../route';
3import { firmStore } from '@/lib/firm-store';
44
55async function checkAuth(request: NextRequest): Promise<NextResponse | null> {
66 const session = request.cookies.get('alecrae_session')?.value;
Modifiedapp/api/firms/route.ts+2−22View fileUnifiedSplit
@@ -1,18 +1,11 @@
11import { NextRequest, NextResponse } from 'next/server';
22import { verifySession } from '@/lib/auth';
33import {
4 FirmProfile,
54 FirmProfileInput,
65 createFirmProfile,
76 validateFirmProfile,
87} from '@/lib/firm-profiles';
9
10/**
11 * In-memory store for firm profiles.
12 * When the database (Neon PostgreSQL) is connected in Phase 2,
13 * this will be replaced with proper database queries.
14 */
15const firmStore = new Map<string, FirmProfile>();
8import { firmStore } from '@/lib/firm-store';
169
1710async function checkAuth(request: NextRequest): Promise<NextResponse | null> {
1811 const session = request.cookies.get('alecrae_session')?.value;
@@ -22,9 +15,6 @@ async function checkAuth(request: NextRequest): Promise<NextResponse | null> {
2215 return null;
2316}
2417
25/**
26 * GET /api/firms — List all firm profiles
27 */
2818export async function GET(request: NextRequest) {
2919 const authError = await checkAuth(request);
3020 if (authError) return authError;
@@ -36,9 +26,6 @@ export async function GET(request: NextRequest) {
3626 return NextResponse.json({ firms });
3727}
3828
39/**
40 * POST /api/firms — Create a new firm profile
41 */
4229export async function POST(request: NextRequest) {
4330 const authError = await checkAuth(request);
4431 if (authError) return authError;
@@ -63,10 +50,6 @@ export async function POST(request: NextRequest) {
6350 }
6451}
6552
66/**
67 * PUT /api/firms — Update an existing firm profile
68 * Expects { id, ...updates } in the request body
69 */
7053export async function PUT(request: NextRequest) {
7154 const authError = await checkAuth(request);
7255 if (authError) return authError;
@@ -89,7 +72,7 @@ export async function PUT(request: NextRequest) {
8972 return NextResponse.json({ errors }, { status: 400 });
9073 }
9174
92 const updated: FirmProfile = {
75 const updated = {
9376 ...existing,
9477 ...updates,
9578 id: existing.id,
@@ -107,6 +90,3 @@ export async function PUT(request: NextRequest) {
10790 );
10891 }
10992}
110
111// Export for use in the [id] route
112export { firmStore };
Modifiedapp/api/transcribe/route.ts+24−5View fileUnifiedSplit
@@ -1,13 +1,25 @@
11import { NextRequest, NextResponse } from 'next/server';
22import OpenAI from 'openai';
3import { getWhisperPromptForLanguage } from '@/lib/languages';
34
45export const maxDuration = 60;
56
7// Map language codes to Whisper language codes
8const WHISPER_LANG_MAP: Record<string, string> = {
9 'en-US': 'en', 'en-GB': 'en', 'en-AU': 'en', 'en-NZ': 'en',
10 'es': 'es', 'fr': 'fr', 'de': 'de', 'it': 'it',
11 'pt-BR': 'pt', 'pt-PT': 'pt', 'nl': 'nl', 'ja': 'ja',
12 'ko': 'ko', 'zh': 'zh', 'ar': 'ar', 'hi': 'hi',
13 'ru': 'ru', 'tr': 'tr', 'pl': 'pl', 'sv': 'sv',
14 'th': 'th', 'vi': 'vi', 'id': 'id',
15};
16
617export async function POST(request: NextRequest) {
718 try {
819 const formData = await request.formData();
920 const audioFile = formData.get('audio') as File;
1021 const customVocab = formData.get('vocabulary') as string | null;
22 const language = formData.get('language') as string | null;
1123
1224 if (!audioFile) {
1325 return NextResponse.json({ error: 'No audio file provided' }, { status: 400 });
@@ -15,15 +27,22 @@ export async function POST(request: NextRequest) {
1527
1628 const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
1729
18 // Build vocabulary prompt for Whisper — helps with specialised terms
19 const vocabHint = customVocab
20 ? `Legal and accounting dictation. Key terms: ${customVocab}`
21 : 'Legal and accounting dictation. Attorney correspondence, memorandum, court filing, deposition, engagement letter, accounting report, tax advisory, audit opinion. Prima facie, res ipsa loquitur, habeas corpus, voir dire, stare decisis, certiorari, GAAP, IFRS, EBITDA, IRC, PCAOB.';
30 // Determine Whisper language code
31 const whisperLang = language ? (WHISPER_LANG_MAP[language] || language.split('-')[0]) : 'en';
32
33 // Build vocabulary prompt — language-specific with custom terms
34 let vocabHint: string;
35 if (customVocab) {
36 const langHint = getWhisperPromptForLanguage(language || 'en-US');
37 vocabHint = `${langHint} Additional terms: ${customVocab}`;
38 } else {
39 vocabHint = getWhisperPromptForLanguage(language || 'en-US');
40 }
2241
2342 const transcription = await openai.audio.transcriptions.create({
2443 file: audioFile,
2544 model: 'whisper-1',
26 language: 'en',
45 language: whisperLang,
2746 response_format: 'verbose_json',
2847 prompt: vocabHint,
2948 });
Modifiedapp/layout.tsx+62−5View fileUnifiedSplit
@@ -1,4 +1,5 @@
11import type { Metadata, Viewport } from 'next'
2import Script from 'next/script'
23import './globals.css'
34
45export const metadata: Metadata = {
@@ -6,21 +7,77 @@ export const metadata: Metadata = {
67 description: 'AI-powered dictation for legal and accounting professionals. Whisper transcription + Claude AI formatting.',
78 manifest: '/manifest.json',
89 appleWebApp: { capable: true, statusBarStyle: 'black-translucent', title: 'AlecRae Voice' },
9 icons: { apple: '/icon-192.png' },
10 icons: {
11 icon: [
12 { url: '/icon-192.png', sizes: '192x192', type: 'image/png' },
13 { url: '/icon-512.png', sizes: '512x512', type: 'image/png' },
14 ],
15 apple: [
16 { url: '/icon-192.png', sizes: '192x192' },
17 ],
18 },
19 applicationName: 'AlecRae Voice',
20 other: {
21 // Android Chrome
22 'mobile-web-app-capable': 'yes',
23 // Windows pinned site
24 'msapplication-TileColor': '#111920',
25 'msapplication-TileImage': '/icon-192.png',
26 'msapplication-config': 'none',
27 // Disable automatic phone number detection
28 'format-detection': 'telephone=no',
29 // Permissions policy for microphone access
30 'permissions-policy': 'microphone=(self)',
31 },
1032}
1133
1234export const viewport: Viewport = {
1335 width: 'device-width',
1436 initialScale: 1,
15 maximumScale: 1,
16 userScalable: false,
17 themeColor: '#111920',
37 maximumScale: 5,
38 minimumScale: 1,
39 userScalable: true,
40 viewportFit: 'cover',
41 themeColor: [
42 { media: '(prefers-color-scheme: dark)', color: '#111920' },
43 { media: '(prefers-color-scheme: light)', color: '#111920' },
44 ],
1845}
1946
2047export default function RootLayout({ children }: { children: React.ReactNode }) {
2148 return (
2249 <html lang="en" className="h-full">
23 <body className="h-full bg-ink-950 text-ink-100 font-body antialiased">{children}</body>
50 <head>
51 {/* iOS-specific meta tags for full PWA experience */}
52 <meta name="apple-mobile-web-app-capable" content="yes" />
53 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
54 <meta name="apple-mobile-web-app-title" content="AlecRae Voice" />
55
56 {/* Android Chrome theme */}
57 <meta name="mobile-web-app-capable" content="yes" />
58
59 {/* Prevent zoom on input focus for iOS Safari */}
60 <meta name="HandheldFriendly" content="true" />
61 </head>
62 <body className="h-full bg-ink-950 text-ink-100 font-body antialiased">
63 {children}
64 <Script
65 id="register-sw"
66 strategy="afterInteractive"
67 dangerouslySetInnerHTML={{
68 __html: `
69 if ('serviceWorker' in navigator) {
70 window.addEventListener('load', function() {
71 navigator.serviceWorker.register('/sw.js', { scope: '/' })
72 .catch(function(err) {
73 console.warn('[SW] Registration failed:', err);
74 });
75 });
76 }
77 `,
78 }}
79 />
80 </body>
2481 </html>
2582 )
2683}
Modifiedapp/page.tsx+157−24View fileUnifiedSplit
@@ -2,14 +2,43 @@
22
33import { useState } from 'react';
44import { useRouter } from 'next/navigation';
5import Link from 'next/link';
56
67export default function LoginPage() {
8 const [mode, setMode] = useState<'email' | 'admin'>('email');
9 const [email, setEmail] = useState('');
710 const [password, setPassword] = useState('');
811 const [error, setError] = useState('');
912 const [loading, setLoading] = useState(false);
1013 const router = useRouter();
1114
12 const handleLogin = async (e: React.FormEvent) => {
15 const handleEmailLogin = async (e: React.FormEvent) => {
16 e.preventDefault();
17 if (!email.trim() || !password.trim()) return;
18 setLoading(true);
19 setError('');
20
21 try {
22 const res = await fetch('/api/auth', {
23 method: 'POST',
24 headers: { 'Content-Type': 'application/json' },
25 body: JSON.stringify({ email: email.trim(), password }),
26 });
27
28 if (!res.ok) {
29 setError('Invalid email or password');
30 setLoading(false);
31 return;
32 }
33
34 router.push('/app');
35 } catch {
36 setError('Connection failed');
37 setLoading(false);
38 }
39 };
40
41 const handleAdminLogin = async (e: React.FormEvent) => {
1342 e.preventDefault();
1443 if (!password.trim()) return;
1544 setLoading(true);
@@ -38,40 +67,144 @@ export default function LoginPage() {
3867 return (
3968 <main className="h-full flex items-center justify-center px-4">
4069 <div className="w-full max-w-sm">
41 <div className="text-center mb-10">
70 <div className="text-center mb-8">
4271 <h1 className="font-display text-3xl text-ink-50 tracking-tight mb-1">
4372 AlecRae <span className="text-gold-400">Voice</span>
4473 </h1>
4574 <p className="text-ink-400 text-sm">Professional dictation for legal & accounting</p>
4675 </div>
4776
48 <form onSubmit={handleLogin} className="space-y-4">
49 <div>
50 <input
51 type="password"
52 value={password}
53 onChange={(e) => setPassword(e.target.value)}
54 placeholder="Admin password"
55 autoFocus
56 autoComplete="current-password"
57 className="w-full bg-ink-900 border border-ink-700/50 rounded-xl px-4 py-3 text-ink-100 placeholder:text-ink-500 focus:border-gold-500/60 transition-colors text-center text-lg tracking-wider"
58 />
59 </div>
60
61 {error && (
62 <p className="text-red-400 text-sm text-center animate-fade-in">{error}</p>
63 )}
77 <div className="space-y-3 mb-6">
78 <a
79 href="/api/auth/sso/google"
80 className="w-full flex items-center justify-center gap-3 bg-ink-900 border border-ink-700/50 hover:border-ink-600 rounded-xl px-4 py-3 text-ink-200 transition-colors text-sm font-medium"
81 >
82 <svg className="w-5 h-5" viewBox="0 0 24 24" fill="none">
83 <path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4" />
84 <path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
85 <path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
86 <path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
87 </svg>
88 Sign in with Google
89 </a>
90
91 <a
92 href="/api/auth/sso/microsoft"
93 className="w-full flex items-center justify-center gap-3 bg-ink-900 border border-ink-700/50 hover:border-ink-600 rounded-xl px-4 py-3 text-ink-200 transition-colors text-sm font-medium"
94 >
95 <svg className="w-5 h-5" viewBox="0 0 24 24" fill="none">
96 <rect x="1" y="1" width="10" height="10" fill="#F25022" />
97 <rect x="13" y="1" width="10" height="10" fill="#7FBA00" />
98 <rect x="1" y="13" width="10" height="10" fill="#00A4EF" />
99 <rect x="13" y="13" width="10" height="10" fill="#FFB900" />
100 </svg>
101 Sign in with Microsoft
102 </a>
103 </div>
104
105 <div className="flex items-center gap-3 mb-6">
106 <div className="flex-1 h-px bg-ink-700/50" />
107 <span className="text-ink-500 text-xs uppercase tracking-wider">or</span>
108 <div className="flex-1 h-px bg-ink-700/50" />
109 </div>
64110
111 <div className="flex gap-1 mb-5 bg-ink-900/50 rounded-lg p-1">
65112 <button
66 type="submit"
67 disabled={loading || !password.trim()}
68 className="w-full bg-gold-500 hover:bg-gold-400 disabled:bg-ink-700 disabled:text-ink-400 text-ink-950 font-medium py-3 rounded-xl transition-all text-sm"
113 type="button"
114 onClick={() => { setMode('email'); setError(''); }}
115 className={`flex-1 py-2 rounded-md text-xs font-medium transition-colors ${
116 mode === 'email'
117 ? 'bg-ink-800 text-ink-100'
118 : 'text-ink-500 hover:text-ink-300'
119 }`}
69120 >
70 {loading ? 'Signing in...' : 'Sign in'}
121 Email sign in
71122 </button>
72 </form>
123 <button
124 type="button"
125 onClick={() => { setMode('admin'); setError(''); }}
126 className={`flex-1 py-2 rounded-md text-xs font-medium transition-colors ${
127 mode === 'admin'
128 ? 'bg-ink-800 text-ink-100'
129 : 'text-ink-500 hover:text-ink-300'
130 }`}
131 >
132 Quick admin access
133 </button>
134 </div>
135
136 {mode === 'email' ? (
137 <form onSubmit={handleEmailLogin} className="space-y-4">
138 <div>
139 <input
140 type="email"
141 value={email}
142 onChange={(e) => setEmail(e.target.value)}
143 placeholder="Email address"
144 autoFocus
145 autoComplete="email"
146 className="w-full bg-ink-900 border border-ink-700/50 rounded-xl px-4 py-3 text-ink-100 placeholder:text-ink-500 focus:border-gold-500/60 transition-colors text-sm"
147 />
148 </div>
149 <div>
150 <input
151 type="password"
152 value={password}
153 onChange={(e) => setPassword(e.target.value)}
154 placeholder="Password"
155 autoComplete="current-password"
156 className="w-full bg-ink-900 border border-ink-700/50 rounded-xl px-4 py-3 text-ink-100 placeholder:text-ink-500 focus:border-gold-500/60 transition-colors text-sm"
157 />
158 </div>
159
160 {error && (
161 <p className="text-red-400 text-sm text-center animate-fade-in">{error}</p>
162 )}
163
164 <button
165 type="submit"
166 disabled={loading || !email.trim() || !password.trim()}
167 className="w-full bg-gold-500 hover:bg-gold-400 disabled:bg-ink-700 disabled:text-ink-400 text-ink-950 font-medium py-3 rounded-xl transition-all text-sm"
168 >
169 {loading ? 'Signing in...' : 'Sign in'}
170 </button>
171 </form>
172 ) : (
173 <form onSubmit={handleAdminLogin} className="space-y-4">
174 <div>
175 <input
176 type="password"
177 value={password}
178 onChange={(e) => setPassword(e.target.value)}
179 placeholder="Admin password"
180 autoFocus
181 autoComplete="current-password"
182 className="w-full bg-ink-900 border border-ink-700/50 rounded-xl px-4 py-3 text-ink-100 placeholder:text-ink-500 focus:border-gold-500/60 transition-colors text-center text-lg tracking-wider"
183 />
184 </div>
185
186 {error && (
187 <p className="text-red-400 text-sm text-center animate-fade-in">{error}</p>
188 )}
189
190 <button
191 type="submit"
192 disabled={loading || !password.trim()}
193 className="w-full bg-gold-500 hover:bg-gold-400 disabled:bg-ink-700 disabled:text-ink-400 text-ink-950 font-medium py-3 rounded-xl transition-all text-sm"
194 >
195 {loading ? 'Signing in...' : 'Sign in'}
196 </button>
197 </form>
198 )}
199
200 <p className="text-ink-500 text-xs text-center mt-6">
201 No account?{' '}
202 <Link href="/register" className="text-gold-400 hover:text-gold-300 transition-colors">
203 Create one
204 </Link>
205 </p>
73206
74 <p className="text-ink-600 text-xs text-center mt-8">
207 <p className="text-ink-600 text-xs text-center mt-6">
75208 Secured access · All data encrypted in transit
76209 </p>
77210 </div>
Addedapp/register/layout.tsx+3−0View fileUnifiedSplit
@@ -0,0 +1,3 @@
1export default function RegisterLayout({ children }: { children: React.ReactNode }) {
2 return children;
3}
Addedapp/register/page.tsx+247−0View fileUnifiedSplit
@@ -0,0 +1,247 @@
1'use client';
2
3import { useState, useMemo } from 'react';
4import { useRouter } from 'next/navigation';
5import Link from 'next/link';
6
7function getPasswordStrength(password: string): { score: number; label: string; color: string } {
8 let score = 0;
9 if (password.length >= 8) score++;
10 if (password.length >= 12) score++;
11 if (/[A-Z]/.test(password)) score++;
12 if (/[0-9]/.test(password)) score++;
13 if (/[^A-Za-z0-9]/.test(password)) score++;
14
15 if (score <= 1) return { score, label: 'Weak', color: 'bg-red-500' };
16 if (score <= 2) return { score, label: 'Fair', color: 'bg-orange-500' };
17 if (score <= 3) return { score, label: 'Good', color: 'bg-yellow-500' };
18 return { score, label: 'Strong', color: 'bg-emerald-500' };
19}
20
21export default function RegisterPage() {
22 const [name, setName] = useState('');
23 const [email, setEmail] = useState('');
24 const [password, setPassword] = useState('');
25 const [confirmPassword, setConfirmPassword] = useState('');
26 const [firmName, setFirmName] = useState('');
27 const [acceptedTerms, setAcceptedTerms] = useState(false);
28 const [error, setError] = useState('');
29 const [loading, setLoading] = useState(false);
30 const router = useRouter();
31
32 const strength = useMemo(() => getPasswordStrength(password), [password]);
33
34 const handleRegister = async (e: React.FormEvent) => {
35 e.preventDefault();
36 setError('');
37
38 if (!name.trim() || !email.trim() || !password.trim()) {
39 setError('Please fill in all required fields');
40 return;
41 }
42
43 if (password !== confirmPassword) {
44 setError('Passwords do not match');
45 return;
46 }
47
48 if (password.length < 8) {
49 setError('Password must be at least 8 characters');
50 return;
51 }
52
53 if (!acceptedTerms) {
54 setError('Please accept the terms and conditions');
55 return;
56 }
57
58 setLoading(true);
59
60 try {
61 const res = await fetch('/api/auth/register', {
62 method: 'POST',
63 headers: { 'Content-Type': 'application/json' },
64 body: JSON.stringify({
65 name: name.trim(),
66 email: email.trim(),
67 password,
68 firmName: firmName.trim() || undefined,
69 }),
70 });
71
72 if (!res.ok) {
73 const data = await res.json().catch(() => null);
74 setError(data?.error || 'Registration failed');
75 setLoading(false);
76 return;
77 }
78
79 router.push('/app');
80 } catch {
81 setError('Connection failed');
82 setLoading(false);
83 }
84 };
85
86 return (
87 <main className="min-h-full flex items-center justify-center px-4 py-12">
88 <div className="w-full max-w-md">
89 <div className="text-center mb-8">
90 <h1 className="font-display text-3xl text-ink-50 tracking-tight mb-1">
91 AlecRae <span className="text-gold-400">Voice</span>
92 </h1>
93 <p className="text-ink-400 text-sm">Create your professional account</p>
94 </div>
95
96 <div className="space-y-3 mb-6">
97 <a
98 href="/api/auth/sso/google"
99 className="w-full flex items-center justify-center gap-3 bg-ink-900 border border-ink-700/50 hover:border-ink-600 rounded-xl px-4 py-3 text-ink-200 transition-colors text-sm font-medium"
100 >
101 <svg className="w-5 h-5" viewBox="0 0 24 24" fill="none">
102 <path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4" />
103 <path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
104 <path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
105 <path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
106 </svg>
107 Sign in with Google
108 </a>
109
110 <a
111 href="/api/auth/sso/microsoft"
112 className="w-full flex items-center justify-center gap-3 bg-ink-900 border border-ink-700/50 hover:border-ink-600 rounded-xl px-4 py-3 text-ink-200 transition-colors text-sm font-medium"
113 >
114 <svg className="w-5 h-5" viewBox="0 0 24 24" fill="none">
115 <rect x="1" y="1" width="10" height="10" fill="#F25022" />
116 <rect x="13" y="1" width="10" height="10" fill="#7FBA00" />
117 <rect x="1" y="13" width="10" height="10" fill="#00A4EF" />
118 <rect x="13" y="13" width="10" height="10" fill="#FFB900" />
119 </svg>
120 Sign in with Microsoft
121 </a>
122 </div>
123
124 <div className="flex items-center gap-3 mb-6">
125 <div className="flex-1 h-px bg-ink-700/50" />
126 <span className="text-ink-500 text-xs uppercase tracking-wider">or register with email</span>
127 <div className="flex-1 h-px bg-ink-700/50" />
128 </div>
129
130 <form onSubmit={handleRegister} className="space-y-4">
131 <div>
132 <label className="block text-ink-400 text-xs mb-1.5 ml-1">Full name</label>
133 <input
134 type="text"
135 value={name}
136 onChange={(e) => setName(e.target.value)}
137 placeholder="Jane Smith"
138 autoComplete="name"
139 className="w-full bg-ink-900 border border-ink-700/50 rounded-xl px-4 py-3 text-ink-100 placeholder:text-ink-500 focus:border-gold-500/60 transition-colors text-sm"
140 />
141 </div>
142
143 <div>
144 <label className="block text-ink-400 text-xs mb-1.5 ml-1">Email address</label>
145 <input
146 type="email"
147 value={email}
148 onChange={(e) => setEmail(e.target.value)}
149 placeholder="jane@firm.com"
150 autoComplete="email"
151 className="w-full bg-ink-900 border border-ink-700/50 rounded-xl px-4 py-3 text-ink-100 placeholder:text-ink-500 focus:border-gold-500/60 transition-colors text-sm"
152 />
153 </div>
154
155 <div>
156 <label className="block text-ink-400 text-xs mb-1.5 ml-1">Firm name <span className="text-ink-600">(optional)</span></label>
157 <input
158 type="text"
159 value={firmName}
160 onChange={(e) => setFirmName(e.target.value)}
161 placeholder="Smith & Associates LLP"
162 autoComplete="organization"
163 className="w-full bg-ink-900 border border-ink-700/50 rounded-xl px-4 py-3 text-ink-100 placeholder:text-ink-500 focus:border-gold-500/60 transition-colors text-sm"
164 />
165 </div>
166
167 <div>
168 <label className="block text-ink-400 text-xs mb-1.5 ml-1">Password</label>
169 <input
170 type="password"
171 value={password}
172 onChange={(e) => setPassword(e.target.value)}
173 placeholder="Min. 8 characters"
174 autoComplete="new-password"
175 className="w-full bg-ink-900 border border-ink-700/50 rounded-xl px-4 py-3 text-ink-100 placeholder:text-ink-500 focus:border-gold-500/60 transition-colors text-sm"
176 />
177 {password.length > 0 && (
178 <div className="mt-2 flex items-center gap-2">
179 <div className="flex-1 flex gap-1">
180 {[1, 2, 3, 4, 5].map((i) => (
181 <div
182 key={i}
183 className={`h-1 flex-1 rounded-full transition-colors ${
184 i <= strength.score ? strength.color : 'bg-ink-800'
185 }`}
186 />
187 ))}
188 </div>
189 <span className="text-xs text-ink-400">{strength.label}</span>
190 </div>
191 )}
192 </div>
193
194 <div>
195 <label className="block text-ink-400 text-xs mb-1.5 ml-1">Confirm password</label>
196 <input
197 type="password"
198 value={confirmPassword}
199 onChange={(e) => setConfirmPassword(e.target.value)}
200 placeholder="Re-enter password"
201 autoComplete="new-password"
202 className="w-full bg-ink-900 border border-ink-700/50 rounded-xl px-4 py-3 text-ink-100 placeholder:text-ink-500 focus:border-gold-500/60 transition-colors text-sm"
203 />
204 {confirmPassword.length > 0 && password !== confirmPassword && (
205 <p className="text-red-400 text-xs mt-1 ml-1">Passwords do not match</p>
206 )}
207 </div>
208
209 <label className="flex items-start gap-3 cursor-pointer pt-1">
210 <input
211 type="checkbox"
212 checked={acceptedTerms}
213 onChange={(e) => setAcceptedTerms(e.target.checked)}
214 className="mt-0.5 w-4 h-4 rounded border-ink-600 bg-ink-900 text-gold-500 focus:ring-gold-500/30 focus:ring-offset-0"
215 />
216 <span className="text-ink-400 text-xs leading-relaxed">
217 I agree to the Terms of Service and Privacy Policy
218 </span>
219 </label>
220
221 {error && (
222 <p className="text-red-400 text-sm text-center animate-fade-in">{error}</p>
223 )}
224
225 <button
226 type="submit"
227 disabled={loading || !name.trim() || !email.trim() || !password.trim() || !confirmPassword.trim() || !acceptedTerms}
228 className="w-full bg-gold-500 hover:bg-gold-400 disabled:bg-ink-700 disabled:text-ink-400 text-ink-950 font-medium py-3 rounded-xl transition-all text-sm"
229 >
230 {loading ? 'Creating account...' : 'Create account'}
231 </button>
232 </form>
233
234 <p className="text-ink-500 text-xs text-center mt-6">
235 Already have an account?{' '}
236 <Link href="/" className="text-gold-400 hover:text-gold-300 transition-colors">
237 Sign in
238 </Link>
239 </p>
240
241 <p className="text-ink-600 text-xs text-center mt-6">
242 Secured access · All data encrypted in transit
243 </p>
244 </div>
245 </main>
246 );
247}
Addedlib/consumer-modes.ts+108−0View fileUnifiedSplit
@@ -0,0 +1,108 @@
1export type ConsumerMode =
2 | 'personal-letter'
3 | 'business-email'
4 | 'resume-cover-letter'
5 | 'social-media'
6 | 'blog-post'
7 | 'academic-paper'
8 | 'medical-notes'
9 | 'real-estate'
10 | 'insurance-claim'
11 | 'complaint-letter';
12
13export interface ConsumerModeConfig {
14 value: ConsumerMode;
15 label: string;
16 description: string;
17 category: 'personal' | 'business' | 'academic' | 'specialist';
18}
19
20export const CONSUMER_MODES: ConsumerModeConfig[] = [
21 { value: 'personal-letter', label: 'Personal letter', description: 'Warm, structured personal correspondence', category: 'personal' },
22 { value: 'business-email', label: 'Business email', description: 'Professional business communication', category: 'business' },
23 { value: 'resume-cover-letter', label: 'Resume / Cover letter', description: 'Job application formatting', category: 'business' },
24 { value: 'social-media', label: 'Social media post', description: 'Concise, engaging social content', category: 'personal' },
25 { value: 'blog-post', label: 'Blog post', description: 'Structured article with headers', category: 'personal' },
26 { value: 'academic-paper', label: 'Academic paper', description: 'Formal academic writing', category: 'academic' },
27 { value: 'medical-notes', label: 'Medical notes', description: 'SOAP format clinical notes', category: 'specialist' },
28 { value: 'real-estate', label: 'Real estate', description: 'Property documents and listings', category: 'specialist' },
29 { value: 'insurance-claim', label: 'Insurance claim', description: 'Claim filing documentation', category: 'specialist' },
30 { value: 'complaint-letter', label: 'Complaint letter', description: 'Formal complaint with resolution request', category: 'specialist' },
31];
32
33const BASE_RULES = `
34CRITICAL RULES:
35- NEVER add substantive content the speaker did not dictate.
36- ONLY clean up, format, and structure what was actually said.
37- Fix all grammar, punctuation, and sentence structure.
38- Remove filler words (um, uh, like, you know, so, basically).
39- Apply professional formatting with proper paragraph breaks.
40- Preserve the speaker's intent, reasoning, and all factual content exactly.
41`;
42
43export const CONSUMER_SYSTEM_PROMPTS: Record<ConsumerMode, string> = {
44 'personal-letter': `You are an expert personal secretary helping draft warm, genuine personal correspondence. Transform dictated text into a well-structured personal letter.
45Format: Date, greeting, body paragraphs, warm closing, signature.
46Tone: Warm, sincere, and personal. Not stiff or corporate.
47Keep the speaker's authentic voice while improving clarity and flow.
48${BASE_RULES}`,
49
50 'business-email': `You are an expert executive assistant drafting professional business communications. Transform dictated text into a polished business email.
51Format: Greeting, concise body paragraphs, clear action items or next steps, professional sign-off.
52Tone: Professional, confident, and direct. Respect the reader's time.
53Keep paragraphs short (2-3 sentences max) for email readability.
54${BASE_RULES}`,
55
56 'resume-cover-letter': `You are an expert career consultant and professional writer. Transform dictated text into a compelling resume cover letter.
57Format: Professional header, date, recipient details (if mentioned), opening hook, 2-3 body paragraphs highlighting relevant experience and skills, strong closing with call to action.
58Tone: Confident but not arrogant. Action-oriented with quantified achievements where mentioned.
59Use strong action verbs: led, developed, implemented, achieved, delivered.
60${BASE_RULES}`,
61
62 'social-media': `You are an expert social media content creator. Transform dictated text into engaging social media content.
63Format: Hook/opening line, concise body, call to action or engaging close.
64Tone: Conversational, engaging, authentic. Match the platform's conventions.
65Keep it concise — aim for maximum impact with minimum words.
66Use short paragraphs or line breaks for readability. Suggest hashtags only if the speaker mentioned topics suitable for them.
67${BASE_RULES}`,
68
69 'blog-post': `You are an expert content writer and editor. Transform dictated text into a well-structured blog post.
70Format: Compelling headline, introduction hook, body with clear H2/H3 subheadings, conclusion with takeaway.
71Tone: Engaging, informative, and accessible. Write for scanning — use short paragraphs, bullet points where appropriate.
72Include a strong opening hook. Break complex ideas into digestible sections.
73${BASE_RULES}`,
74
75 'academic-paper': `You are an expert academic editor with experience across disciplines. Transform dictated text into formal academic writing.
76Format: Follow standard academic structure — introduction, literature context, methodology (if applicable), findings/analysis, conclusion.
77Tone: Formal, objective, third person. Avoid colloquialisms.
78Use hedging language where appropriate (suggests, indicates, appears to).
79Preserve all citations, references, and attributions the speaker mentioned. Format in-text citations consistently.
80${BASE_RULES}`,
81
82 'medical-notes': `You are an expert medical transcriptionist. Transform dictated text into structured clinical notes.
83Format: SOAP format where applicable — Subjective (patient complaints, history), Objective (examination findings, vitals), Assessment (diagnosis, differential), Plan (treatment, follow-up).
84Use standard medical abbreviations: pt (patient), Hx (history), Dx (diagnosis), Tx (treatment), Rx (prescription), PRN (as needed), BID/TID/QID (dosing frequency).
85Tone: Clinical, precise, factual. No opinions — only observed/reported findings.
86${BASE_RULES}`,
87
88 'real-estate': `You are an expert real estate professional writer. Transform dictated text into polished real estate documentation.
89For property listings: Highlight key features, square footage, location benefits, unique selling points. Use aspirational but accurate language.
90For offer letters/contracts: Formal structure with property address, terms, conditions, contingencies.
91For lease agreements: Clear terms, obligations, restrictions, dates, amounts.
92Tone: Professional and precise for legal documents. Compelling and descriptive for listings.
93${BASE_RULES}`,
94
95 'insurance-claim': `You are an expert insurance documentation specialist. Transform dictated text into a properly structured insurance claim.
96Format: Policy reference, claimant details, incident date/time/location, detailed description of loss/damage, supporting evidence referenced, amount claimed, declaration.
97Tone: Factual, precise, chronological. State only what happened — no speculation.
98Use clear timelines. Reference policy numbers, claim numbers, and dates throughout.
99Include all details the speaker mentioned about the incident, damages, witnesses, and documentation.
100${BASE_RULES}`,
101
102 'complaint-letter': `You are an expert consumer advocate and professional writer. Transform dictated text into an effective formal complaint letter.
103Format: Your details, recipient details, date, reference numbers, clear description of the issue, timeline of events, impact statement, specific resolution requested, deadline for response, escalation notice.
104Tone: Firm, factual, and assertive — but not hostile. Professional disappointment, not anger.
105Reference specific dates, names, order numbers, and previous communications the speaker mentioned.
106State the desired resolution clearly and give a reasonable deadline for response.
107${BASE_RULES}`,
108};
Addedlib/firm-store.ts+8−0View fileUnifiedSplit
@@ -0,0 +1,8 @@
1import { FirmProfile } from './firm-profiles';
2
3/**
4 * In-memory store for firm profiles.
5 * When the database (Neon PostgreSQL) is connected,
6 * this will be replaced with proper database queries.
7 */
8export const firmStore = new Map<string, FirmProfile>();
Addedlib/grammar-engine.ts+89−0View fileUnifiedSplit
@@ -0,0 +1,89 @@
1export interface GrammarConfig {
2 language: string;
3 profession: 'legal' | 'accounting' | 'general';
4 strictness: 'standard' | 'formal' | 'court';
5 spellingPreference: 'US' | 'UK' | 'AU' | 'NZ' | 'auto';
6 dateFormat: string;
7 numberFormat: 'US' | 'EU';
8 citationStyle: 'bluebook' | 'oscola' | 'aglc' | 'mcgill' | 'none';
9 currencyFormat: string;
10 honorifics: boolean;
11 oxfordComma: boolean;
12}
13
14export const DEFAULT_GRAMMAR_CONFIG: GrammarConfig = {
15 language: 'en-US',
16 profession: 'legal',
17 strictness: 'formal',
18 spellingPreference: 'US',
19 dateFormat: 'MM/DD/YYYY',
20 numberFormat: 'US',
21 citationStyle: 'bluebook',
22 currencyFormat: 'USD',
23 honorifics: true,
24 oxfordComma: true,
25};
26
27export function buildGrammarPrompt(config: GrammarConfig): string {
28 const sections: string[] = [];
29
30 // Spelling rules
31 const spellingRules: Record<string, string> = {
32 US: 'Use American English spelling: analyze, organize, honor, defense, judgment (without e), center, color, favor, program.',
33 UK: 'Use British English spelling: analyse, organise, honour, defence, judgement (with e in non-legal), centre, colour, favour, programme.',
34 AU: 'Use Australian English spelling (follows British conventions): analyse, organise, honour, defence, centre, colour. Use "judgment" (no e) in legal contexts per Australian court convention.',
35 NZ: 'Use New Zealand English spelling (follows British conventions): analyse, organise, honour, defence, centre, colour. Similar to AU conventions.',
36 };
37 if (config.spellingPreference !== 'auto' && spellingRules[config.spellingPreference]) {
38 sections.push(`SPELLING: ${spellingRules[config.spellingPreference]}`);
39 }
40
41 // Citation style
42 const citationRules: Record<string, string> = {
43 bluebook: 'CITATIONS: Follow The Bluebook (US). Italicize case names. Use volume-reporter-page format (e.g., 347 U.S. 483). Abbreviate court names per Bluebook tables. Use "Id." for immediately preceding citation. Use supra/infra for cross-references.',
44 oscola: 'CITATIONS: Follow OSCOLA (UK). Use footnotes, not in-text citations. Case names in italics. Neutral citations where available (e.g., [2023] UKSC 1). No full stops in abbreviations. Use (n X) for cross-references to footnotes.',
45 aglc: 'CITATIONS: Follow AGLC (Australian). Use footnotes. Case names in italics. Medium neutral citations (e.g., [2023] HCA 1). Pinpoint references with specific paragraph numbers. Use above/below n X for cross-references.',
46 mcgill: 'CITATIONS: Follow McGill Guide (Canadian). Bilingual citation where applicable. Case names in italics. Use neutral citations (e.g., 2023 SCC 1). Include parallel citations for official reporters.',
47 none: '',
48 };
49 if (citationRules[config.citationStyle]) {
50 sections.push(citationRules[config.citationStyle]);
51 }
52
53 // Date and number formatting
54 sections.push(`DATE FORMAT: Always format dates as ${config.dateFormat}. Be consistent throughout the document.`);
55 if (config.numberFormat === 'US') {
56 sections.push('NUMBER FORMAT: Use US conventions — comma as thousands separator, period as decimal (e.g., 1,000,000.50). Use $ before amounts.');
57 } else {
58 sections.push('NUMBER FORMAT: Use European conventions — period or space as thousands separator, comma as decimal (e.g., 1.000.000,50 or 1 000 000,50).');
59 }
60
61 // Formality level
62 const formalityRules: Record<string, string> = {
63 standard: 'TONE: Professional but accessible. Clear, direct sentences. Avoid unnecessary jargon when a plain-language equivalent exists.',
64 formal: 'TONE: Formal professional register. Use precise legal/accounting terminology. Avoid contractions. Full sentences with proper subordinate clauses. Address parties by full title.',
65 court: 'TONE: Highest formality — court filing register. Use "COMES NOW," "WHEREFORE," "respectfully submits." Number all paragraphs. No contractions. Refer to parties by their litigation role (Plaintiff, Defendant). Use "the Court" with capital C.',
66 };
67 sections.push(formalityRules[config.strictness]);
68
69 // Profession-specific rules
70 if (config.profession === 'legal') {
71 sections.push('LEGAL GRAMMAR: Use active voice where possible (except in court filings where passive is conventional). "Which" for non-restrictive clauses, "that" for restrictive. Avoid "said" and "aforementioned" — use "the" or "this." Use "shall" for obligations, "may" for permissions, "must" for requirements.');
72 } else if (config.profession === 'accounting') {
73 sections.push('ACCOUNTING GRAMMAR: Use precise quantitative language. "Approximately" when estimates. "As of [date]" for point-in-time references. Spell out numbers below 10, use digits for 10+. Use "fiscal year" not "financial year" (US) or vice versa (UK). Format currency amounts consistently with two decimal places.');
74 }
75
76 // Oxford comma
77 if (config.oxfordComma) {
78 sections.push('PUNCTUATION: Use the Oxford comma (serial comma) in lists (e.g., "A, B, and C").');
79 } else {
80 sections.push('PUNCTUATION: Do NOT use the Oxford comma in lists (e.g., "A, B and C").');
81 }
82
83 // Honorifics
84 if (config.honorifics) {
85 sections.push('HONORIFICS: Use appropriate titles (Mr., Ms., Dr., Hon., J., etc.). In legal contexts, refer to judges as "the Honorable [Name]" or "Justice [Name]." Use Esq. for attorneys where conventional.');
86 }
87
88 return '\n\nGRAMMAR & FORMATTING RULES:\n' + sections.filter(Boolean).join('\n');
89}
Addedlib/languages.ts+199−0View fileUnifiedSplit
@@ -0,0 +1,199 @@
1import { DocMode } from './templates';
2
3export interface Language {
4 code: string;
5 whisperCode: string;
6 name: string;
7 nativeName: string;
8 legalTerminology: string[];
9 accountingTerminology: string[];
10 dateFormat: string;
11 currencySymbol: string;
12 spellingVariant?: string;
13 grammarNotes: string;
14}
15
16export const LANGUAGES: Language[] = [
17 {
18 code: 'en-US', whisperCode: 'en', name: 'English (US)', nativeName: 'English (US)',
19 legalTerminology: ['habeas corpus', 'res ipsa loquitur', 'voir dire', 'stare decisis', 'certiorari', 'prima facie', 'amicus curiae', 'subpoena duces tecum', 'pro se', 'nunc pro tunc', 'mandamus', 'in limine', 'ex parte', 'sua sponte', 'quantum meruit'],
20 accountingTerminology: ['GAAP', 'FASB', 'ASC', 'IRC', 'EBITDA', 'SOX', 'PCAOB', 'NOL', 'AMT', '10-K', '10-Q', 'Schedule C'],
21 dateFormat: 'MM/DD/YYYY', currencySymbol: '$', spellingVariant: 'US',
22 grammarNotes: 'Use Bluebook citation style. American spelling (analyze, organize, honor). Oxford comma preferred in legal writing.',
23 },
24 {
25 code: 'en-GB', whisperCode: 'en', name: 'English (UK)', nativeName: 'English (UK)',
26 legalTerminology: ['solicitor', 'barrister', 'tribunal', 'Crown Court', 'High Court', 'judicial review', 'claimant', 'defendant', 'magistrate', 'injunction', 'tort', 'negligence', 'Queen\'s Counsel', 'statutory instrument', 'common law'],
27 accountingTerminology: ['IFRS', 'HMRC', 'FCA', 'Companies House', 'VAT', 'corporation tax', 'PAYE', 'National Insurance', 'FRS 102', 'audit committee'],
28 dateFormat: 'DD/MM/YYYY', currencySymbol: '£', spellingVariant: 'UK',
29 grammarNotes: 'Use OSCOLA citation style. British spelling (analyse, organise, honour). No Oxford comma in standard usage.',
30 },
31 {
32 code: 'en-AU', whisperCode: 'en', name: 'English (AU)', nativeName: 'English (AU)',
33 legalTerminology: ['magistrate', 'Federal Court', 'ASIC', 'High Court of Australia', 'barrister', 'solicitor', 'tribunal', 'statutory declaration', 'affidavit', 'subpoena', 'writ', 'injunction', 'tort', 'duty of care'],
34 accountingTerminology: ['AASB', 'ATO', 'ASIC', 'GST', 'BAS', 'superannuation', 'franking credits', 'PAYG', 'tax file number', 'CGT'],
35 dateFormat: 'DD/MM/YYYY', currencySymbol: 'A$', spellingVariant: 'AU',
36 grammarNotes: 'Use AGLC citation style. British-influenced spelling. Legal writing follows UK conventions with local variations.',
37 },
38 {
39 code: 'en-NZ', whisperCode: 'en', name: 'English (NZ)', nativeName: 'English (NZ)',
40 legalTerminology: ['District Court', 'High Court', 'Employment Court', 'IRD', 'Treaty of Waitangi', 'Māori Land Court', 'Resource Management Act', 'statutory declaration', 'affidavit', 'barrister', 'solicitor'],
41 accountingTerminology: ['IRD', 'GST', 'PAYE', 'ACC', 'KiwiSaver', 'NZ IFRS', 'Financial Markets Authority', 'Chartered Accountants ANZ'],
42 dateFormat: 'DD/MM/YYYY', currencySymbol: 'NZ$', spellingVariant: 'NZ',
43 grammarNotes: 'Similar to UK/AU conventions. NZ-specific statutes and court hierarchy. Te reo Māori terms may appear in legal contexts.',
44 },
45 {
46 code: 'es', whisperCode: 'es', name: 'Spanish', nativeName: 'Español',
47 legalTerminology: ['demandante', 'demandado', 'sentencia', 'amparo', 'recurso', 'juzgado', 'tribunal', 'fiscal', 'abogado', 'procurador', 'auto', 'providencia', 'escritura pública', 'poder notarial', 'litispendencia'],
48 accountingTerminology: ['NIF', 'NIIF', 'IVA', 'IRPF', 'balance general', 'cuenta de resultados', 'activo', 'pasivo', 'patrimonio neto', 'amortización'],
49 dateFormat: 'DD/MM/YYYY', currencySymbol: '€',
50 grammarNotes: 'Spanish legal writing uses formal register. Capitalize proper nouns of institutions. Use usted form in formal correspondence.',
51 },
52 {
53 code: 'fr', whisperCode: 'fr', name: 'French', nativeName: 'Français',
54 legalTerminology: ['plaignant', 'défendeur', 'arrêt', 'cour d\'appel', 'mise en demeure', 'assignation', 'tribunal de grande instance', 'juge d\'instruction', 'procureur', 'greffier', 'jugement', 'ordonnance', 'pourvoi en cassation', 'référé'],
55 accountingTerminology: ['PCG', 'IFRS', 'TVA', 'bilan', 'compte de résultat', 'actif', 'passif', 'capitaux propres', 'amortissement', 'provisions'],
56 dateFormat: 'DD/MM/YYYY', currencySymbol: '€',
57 grammarNotes: 'French legal writing is highly formal. Use passé composé and present tense. Capitalize Tribunal, Cour, État. Use « guillemets » for quotes.',
58 },
59 {
60 code: 'de', whisperCode: 'de', name: 'German', nativeName: 'Deutsch',
61 legalTerminology: ['Kläger', 'Beklagter', 'Urteil', 'Berufung', 'Grundgesetz', 'Bundesgerichtshof', 'Verwaltungsgericht', 'Rechtsanwalt', 'Staatsanwalt', 'Beschluss', 'Verfügung', 'Revision', 'Klageerhebung', 'einstweilige Verfügung'],
62 accountingTerminology: ['HGB', 'IFRS', 'Umsatzsteuer', 'Bilanz', 'GuV', 'Aktiva', 'Passiva', 'Abschreibung', 'Rückstellung', 'Wirtschaftsprüfer'],
63 dateFormat: 'DD.MM.YYYY', currencySymbol: '€',
64 grammarNotes: 'German legal writing uses long compound sentences. Nouns are capitalized. Use formal Sie form. Legal citations follow German format.',
65 },
66 {
67 code: 'it', whisperCode: 'it', name: 'Italian', nativeName: 'Italiano',
68 legalTerminology: ['attore', 'convenuto', 'sentenza', 'ricorso', 'codice civile', 'codice penale', 'tribunale', 'corte d\'appello', 'Cassazione', 'decreto', 'ordinanza', 'giurisprudenza', 'procura', 'atto di citazione'],
69 accountingTerminology: ['OIC', 'IFRS', 'IVA', 'bilancio', 'conto economico', 'stato patrimoniale', 'ammortamento', 'accantonamento', 'revisore dei conti', 'partita IVA'],
70 dateFormat: 'DD/MM/YYYY', currencySymbol: '€',
71 grammarNotes: 'Italian legal writing uses formal register. Capitalize institutional names. Use Lei form for formal address.',
72 },
73 {
74 code: 'pt-BR', whisperCode: 'pt', name: 'Portuguese (BR)', nativeName: 'Português (BR)',
75 legalTerminology: ['autor', 'réu', 'acórdão', 'recurso extraordinário', 'mandado de segurança', 'habeas corpus', 'STF', 'STJ', 'comarca', 'desembargador', 'juiz de direito', 'Ministério Público', 'petição inicial', 'contestação'],
76 accountingTerminology: ['CPC', 'IFRS', 'ICMS', 'ISS', 'PIS', 'COFINS', 'balanço patrimonial', 'DRE', 'CVM', 'Receita Federal'],
77 dateFormat: 'DD/MM/YYYY', currencySymbol: 'R$',
78 grammarNotes: 'Brazilian legal Portuguese is highly formal. Use third person. Capitalize court names and institutions.',
79 },
80 {
81 code: 'pt-PT', whisperCode: 'pt', name: 'Portuguese (PT)', nativeName: 'Português (PT)',
82 legalTerminology: ['queixoso', 'arguido', 'tribunal da relação', 'Supremo Tribunal de Justiça', 'acórdão', 'providência cautelar', 'Ministério Público', 'procurador', 'advogado', 'juiz', 'sentença', 'recurso', 'constituição'],
83 accountingTerminology: ['SNC', 'IFRS', 'IVA', 'IRC', 'IRS', 'balanço', 'demonstração de resultados', 'Autoridade Tributária', 'TOC', 'ROC'],
84 dateFormat: 'DD/MM/YYYY', currencySymbol: '€',
85 grammarNotes: 'European Portuguese legal writing differs from Brazilian. Use formal register with European spelling conventions.',
86 },
87 {
88 code: 'nl', whisperCode: 'nl', name: 'Dutch', nativeName: 'Nederlands',
89 legalTerminology: ['eiser', 'gedaagde', 'vonnis', 'hoger beroep', 'rechtbank', 'gerechtshof', 'Hoge Raad', 'advocaat', 'officier van justitie', 'dagvaarding', 'kort geding', 'cassatie', 'arrest'],
90 accountingTerminology: ['RJ', 'IFRS', 'BTW', 'balans', 'winst-en-verliesrekening', 'jaarrekening', 'accountant', 'Belastingdienst', 'vennootschapsbelasting', 'fiscaal'],
91 dateFormat: 'DD-MM-YYYY', currencySymbol: '€',
92 grammarNotes: 'Dutch legal writing uses formal register. Follow Dutch spelling rules. Capitalize proper institutional names.',
93 },
94 {
95 code: 'ja', whisperCode: 'ja', name: 'Japanese', nativeName: '日本語',
96 legalTerminology: ['原告', '被告', '判決', '控訴', '民法', '刑法', '裁判所', '弁護士', '検察官', '訴状', '答弁書', '準備書面', '最高裁判所', '地方裁判所'],
97 accountingTerminology: ['JGAAP', 'IFRS', '貸借対照表', '損益計算書', '監査法人', '公認会計士', '税理士', '消費税', '法人税', '確定申告'],
98 dateFormat: 'YYYY/MM/DD', currencySymbol: '¥',
99 grammarNotes: 'Japanese legal writing uses highly formal keigo. Use desu/masu form. Legal documents use specific kanji compounds.',
100 },
101 {
102 code: 'ko', whisperCode: 'ko', name: 'Korean', nativeName: '한국어',
103 legalTerminology: ['원고', '피고', '판결', '항소', '대법원', '헌법재판소', '변호사', '검사', '소장', '답변서', '준비서면', '민법', '형법', '상고'],
104 accountingTerminology: ['K-IFRS', 'K-GAAP', '재무제표', '대차대조표', '손익계산서', '공인회계사', '세무사', '부가가치세', '법인세', '국세청'],
105 dateFormat: 'YYYY.MM.DD', currencySymbol: '₩',
106 grammarNotes: 'Korean legal writing uses formal hasipsio-che style. Use appropriate honorifics. Legal terminology uses Sino-Korean vocabulary.',
107 },
108 {
109 code: 'zh', whisperCode: 'zh', name: 'Chinese (Mandarin)', nativeName: '中文',
110 legalTerminology: ['原告', '被告', '判决', '上诉', '民法典', '刑法', '人民法院', '律师', '检察院', '起诉状', '答辩状', '最高人民法院', '仲裁', '合同法'],
111 accountingTerminology: ['CAS', 'IFRS', '资产负债表', '利润表', '注册会计师', '审计', '增值税', '企业所得税', '财务报表', '会计准则'],
112 dateFormat: 'YYYY年MM月DD日', currencySymbol: '¥',
113 grammarNotes: 'Chinese legal writing uses formal written Chinese (书面语). Use simplified characters for PRC, traditional for Taiwan/HK.',
114 },
115 {
116 code: 'ar', whisperCode: 'ar', name: 'Arabic', nativeName: 'العربية',
117 legalTerminology: ['المدعي', 'المدعى عليه', 'الحكم', 'الاستئناف', 'المحكمة', 'القاضي', 'المحامي', 'النيابة العامة', 'الشريعة', 'الدعوى', 'الطعن', 'النقض', 'العقد', 'التحكيم'],
118 accountingTerminology: ['معايير المحاسبة', 'الميزانية العمومية', 'قائمة الدخل', 'المراجعة', 'ضريبة القيمة المضافة', 'الزكاة', 'المحاسب القانوني', 'القوائم المالية'],
119 dateFormat: 'DD/MM/YYYY', currencySymbol: 'د.إ',
120 grammarNotes: 'Arabic legal writing uses Modern Standard Arabic (فصحى). Right-to-left text. Use formal register with proper diacritical marks for ambiguous terms.',
121 },
122 {
123 code: 'hi', whisperCode: 'hi', name: 'Hindi', nativeName: 'हिन्दी',
124 legalTerminology: ['वादी', 'प्रतिवादी', 'निर्णय', 'अपील', 'उच्चतम न्यायालय', 'उच्च न्यायालय', 'अधिवक्ता', 'न्यायाधीश', 'याचिका', 'रिट', 'जमानत', 'दंड संहिता', 'सिविल प्रक्रिया संहिता'],
125 accountingTerminology: ['ICAI', 'GST', 'आयकर', 'लेखा परीक्षा', 'तुलन पत्र', 'लाभ हानि खाता', 'चार्टर्ड अकाउंटेंट', 'TDS', 'PAN', 'वित्तीय विवरण'],
126 dateFormat: 'DD/MM/YYYY', currencySymbol: '₹',
127 grammarNotes: 'Hindi legal writing often mixes Hindi and English terminology. Use formal Shuddh Hindi where possible. Devanagari script.',
128 },
129 {
130 code: 'ru', whisperCode: 'ru', name: 'Russian', nativeName: 'Русский',
131 legalTerminology: ['истец', 'ответчик', 'решение суда', 'апелляция', 'кассация', 'арбитражный суд', 'адвокат', 'прокурор', 'исковое заявление', 'определение', 'постановление', 'Конституционный Суд', 'Верховный Суд'],
132 accountingTerminology: ['РСБУ', 'МСФО', 'НДС', 'бухгалтерский баланс', 'отчёт о прибылях и убытках', 'аудитор', 'налог на прибыль', 'ФНС', 'главный бухгалтер'],
133 dateFormat: 'DD.MM.YYYY', currencySymbol: '₽',
134 grammarNotes: 'Russian legal writing uses formal register. Genitive case used extensively in legal constructions. Capitalize institutional names.',
135 },
136 {
137 code: 'tr', whisperCode: 'tr', name: 'Turkish', nativeName: 'Türkçe',
138 legalTerminology: ['davacı', 'davalı', 'karar', 'temyiz', 'mahkeme', 'hakim', 'avukat', 'savcı', 'dava dilekçesi', 'cevap dilekçesi', 'Yargıtay', 'Anayasa Mahkemesi', 'icra', 'ihtiyati tedbir'],
139 accountingTerminology: ['VUK', 'TFRS', 'KDV', 'bilanço', 'gelir tablosu', 'denetçi', 'SMMM', 'YMM', 'vergi dairesi', 'kurumlar vergisi'],
140 dateFormat: 'DD.MM.YYYY', currencySymbol: '₺',
141 grammarNotes: 'Turkish legal writing uses formal Ottoman-influenced vocabulary. Agglutinative grammar requires attention to suffixes.',
142 },
143 {
144 code: 'pl', whisperCode: 'pl', name: 'Polish', nativeName: 'Polski',
145 legalTerminology: ['powód', 'pozwany', 'wyrok', 'apelacja', 'kasacja', 'sąd', 'sędzia', 'adwokat', 'radca prawny', 'prokuratura', 'pozew', 'Sąd Najwyższy', 'postanowienie', 'nakaz zapłaty'],
146 accountingTerminology: ['ustawa o rachunkowości', 'MSR', 'MSSF', 'VAT', 'PIT', 'CIT', 'bilans', 'rachunek zysków i strat', 'biegły rewident', 'KRS'],
147 dateFormat: 'DD.MM.YYYY', currencySymbol: 'zł',
148 grammarNotes: 'Polish legal writing uses highly formal register. Complex case system affects legal terminology. Capitalize institutional names.',
149 },
150 {
151 code: 'sv', whisperCode: 'sv', name: 'Swedish', nativeName: 'Svenska',
152 legalTerminology: ['kärande', 'svarande', 'dom', 'överklagande', 'tingsrätt', 'hovrätt', 'Högsta domstolen', 'advokat', 'åklagare', 'stämningsansökan', 'svaromål', 'förordning', 'lagrum'],
153 accountingTerminology: ['BFNAR', 'IFRS', 'moms', 'balansräkning', 'resultaträkning', 'revisor', 'Skatteverket', 'bolagsskatt', 'årsredovisning', 'bokföring'],
154 dateFormat: 'YYYY-MM-DD', currencySymbol: 'kr',
155 grammarNotes: 'Swedish legal writing uses formal register. ISO date format standard. Definite articles are suffixed to nouns.',
156 },
157 {
158 code: 'th', whisperCode: 'th', name: 'Thai', nativeName: 'ไทย',
159 legalTerminology: ['โจทก์', 'จำเลย', 'คำพิพากษา', 'อุทธรณ์', 'ฎีกา', 'ศาล', 'ผู้พิพากษา', 'ทนายความ', 'อัยการ', 'คำฟ้อง', 'คำให้การ', 'ศาลฎีกา', 'พระราชบัญญัติ'],
160 accountingTerminology: ['TFRS', 'ภาษีมูลค่าเพิ่ม', 'งบดุล', 'งบกำไรขาดทุน', 'ผู้สอบบัญชี', 'กรมสรรพากร', 'ภาษีเงินได้นิติบุคคล', 'สภาวิชาชีพบัญชี'],
161 dateFormat: 'DD/MM/YYYY', currencySymbol: '฿',
162 grammarNotes: 'Thai legal writing uses royal/formal register (ราชาศัพท์ for court). No spaces between words — context determines boundaries.',
163 },
164 {
165 code: 'vi', whisperCode: 'vi', name: 'Vietnamese', nativeName: 'Tiếng Việt',
166 legalTerminology: ['nguyên đơn', 'bị đơn', 'bản án', 'kháng cáo', 'tòa án', 'thẩm phán', 'luật sư', 'viện kiểm sát', 'đơn khởi kiện', 'bản cáo trạng', 'Tòa án nhân dân tối cao', 'pháp lệnh', 'nghị định'],
167 accountingTerminology: ['VAS', 'IFRS', 'thuế GTGT', 'bảng cân đối kế toán', 'báo cáo kết quả kinh doanh', 'kiểm toán viên', 'Tổng cục Thuế', 'thuế thu nhập doanh nghiệp'],
168 dateFormat: 'DD/MM/YYYY', currencySymbol: '₫',
169 grammarNotes: 'Vietnamese legal writing uses formal register with Sino-Vietnamese legal vocabulary. Tonal marks are essential for meaning.',
170 },
171 {
172 code: 'id', whisperCode: 'id', name: 'Indonesian', nativeName: 'Bahasa Indonesia',
173 legalTerminology: ['penggugat', 'tergugat', 'putusan', 'banding', 'kasasi', 'pengadilan', 'hakim', 'advokat', 'jaksa', 'gugatan', 'jawaban', 'Mahkamah Agung', 'Mahkamah Konstitusi', 'peraturan pemerintah'],
174 accountingTerminology: ['SAK', 'IFRS', 'PPN', 'neraca', 'laporan laba rugi', 'akuntan publik', 'Direktorat Jenderal Pajak', 'PPh', 'audit', 'laporan keuangan'],
175 dateFormat: 'DD/MM/YYYY', currencySymbol: 'Rp',
176 grammarNotes: 'Indonesian legal writing uses formal Bahasa Indonesia. No verb conjugation but formal prefixes/suffixes are important (me-, ber-, pe-, ke-an).',
177 },
178];
179
180export function getLanguage(code: string): Language | undefined {
181 return LANGUAGES.find(l => l.code === code) || LANGUAGES.find(l => l.code === code.split('-')[0]);
182}
183
184export function getWhisperPromptForLanguage(code: string): string {
185 const lang = getLanguage(code);
186 if (!lang) return 'Professional dictation transcription.';
187 const terms = [...lang.legalTerminology.slice(0, 10), ...lang.accountingTerminology.slice(0, 6)].join(', ');
188 return `Professional legal and accounting dictation in ${lang.name}. Key terms: ${terms}`;
189}
190
191export function getLanguagesByRegion(): Record<string, Language[]> {
192 return {
193 'English': LANGUAGES.filter(l => l.code.startsWith('en')),
194 'European': LANGUAGES.filter(l => ['es', 'fr', 'de', 'it', 'nl', 'pl', 'sv'].includes(l.code)),
195 'Portuguese': LANGUAGES.filter(l => l.code.startsWith('pt')),
196 'Asian': LANGUAGES.filter(l => ['ja', 'ko', 'zh', 'th', 'vi', 'id'].includes(l.code)),
197 'Other': LANGUAGES.filter(l => ['ar', 'hi', 'ru', 'tr'].includes(l.code)),
198 };
199}
Addedlib/platform.ts+252−0View fileUnifiedSplit
@@ -0,0 +1,252 @@
1export interface PlatformInfo {
2 os: 'ios' | 'android' | 'windows' | 'mac' | 'linux' | 'unknown';
3 browser: 'chrome' | 'safari' | 'firefox' | 'edge' | 'opera' | 'samsung' | 'unknown';
4 isNative: boolean;
5 isPWA: boolean;
6 isMobile: boolean;
7 isTablet: boolean;
8 isDesktop: boolean;
9 supportsMediaRecorder: boolean;
10 supportsSpeechRecognition: boolean;
11 supportsNotifications: boolean;
12 maxAudioFormat: 'webm' | 'mp4' | 'ogg';
13}
14
15function detectOS(ua: string): PlatformInfo['os'] {
16 if (/iPad|iPhone|iPod/.test(ua) || (ua.includes('Mac') && 'ontouchend' in document)) {
17 return 'ios';
18 }
19 if (/Android/.test(ua)) return 'android';
20 if (/Win/.test(ua)) return 'windows';
21 if (/Mac/.test(ua)) return 'mac';
22 if (/Linux/.test(ua)) return 'linux';
23 return 'unknown';
24}
25
26function detectBrowser(ua: string): PlatformInfo['browser'] {
27 // Order matters — Edge and Opera include Chrome in their UA strings
28 if (/SamsungBrowser/i.test(ua)) return 'samsung';
29 if (/OPR|Opera/i.test(ua)) return 'opera';
30 if (/Edg/i.test(ua)) return 'edge';
31 if (/Chrome|CriOS/i.test(ua)) return 'chrome';
32 if (/Safari/i.test(ua) && !/Chrome/i.test(ua)) return 'safari';
33 if (/Firefox|FxiOS/i.test(ua)) return 'firefox';
34 return 'unknown';
35}
36
37function detectIsTablet(ua: string, os: PlatformInfo['os']): boolean {
38 if (os === 'ios') {
39 // iPad with desktop UA — check for touch support + Mac platform
40 return /iPad/.test(ua) || (ua.includes('Mac') && 'ontouchend' in document && window.innerWidth >= 768);
41 }
42 if (os === 'android') {
43 // Android tablets generally don't include "Mobile" in UA
44 return !/Mobile/.test(ua);
45 }
46 return false;
47}
48
49function detectBestAudioFormat(browser: PlatformInfo['browser'], os: PlatformInfo['os']): PlatformInfo['maxAudioFormat'] {
50 // Safari and iOS do not support webm recording
51 if (browser === 'safari' || os === 'ios') return 'mp4';
52
53 // Firefox supports both webm and ogg, but webm/opus is preferred
54 if (browser === 'firefox') {
55 if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) {
56 return 'webm';
57 }
58 return 'ogg';
59 }
60
61 // Chrome, Edge, Opera, Samsung — webm/opus is well supported
62 return 'webm';
63}
64
65export function detectPlatform(): PlatformInfo {
66 if (typeof window === 'undefined' || typeof navigator === 'undefined') {
67 // Server-side fallback
68 return {
69 os: 'unknown',
70 browser: 'unknown',
71 isNative: false,
72 isPWA: false,
73 isMobile: false,
74 isTablet: false,
75 isDesktop: true,
76 supportsMediaRecorder: false,
77 supportsSpeechRecognition: false,
78 supportsNotifications: false,
79 maxAudioFormat: 'webm',
80 };
81 }
82
83 const ua = navigator.userAgent;
84 const os = detectOS(ua);
85 const browser = detectBrowser(ua);
86
87 // Capacitor injects a global object
88 const isNative = !!(window as unknown as Record<string, unknown>).Capacitor;
89
90 // Detect installed PWA via display-mode media query or iOS standalone
91 const isPWA =
92 window.matchMedia('(display-mode: standalone)').matches ||
93 window.matchMedia('(display-mode: fullscreen)').matches ||
94 (navigator as unknown as Record<string, unknown>).standalone === true;
95
96 const isTablet = detectIsTablet(ua, os);
97 const isMobile = (os === 'ios' || os === 'android') && !isTablet;
98 const isDesktop = !isMobile && !isTablet;
99
100 const supportsMediaRecorder = typeof MediaRecorder !== 'undefined';
101
102 const supportsSpeechRecognition =
103 'SpeechRecognition' in window ||
104 'webkitSpeechRecognition' in window;
105
106 const supportsNotifications =
107 'Notification' in window && 'serviceWorker' in navigator;
108
109 const maxAudioFormat = detectBestAudioFormat(browser, os);
110
111 return {
112 os,
113 browser,
114 isNative,
115 isPWA,
116 isMobile,
117 isTablet,
118 isDesktop,
119 supportsMediaRecorder,
120 supportsSpeechRecognition,
121 supportsNotifications,
122 maxAudioFormat,
123 };
124}
125
126export function getOptimalAudioConfig(platform: PlatformInfo): MediaRecorderOptions {
127 switch (platform.maxAudioFormat) {
128 case 'mp4':
129 // Safari / iOS — AAC in MP4 container
130 return {
131 mimeType: 'audio/mp4',
132 audioBitsPerSecond: 128000,
133 };
134
135 case 'ogg':
136 // Firefox fallback when webm isn't supported
137 return {
138 mimeType: 'audio/ogg;codecs=opus',
139 audioBitsPerSecond: 128000,
140 };
141
142 case 'webm':
143 default:
144 // Chrome, Edge, Opera, Samsung — best quality
145 return {
146 mimeType: 'audio/webm;codecs=opus',
147 audioBitsPerSecond: 128000,
148 };
149 }
150}
151
152export function getInstallInstructions(platform: PlatformInfo): string {
153 if (platform.isPWA || platform.isNative) {
154 return 'AlecRae Voice is already installed on this device.';
155 }
156
157 switch (platform.os) {
158 case 'ios':
159 return [
160 'To install AlecRae Voice on your iPhone or iPad:',
161 '1. Tap the Share button (square with arrow) in Safari',
162 '2. Scroll down and tap "Add to Home Screen"',
163 '3. Tap "Add" to confirm',
164 '',
165 'The app will appear on your home screen and run in full-screen mode.',
166 ].join('\n');
167
168 case 'android':
169 if (platform.browser === 'chrome' || platform.browser === 'edge') {
170 return [
171 'To install AlecRae Voice on your Android device:',
172 '1. Tap the three-dot menu in the top right',
173 '2. Tap "Install app" or "Add to Home screen"',
174 '3. Tap "Install" to confirm',
175 '',
176 'The app will appear in your app drawer and home screen.',
177 ].join('\n');
178 }
179 if (platform.browser === 'samsung') {
180 return [
181 'To install AlecRae Voice on your Samsung device:',
182 '1. Tap the menu icon (three lines) at the bottom',
183 '2. Tap "Add page to" then "Home screen"',
184 '3. Tap "Add" to confirm',
185 ].join('\n');
186 }
187 return [
188 'To install AlecRae Voice:',
189 '1. Open this page in Chrome or Edge',
190 '2. Tap the browser menu and select "Install app"',
191 ].join('\n');
192
193 case 'mac':
194 if (platform.browser === 'chrome' || platform.browser === 'edge') {
195 return [
196 'To install AlecRae Voice on your Mac:',
197 '1. Click the install icon in the address bar (right side)',
198 ' Or go to the browser menu and select "Install AlecRae Voice"',
199 '2. Click "Install" to confirm',
200 '',
201 'The app will appear in your Applications folder and Dock.',
202 ].join('\n');
203 }
204 if (platform.browser === 'safari') {
205 return [
206 'To install AlecRae Voice on your Mac (macOS Sonoma or later):',
207 '1. Click File in the menu bar',
208 '2. Select "Add to Dock"',
209 '',
210 'The app will appear in your Dock and run in its own window.',
211 ].join('\n');
212 }
213 return [
214 'To install AlecRae Voice:',
215 'Open this page in Chrome, Edge, or Safari (macOS Sonoma+)',
216 'and use the browser\'s install option.',
217 ].join('\n');
218
219 case 'windows':
220 if (platform.browser === 'chrome' || platform.browser === 'edge') {
221 return [
222 'To install AlecRae Voice on Windows:',
223 '1. Click the install icon in the address bar (right side)',
224 ' Or go to the browser menu and select "Install AlecRae Voice"',
225 '2. Click "Install" to confirm',
226 '',
227 'The app will appear in your Start menu and can be pinned to the taskbar.',
228 ].join('\n');
229 }
230 return [
231 'To install AlecRae Voice:',
232 'Open this page in Chrome or Edge and use the browser\'s install option.',
233 ].join('\n');
234
235 case 'linux':
236 return [
237 'To install AlecRae Voice on Linux:',
238 '1. Open this page in Chrome or Edge',
239 '2. Click the install icon in the address bar',
240 '3. Click "Install" to confirm',
241 '',
242 'The app will be available as a desktop application.',
243 ].join('\n');
244
245 default:
246 return [
247 'To install AlecRae Voice:',
248 'Open this page in a modern browser (Chrome, Edge, or Safari)',
249 'and look for the install option in the address bar or browser menu.',
250 ].join('\n');
251 }
252}
Addedlib/register-sw.ts+80−0View fileUnifiedSplit
@@ -0,0 +1,80 @@
1/**
2 * Registers the service worker for offline support and push notifications.
3 * Should be called once on app startup (client-side only).
4 */
5export async function registerServiceWorker(): Promise<ServiceWorkerRegistration | null> {
6 if (typeof window === 'undefined' || !('serviceWorker' in navigator)) {
7 return null;
8 }
9
10 try {
11 const registration = await navigator.serviceWorker.register('/sw.js', {
12 scope: '/',
13 updateViaCache: 'none',
14 });
15
16 // Check for updates periodically (every 60 minutes)
17 setInterval(() => {
18 registration.update().catch(() => {
19 // Silent fail — update check is non-critical
20 });
21 }, 60 * 60 * 1000);
22
23 // Handle updates — notify user when a new version is available
24 registration.addEventListener('updatefound', () => {
25 const newWorker = registration.installing;
26 if (!newWorker) return;
27
28 newWorker.addEventListener('statechange', () => {
29 if (
30 newWorker.state === 'installed' &&
31 navigator.serviceWorker.controller
32 ) {
33 // New version available — dispatch a custom event for the UI to handle
34 window.dispatchEvent(
35 new CustomEvent('sw-update-available', {
36 detail: { registration },
37 })
38 );
39 }
40 });
41 });
42
43 return registration;
44 } catch (error) {
45 console.warn('[SW] Registration failed:', error);
46 return null;
47 }
48}
49
50/**
51 * Requests notification permission from the user.
52 * Returns true if permission was granted.
53 */
54export async function requestNotificationPermission(): Promise<boolean> {
55 if (typeof window === 'undefined' || !('Notification' in window)) {
56 return false;
57 }
58
59 // Already granted
60 if (Notification.permission === 'granted') {
61 return true;
62 }
63
64 // Previously denied — cannot re-request
65 if (Notification.permission === 'denied') {
66 return false;
67 }
68
69 try {
70 const result = await Notification.requestPermission();
71 return result === 'granted';
72 } catch {
73 // Safari < 16.4 uses callback-based API
74 return new Promise((resolve) => {
75 Notification.requestPermission((result) => {
76 resolve(result === 'granted');
77 });
78 });
79 }
80}
Addedlib/shortcuts.ts+358−0View fileUnifiedSplit
@@ -0,0 +1,358 @@
1export interface ShortcutDef {
2 id: string;
3 label: string;
4 description: string;
5 mac: string;
6 windows: string;
7 action: string;
8 category: 'recording' | 'editing' | 'navigation' | 'export';
9}
10
11const SHORTCUTS: ShortcutDef[] = [
12 // Recording
13 {
14 id: 'toggle-recording',
15 label: 'Start / Stop Recording',
16 description: 'Toggle dictation recording on or off',
17 mac: '⌘+Shift+R',
18 windows: 'Ctrl+Shift+R',
19 action: 'toggleRecording',
20 category: 'recording',
21 },
22 {
23 id: 'enhance-text',
24 label: 'Enhance Text',
25 description: 'Send raw dictation to Claude for AI enhancement',
26 mac: '⌘+Shift+E',
27 windows: 'Ctrl+Shift+E',
28 action: 'enhanceText',
29 category: 'recording',
30 },
31 {
32 id: 'toggle-privacy',
33 label: 'Toggle Privacy Mode',
34 description: 'Enable or disable privacy mode (no history saved)',
35 mac: '⌘+Shift+P',
36 windows: 'Ctrl+Shift+P',
37 action: 'togglePrivacy',
38 category: 'recording',
39 },
40
41 // Editing
42 {
43 id: 'copy-raw',
44 label: 'Copy Raw Text',
45 description: 'Copy the raw transcription to clipboard',
46 mac: '⌘+Shift+C',
47 windows: 'Ctrl+Shift+C',
48 action: 'copyRaw',
49 category: 'editing',
50 },
51 {
52 id: 'copy-enhanced',
53 label: 'Copy Enhanced Text',
54 description: 'Copy the AI-enhanced text to clipboard',
55 mac: '⌘+Shift+X',
56 windows: 'Ctrl+Shift+X',
57 action: 'copyEnhanced',
58 category: 'editing',
59 },
60 {
61 id: 'clear-all',
62 label: 'Clear All',
63 description: 'Clear both raw and enhanced text',
64 mac: '⌘+Shift+Delete',
65 windows: 'Ctrl+Shift+Delete',
66 action: 'clearAll',
67 category: 'editing',
68 },
69 {
70 id: 'undo',
71 label: 'Undo',
72 description: 'Undo the last text edit',
73 mac: '⌘+Z',
74 windows: 'Ctrl+Z',
75 action: 'undo',
76 category: 'editing',
77 },
78 {
79 id: 'redo',
80 label: 'Redo',
81 description: 'Redo the last undone text edit',
82 mac: '⌘+Shift+Z',
83 windows: 'Ctrl+Shift+Z',
84 action: 'redo',
85 category: 'editing',
86 },
87 {
88 id: 'select-all',
89 label: 'Select All',
90 description: 'Select all text in the active editor',
91 mac: '⌘+A',
92 windows: 'Ctrl+A',
93 action: 'selectAll',
94 category: 'editing',
95 },
96 {
97 id: 'find-replace',
98 label: 'Find and Replace',
99 description: 'Open the find and replace dialog',
100 mac: '⌘+H',
101 windows: 'Ctrl+H',
102 action: 'findReplace',
103 category: 'editing',
104 },
105
106 // Export
107 {
108 id: 'export-docx',
109 label: 'Export .docx',
110 description: 'Export the enhanced text as a Word document',
111 mac: '⌘+Shift+D',
112 windows: 'Ctrl+Shift+D',
113 action: 'exportDocx',
114 category: 'export',
115 },
116 {
117 id: 'batch-upload',
118 label: 'Batch Upload',
119 description: 'Open the batch audio file upload dialog',
120 mac: '⌘+Shift+U',
121 windows: 'Ctrl+Shift+U',
122 action: 'batchUpload',
123 category: 'export',
124 },
125 {
126 id: 'template-picker',
127 label: 'Template Picker',
128 description: 'Open the document template selector',
129 mac: '⌘+Shift+T',
130 windows: 'Ctrl+Shift+T',
131 action: 'templatePicker',
132 category: 'export',
133 },
134
135 // Navigation
136 {
137 id: 'open-settings',
138 label: 'Settings',
139 description: 'Open the settings panel',
140 mac: '⌘+,',
141 windows: 'Ctrl+,',
142 action: 'openSettings',
143 category: 'navigation',
144 },
145 {
146 id: 'open-vocabulary',
147 label: 'Vocabulary',
148 description: 'Open the custom vocabulary editor',
149 mac: '⌘+Shift+V',
150 windows: 'Ctrl+Shift+V',
151 action: 'openVocabulary',
152 category: 'navigation',
153 },
154 {
155 id: 'open-hotkeys',
156 label: 'Hotkeys',
157 description: 'Open the keyboard shortcut configuration',
158 mac: '⌘+Shift+K',
159 windows: 'Ctrl+Shift+K',
160 action: 'openHotkeys',
161 category: 'navigation',
162 },
163 {
164 id: 'open-history',
165 label: 'History',
166 description: 'Open the dictation history panel',
167 mac: '⌘+Shift+H',
168 windows: 'Ctrl+Shift+H',
169 action: 'openHistory',
170 category: 'navigation',
171 },
172 {
173 id: 'open-admin',
174 label: 'Admin Dashboard',
175 description: 'Navigate to the admin dashboard',
176 mac: '⌘+Shift+A',
177 windows: 'Ctrl+Shift+A',
178 action: 'openAdmin',
179 category: 'navigation',
180 },
181 {
182 id: 'open-billing',
183 label: 'Billing',
184 description: 'Navigate to the billing page',
185 mac: '⌘+Shift+B',
186 windows: 'Ctrl+Shift+B',
187 action: 'openBilling',
188 category: 'navigation',
189 },
190
191 // Document mode switching (1-9, 0)
192 {
193 id: 'mode-1',
194 label: 'Mode 1: General Cleanup',
195 description: 'Switch document mode to General Cleanup',
196 mac: '⌘+1',
197 windows: 'Ctrl+1',
198 action: 'setMode:general',
199 category: 'navigation',
200 },
201 {
202 id: 'mode-2',
203 label: 'Mode 2: Client Email',
204 description: 'Switch document mode to Client Email',
205 mac: '⌘+2',
206 windows: 'Ctrl+2',
207 action: 'setMode:email',
208 category: 'navigation',
209 },
210 {
211 id: 'mode-3',
212 label: 'Mode 3: Meeting Notes',
213 description: 'Switch document mode to Meeting Notes',
214 mac: '⌘+3',
215 windows: 'Ctrl+3',
216 action: 'setMode:meeting',
217 category: 'navigation',
218 },
219 {
220 id: 'mode-4',
221 label: 'Mode 4: Legal Letter',
222 description: 'Switch document mode to Legal Letter',
223 mac: '⌘+4',
224 windows: 'Ctrl+4',
225 action: 'setMode:legal-letter',
226 category: 'navigation',
227 },
228 {
229 id: 'mode-5',
230 label: 'Mode 5: Legal Memorandum',
231 description: 'Switch document mode to Legal Memorandum',
232 mac: '⌘+5',
233 windows: 'Ctrl+5',
234 action: 'setMode:legal-memo',
235 category: 'navigation',
236 },
237 {
238 id: 'mode-6',
239 label: 'Mode 6: Court Filing',
240 description: 'Switch document mode to Court Filing',
241 mac: '⌘+6',
242 windows: 'Ctrl+6',
243 action: 'setMode:court-filing',
244 category: 'navigation',
245 },
246 {
247 id: 'mode-7',
248 label: 'Mode 7: Demand Letter',
249 description: 'Switch document mode to Demand Letter',
250 mac: '⌘+7',
251 windows: 'Ctrl+7',
252 action: 'setMode:demand-letter',
253 category: 'navigation',
254 },
255 {
256 id: 'mode-8',
257 label: 'Mode 8: Deposition Summary',
258 description: 'Switch document mode to Deposition Summary',
259 mac: '⌘+8',
260 windows: 'Ctrl+8',
261 action: 'setMode:deposition',
262 category: 'navigation',
263 },
264 {
265 id: 'mode-9',
266 label: 'Mode 9: Engagement Letter',
267 description: 'Switch document mode to Engagement Letter',
268 mac: '⌘+9',
269 windows: 'Ctrl+9',
270 action: 'setMode:engagement',
271 category: 'navigation',
272 },
273 {
274 id: 'mode-0',
275 label: 'Mode 10: Accounting Report',
276 description: 'Switch document mode to Accounting Report',
277 mac: '⌘+0',
278 windows: 'Ctrl+0',
279 action: 'setMode:accounting',
280 category: 'navigation',
281 },
282];
283
284export function getShortcuts(os: 'mac' | 'windows' | 'linux'): ShortcutDef[] {
285 return SHORTCUTS;
286}
287
288export function formatShortcut(shortcut: ShortcutDef, os: string): string {
289 if (os === 'mac') {
290 return shortcut.mac;
291 }
292 // Windows and Linux use the same key bindings
293 return shortcut.windows;
294}
295
296/**
297 * Returns shortcuts filtered by category.
298 */
299export function getShortcutsByCategory(
300 os: 'mac' | 'windows' | 'linux',
301 category: ShortcutDef['category']
302): ShortcutDef[] {
303 return SHORTCUTS.filter((s) => s.category === category);
304}
305
306/**
307 * Finds a shortcut definition by its action name.
308 */
309export function getShortcutByAction(action: string): ShortcutDef | undefined {
310 return SHORTCUTS.find((s) => s.action === action);
311}
312
313/**
314 * Parses a keyboard event into a shortcut string for matching.
315 * Returns format like "Ctrl+Shift+R" or "⌘+Shift+R".
316 */
317export function eventToShortcutString(event: KeyboardEvent, os: string): string {
318 const parts: string[] = [];
319
320 if (os === 'mac') {
321 if (event.metaKey) parts.push('⌘');
322 if (event.ctrlKey) parts.push('Ctrl');
323 } else {
324 if (event.ctrlKey) parts.push('Ctrl');
325 if (event.metaKey) parts.push('Meta');
326 }
327
328 if (event.altKey) parts.push('Alt');
329 if (event.shiftKey) parts.push('Shift');
330
331 // Map special keys
332 const keyMap: Record<string, string> = {
333 Backspace: 'Delete',
334 Delete: 'Delete',
335 ',': ',',
336 };
337
338 const key = keyMap[event.key] || event.key.toUpperCase();
339 if (!['Control', 'Meta', 'Alt', 'Shift'].includes(event.key)) {
340 parts.push(key);
341 }
342
343 return parts.join('+');
344}
345
346/**
347 * Finds the matching shortcut for a keyboard event.
348 */
349export function matchShortcut(
350 event: KeyboardEvent,
351 os: 'mac' | 'windows' | 'linux'
352): ShortcutDef | undefined {
353 const pressed = eventToShortcutString(event, os);
354 return SHORTCUTS.find((s) => {
355 const expected = os === 'mac' ? s.mac : s.windows;
356 return expected === pressed;
357 });
358}
Modifiedmiddleware.ts+1−1View fileUnifiedSplit
@@ -5,7 +5,7 @@ export async function middleware(request: NextRequest) {
55 const { pathname } = request.nextUrl;
66
77 // Public routes — no auth needed
8 if (pathname === '/' || pathname.startsWith('/api/auth') || pathname.startsWith('/_next') || pathname.startsWith('/manifest') || pathname.endsWith('.png') || pathname.endsWith('.ico')) {
8 if (pathname === '/' || pathname.startsWith('/register') || pathname.startsWith('/api/auth') || pathname.startsWith('/_next') || pathname.startsWith('/manifest') || pathname.endsWith('.png') || pathname.endsWith('.ico')) {
99 return NextResponse.next();
1010 }
1111
Modifiednext.config.js+5−0View fileUnifiedSplit
@@ -7,6 +7,11 @@ const nextConfig = {
77 headers: [
88 { key: 'X-Content-Type-Options', value: 'nosniff' },
99 { key: 'X-Frame-Options', value: 'DENY' },
10 { key: 'X-XSS-Protection', value: '1; mode=block' },
11 { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
12 { key: 'Permissions-Policy', value: 'microphone=(self), camera=(), geolocation=()' },
13 { key: 'Strict-Transport-Security', value: 'max-age=31536000; includeSubDomains; preload' },
14 { key: 'X-DNS-Prefetch-Control', value: 'on' },
1015 ],
1116 },
1217 ];
Modifiedpublic/manifest.json+142−5View fileUnifiedSplit
@@ -1,14 +1,151 @@
11{
2 "name": "AlecRae Voice",
2 "name": "AlecRae Voice — Professional Dictation",
33 "short_name": "AlecRae Voice",
4 "description": "Professional dictation for legal and accounting professionals",
4 "description": "AI-powered dictation for legal and accounting professionals. Whisper transcription + Claude AI formatting with 12 specialised document modes.",
55 "start_url": "/app",
6 "scope": "/",
67 "display": "standalone",
78 "background_color": "#111920",
89 "theme_color": "#111920",
910 "orientation": "any",
11 "categories": ["productivity", "business", "utilities"],
12 "lang": "en",
13 "dir": "ltr",
1014 "icons": [
11 { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
12 { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
13 ]
15 {
16 "src": "/icon-192.png",
17 "sizes": "192x192",
18 "type": "image/png",
19 "purpose": "any"
20 },
21 {
22 "src": "/icon-192.png",
23 "sizes": "192x192",
24 "type": "image/png",
25 "purpose": "maskable"
26 },
27 {
28 "src": "/icon-512.png",
29 "sizes": "512x512",
30 "type": "image/png",
31 "purpose": "any"
32 },
33 {
34 "src": "/icon-512.png",
35 "sizes": "512x512",
36 "type": "image/png",
37 "purpose": "maskable"
38 }
39 ],
40 "screenshots": [
41 {
42 "src": "/screenshots/desktop-dictation.png",
43 "sizes": "1920x1080",
44 "type": "image/png",
45 "form_factor": "wide",
46 "label": "Dictation interface on desktop"
47 },
48 {
49 "src": "/screenshots/desktop-enhanced.png",
50 "sizes": "1920x1080",
51 "type": "image/png",
52 "form_factor": "wide",
53 "label": "AI-enhanced legal document"
54 },
55 {
56 "src": "/screenshots/mobile-dictation.png",
57 "sizes": "750x1334",
58 "type": "image/png",
59 "form_factor": "narrow",
60 "label": "Dictation interface on mobile"
61 },
62 {
63 "src": "/screenshots/mobile-modes.png",
64 "sizes": "750x1334",
65 "type": "image/png",
66 "form_factor": "narrow",
67 "label": "Document mode selection on mobile"
68 }
69 ],
70 "shortcuts": [
71 {
72 "name": "Record Dictation",
73 "short_name": "Record",
74 "description": "Start a new dictation recording",
75 "url": "/app?action=record",
76 "icons": [{ "src": "/icon-192.png", "sizes": "192x192" }]
77 },
78 {
79 "name": "Batch Upload",
80 "short_name": "Upload",
81 "description": "Upload audio files for batch transcription",
82 "url": "/app?action=batch",
83 "icons": [{ "src": "/icon-192.png", "sizes": "192x192" }]
84 },
85 {
86 "name": "Templates",
87 "short_name": "Templates",
88 "description": "Browse document templates",
89 "url": "/app?action=templates",
90 "icons": [{ "src": "/icon-192.png", "sizes": "192x192" }]
91 },
92 {
93 "name": "Settings",
94 "short_name": "Settings",
95 "description": "Configure voice, vocabulary, and preferences",
96 "url": "/app?action=settings",
97 "icons": [{ "src": "/icon-192.png", "sizes": "192x192" }]
98 }
99 ],
100 "share_target": {
101 "action": "/app?action=share",
102 "method": "POST",
103 "enctype": "multipart/form-data",
104 "params": {
105 "title": "title",
106 "text": "text",
107 "url": "url",
108 "files": [
109 {
110 "name": "audio",
111 "accept": ["audio/*", ".mp3", ".wav", ".m4a", ".webm", ".ogg", ".mp4"]
112 }
113 ]
114 }
115 },
116 "file_handlers": [
117 {
118 "action": "/app?action=open",
119 "accept": {
120 "audio/webm": [".webm"],
121 "audio/mp4": [".m4a", ".mp4"],
122 "audio/mpeg": [".mp3"],
123 "audio/wav": [".wav"],
124 "audio/ogg": [".ogg"],
125 "audio/x-m4a": [".m4a"]
126 }
127 }
128 ],
129 "related_applications": [
130 {
131 "platform": "play",
132 "url": "https://play.google.com/store/apps/details?id=app.alecrae.voice",
133 "id": "app.alecrae.voice"
134 },
135 {
136 "platform": "itunes",
137 "url": "https://apps.apple.com/app/alecrae-voice/id0000000000"
138 },
139 {
140 "platform": "windows",
141 "url": "https://www.microsoft.com/store/apps/alecrae-voice"
142 }
143 ],
144 "prefer_related_applications": false,
145 "launch_handler": {
146 "client_mode": "navigate-existing"
147 },
148 "edge_side_panel": {
149 "preferred_width": 480
150 }
14151}
Addedpublic/sw.js+301−0View fileUnifiedSplit
@@ -0,0 +1,301 @@
1/// <reference lib="webworker" />
2
3const CACHE_NAME = 'alecrae-voice-v1';
4const STATIC_CACHE = 'alecrae-static-v1';
5const SYNC_QUEUE = 'alecrae-sync-queue';
6
7// App shell resources to cache on install
8const APP_SHELL = [
9 '/',
10 '/app',
11 '/manifest.json',
12 '/icon-192.png',
13 '/icon-512.png',
14];
15
16// Static asset patterns that should be cached aggressively
17const STATIC_ASSET_PATTERNS = [
18 /\/_next\/static\//,
19 /\.(?:png|jpg|jpeg|svg|gif|ico|webp|woff2?|ttf|eot)$/,
20];
21
22// API routes that support background sync
23const SYNCABLE_API_ROUTES = [
24 '/api/transcribe',
25 '/api/enhance',
26];
27
28// ─── Install ─────────────────────────────────────────────────────────
29
30self.addEventListener('install', (event) => {
31 event.waitUntil(
32 caches.open(CACHE_NAME).then((cache) => {
33 return cache.addAll(APP_SHELL).catch((err) => {
34 // Non-fatal — some shell resources may not exist yet
35 console.warn('[SW] Some app shell resources failed to cache:', err);
36 });
37 })
38 );
39 // Activate immediately without waiting for existing clients to close
40 self.skipWaiting();
41});
42
43// ─── Activate ────────────────────────────────────────────────────────
44
45self.addEventListener('activate', (event) => {
46 event.waitUntil(
47 caches.keys().then((cacheNames) => {
48 return Promise.all(
49 cacheNames
50 .filter((name) => name !== CACHE_NAME && name !== STATIC_CACHE)
51 .map((name) => caches.delete(name))
52 );
53 })
54 );
55 // Take control of all clients immediately
56 self.clients.claim();
57});
58
59// ─── Fetch ───────────────────────────────────────────────────────────
60
61self.addEventListener('fetch', (event) => {
62 const { request } = event;
63 const url = new URL(request.url);
64
65 // Skip non-GET requests for caching (POST/PUT/DELETE go straight to network)
66 if (request.method !== 'GET') {
67 // For syncable API routes, attempt network and queue on failure
68 if (SYNCABLE_API_ROUTES.some((route) => url.pathname.startsWith(route))) {
69 event.respondWith(networkWithSync(request));
70 }
71 return;
72 }
73
74 // Static assets — cache-first
75 if (isStaticAsset(url)) {
76 event.respondWith(cacheFirst(request));
77 return;
78 }
79
80 // API calls — network-first with cache fallback for GET
81 if (url.pathname.startsWith('/api/')) {
82 event.respondWith(networkFirst(request));
83 return;
84 }
85
86 // Navigation and app shell — network-first
87 event.respondWith(networkFirst(request));
88});
89
90// ─── Cache Strategies ────────────────────────────────────────────────
91
92async function cacheFirst(request) {
93 const cached = await caches.match(request);
94 if (cached) return cached;
95
96 try {
97 const response = await fetch(request);
98 if (response.ok) {
99 const cache = await caches.open(STATIC_CACHE);
100 cache.put(request, response.clone());
101 }
102 return response;
103 } catch {
104 // Return a basic offline response for static assets
105 return new Response('', { status: 503, statusText: 'Offline' });
106 }
107}
108
109async function networkFirst(request) {
110 try {
111 const response = await fetch(request);
112 // Cache successful GET responses
113 if (response.ok && request.method === 'GET') {
114 const cache = await caches.open(CACHE_NAME);
115 cache.put(request, response.clone());
116 }
117 return response;
118 } catch {
119 // Network failed — try cache
120 const cached = await caches.match(request);
121 if (cached) return cached;
122
123 // For navigation requests, serve the app shell
124 if (request.mode === 'navigate') {
125 const appShell = await caches.match('/app');
126 if (appShell) return appShell;
127 const root = await caches.match('/');
128 if (root) return root;
129 }
130
131 return new Response(
132 JSON.stringify({ error: 'You are offline. Please check your connection.' }),
133 {
134 status: 503,
135 headers: { 'Content-Type': 'application/json' },
136 }
137 );
138 }
139}
140
141async function networkWithSync(request) {
142 try {
143 return await fetch(request.clone());
144 } catch {
145 // Network failed — queue for background sync if supported
146 await queueForSync(request);
147 return new Response(
148 JSON.stringify({
149 error: 'You are offline. This request has been queued and will be sent when connectivity is restored.',
150 queued: true,
151 }),
152 {
153 status: 503,
154 headers: { 'Content-Type': 'application/json' },
155 }
156 );
157 }
158}
159
160// ─── Background Sync ─────────────────────────────────────────────────
161
162async function queueForSync(request) {
163 try {
164 // Store request details in IndexedDB for replay
165 const db = await openSyncDB();
166 const body = await request.clone().arrayBuffer();
167 const entry = {
168 url: request.url,
169 method: request.method,
170 headers: Object.fromEntries(request.headers.entries()),
171 body: body,
172 timestamp: Date.now(),
173 };
174
175 const tx = db.transaction('requests', 'readwrite');
176 tx.objectStore('requests').add(entry);
177 await new Promise((resolve, reject) => {
178 tx.oncomplete = resolve;
179 tx.onerror = () => reject(tx.error);
180 });
181
182 // Register for background sync
183 if (self.registration && self.registration.sync) {
184 await self.registration.sync.register(SYNC_QUEUE);
185 }
186 } catch (err) {
187 console.warn('[SW] Failed to queue request for sync:', err);
188 }
189}
190
191function openSyncDB() {
192 return new Promise((resolve, reject) => {
193 const req = indexedDB.open('alecrae-sync', 1);
194 req.onupgradeneeded = () => {
195 const db = req.result;
196 if (!db.objectStoreNames.contains('requests')) {
197 db.createObjectStore('requests', { keyPath: 'id', autoIncrement: true });
198 }
199 };
200 req.onsuccess = () => resolve(req.result);
201 req.onerror = () => reject(req.error);
202 });
203}
204
205self.addEventListener('sync', (event) => {
206 if (event.tag === SYNC_QUEUE) {
207 event.waitUntil(replaySyncQueue());
208 }
209});
210
211async function replaySyncQueue() {
212 try {
213 const db = await openSyncDB();
214 const tx = db.transaction('requests', 'readonly');
215 const store = tx.objectStore('requests');
216 const entries = await new Promise((resolve, reject) => {
217 const req = store.getAll();
218 req.onsuccess = () => resolve(req.result);
219 req.onerror = () => reject(req.error);
220 });
221
222 for (const entry of entries) {
223 try {
224 await fetch(entry.url, {
225 method: entry.method,
226 headers: entry.headers,
227 body: entry.body,
228 });
229
230 // Remove from queue on success
231 const deleteTx = db.transaction('requests', 'readwrite');
232 deleteTx.objectStore('requests').delete(entry.id);
233 await new Promise((resolve, reject) => {
234 deleteTx.oncomplete = resolve;
235 deleteTx.onerror = () => reject(deleteTx.error);
236 });
237 } catch {
238 // Leave in queue — sync will be retried
239 console.warn('[SW] Failed to replay queued request:', entry.url);
240 }
241 }
242 } catch (err) {
243 console.warn('[SW] Failed to replay sync queue:', err);
244 }
245}
246
247// ─── Push Notifications ──────────────────────────────────────────────
248
249self.addEventListener('push', (event) => {
250 if (!event.data) return;
251
252 let data;
253 try {
254 data = event.data.json();
255 } catch {
256 data = {
257 title: 'AlecRae Voice',
258 body: event.data.text(),
259 };
260 }
261
262 const options = {
263 body: data.body || '',
264 icon: '/icon-192.png',
265 badge: '/icon-192.png',
266 vibrate: [100, 50, 100],
267 data: {
268 url: data.url || '/app',
269 },
270 actions: data.actions || [],
271 tag: data.tag || 'alecrae-notification',
272 renotify: !!data.renotify,
273 };
274
275 event.waitUntil(self.registration.showNotification(data.title || 'AlecRae Voice', options));
276});
277
278self.addEventListener('notificationclick', (event) => {
279 event.notification.close();
280
281 const targetUrl = event.notification.data?.url || '/app';
282
283 event.waitUntil(
284 self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
285 // Focus existing window if open
286 for (const client of clients) {
287 if (client.url.includes(targetUrl) && 'focus' in client) {
288 return client.focus();
289 }
290 }
291 // Open new window
292 return self.clients.openWindow(targetUrl);
293 })
294 );
295});
296
297// ─── Helpers ─────────────────────────────────────────────────────────
298
299function isStaticAsset(url) {
300 return STATIC_ASSET_PATTERNS.some((pattern) => pattern.test(url.pathname));
301}
0302
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts