diff --git a/app/(app)/capture/page.tsx b/app/(app)/capture/page.tsx index 5578acab7..2f8c51890 100644 --- a/app/(app)/capture/page.tsx +++ b/app/(app)/capture/page.tsx @@ -7,12 +7,16 @@ // the first paint: a share that cold-starts the app still shows the text, // where a client-side read would flash an empty box first. import type { Metadata } from 'next'; +import { getTranslations } from '@/lib/i18n/server'; import { requireUserContext } from '@/lib/supabase/auth'; import { CaptureShell } from '@/components/capture/capture-shell'; import { loadCaptureShortcuts } from '@/app/(app)/capture/shortcuts-actions'; import { sharedCaptureText } from '@/lib/capture/share'; -export const metadata: Metadata = { title: 'Capture' }; +export async function generateMetadata(): Promise { + const t = await getTranslations(); + return { title: t('captureShell.capture') }; +} export const dynamic = 'force-dynamic'; export default async function CapturePage({ diff --git a/components/capture/capture-shortcuts.tsx b/components/capture/capture-shortcuts.tsx index 82418e3ee..ea363e1e4 100644 --- a/components/capture/capture-shortcuts.tsx +++ b/components/capture/capture-shortcuts.tsx @@ -32,53 +32,54 @@ import { sanitizeShortcutKeys, resolveSavedShortcuts, } from '@/lib/capture/shortcuts'; import { loadCaptureShortcuts, saveCaptureShortcutsAction } from '@/app/(app)/capture/shortcuts-actions'; -import { useTranslations } from '@/components/i18n/locale-provider'; +import { useLocale, useTranslations } from '@/components/i18n/locale-provider'; -export type Shortcut = { key: string; icon: typeof Calendar; label: string; href: string; hint: string }; +export type Shortcut = { key: string; icon: typeof Calendar; labelKey: string; href: string; hintKey: string }; /** Catalog of "jump directly to" destinations the picker draws from. Keys are - * stable so saved layouts survive label/icon tweaks. */ + * stable so saved layouts survive label/icon tweaks. Every rendered label and + * hint resolves a message key using the active locale. */ export const SHORTCUT_CATALOG: Shortcut[] = [ - { key: 'calendar', icon: Calendar, label: 'Calendar', href: '/dashboard/calendar', hint: 'Add event' }, - { key: 'tasks', icon: CheckSquare, label: 'Tasks', href: '/dashboard/chores', hint: 'Create task' }, - { key: 'grocery', icon: ShoppingCart, label: 'Grocery', href: '/dashboard/grocery', hint: 'Add to list' }, - { key: 'home', icon: Home, label: 'Home Maintenance', href: '/dashboard/home', hint: 'Home task' }, - { key: 'health', icon: HeartPulse, label: 'Health', href: '/dashboard/health', hint: 'Log health' }, - { key: 'trip', icon: Plane, label: 'Trip', href: '/dashboard/trips', hint: 'Plan trip' }, - { key: 'notes', icon: StickyNote, label: 'Notes', href: '/dashboard/notes', hint: 'Jot a note' }, - { key: 'meals', icon: UtensilsCrossed, label: 'Meals', href: '/dashboard/meals', hint: 'Plan meals' }, - { key: 'reminders', icon: Bell, label: 'Reminders', href: '/dashboard/reminders', hint: 'Set reminder' }, - { key: 'documents', icon: FileText, label: 'Documents', href: '/dashboard/documents', hint: 'Add document' }, - { key: 'wallet', icon: Wallet, label: 'Wallet', href: '/wallet', hint: 'Family wallet' }, - { key: 'pets', icon: PawPrint, label: 'Pets', href: '/dashboard/pets', hint: 'Pet care' }, - { key: 'closet', icon: Shirt, label: 'Closet', href: '/dashboard/closet', hint: 'Outfit today' }, - { key: 'watchlist', icon: Clapperboard, label: 'Watchlist', href: '/dashboard/watchlist', hint: 'Movie night' }, - { key: 'inventory', icon: PackageSearch, label: 'Inventory', href: '/dashboard/inventory', hint: 'Where is it?' }, - { key: 'sleep', icon: MoonStar, label: 'Sleep', href: '/dashboard/sleep', hint: 'Log last night' }, - { key: 'declutter', icon: Sparkle, label: 'Declutter', href: '/dashboard/declutter', hint: '15-minute mission' }, - { key: 'moving', icon: Truck, label: 'Move', href: '/dashboard/moving', hint: 'Box or task' }, - { key: 'projects', icon: Hammer, label: 'Project', href: '/dashboard/projects', hint: 'Idea or quote' }, - { key: 'career', icon: Briefcase, label: 'Job', href: '/dashboard/career', hint: 'Application or lead' }, - { key: 'language', icon: Languages, label: 'Language', href: '/dashboard/language', hint: 'Review or log practice' }, - { key: 'goals', icon: Target, label: 'Goals', href: '/dashboard/goals', hint: 'Family goal' }, - { key: 'finances', icon: CreditCard, label: 'Finances', href: '/dashboard/billing', hint: 'Track money' }, - { key: 'contacts', icon: Users, label: 'Contacts', href: '/dashboard/contacts', hint: 'Add contact' }, - { key: 'photos', icon: ImageIcon, label: 'Photos', href: '/dashboard/photos', hint: 'Add photo' }, - { key: 'school', icon: GraduationCap, label: 'School', href: '/dashboard/school', hint: 'School item' }, - { key: 'wishlists', icon: Gift, label: 'Wish Lists', href: '/dashboard/wishlists', hint: 'Add a wish' }, - { key: 'messages', icon: MessageCircle, label: 'Messages', href: '/dashboard/messages', hint: 'Send a message' }, - { key: 'memories', icon: BookHeart, label: 'Memories', href: '/dashboard/memories', hint: 'Save a moment' }, - { key: 'budgets', icon: PiggyBank, label: 'Budgets', href: '/dashboard/budgets', hint: 'Set budgets' }, - { key: 'bills', icon: Receipt, label: 'Bills', href: '/dashboard/bills', hint: 'Manage bills' }, - { key: 'subscriptions', icon: RefreshCw, label: 'Subscriptions', href: '/dashboard/subscriptions', hint: 'Track plans' }, - { key: 'locator', icon: MapPin, label: 'Family Map', href: '/dashboard/locator', hint: 'Find family' }, - { key: 'journal', icon: NotebookPen, label: 'Journal', href: '/dashboard/journal', hint: 'Write entry' }, - { key: 'habits', icon: Repeat, label: 'Habits', href: '/dashboard/habits', hint: 'Track habit' }, - { key: 'weather', icon: CloudSun, label: 'Weather', href: '/dashboard/weather', hint: 'Check forecast' }, - { key: 'celebrations', icon: Cake, label: 'Celebrations', href: '/dashboard/celebrations', hint: 'Mark occasion' }, - { key: 'announcements', icon: Megaphone, label: 'Announcements', href: '/dashboard/announcements', hint: 'Tell everyone' }, - { key: 'recipes', icon: ChefHat, label: 'Recipes', href: '/dashboard/recipes', hint: 'Find recipe' }, - { key: 'todos', icon: ListChecks, label: 'To-Dos', href: '/dashboard/todos', hint: 'Make a list' }, + { key: 'calendar', icon: Calendar, labelKey: 'calendar.calendar', href: '/dashboard/calendar', hintKey: 'captureShortcuts.calendarHint' }, + { key: 'tasks', icon: CheckSquare, labelKey: 'dashboardPlanning.tasks', href: '/dashboard/chores', hintKey: 'home.createTask' }, + { key: 'grocery', icon: ShoppingCart, labelKey: 'kitchenDashboard.grocery', href: '/dashboard/grocery', hintKey: 'captureShortcuts.groceryHint' }, + { key: 'home', icon: Home, labelKey: 'trustDomain.homeMaintenance', href: '/dashboard/home', hintKey: 'captureShortcuts.homeHint' }, + { key: 'health', icon: HeartPulse, labelKey: 'health.health', href: '/dashboard/health', hintKey: 'captureShortcuts.healthHint' }, + { key: 'trip', icon: Plane, labelKey: 'search.kindTrip', href: '/dashboard/trips', hintKey: 'captureShortcuts.tripHint' }, + { key: 'notes', icon: StickyNote, labelKey: 'dashboardPlanning.notes', href: '/dashboard/notes', hintKey: 'captureShortcuts.notesHint' }, + { key: 'meals', icon: UtensilsCrossed, labelKey: 'meals.meals', href: '/dashboard/meals', hintKey: 'aiHomeDashboard.planMeals' }, + { key: 'reminders', icon: Bell, labelKey: 'dashboardPlanning.reminders', href: '/dashboard/reminders', hintKey: 'captureShortcuts.remindersHint' }, + { key: 'documents', icon: FileText, labelKey: 'dashboardPlanning.documents', href: '/dashboard/documents', hintKey: 'taxVault.addDocument' }, + { key: 'wallet', icon: Wallet, labelKey: 'captureShortcuts.walletLabel', href: '/wallet', hintKey: 'captureShortcuts.walletHint' }, + { key: 'pets', icon: PawPrint, labelKey: 'pets.pets', href: '/dashboard/pets', hintKey: 'captureShortcuts.petsHint' }, + { key: 'closet', icon: Shirt, labelKey: 'beforeYouBuy.closet', href: '/dashboard/closet', hintKey: 'captureShortcuts.closetHint' }, + { key: 'watchlist', icon: Clapperboard, labelKey: 'captureShortcuts.watchlistLabel', href: '/dashboard/watchlist', hintKey: 'captureShortcuts.watchlistHint' }, + { key: 'inventory', icon: PackageSearch, labelKey: 'captureShortcuts.inventoryLabel', href: '/dashboard/inventory', hintKey: 'captureShortcuts.inventoryHint' }, + { key: 'sleep', icon: MoonStar, labelKey: 'health.sleep', href: '/dashboard/sleep', hintKey: 'sleep.logLastNight' }, + { key: 'declutter', icon: Sparkle, labelKey: 'captureShortcuts.declutterLabel', href: '/dashboard/declutter', hintKey: 'captureShortcuts.declutterHint' }, + { key: 'moving', icon: Truck, labelKey: 'captureShortcuts.movingLabel', href: '/dashboard/moving', hintKey: 'captureShortcuts.movingHint' }, + { key: 'projects', icon: Hammer, labelKey: 'projects.project', href: '/dashboard/projects', hintKey: 'captureShortcuts.projectsHint' }, + { key: 'career', icon: Briefcase, labelKey: 'captureShortcuts.careerLabel', href: '/dashboard/career', hintKey: 'captureShortcuts.careerHint' }, + { key: 'language', icon: Languages, labelKey: 'language.language', href: '/dashboard/language', hintKey: 'captureShortcuts.languageHint' }, + { key: 'goals', icon: Target, labelKey: 'childDetail.goals', href: '/dashboard/goals', hintKey: 'goalsView.familyGoal' }, + { key: 'finances', icon: CreditCard, labelKey: 'finances.finances', href: '/dashboard/billing', hintKey: 'captureShortcuts.financesHint' }, + { key: 'contacts', icon: Users, labelKey: 'dashboardPlanning.contacts', href: '/dashboard/contacts', hintKey: 'dashboardFamilyEmergency.addContact' }, + { key: 'photos', icon: ImageIcon, labelKey: 'displayGrid.photos', href: '/dashboard/photos', hintKey: 'captureShortcuts.photosHint' }, + { key: 'school', icon: GraduationCap, labelKey: 'school.school', href: '/dashboard/school', hintKey: 'captureShortcuts.schoolHint' }, + { key: 'wishlists', icon: Gift, labelKey: 'wishlists.wishLists', href: '/dashboard/wishlists', hintKey: 'wishlists.addAWish' }, + { key: 'messages', icon: MessageCircle, labelKey: 'messages.messages', href: '/dashboard/messages', hintKey: 'captureShortcuts.messagesHint' }, + { key: 'memories', icon: BookHeart, labelKey: 'dashboardMemories.memories', href: '/dashboard/memories', hintKey: 'captureShortcuts.memoriesHint' }, + { key: 'budgets', icon: PiggyBank, labelKey: 'billing.budgets', href: '/dashboard/budgets', hintKey: 'captureShortcuts.budgetsHint' }, + { key: 'bills', icon: Receipt, labelKey: 'billing.bills', href: '/dashboard/bills', hintKey: 'captureShortcuts.billsHint' }, + { key: 'subscriptions', icon: RefreshCw, labelKey: 'subscriptions.subscriptions', href: '/dashboard/subscriptions', hintKey: 'captureShortcuts.subscriptionsHint' }, + { key: 'locator', icon: MapPin, labelKey: 'findPhone.familyMap', href: '/dashboard/locator', hintKey: 'captureShortcuts.locatorHint' }, + { key: 'journal', icon: NotebookPen, labelKey: 'journal.journal', href: '/dashboard/journal', hintKey: 'captureShortcuts.journalHint' }, + { key: 'habits', icon: Repeat, labelKey: 'habits.habits', href: '/dashboard/habits', hintKey: 'captureShortcuts.habitsHint' }, + { key: 'weather', icon: CloudSun, labelKey: 'weather.weather', href: '/dashboard/weather', hintKey: 'captureShortcuts.weatherHint' }, + { key: 'celebrations', icon: Cake, labelKey: 'celebrations.celebrations', href: '/dashboard/celebrations', hintKey: 'captureShortcuts.celebrationsHint' }, + { key: 'announcements', icon: Megaphone, labelKey: 'announcements.announcements', href: '/dashboard/announcements', hintKey: 'captureShortcuts.announcementsHint' }, + { key: 'recipes', icon: ChefHat, labelKey: 'dashboardFood.recipes', href: '/dashboard/recipes', hintKey: 'captureShortcuts.recipesHint' }, + { key: 'todos', icon: ListChecks, labelKey: 'captureShortcuts.todosLabel', href: '/dashboard/todos', hintKey: 'captureShortcuts.todosHint' }, ]; export const SHORTCUT_BY_KEY: Record = Object.fromEntries(SHORTCUT_CATALOG.map((s) => [s.key, s])); export const CATALOG_KEYS = SHORTCUT_CATALOG.map((s) => s.key); @@ -104,6 +105,7 @@ function writeCache(keys: string[]) { * (instant) → background Supabase load (authoritative) → starter defaults. */ export function useCaptureShortcuts(initialKeys?: string[] | null) { + const t = useTranslations(); const { error: toastError } = useToast(); const hasServerInitial = initialKeys !== undefined && initialKeys !== null; const [keys, setKeys] = useState(() => @@ -138,15 +140,15 @@ export function useCaptureShortcuts(initialKeys?: string[] | null) { setKeys(clean); writeCache(clean); void saveCaptureShortcutsAction({ keys: clean }).then((res) => { - if (!res.ok) toastError(res.error ?? 'Could not save shortcuts'); - }); + if (!res.ok) toastError(t('captureShortcuts.saveFailed')); + }).catch(() => toastError(t('captureShortcuts.saveFailed'))); } return { keys, persist }; } export function CaptureShortcuts({ - initialKeys, heading = 'Shortcuts', columns = 4, onNavigate, + initialKeys, heading: headingProp, columns = 4, onNavigate, editing: editingProp, onEditingChange, showCustomizeButton = true, }: { initialKeys?: string[] | null; @@ -163,6 +165,8 @@ export function CaptureShortcuts({ showCustomizeButton?: boolean; }) { const t = useTranslations(); + const locale = useLocale().code; + const heading = headingProp ?? t('captureShortcuts.heading'); const { keys, persist } = useCaptureShortcuts(initialKeys); const [editingInternal, setEditingInternal] = useState(false); const editing = editingProp ?? editingInternal; @@ -183,6 +187,9 @@ export function CaptureShortcuts({ setPicker(null); } + const countLabel = t('captureShortcuts.countOfLimit', { + count: keys.length.toLocaleString(locale), max: MAX_CAPTURE_SHORTCUTS.toLocaleString(locale), + }); const gridCols = columns === 3 ? 'grid-cols-3' : 'grid-cols-4'; return ( @@ -197,7 +204,7 @@ export function CaptureShortcuts({ className="flex items-center gap-1 rounded-lg px-2 py-1 text-xs font-medium text-brand-text transition hover:bg-brand/10" > {editing - ? <>{keys.length}/{MAX_CAPTURE_SHORTCUTS} · {t('captureShortcuts.done')} + ? <>{countLabel} · {t('captureShortcuts.done')} : <> {t('captureShortcuts.customize')}} )} @@ -211,7 +218,7 @@ export function CaptureShortcuts({ onClick={() => setEditing(true)} className="w-full rounded-2xl border-2 border-dashed border-border px-3 py-4 text-center text-xs text-muted transition hover:border-brand/40 hover:text-brand-text" > - {t('captureShortcuts.noShortcutsYetTapToAdd')} {MAX_CAPTURE_SHORTCUTS}. + {t('captureShortcuts.emptyWithLimit', { max: MAX_CAPTURE_SHORTCUTS.toLocaleString(locale) })} )} @@ -229,13 +236,13 @@ export function CaptureShortcuts({ className="flex w-full flex-col items-center gap-1 rounded-2xl border border-brand/30 bg-brand/5 px-2 py-3 text-center transition hover:bg-brand/10" > - {s.label} + {t(s.labelKey)} {t('captureShortcuts.tapToChange')} )} {/* Picker */} {picker && ( - setPicker(null)}> + setPicker(null)}>
{(picker.mode === 'replace' ? SHORTCUT_CATALOG.filter((s) => !keys.includes(s.key) || s.key === keys[picker.index]) @@ -290,7 +297,7 @@ export function CaptureShortcuts({ isCurrent ? 'border-brand bg-brand/10' : 'border-border bg-surface/40 hover:border-brand/40 hover:bg-elevated')} > - {s.label} + {t(s.labelKey)} ); })} diff --git a/lib/i18n/messages/de-DE.json b/lib/i18n/messages/de-DE.json index 87c8c2efd..e59e552ea 100644 --- a/lib/i18n/messages/de-DE.json +++ b/lib/i18n/messages/de-DE.json @@ -2767,11 +2767,58 @@ "captureShell.undone": "Rückgängig gemacht", "captureShell.viewIn": "Anzeigen in", "captureShell.voiceInputIsNotSupported": "Spracheingabe wird in diesem Browser nicht unterstützt.", + "captureShortcuts.addPickerTitle": "Verknüpfung hinzufügen", "captureShortcuts.addShortcut": "Verknüpfung hinzufügen", + "captureShortcuts.announcementsHint": "Alle informieren", + "captureShortcuts.billsHint": "Rechnungen verwalten", + "captureShortcuts.budgetsHint": "Budgets festlegen", + "captureShortcuts.calendarHint": "Ereignis hinzufügen", + "captureShortcuts.careerHint": "Bewerbung oder Stellenkontakt", + "captureShortcuts.careerLabel": "Beruf", + "captureShortcuts.celebrationsHint": "Anlass festhalten", + "captureShortcuts.changeShortcut": "Verknüpfung ändern", + "captureShortcuts.closetHint": "Heutiges Outfit", + "captureShortcuts.countOfLimit": "{count}/{max}", "captureShortcuts.customize": "Anpassen", + "captureShortcuts.declutterHint": "15-Minuten-Aufgabe", + "captureShortcuts.declutterLabel": "Ausmisten", "captureShortcuts.done": "Fertig", + "captureShortcuts.emptyWithLimit": "Noch keine Verknüpfungen – tippen Sie, um bis zu {max} hinzuzufügen.", + "captureShortcuts.financesHint": "Finanzen verfolgen", + "captureShortcuts.groceryHint": "Zur Liste hinzufügen", + "captureShortcuts.habitsHint": "Gewohnheit verfolgen", + "captureShortcuts.heading": "Verknüpfungen", + "captureShortcuts.healthHint": "Gesundheit dokumentieren", + "captureShortcuts.homeHint": "Aufgabe im Haushalt", + "captureShortcuts.inventoryHint": "Wo ist es?", + "captureShortcuts.inventoryLabel": "Inventar", + "captureShortcuts.journalHint": "Eintrag schreiben", + "captureShortcuts.languageHint": "Wiederholen oder Übung eintragen", + "captureShortcuts.locatorHint": "Familie finden", + "captureShortcuts.memoriesHint": "Moment festhalten", + "captureShortcuts.messagesHint": "Nachricht senden", + "captureShortcuts.movingHint": "Karton oder Aufgabe", + "captureShortcuts.movingLabel": "Umzug", "captureShortcuts.noShortcutsYetTapToAdd": "Noch keine Kurzbefehle – tippen Sie, um bis zu", + "captureShortcuts.notesHint": "Notiz schreiben", + "captureShortcuts.petsHint": "Tierpflege", + "captureShortcuts.photosHint": "Foto hinzufügen", + "captureShortcuts.projectsHint": "Idee oder Angebot", + "captureShortcuts.recipesHint": "Rezept finden", + "captureShortcuts.remindersHint": "Erinnerung einrichten", + "captureShortcuts.removeNamed": "{name} entfernen", + "captureShortcuts.saveFailed": "Die Verknüpfungen konnten nicht gespeichert werden. Bitte versuchen Sie es erneut.", + "captureShortcuts.schoolHint": "Schuleintrag", + "captureShortcuts.subscriptionsHint": "Abonnements verfolgen", "captureShortcuts.tapToChange": "Zum Ändern tippen", + "captureShortcuts.todosHint": "Liste erstellen", + "captureShortcuts.todosLabel": "Aufgabenliste", + "captureShortcuts.tripHint": "Reise planen", + "captureShortcuts.walletHint": "Familiengeldbörse", + "captureShortcuts.walletLabel": "Geldbörse", + "captureShortcuts.watchlistHint": "Filmabend", + "captureShortcuts.watchlistLabel": "Merkliste", + "captureShortcuts.weatherHint": "Wettervorhersage ansehen", "cardRevealModal.cardNumber": "Kartennummer", "cardRevealModal.couldNotRevealTheCard": "Die Karte konnte nicht angezeigt werden.", "cardRevealModal.expires": "Läuft ab", diff --git a/lib/i18n/messages/en-US.json b/lib/i18n/messages/en-US.json index eb1f618a4..d613b9114 100644 --- a/lib/i18n/messages/en-US.json +++ b/lib/i18n/messages/en-US.json @@ -2767,11 +2767,58 @@ "captureShell.undone": "Undone", "captureShell.viewIn": "View in", "captureShell.voiceInputIsNotSupported": "Voice input is not supported in this browser.", + "captureShortcuts.addPickerTitle": "Add a shortcut", "captureShortcuts.addShortcut": "Add shortcut", + "captureShortcuts.announcementsHint": "Tell everyone", + "captureShortcuts.billsHint": "Manage bills", + "captureShortcuts.budgetsHint": "Set budgets", + "captureShortcuts.calendarHint": "Add event", + "captureShortcuts.careerHint": "Application or lead", + "captureShortcuts.careerLabel": "Job", + "captureShortcuts.celebrationsHint": "Mark occasion", + "captureShortcuts.changeShortcut": "Change shortcut", + "captureShortcuts.closetHint": "Outfit today", + "captureShortcuts.countOfLimit": "{count}/{max}", "captureShortcuts.customize": "Customize", + "captureShortcuts.declutterHint": "15-minute mission", + "captureShortcuts.declutterLabel": "Declutter", "captureShortcuts.done": "Done", + "captureShortcuts.emptyWithLimit": "No shortcuts yet — tap to add up to {max}.", + "captureShortcuts.financesHint": "Track money", + "captureShortcuts.groceryHint": "Add to list", + "captureShortcuts.habitsHint": "Track habit", + "captureShortcuts.heading": "Shortcuts", + "captureShortcuts.healthHint": "Log health", + "captureShortcuts.homeHint": "Home task", + "captureShortcuts.inventoryHint": "Where is it?", + "captureShortcuts.inventoryLabel": "Inventory", + "captureShortcuts.journalHint": "Write entry", + "captureShortcuts.languageHint": "Review or log practice", + "captureShortcuts.locatorHint": "Find family", + "captureShortcuts.memoriesHint": "Save a moment", + "captureShortcuts.messagesHint": "Send a message", + "captureShortcuts.movingHint": "Box or task", + "captureShortcuts.movingLabel": "Move", "captureShortcuts.noShortcutsYetTapToAdd": "No shortcuts yet — tap to add up to", + "captureShortcuts.notesHint": "Jot a note", + "captureShortcuts.petsHint": "Pet care", + "captureShortcuts.photosHint": "Add photo", + "captureShortcuts.projectsHint": "Idea or quote", + "captureShortcuts.recipesHint": "Find recipe", + "captureShortcuts.remindersHint": "Set reminder", + "captureShortcuts.removeNamed": "Remove {name}", + "captureShortcuts.saveFailed": "Could not save shortcuts. Please try again.", + "captureShortcuts.schoolHint": "School item", + "captureShortcuts.subscriptionsHint": "Track plans", "captureShortcuts.tapToChange": "Tap to change", + "captureShortcuts.todosHint": "Make a list", + "captureShortcuts.todosLabel": "To-Dos", + "captureShortcuts.tripHint": "Plan trip", + "captureShortcuts.walletHint": "Family wallet", + "captureShortcuts.walletLabel": "Wallet", + "captureShortcuts.watchlistHint": "Movie night", + "captureShortcuts.watchlistLabel": "Watchlist", + "captureShortcuts.weatherHint": "Check forecast", "cardRevealModal.cardNumber": "Card number", "cardRevealModal.couldNotRevealTheCard": "Could not reveal the card.", "cardRevealModal.expires": "Expires", diff --git a/lib/i18n/messages/es-ES.json b/lib/i18n/messages/es-ES.json index 553890159..7a6f4dcdf 100644 --- a/lib/i18n/messages/es-ES.json +++ b/lib/i18n/messages/es-ES.json @@ -2767,11 +2767,58 @@ "captureShell.undone": "Deshecho", "captureShell.viewIn": "Ver en", "captureShell.voiceInputIsNotSupported": "Este navegador no admite la entrada por voz.", + "captureShortcuts.addPickerTitle": "Añadir un acceso directo", "captureShortcuts.addShortcut": "Añadir acceso directo", + "captureShortcuts.announcementsHint": "Avisar a todos", + "captureShortcuts.billsHint": "Gestionar facturas", + "captureShortcuts.budgetsHint": "Establecer presupuestos", + "captureShortcuts.calendarHint": "Añadir evento", + "captureShortcuts.careerHint": "Candidatura o contacto laboral", + "captureShortcuts.careerLabel": "Empleo", + "captureShortcuts.celebrationsHint": "Anotar una ocasión", + "captureShortcuts.changeShortcut": "Cambiar acceso directo", + "captureShortcuts.closetHint": "Conjunto de hoy", + "captureShortcuts.countOfLimit": "{count}/{max}", "captureShortcuts.customize": "Personalizar", + "captureShortcuts.declutterHint": "Misión de 15 minutos", + "captureShortcuts.declutterLabel": "Poner orden", "captureShortcuts.done": "Hecho", + "captureShortcuts.emptyWithLimit": "Todavía no hay accesos directos: toca para añadir hasta {max}.", + "captureShortcuts.financesHint": "Controlar el dinero", + "captureShortcuts.groceryHint": "Añadir a la lista", + "captureShortcuts.habitsHint": "Registrar hábito", + "captureShortcuts.heading": "Accesos directos", + "captureShortcuts.healthHint": "Registrar salud", + "captureShortcuts.homeHint": "Tarea del hogar", + "captureShortcuts.inventoryHint": "¿Dónde está?", + "captureShortcuts.inventoryLabel": "Inventario", + "captureShortcuts.journalHint": "Escribir entrada", + "captureShortcuts.languageHint": "Repasar o registrar práctica", + "captureShortcuts.locatorHint": "Localizar a la familia", + "captureShortcuts.memoriesHint": "Guardar un momento", + "captureShortcuts.messagesHint": "Enviar un mensaje", + "captureShortcuts.movingHint": "Caja o tarea", + "captureShortcuts.movingLabel": "Mudanza", "captureShortcuts.noShortcutsYetTapToAdd": "Todavía no hay atajos: toca para añadir hasta", + "captureShortcuts.notesHint": "Escribir una nota", + "captureShortcuts.petsHint": "Cuidado de mascotas", + "captureShortcuts.photosHint": "Añadir foto", + "captureShortcuts.projectsHint": "Idea o presupuesto", + "captureShortcuts.recipesHint": "Buscar receta", + "captureShortcuts.remindersHint": "Crear recordatorio", + "captureShortcuts.removeNamed": "Eliminar {name}", + "captureShortcuts.saveFailed": "No se han podido guardar los accesos directos. Inténtalo de nuevo.", + "captureShortcuts.schoolHint": "Asunto escolar", + "captureShortcuts.subscriptionsHint": "Controlar suscripciones", "captureShortcuts.tapToChange": "Toca para cambiar", + "captureShortcuts.todosHint": "Crear una lista", + "captureShortcuts.todosLabel": "Lista de tareas", + "captureShortcuts.tripHint": "Planificar viaje", + "captureShortcuts.walletHint": "Cartera familiar", + "captureShortcuts.walletLabel": "Cartera", + "captureShortcuts.watchlistHint": "Noche de cine", + "captureShortcuts.watchlistLabel": "Lista para ver", + "captureShortcuts.weatherHint": "Consultar la previsión", "cardRevealModal.cardNumber": "Número de tarjeta", "cardRevealModal.couldNotRevealTheCard": "No se han podido mostrar los datos de la tarjeta.", "cardRevealModal.expires": "Caduca", diff --git a/lib/i18n/messages/fr-FR.json b/lib/i18n/messages/fr-FR.json index 5692034dd..b952873f0 100644 --- a/lib/i18n/messages/fr-FR.json +++ b/lib/i18n/messages/fr-FR.json @@ -2767,11 +2767,58 @@ "captureShell.undone": "Annulé", "captureShell.viewIn": "Voir dans", "captureShell.voiceInputIsNotSupported": "La saisie vocale n’est pas prise en charge par ce navigateur.", + "captureShortcuts.addPickerTitle": "Ajouter un raccourci", "captureShortcuts.addShortcut": "Ajouter un raccourci", + "captureShortcuts.announcementsHint": "Informer tout le monde", + "captureShortcuts.billsHint": "Gérer les factures", + "captureShortcuts.budgetsHint": "Définir les budgets", + "captureShortcuts.calendarHint": "Ajouter un événement", + "captureShortcuts.careerHint": "Candidature ou piste d’emploi", + "captureShortcuts.careerLabel": "Emploi", + "captureShortcuts.celebrationsHint": "Noter une occasion", + "captureShortcuts.changeShortcut": "Modifier le raccourci", + "captureShortcuts.closetHint": "Tenue du jour", + "captureShortcuts.countOfLimit": "{count}/{max}", "captureShortcuts.customize": "Personnaliser", + "captureShortcuts.declutterHint": "Mission de 15 minutes", + "captureShortcuts.declutterLabel": "Désencombrer", "captureShortcuts.done": "Terminé", + "captureShortcuts.emptyWithLimit": "Aucun raccourci pour l’instant — appuyez pour en ajouter jusqu’à {max}.", + "captureShortcuts.financesHint": "Suivre les finances", + "captureShortcuts.groceryHint": "Ajouter à la liste", + "captureShortcuts.habitsHint": "Suivre une habitude", + "captureShortcuts.heading": "Raccourcis", + "captureShortcuts.healthHint": "Noter des données de santé", + "captureShortcuts.homeHint": "Tâche ménagère", + "captureShortcuts.inventoryHint": "Où est-ce ?", + "captureShortcuts.inventoryLabel": "Inventaire", + "captureShortcuts.journalHint": "Rédiger une entrée", + "captureShortcuts.languageHint": "Réviser ou noter une séance", + "captureShortcuts.locatorHint": "Localiser la famille", + "captureShortcuts.memoriesHint": "Conserver un moment", + "captureShortcuts.messagesHint": "Envoyer un message", + "captureShortcuts.movingHint": "Carton ou tâche", + "captureShortcuts.movingLabel": "Déménagement", "captureShortcuts.noShortcutsYetTapToAdd": "Aucun raccourci pour l'instant — appuyez pour en ajouter jusqu'à", + "captureShortcuts.notesHint": "Écrire une note", + "captureShortcuts.petsHint": "Soins des animaux", + "captureShortcuts.photosHint": "Ajouter une photo", + "captureShortcuts.projectsHint": "Idée ou devis", + "captureShortcuts.recipesHint": "Trouver une recette", + "captureShortcuts.remindersHint": "Créer un rappel", + "captureShortcuts.removeNamed": "Supprimer {name}", + "captureShortcuts.saveFailed": "Impossible d’enregistrer les raccourcis. Veuillez réessayer.", + "captureShortcuts.schoolHint": "Élément scolaire", + "captureShortcuts.subscriptionsHint": "Suivre les abonnements", "captureShortcuts.tapToChange": "Appuyez pour modifier", + "captureShortcuts.todosHint": "Créer une liste", + "captureShortcuts.todosLabel": "Liste de tâches", + "captureShortcuts.tripHint": "Planifier un voyage", + "captureShortcuts.walletHint": "Portefeuille familial", + "captureShortcuts.walletLabel": "Portefeuille", + "captureShortcuts.watchlistHint": "Soirée cinéma", + "captureShortcuts.watchlistLabel": "Liste à regarder", + "captureShortcuts.weatherHint": "Consulter les prévisions", "cardRevealModal.cardNumber": "Numéro de carte", "cardRevealModal.couldNotRevealTheCard": "Impossible d’afficher la carte.", "cardRevealModal.expires": "Expire", diff --git a/lib/i18n/messages/it-IT.json b/lib/i18n/messages/it-IT.json index 8503a06c2..b98b92947 100644 --- a/lib/i18n/messages/it-IT.json +++ b/lib/i18n/messages/it-IT.json @@ -2767,11 +2767,58 @@ "captureShell.undone": "Annullato", "captureShell.viewIn": "Vedi in", "captureShell.voiceInputIsNotSupported": "L’input vocale non è supportato in questo browser.", + "captureShortcuts.addPickerTitle": "Aggiungi una scorciatoia", "captureShortcuts.addShortcut": "Aggiungi scorciatoia", + "captureShortcuts.announcementsHint": "Avvisa tutti", + "captureShortcuts.billsHint": "Gestisci le bollette", + "captureShortcuts.budgetsHint": "Imposta i budget", + "captureShortcuts.calendarHint": "Aggiungi evento", + "captureShortcuts.careerHint": "Candidatura o contatto di lavoro", + "captureShortcuts.careerLabel": "Lavoro", + "captureShortcuts.celebrationsHint": "Segna un’occasione", + "captureShortcuts.changeShortcut": "Cambia scorciatoia", + "captureShortcuts.closetHint": "Abbigliamento di oggi", + "captureShortcuts.countOfLimit": "{count}/{max}", "captureShortcuts.customize": "Personalizza", + "captureShortcuts.declutterHint": "Missione di 15 minuti", + "captureShortcuts.declutterLabel": "Fare ordine", "captureShortcuts.done": "Fatto", + "captureShortcuts.emptyWithLimit": "Non ci sono ancora scorciatoie: tocca per aggiungerne fino a {max}.", + "captureShortcuts.financesHint": "Monitora le finanze", + "captureShortcuts.groceryHint": "Aggiungi alla lista", + "captureShortcuts.habitsHint": "Monitora un’abitudine", + "captureShortcuts.heading": "Scorciatoie", + "captureShortcuts.healthHint": "Registra dati sulla salute", + "captureShortcuts.homeHint": "Attività domestica", + "captureShortcuts.inventoryHint": "Dov’è?", + "captureShortcuts.inventoryLabel": "Inventario", + "captureShortcuts.journalHint": "Scrivi una pagina", + "captureShortcuts.languageHint": "Ripassa o registra la pratica", + "captureShortcuts.locatorHint": "Trova la famiglia", + "captureShortcuts.memoriesHint": "Salva un momento", + "captureShortcuts.messagesHint": "Invia un messaggio", + "captureShortcuts.movingHint": "Scatola o attività", + "captureShortcuts.movingLabel": "Trasloco", "captureShortcuts.noShortcutsYetTapToAdd": "Nessuna scorciatoia: tocca per aggiungerne fino a", + "captureShortcuts.notesHint": "Scrivi una nota", + "captureShortcuts.petsHint": "Cura degli animali", + "captureShortcuts.photosHint": "Aggiungi foto", + "captureShortcuts.projectsHint": "Idea o preventivo", + "captureShortcuts.recipesHint": "Trova ricetta", + "captureShortcuts.remindersHint": "Imposta promemoria", + "captureShortcuts.removeNamed": "Rimuovi {name}", + "captureShortcuts.saveFailed": "Impossibile salvare le scorciatoie. Riprova.", + "captureShortcuts.schoolHint": "Voce scolastica", + "captureShortcuts.subscriptionsHint": "Monitora gli abbonamenti", "captureShortcuts.tapToChange": "Tocca per cambiare", + "captureShortcuts.todosHint": "Crea una lista", + "captureShortcuts.todosLabel": "Lista di attività", + "captureShortcuts.tripHint": "Pianifica viaggio", + "captureShortcuts.walletHint": "Portafoglio di famiglia", + "captureShortcuts.walletLabel": "Portafoglio", + "captureShortcuts.watchlistHint": "Serata cinema", + "captureShortcuts.watchlistLabel": "Da guardare", + "captureShortcuts.weatherHint": "Controlla le previsioni", "cardRevealModal.cardNumber": "Numero della carta", "cardRevealModal.couldNotRevealTheCard": "Impossibile visualizzare la carta.", "cardRevealModal.expires": "Scade", diff --git a/lib/i18n/messages/nl-NL.json b/lib/i18n/messages/nl-NL.json index 4c5d36bec..d554b7871 100644 --- a/lib/i18n/messages/nl-NL.json +++ b/lib/i18n/messages/nl-NL.json @@ -2767,11 +2767,58 @@ "captureShell.undone": "Ongedaan gemaakt", "captureShell.viewIn": "Bekijken in", "captureShell.voiceInputIsNotSupported": "Spraakinvoer wordt niet ondersteund in deze browser.", + "captureShortcuts.addPickerTitle": "Snelkoppeling toevoegen", "captureShortcuts.addShortcut": "Snelkoppeling toevoegen", + "captureShortcuts.announcementsHint": "Iedereen informeren", + "captureShortcuts.billsHint": "Rekeningen beheren", + "captureShortcuts.budgetsHint": "Budgetten instellen", + "captureShortcuts.calendarHint": "Afspraak toevoegen", + "captureShortcuts.careerHint": "Sollicitatie of vacaturetip", + "captureShortcuts.careerLabel": "Werk", + "captureShortcuts.celebrationsHint": "Gelegenheid vastleggen", + "captureShortcuts.changeShortcut": "Snelkoppeling wijzigen", + "captureShortcuts.closetHint": "Outfit van vandaag", + "captureShortcuts.countOfLimit": "{count}/{max}", "captureShortcuts.customize": "Aanpassen", + "captureShortcuts.declutterHint": "Klus van 15 minuten", + "captureShortcuts.declutterLabel": "Opruimen", "captureShortcuts.done": "Klaar", + "captureShortcuts.emptyWithLimit": "Nog geen snelkoppelingen — tik om er maximaal {max} toe te voegen.", + "captureShortcuts.financesHint": "Geld bijhouden", + "captureShortcuts.groceryHint": "Aan lijst toevoegen", + "captureShortcuts.habitsHint": "Gewoonte bijhouden", + "captureShortcuts.heading": "Snelkoppelingen", + "captureShortcuts.healthHint": "Gezondheid vastleggen", + "captureShortcuts.homeHint": "Huishoudelijke taak", + "captureShortcuts.inventoryHint": "Waar is het?", + "captureShortcuts.inventoryLabel": "Inventaris", + "captureShortcuts.journalHint": "Dagboeknotitie schrijven", + "captureShortcuts.languageHint": "Herhalen of oefening vastleggen", + "captureShortcuts.locatorHint": "Gezin vinden", + "captureShortcuts.memoriesHint": "Moment bewaren", + "captureShortcuts.messagesHint": "Bericht sturen", + "captureShortcuts.movingHint": "Doos of taak", + "captureShortcuts.movingLabel": "Verhuizing", "captureShortcuts.noShortcutsYetTapToAdd": "Nog geen snelkoppelingen — tik om er tot", + "captureShortcuts.notesHint": "Notitie schrijven", + "captureShortcuts.petsHint": "Dierenverzorging", + "captureShortcuts.photosHint": "Foto toevoegen", + "captureShortcuts.projectsHint": "Idee of offerte", + "captureShortcuts.recipesHint": "Recept zoeken", + "captureShortcuts.remindersHint": "Herinnering instellen", + "captureShortcuts.removeNamed": "{name} verwijderen", + "captureShortcuts.saveFailed": "De snelkoppelingen konden niet worden opgeslagen. Probeer het opnieuw.", + "captureShortcuts.schoolHint": "Schoolnotitie", + "captureShortcuts.subscriptionsHint": "Abonnementen bijhouden", "captureShortcuts.tapToChange": "Tik om te wijzigen", + "captureShortcuts.todosHint": "Lijst maken", + "captureShortcuts.todosLabel": "Takenlijst", + "captureShortcuts.tripHint": "Reis plannen", + "captureShortcuts.walletHint": "Gezinsportemonnee", + "captureShortcuts.walletLabel": "Portemonnee", + "captureShortcuts.watchlistHint": "Filmavond", + "captureShortcuts.watchlistLabel": "Kijklijst", + "captureShortcuts.weatherHint": "Weersverwachting bekijken", "cardRevealModal.cardNumber": "Kaartnummer", "cardRevealModal.couldNotRevealTheCard": "Kan de kaart niet tonen.", "cardRevealModal.expires": "Verloopt", diff --git a/lib/i18n/messages/pt-PT.json b/lib/i18n/messages/pt-PT.json index bd9f8d1ed..0cd019571 100644 --- a/lib/i18n/messages/pt-PT.json +++ b/lib/i18n/messages/pt-PT.json @@ -2767,11 +2767,58 @@ "captureShell.undone": "Anulado", "captureShell.viewIn": "Ver em", "captureShell.voiceInputIsNotSupported": "A entrada por voz não é suportada neste navegador.", + "captureShortcuts.addPickerTitle": "Adicionar um atalho", "captureShortcuts.addShortcut": "Adicionar atalho", + "captureShortcuts.announcementsHint": "Informar todos", + "captureShortcuts.billsHint": "Gerir faturas", + "captureShortcuts.budgetsHint": "Definir orçamentos", + "captureShortcuts.calendarHint": "Adicionar evento", + "captureShortcuts.careerHint": "Candidatura ou contacto profissional", + "captureShortcuts.careerLabel": "Emprego", + "captureShortcuts.celebrationsHint": "Assinalar uma ocasião", + "captureShortcuts.changeShortcut": "Alterar atalho", + "captureShortcuts.closetHint": "Conjunto de hoje", + "captureShortcuts.countOfLimit": "{count}/{max}", "captureShortcuts.customize": "Personalizar", + "captureShortcuts.declutterHint": "Missão de 15 minutos", + "captureShortcuts.declutterLabel": "Destralhar", "captureShortcuts.done": "Concluído", + "captureShortcuts.emptyWithLimit": "Ainda não há atalhos — toque para adicionar até {max}.", + "captureShortcuts.financesHint": "Acompanhar as finanças", + "captureShortcuts.groceryHint": "Adicionar à lista", + "captureShortcuts.habitsHint": "Acompanhar hábito", + "captureShortcuts.heading": "Atalhos", + "captureShortcuts.healthHint": "Registar dados de saúde", + "captureShortcuts.homeHint": "Tarefa doméstica", + "captureShortcuts.inventoryHint": "Onde está?", + "captureShortcuts.inventoryLabel": "Inventário", + "captureShortcuts.journalHint": "Escrever entrada", + "captureShortcuts.languageHint": "Rever ou registar prática", + "captureShortcuts.locatorHint": "Localizar a família", + "captureShortcuts.memoriesHint": "Guardar um momento", + "captureShortcuts.messagesHint": "Enviar uma mensagem", + "captureShortcuts.movingHint": "Caixa ou tarefa", + "captureShortcuts.movingLabel": "Mudança de casa", "captureShortcuts.noShortcutsYetTapToAdd": "Ainda não há atalhos — toque para adicionar até", + "captureShortcuts.notesHint": "Escrever uma nota", + "captureShortcuts.petsHint": "Cuidados dos animais", + "captureShortcuts.photosHint": "Adicionar fotografia", + "captureShortcuts.projectsHint": "Ideia ou orçamento", + "captureShortcuts.recipesHint": "Encontrar receita", + "captureShortcuts.remindersHint": "Definir lembrete", + "captureShortcuts.removeNamed": "Remover {name}", + "captureShortcuts.saveFailed": "Não foi possível guardar os atalhos. Por favor, tente novamente.", + "captureShortcuts.schoolHint": "Assunto escolar", + "captureShortcuts.subscriptionsHint": "Acompanhar subscrições", "captureShortcuts.tapToChange": "Toque para alterar", + "captureShortcuts.todosHint": "Criar uma lista", + "captureShortcuts.todosLabel": "Lista de tarefas", + "captureShortcuts.tripHint": "Planear viagem", + "captureShortcuts.walletHint": "Carteira da família", + "captureShortcuts.walletLabel": "Carteira", + "captureShortcuts.watchlistHint": "Noite de cinema", + "captureShortcuts.watchlistLabel": "Lista para ver", + "captureShortcuts.weatherHint": "Consultar a previsão", "cardRevealModal.cardNumber": "Número do cartão", "cardRevealModal.couldNotRevealTheCard": "Não foi possível mostrar o cartão.", "cardRevealModal.expires": "Expira", diff --git a/tests/capture-page-localization.test.ts b/tests/capture-page-localization.test.ts new file mode 100644 index 000000000..c331b367e --- /dev/null +++ b/tests/capture-page-localization.test.ts @@ -0,0 +1,51 @@ +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { getMessages, getRawMessages, translate } from '@/lib/i18n/messages'; +import type { LocaleCode } from '@/lib/i18n/locales'; + +const state = vi.hoisted(() => ({ locale: 'en-US' as LocaleCode, auth: vi.fn(), load: vi.fn() })); +vi.mock('@/lib/i18n/server', () => ({ + getTranslations: async () => (key: string) => translate(getMessages(state.locale), key), +})); +vi.mock('@/lib/supabase/auth', () => ({ requireUserContext: state.auth })); +vi.mock('@/app/(app)/capture/shortcuts-actions', () => ({ loadCaptureShortcuts: state.load })); +vi.mock('@/components/capture/capture-shell', () => ({ + CaptureShell: ({ initialText, initialShortcuts }: { initialText: string; initialShortcuts: string[] }) => + createElement('section', { 'data-shortcuts': JSON.stringify(initialShortcuts) }, initialText), +})); +const { default: CapturePage, generateMetadata } = await import('@/app/(app)/capture/page'); +const locales: LocaleCode[] = ['en-US', 'de-DE', 'es-ES', 'fr-FR', 'it-IT', 'nl-NL', 'pt-PT']; + +beforeEach(() => { + state.locale = 'en-US'; + state.auth.mockReset().mockResolvedValue({}); + state.load.mockReset().mockResolvedValue(['calendar', 'tasks']); +}); + +describe('Capture page locale and server boundaries', () => { + it.each(locales)('uses the %s Capture heading in page metadata', async (locale) => { + state.locale = locale; + const expected = getRawMessages(locale)['captureShell.capture']; + expect(expected).toBeTruthy(); + expect(await generateMetadata()).toEqual({ title: expected }); + expect(state.auth).not.toHaveBeenCalled(); + expect(state.load).not.toHaveBeenCalled(); + }); + + it('keeps saved shortcut IDs and shared text in the authenticated first render', async () => { + const html = renderToStaticMarkup(await CapturePage({ searchParams: Promise.resolve({ text: 'A shared school note' }) })); + expect(html).toContain('A shared school note'); + expect(html).toContain('data-shortcuts="["calendar","tasks"]"'); + expect(state.auth).toHaveBeenCalledOnce(); + expect(state.load).toHaveBeenCalledOnce(); + expect(state.auth.mock.invocationCallOrder[0]).toBeLessThan(state.load.mock.invocationCallOrder[0]); + }); + + it('preserves login redirects before reading the saved shortcut layout', async () => { + const redirect = new Error('NEXT_REDIRECT'); + state.auth.mockRejectedValue(redirect); + await expect(CapturePage({})).rejects.toBe(redirect); + expect(state.load).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/capture-shortcuts-render.test.ts b/tests/capture-shortcuts-render.test.ts new file mode 100644 index 000000000..ec87c3fab --- /dev/null +++ b/tests/capture-shortcuts-render.test.ts @@ -0,0 +1,262 @@ +import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { LocaleProvider } from '@/components/i18n/locale-provider'; +import { getMessages, getRawMessages, translate } from '@/lib/i18n/messages'; +import { localeOrDefault, type LocaleCode } from '@/lib/i18n/locales'; + +const harness = vi.hoisted(() => ({ + slots: [] as unknown[], cursor: 0, + load: vi.fn(), save: vi.fn(), toast: vi.fn(), setItem: vi.fn(), +})); +vi.mock('@/app/(app)/capture/shortcuts-actions', () => ({ + loadCaptureShortcuts: harness.load, saveCaptureShortcutsAction: harness.save, +})); +vi.mock('@/components/ui/toast', () => ({ useToast: () => ({ error: harness.toast }) })); +vi.mock('next/link', () => ({ default: 'a' })); +// Keep actual shortcut markup and callbacks. Portal focus management is a +// separate browser concern; the picker title and children still render here. +vi.mock('@/components/ui/modal', () => ({ + Modal: ({ open, title, children }: { open: boolean; title: string; children: ReactNode }) => open + ? createElement('section', { role: 'dialog', 'aria-label': title }, createElement('h2', null, title), children) + : null, +})); +// Persist only the component's hook state between server renders, so invoking +// its real event handlers can be followed by real markup in a LocaleProvider. +// No DOM package is installed; this intentionally does not emulate a browser. +vi.mock('react', async (original) => ({ + ...await original(), + useState: (initial: unknown) => { + const index = harness.cursor++; + if (!(index in harness.slots)) harness.slots[index] = typeof initial === 'function' ? initial() : initial; + return [harness.slots[index], (next: unknown) => { + harness.slots[index] = typeof next === 'function' ? next(harness.slots[index]) : next; + }]; + }, + useRef: (initial: unknown) => { + const index = harness.cursor++; + if (!(index in harness.slots)) harness.slots[index] = { current: initial }; + return harness.slots[index]; + }, + useEffect: () => {}, +})); + +const { CaptureShortcuts, SHORTCUT_CATALOG } = await import('@/components/capture/capture-shortcuts'); +type Props = Parameters[0]; +type ElementProps = { children?: ReactNode; href?: string; title?: string; 'aria-label'?: string; onClick?: () => void }; +const locales: LocaleCode[] = ['en-US', 'de-DE', 'es-ES', 'fr-FR', 'it-IT', 'nl-NL', 'pt-PT']; +const byKey = new Map(SHORTCUT_CATALOG.map((shortcut) => [shortcut.key, shortcut])); +// Public destinations and saved IDs are independent of display translations. +const destinations = { + calendar: '/dashboard/calendar', tasks: '/dashboard/chores', grocery: '/dashboard/grocery', + home: '/dashboard/home', health: '/dashboard/health', trip: '/dashboard/trips', notes: '/dashboard/notes', + meals: '/dashboard/meals', reminders: '/dashboard/reminders', documents: '/dashboard/documents', + wallet: '/wallet', pets: '/dashboard/pets', closet: '/dashboard/closet', watchlist: '/dashboard/watchlist', + inventory: '/dashboard/inventory', sleep: '/dashboard/sleep', declutter: '/dashboard/declutter', + moving: '/dashboard/moving', projects: '/dashboard/projects', career: '/dashboard/career', + language: '/dashboard/language', goals: '/dashboard/goals', finances: '/dashboard/billing', + contacts: '/dashboard/contacts', photos: '/dashboard/photos', school: '/dashboard/school', + wishlists: '/dashboard/wishlists', messages: '/dashboard/messages', memories: '/dashboard/memories', + budgets: '/dashboard/budgets', bills: '/dashboard/bills', subscriptions: '/dashboard/subscriptions', + locator: '/dashboard/locator', journal: '/dashboard/journal', habits: '/dashboard/habits', + weather: '/dashboard/weather', celebrations: '/dashboard/celebrations', announcements: '/dashboard/announcements', + recipes: '/dashboard/recipes', todos: '/dashboard/todos', +}; + +function textOf(node: ReactNode): string { + if (Array.isArray(node)) return node.map(textOf).join(''); + if (isValidElement(node)) return textOf(node.props.children); + return typeof node === 'string' || typeof node === 'number' ? String(node) : ''; +} +function elements(node: ReactNode): ReactElement[] { + if (Array.isArray(node)) return node.flatMap(elements); + if (!isValidElement(node)) return []; + return [node, ...elements(node.props.children)]; +} +function button(tree: ReactNode, text: string) { + const result = elements(tree).find((node) => node.type === 'button' && textOf(node).trim() === text.trim()); + expect(result, `button ${JSON.stringify(text)} exists`).toBeDefined(); + return result!; +} +function escaped(text: string): string { + return renderToStaticMarkup(createElement('span', null, text)).replace(/^|<\/span>$/g, ''); +} +function t(locale: LocaleCode, key: string, params?: Record) { + return translate(getMessages(locale), key, params); +} +function render(locale: LocaleCode, props: Props) { + let tree: ReactNode; + function Subject() { + harness.cursor = 0; + tree = CaptureShortcuts(props); + return tree; + } + const html = renderToStaticMarkup(createElement(LocaleProvider, { + locale: localeOrDefault(locale), source: 'cookie', messages: getMessages(locale), + } as Parameters[0], createElement(Subject))); + return { html, tree: tree! }; +} +function fresh() { harness.slots = []; harness.cursor = 0; } + +beforeEach(() => { + fresh(); + harness.load.mockReset().mockResolvedValue(null); + harness.save.mockReset().mockResolvedValue({ ok: true }); + harness.toast.mockReset(); + harness.setItem.mockReset(); + vi.stubGlobal('localStorage', { getItem: () => null, setItem: harness.setItem }); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe.each(locales)('Capture shortcuts in %s', (locale) => { + it('renders every destination with its localized label and hint and unchanged href', () => { + const keys = Object.keys(destinations); + expect(SHORTCUT_CATALOG.map((s) => s.key)).toEqual(keys); + for (let start = 0; start < keys.length; start += 10) { + fresh(); + const selected = keys.slice(start, start + 10); + const { html } = render(locale, { initialKeys: selected }); + const links = html.match(/]*>[\s\S]*?<\/a>/g) ?? []; + expect(links).toHaveLength(selected.length); + selected.forEach((key, index) => { + const shortcut = byKey.get(key)!; + for (const messageKey of [shortcut.labelKey, shortcut.hintKey]) { + expect(getRawMessages(locale)[messageKey], `${locale} owns ${messageKey}`).toBeTruthy(); + expect(links[index]).toContain(escaped(t(locale, messageKey))); + } + expect(links[index]).toContain(`href="${destinations[key as keyof typeof destinations]}"`); + }); + expect(html).toContain(escaped(t(locale, 'captureShortcuts.heading'))); + expect(html).toContain(escaped(t(locale, 'captureShortcuts.customize'))); + expect(html).not.toContain('captureShortcuts.'); + } + expect(harness.save).not.toHaveBeenCalled(); + expect(harness.load).not.toHaveBeenCalled(); + }); + + it('localizes remove labels, edit hints and counts for every selected destination', () => { + for (let start = 0; start < SHORTCUT_CATALOG.length; start += 10) { + fresh(); + const selected = SHORTCUT_CATALOG.slice(start, start + 10); + const { html } = render(locale, { initialKeys: selected.map((s) => s.key), editing: true }); + for (const shortcut of selected) { + const remove = t(locale, 'captureShortcuts.removeNamed', { name: t(locale, shortcut.labelKey) }); + expect(html).toContain(`aria-label="${escaped(remove)}"`); + } + expect(html).toContain(escaped(t(locale, 'captureShortcuts.tapToChange'))); + expect(html).toContain(escaped(t(locale, 'captureShortcuts.done'))); + expect(html).toContain(escaped(t(locale, 'captureShortcuts.countOfLimit', { count: selected.length.toLocaleString(locale), max: (10).toLocaleString(locale) }))); + expect(html).not.toContain('{name}'); + } + }); + + it('opens an empty layout picker, translates its entire catalog, and saves the selected stable ID', async () => { + const props = { initialKeys: [] }; + let view = render(locale, props); + const empty = t(locale, 'captureShortcuts.emptyWithLimit', { max: (10).toLocaleString(locale) }); + button(view.tree, empty).props.onClick!(); + view = render(locale, props); + expect(view.html).not.toContain(escaped(empty)); + const count = t(locale, 'captureShortcuts.countOfLimit', { count: '0', max: '10' }); + button(view.tree, t(locale, 'captureShortcuts.addShortcut') + count).props.onClick!(); + view = render(locale, props); + expect(view.html).toContain(`aria-label="${escaped(t(locale, 'captureShortcuts.addPickerTitle'))}"`); + for (const shortcut of SHORTCUT_CATALOG) button(view.tree, t(locale, shortcut.labelKey)); + button(view.tree, t(locale, 'calendar.calendar')).props.onClick!(); + await Promise.resolve(); + view = render(locale, props); + expect(view.html).not.toContain('role="dialog"'); + expect(harness.save).toHaveBeenLastCalledWith({ keys: ['calendar'] }); + expect(harness.setItem).toHaveBeenLastCalledWith('bubaly.capture.shortcuts', '["calendar"]'); + expect(view.html).toContain(escaped(t(locale, 'captureShortcuts.countOfLimit', { count: '1', max: '10' }))); + }); + + it('reports a failed save in the active language while retaining the optimistic edit', async () => { + harness.save.mockResolvedValue({ ok: false, error: 'internal database failure' }); + const props = { initialKeys: ['calendar'], editing: true }; + const view = render(locale, props); + const remove = t(locale, 'captureShortcuts.removeNamed', { name: t(locale, 'calendar.calendar') }); + elements(view.tree).find((node) => node.props['aria-label'] === remove)!.props.onClick!(); + await Promise.resolve(); + expect(harness.toast).toHaveBeenCalledExactlyOnceWith(t(locale, 'captureShortcuts.saveFailed')); + expect(harness.toast).not.toHaveBeenCalledWith('internal database failure'); + expect(harness.save).toHaveBeenCalledWith({ keys: [] }); + expect(render(locale, props).html).not.toContain(`aria-label="${escaped(remove)}"`); + }); +}); + +describe('shortcut editing and navigation contracts', () => { + it('reports a rejected save in German without exposing the transport error or changing saved IDs', async () => { + harness.save.mockRejectedValueOnce(new Error('private transport host: database.internal')); + const locale = 'de-DE'; + const props = { initialKeys: ['calendar', 'tasks'], editing: true }; + const view = render(locale, props); + const remove = t(locale, 'captureShortcuts.removeNamed', { name: t(locale, 'calendar.calendar') }); + elements(view.tree).find((node) => node.props['aria-label'] === remove)!.props.onClick!(); + + await vi.waitFor(() => expect(harness.toast).toHaveBeenCalledExactlyOnceWith(t(locale, 'captureShortcuts.saveFailed'))); + expect(harness.toast.mock.calls.flat().join(' ')).not.toContain('database.internal'); + expect(harness.save).toHaveBeenCalledExactlyOnceWith({ keys: ['tasks'] }); + expect(harness.setItem).toHaveBeenLastCalledWith('bubaly.capture.shortcuts', '["tasks"]'); + const updated = render(locale, props); + expect(updated.html).not.toContain(`aria-label="${escaped(remove)}"`); + expect(updated.html).toContain(escaped(t(locale, 'dashboardPlanning.tasks'))); + }); + + it('replaces and removes without changing other IDs, then navigates only outside edit mode', async () => { + const locale = 'de-DE'; + const onNavigate = vi.fn(); + const props = { initialKeys: ['calendar', 'tasks'], onNavigate }; + let view = render(locale, props); + elements(view.tree).find((node) => node.props.href === '/dashboard/calendar')!.props.onClick!(); + expect(onNavigate).toHaveBeenCalledOnce(); + button(view.tree, t(locale, 'captureShortcuts.customize')).props.onClick!(); + view = render(locale, props); + expect(elements(view.tree).filter((node) => node.props.href)).toHaveLength(0); + button(view.tree, t(locale, 'calendar.calendar') + t(locale, 'captureShortcuts.tapToChange')).props.onClick!(); + view = render(locale, props); + expect(view.html).toContain(escaped(t(locale, 'captureShortcuts.changeShortcut'))); + button(view.tree, t(locale, 'calendar.calendar')); // current choice remains selectable + expect(elements(view.tree).filter((node) => node.type === 'button' && textOf(node) === t(locale, 'dashboardPlanning.tasks'))).toHaveLength(0); + button(view.tree, t(locale, 'dashboardPlanning.notes')).props.onClick!(); + await Promise.resolve(); + expect(harness.save).toHaveBeenLastCalledWith({ keys: ['notes', 'tasks'] }); + view = render(locale, props); + const remove = t(locale, 'captureShortcuts.removeNamed', { name: t(locale, 'dashboardPlanning.tasks') }); + elements(view.tree).find((node) => node.props['aria-label'] === remove)!.props.onClick!(); + await Promise.resolve(); + expect(harness.save).toHaveBeenLastCalledWith({ keys: ['notes'] }); + expect(onNavigate).toHaveBeenCalledOnce(); + view = render(locale, props); + const done = elements(view.tree).find((node) => node.type === 'button' && textOf(node).endsWith(t(locale, 'captureShortcuts.done')))!; + done.props.onClick!(); + view = render(locale, props); + elements(view.tree).find((node) => node.props.href === '/dashboard/notes')!.props.onClick!(); + expect(onNavigate).toHaveBeenCalledTimes(2); + }); + + it('enforces the ten-shortcut limit and offers one add control after removal', () => { + const initialKeys = Object.keys(destinations).slice(0, 10); + const props = { initialKeys: [...initialKeys, 'wallet'], editing: true }; + let view = render('en-US', props); + expect(view.html).not.toContain('Add shortcut'); + expect(elements(view.tree).filter((node) => node.props['aria-label']?.startsWith('Remove '))).toHaveLength(10); + elements(view.tree).find((node) => node.props['aria-label'] === 'Remove Calendar')!.props.onClick!(); + view = render('en-US', props); + expect(elements(view.tree).filter((node) => node.type === 'button' && textOf(node).startsWith('Add shortcut'))).toHaveLength(1); + expect(harness.save).toHaveBeenLastCalledWith({ keys: initialKeys.slice(1) }); + }); + + it('preserves caller headings, intentionally hidden headers and controlled edit mode', () => { + const onEditingChange = vi.fn(); + let view = render('fr-FR', { initialKeys: [], heading: 'My personal destinations', editing: false, onEditingChange }); + expect(view.html).toContain('My personal destinations'); + expect(view.html).not.toContain(escaped(t('fr-FR', 'captureShortcuts.heading'))); + button(view.tree, t('fr-FR', 'captureShortcuts.customize')).props.onClick!(); + expect(onEditingChange).toHaveBeenCalledExactlyOnceWith(true); + view = render('fr-FR', { initialKeys: [], heading: '', editing: true, showCustomizeButton: false }); + expect(view.html).not.toContain(escaped(t('fr-FR', 'captureShortcuts.customize'))); + expect(view.html).not.toContain(escaped(t('fr-FR', 'captureShortcuts.heading'))); + expect(view.html).toContain(escaped(t('fr-FR', 'captureShortcuts.addShortcut'))); + }); +});