feat: internationalize contact

This commit is contained in:
Ivan Mercedes 2026-07-07 17:02:02 -04:00
parent 57a914d547
commit dcfd66792b
6 changed files with 374 additions and 200 deletions

View File

@ -249,5 +249,201 @@
"notes": "Notes", "notes": "Notes",
"deals": "Deals" "deals": "Deals"
} }
},
"Contacts": {
"page": {
"title": "Contacts",
"subtitle": "Manage your contact list. {count} total contacts.",
"subtitleZero": "Manage your contact list.",
"customFieldsBtn": "Custom fields",
"importBtn": "Import",
"addContactBtn": "Add Contact",
"searchPlaceholder": "Search by name, phone, or email...",
"filterByTags": "Filter by tags",
"clearAll": "Clear all",
"noTagsYet": "No tags yet.",
"selectedCount": "{count} selected",
"clearSelection": "Clear",
"deleteSelected": "Delete selected",
"tableColumns": {
"name": "Name",
"phone": "Phone",
"email": "Email",
"company": "Company",
"tags": "Tags",
"createdAt": "Created"
},
"loading": "Loading contacts...",
"noContactsMatch": "No contacts match your filters.",
"noContactsYet": "No contacts yet.",
"addFirstContact": "Add your first contact",
"unnamed": "Unnamed",
"editAction": "Edit",
"deleteAction": "Delete",
"showingPagination": "Showing {start}-{end} of {total}",
"pageCount": "Page {page} of {total}",
"deleteContactTitle": "Delete Contact",
"deleteContactDesc": "Are you sure you want to delete {name}? This action cannot be undone.",
"cancel": "Cancel",
"deleteBtn": "Delete",
"deleteBulkTitle": "Delete Contacts",
"deleteBulkDesc": "Are you sure you want to delete {count} selected contacts? This action cannot be undone.",
"toastFailedLoad": "Failed to load contacts",
"toastFailedDelete": "Failed to delete contact",
"toastDeleted": "Contact deleted",
"toastBulkFailedDelete": "Failed to delete contacts",
"toastBulkDeleted": "{count} contacts deleted"
},
"form": {
"addTitle": "Add Contact",
"addDesc": "Create a new contact. Name is required.",
"editTitle": "Edit Contact",
"editDesc": "Update contact details and tags.",
"nameLabel": "Name",
"namePlaceholder": "Jane Doe",
"phoneLabel": "Phone Number",
"phonePlaceholder": "+1234567890",
"phoneHint": "Include country code, e.g. +1 for US",
"emailLabel": "Email",
"emailPlaceholder": "jane@example.com",
"companyLabel": "Company",
"companyPlaceholder": "Acme Corp",
"tagsLabel": "Tags",
"tagsPlaceholder": "Select tags...",
"noTagsFound": "No tags found.",
"createTagAction": "Create tag \"{value}\"",
"cancel": "Cancel",
"save": "Save Contact",
"update": "Update",
"create": "Create",
"toastSuccessAdd": "Contact created successfully",
"toastSuccessEdit": "Contact updated successfully",
"toastError": "Failed to save contact",
"toastConflict": "A contact with this phone number already exists",
"viewExisting": "View {name}",
"dupExact": "A contact with this phone number already exists.",
"dupSimilar": "A contact with a very similar number already exists.",
"loadingTags": "Loading tags...",
"noTagsAvailable": "No tags available. Create tags in Settings.",
"phoneRequired": "Phone number is required"
},
"detailView": {
"tabs": {
"details": "Details",
"activity": "Activity",
"notes": "Notes",
"custom": "Custom Fields",
"deals": "Deals"
},
"actions": {
"edit": "Edit",
"message": "Message",
"delete": "Delete"
},
"contactInfo": "Contact Information",
"unnamed": "Unknown",
"contactDetailsDesc": "Contact details",
"phone": "Phone",
"email": "Email",
"company": "Company",
"createdAt": "Created At",
"customFields": "Custom Fields",
"noCustomFields": "No custom fields defined. Create them in Settings.",
"manageFields": "Manage fields",
"enterCustomField": "Enter {name}...",
"activityTab": {
"title": "Activity History",
"noActivity": "No activity yet."
},
"notesTab": {
"title": "Internal Notes",
"placeholder": "Write a note...",
"save": "Add Note",
"noNotes": "No notes yet."
},
"dealsTab": {
"noDeals": "No deals yet"
},
"tagsTab": {
"clickTagDesc": "Click a tag to add or remove it from this contact.",
"noTagsAvailable": "No tags available. Create tags in Settings."
},
"sendTemplateBtn": "Send template",
"saveChangesBtn": "Save Changes",
"saveCustomFieldsBtn": "Save Custom Fields",
"toastPhoneRequired": "Phone number is required",
"toastUpdateFailed": "Failed to update contact",
"toastUpdated": "Contact updated",
"toastNotAuthenticated": "Not authenticated",
"toastNoteAddFailed": "Failed to add note",
"toastNoteAdded": "Note added",
"toastNoteDeleteFailed": "Failed to delete note",
"toastNoteDeleted": "Note deleted",
"toastCustomFieldsSaved": "Custom fields saved",
"toastCustomFieldsFailed": "Failed to save custom fields",
"toastTemplateSent": "Template \"{name}\" sent",
"toastTemplateFailed": "Failed to send template: {reason}"
},
"importModal": {
"title": "Import Contacts",
"desc": "Upload a CSV with a required <phoneCode>phone</phoneCode> column. Optional: <nameCode>name</nameCode>, <emailCode>email</emailCode>, <companyCode>company</companyCode>, <tagsCode>tags</tagsCode> (comma-separated; quote multi-tag cells).",
"uploadDropzone": "Click to choose a CSV file",
"uploadHint": ".csv up to your browser limit",
"rowsReady": "{count} row ready",
"rowsReady_plural": "{count} rows ready",
"preview": "Preview · first {count}",
"previewTags": "{tags} tag · {contacts} contact",
"previewTags_plural": "{tags} tags · {contacts} contacts",
"moreRows": "+ {count} more row not shown",
"moreRows_plural": "+ {count} more rows not shown",
"importComplete": "Import complete",
"resultImported": "{count} imported",
"resultTags": "{count} tag assigned",
"resultTags_plural": "{count} tags assigned",
"resultSkipped": "{count} skipped",
"resultFailed": "{count} failed",
"willBeCreated": "{name} (will be created on import)",
"columns": {
"phone": "Phone",
"name": "Name",
"email": "Email",
"company": "Company",
"tags": "Tags"
},
"cancel": "Cancel",
"close": "Close",
"importBtn": "Import {count} contact",
"importBtn_plural": "Import {count} contacts",
"toastNoValidRows": "No valid rows found. Ensure CSV has a \"phone\" column header.",
"toastTagsWarning": "Contacts imported, but some tag assignments failed.",
"toastImported": "{count} contact imported",
"toastImported_plural": "{count} contacts imported",
"toastTagsAssigned": "{count} tag assignment applied",
"toastTagsAssigned_plural": "{count} tag assignments applied",
"toastTagsSkipped": "Unknown tags skipped (create them in Settings first): {sample}{more}",
"toastSkipped": "{count} duplicate skipped",
"toastSkipped_plural": "{count} duplicates skipped",
"toastFailed": "{count} contact failed to import",
"toastFailed_plural": "{count} contacts failed to import",
"toastError": "Import failed"
},
"customFields": {
"title": "Custom fields",
"desc": "Define extra contact fields (e.g. ZIP code, lead source). They appear on every contact and in the “Update Contact Field” automation action.",
"addField": "Add",
"fieldName": "New field name…",
"loading": "Loading…",
"empty": "No custom fields yet.",
"renameAria": "Rename {name}",
"deleteTitle": "Delete field",
"deleteConfirm": "Delete \"{name}\"? This also removes its stored value on every contact. This cannot be undone.",
"toastNoAccount": "Your profile is not linked to an account.",
"toastDuplicate": "A field named \"{name}\" already exists.",
"toastCreateFailed": "Could not create field. You may not have permission.",
"toastCreated": "Created \"{name}\".",
"toastRenameFailed": "Could not rename field.",
"toastDeleteFailed": "Could not delete field.",
"toastDeleted": "Deleted \"{name}\"."
}
} }
} }

View File

@ -14,6 +14,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table'; } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuTrigger, DropdownMenuTrigger,
@ -55,7 +56,7 @@ import { ImportModal } from '@/components/contacts/import-modal';
import { CustomFieldsManager } from '@/components/contacts/custom-fields-manager'; import { CustomFieldsManager } from '@/components/contacts/custom-fields-manager';
import { useCan } from '@/hooks/use-can'; import { useCan } from '@/hooks/use-can';
import { GatedButton } from '@/components/ui/gated-button'; import { GatedButton } from '@/components/ui/gated-button';
import { Checkbox } from '@/components/ui/checkbox'; import { useTranslations } from 'next-intl';
const PAGE_SIZE = 25; const PAGE_SIZE = 25;
@ -64,6 +65,7 @@ interface ContactWithTags extends Contact {
} }
export default function ContactsPage() { export default function ContactsPage() {
const t = useTranslations('Contacts.page');
const supabase = createClient(); const supabase = createClient();
const canEdit = useCan('send-messages'); const canEdit = useCan('send-messages');
const canEditSettings = useCan('edit-settings'); const canEditSettings = useCan('edit-settings');
@ -144,7 +146,7 @@ export default function ContactsPage() {
}); });
if (seq !== fetchSeq.current) return; // superseded by a newer fetch if (seq !== fetchSeq.current) return; // superseded by a newer fetch
if (error) { if (error) {
toast.error('Failed to load contacts'); toast.error(t('toastFailedLoad'));
setLoading(false); setLoading(false);
return; return;
} }
@ -166,7 +168,7 @@ export default function ContactsPage() {
const { data, count: exactCount, error } = await query; const { data, count: exactCount, error } = await query;
if (seq !== fetchSeq.current) return; // superseded by a newer fetch if (seq !== fetchSeq.current) return; // superseded by a newer fetch
if (error) { if (error) {
toast.error('Failed to load contacts'); toast.error(t('toastFailedLoad'));
setLoading(false); setLoading(false);
return; return;
} }
@ -205,7 +207,7 @@ export default function ContactsPage() {
setContacts(enriched); setContacts(enriched);
setLoading(false); setLoading(false);
}, [supabase, page, search, selectedTagIds, tagsMap]); }, [supabase, page, search, selectedTagIds, tagsMap, t]);
// Load-once-on-mount-ish data fetches. Each setter inside runs // Load-once-on-mount-ish data fetches. Each setter inside runs
// inside an async promise completion (Supabase await), not // inside an async promise completion (Supabase await), not
@ -257,9 +259,9 @@ export default function ContactsPage() {
.eq('id', deleteTarget.id); .eq('id', deleteTarget.id);
if (error) { if (error) {
toast.error('Failed to delete contact'); toast.error(t('toastFailedDelete'));
} else { } else {
toast.success('Contact deleted'); toast.success(t('toastDeleted'));
fetchContacts(); fetchContacts();
} }
@ -301,9 +303,9 @@ export default function ContactsPage() {
const { error } = await supabase.from('contacts').delete().in('id', ids); const { error } = await supabase.from('contacts').delete().in('id', ids);
if (error) { if (error) {
toast.error('Failed to delete contacts'); toast.error(t('toastBulkFailedDelete'));
} else { } else {
toast.success(`${ids.length} contact${ids.length === 1 ? '' : 's'} deleted`); toast.success(t('toastBulkDeleted', { count: ids.length }));
setSelected(new Set()); setSelected(new Set());
fetchContacts(); fetchContacts();
} }
@ -342,9 +344,9 @@ export default function ContactsPage() {
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-bold text-foreground">Contacts</h1> <h1 className="text-2xl font-bold text-foreground">{t('title')}</h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Manage your contact list. {totalCount > 0 && `${totalCount} total contacts.`} {totalCount > 0 ? t('subtitle', { count: totalCount }) : t('subtitleZero')}
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -355,7 +357,7 @@ export default function ContactsPage() {
className="border-border text-muted-foreground hover:bg-muted" className="border-border text-muted-foreground hover:bg-muted"
> >
<SlidersHorizontal className="size-4" /> <SlidersHorizontal className="size-4" />
Custom fields {t('customFieldsBtn')}
</Button> </Button>
)} )}
<GatedButton <GatedButton
@ -366,7 +368,7 @@ export default function ContactsPage() {
className="border-border text-muted-foreground hover:bg-muted" className="border-border text-muted-foreground hover:bg-muted"
> >
<Upload className="size-4" /> <Upload className="size-4" />
Import {t('importBtn')}
</GatedButton> </GatedButton>
<GatedButton <GatedButton
canAct={canEdit} canAct={canEdit}
@ -375,7 +377,7 @@ export default function ContactsPage() {
className="bg-primary hover:bg-primary/90 text-primary-foreground" className="bg-primary hover:bg-primary/90 text-primary-foreground"
> >
<Plus className="size-4" /> <Plus className="size-4" />
Add Contact {t('addContactBtn')}
</GatedButton> </GatedButton>
</div> </div>
</div> </div>
@ -393,7 +395,7 @@ export default function ContactsPage() {
// set shrinks/grows, page N may no longer be valid. // set shrinks/grows, page N may no longer be valid.
setPage(0); setPage(0);
}} }}
placeholder="Search by name, phone, or email..." placeholder={t('searchPlaceholder')}
className="pl-8 bg-card border-border text-foreground placeholder:text-muted-foreground" className="pl-8 bg-card border-border text-foreground placeholder:text-muted-foreground"
/> />
</div> </div>
@ -408,7 +410,7 @@ export default function ContactsPage() {
} }
> >
<Filter className="size-4" /> <Filter className="size-4" />
Filter by tags {t('filterByTags')}
{selectedTagIds.length > 0 && ( {selectedTagIds.length > 0 && (
<span className="ml-1 inline-flex items-center justify-center rounded-full bg-primary px-1.5 text-[10px] font-semibold text-primary-foreground"> <span className="ml-1 inline-flex items-center justify-center rounded-full bg-primary px-1.5 text-[10px] font-semibold text-primary-foreground">
{selectedTagIds.length} {selectedTagIds.length}
@ -418,20 +420,20 @@ export default function ContactsPage() {
<PopoverContent align="start" className="w-64 p-0"> <PopoverContent align="start" className="w-64 p-0">
<div className="flex items-center justify-between px-3 py-2 border-b border-border"> <div className="flex items-center justify-between px-3 py-2 border-b border-border">
<span className="text-sm font-medium text-popover-foreground"> <span className="text-sm font-medium text-popover-foreground">
Filter by tags {t('filterByTags')}
</span> </span>
{selectedTagIds.length > 0 && ( {selectedTagIds.length > 0 && (
<button <button
onClick={clearTagFilters} onClick={clearTagFilters}
className="text-xs text-muted-foreground hover:text-foreground" className="text-xs text-muted-foreground hover:text-foreground"
> >
Clear all {t('clearAll')}
</button> </button>
)} )}
</div> </div>
{allTags.length === 0 ? ( {allTags.length === 0 ? (
<p className="px-3 py-4 text-sm text-muted-foreground text-center"> <p className="px-3 py-4 text-sm text-muted-foreground text-center">
No tags yet. {t('noTagsYet')}
</p> </p>
) : ( ) : (
<div className="max-h-64 overflow-y-auto py-1"> <div className="max-h-64 overflow-y-auto py-1">
@ -490,7 +492,7 @@ export default function ContactsPage() {
onClick={clearTagFilters} onClick={clearTagFilters}
className="text-xs text-muted-foreground hover:text-foreground px-1" className="text-xs text-muted-foreground hover:text-foreground px-1"
> >
Clear all {t('clearAll')}
</button> </button>
</div> </div>
)} )}
@ -500,8 +502,7 @@ export default function ContactsPage() {
{selected.size > 0 && ( {selected.size > 0 && (
<div className="flex items-center justify-between gap-4 rounded-lg border border-border bg-muted/40 px-4 py-2"> <div className="flex items-center justify-between gap-4 rounded-lg border border-border bg-muted/40 px-4 py-2">
<p className="text-sm text-foreground"> <p className="text-sm text-foreground">
<span className="font-medium">{selected.size}</span>{' '} {t('selectedCount', { count: selected.size })}
{selected.size === 1 ? 'contact' : 'contacts'} selected
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
@ -510,7 +511,7 @@ export default function ContactsPage() {
onClick={() => setSelected(new Set())} onClick={() => setSelected(new Set())}
className="text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground"
> >
Clear {t('clearSelection')}
</Button> </Button>
<GatedButton <GatedButton
variant="destructive" variant="destructive"
@ -520,7 +521,7 @@ export default function ContactsPage() {
onClick={() => setBulkDeleteOpen(true)} onClick={() => setBulkDeleteOpen(true)}
> >
<Trash2 className="size-4" /> <Trash2 className="size-4" />
Delete selected {t('deleteSelected')}
</GatedButton> </GatedButton>
</div> </div>
</div> </div>
@ -540,12 +541,12 @@ export default function ContactsPage() {
aria-label="Select all contacts on this page" aria-label="Select all contacts on this page"
/> />
</TableHead> </TableHead>
<TableHead className="text-muted-foreground">Name</TableHead> <TableHead className="text-muted-foreground">{t('tableColumns.name')}</TableHead>
<TableHead className="text-muted-foreground">Phone</TableHead> <TableHead className="text-muted-foreground">{t('tableColumns.phone')}</TableHead>
<TableHead className="text-muted-foreground hidden md:table-cell">Email</TableHead> <TableHead className="text-muted-foreground hidden md:table-cell">{t('tableColumns.email')}</TableHead>
<TableHead className="text-muted-foreground hidden lg:table-cell">Company</TableHead> <TableHead className="text-muted-foreground hidden lg:table-cell">{t('tableColumns.company')}</TableHead>
<TableHead className="text-muted-foreground hidden md:table-cell">Tags</TableHead> <TableHead className="text-muted-foreground hidden md:table-cell">{t('tableColumns.tags')}</TableHead>
<TableHead className="text-muted-foreground hidden lg:table-cell">Created</TableHead> <TableHead className="text-muted-foreground hidden lg:table-cell">{t('tableColumns.createdAt')}</TableHead>
<TableHead className="text-muted-foreground w-12" /> <TableHead className="text-muted-foreground w-12" />
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@ -555,7 +556,7 @@ export default function ContactsPage() {
<TableCell colSpan={8} className="text-center py-12"> <TableCell colSpan={8} className="text-center py-12">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<Loader2 className="size-6 animate-spin text-primary" /> <Loader2 className="size-6 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Loading contacts...</p> <p className="text-sm text-muted-foreground">{t('loading')}</p>
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -566,8 +567,8 @@ export default function ContactsPage() {
<Users className="size-8 text-muted-foreground" /> <Users className="size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{hasActiveFilters {hasActiveFilters
? 'No contacts match your filters.' ? t('noContactsMatch')
: 'No contacts yet.'} : t('noContactsYet')}
</p> </p>
{!hasActiveFilters && ( {!hasActiveFilters && (
<GatedButton <GatedButton
@ -579,7 +580,7 @@ export default function ContactsPage() {
className="mt-2 border-border text-muted-foreground hover:bg-muted" className="mt-2 border-border text-muted-foreground hover:bg-muted"
> >
<Plus className="size-3.5" /> <Plus className="size-3.5" />
Add your first contact {t('addFirstContact')}
</GatedButton> </GatedButton>
)} )}
</div> </div>
@ -600,7 +601,7 @@ export default function ContactsPage() {
/> />
</TableCell> </TableCell>
<TableCell className="text-foreground font-medium"> <TableCell className="text-foreground font-medium">
{contact.name || <span className="text-muted-foreground italic">Unnamed</span>} {contact.name || <span className="text-muted-foreground italic">{t('unnamed')}</span>}
</TableCell> </TableCell>
<TableCell className="text-muted-foreground font-mono text-xs"> <TableCell className="text-muted-foreground font-mono text-xs">
{contact.phone} {contact.phone}
@ -669,7 +670,7 @@ export default function ContactsPage() {
className="text-popover-foreground focus:bg-muted focus:text-foreground" className="text-popover-foreground focus:bg-muted focus:text-foreground"
> >
<Pencil className="size-4" /> <Pencil className="size-4" />
Edit {t('editAction')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator className="bg-border" /> <DropdownMenuSeparator className="bg-border" />
<DropdownMenuItem <DropdownMenuItem
@ -680,7 +681,7 @@ export default function ContactsPage() {
}} }}
> >
<Trash2 className="size-4" /> <Trash2 className="size-4" />
Delete {t('deleteAction')}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
@ -696,8 +697,11 @@ export default function ContactsPage() {
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Showing {page * PAGE_SIZE + 1}-{Math.min((page + 1) * PAGE_SIZE, totalCount)} of{' '} {t('showingPagination', {
{totalCount} start: page * PAGE_SIZE + 1,
end: Math.min((page + 1) * PAGE_SIZE, totalCount),
total: totalCount
})}
</p> </p>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Button
@ -710,7 +714,7 @@ export default function ContactsPage() {
<ChevronLeft className="size-4" /> <ChevronLeft className="size-4" />
</Button> </Button>
<span className="text-xs text-muted-foreground px-2"> <span className="text-xs text-muted-foreground px-2">
Page {page + 1} of {totalPages} {t('pageCount', { page: page + 1, total: totalPages })}
</span> </span>
<Button <Button
variant="outline" variant="outline"
@ -768,13 +772,9 @@ export default function ContactsPage() {
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}> <Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<DialogContent className="bg-popover border-border text-popover-foreground sm:max-w-sm"> <DialogContent className="bg-popover border-border text-popover-foreground sm:max-w-sm">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-popover-foreground">Delete Contact</DialogTitle> <DialogTitle className="text-popover-foreground">{t('deleteContactTitle')}</DialogTitle>
<DialogDescription className="text-muted-foreground"> <DialogDescription className="text-muted-foreground">
Are you sure you want to delete{' '} {t('deleteContactDesc', { name: deleteTarget?.name || deleteTarget?.phone || '' })}
<span className="text-popover-foreground font-medium">
{deleteTarget?.name || deleteTarget?.phone}
</span>
? This action cannot be undone.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter className="bg-popover border-border"> <DialogFooter className="bg-popover border-border">
@ -783,7 +783,7 @@ export default function ContactsPage() {
onClick={() => setDeleteConfirmOpen(false)} onClick={() => setDeleteConfirmOpen(false)}
className="border-border text-muted-foreground hover:bg-muted" className="border-border text-muted-foreground hover:bg-muted"
> >
Cancel {t('cancel')}
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
@ -791,7 +791,7 @@ export default function ContactsPage() {
disabled={deleting} disabled={deleting}
> >
{deleting && <Loader2 className="size-4 animate-spin" />} {deleting && <Loader2 className="size-4 animate-spin" />}
Delete {t('deleteBtn')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@ -802,14 +802,10 @@ export default function ContactsPage() {
<DialogContent className="bg-popover border-border text-popover-foreground sm:max-w-sm"> <DialogContent className="bg-popover border-border text-popover-foreground sm:max-w-sm">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-popover-foreground"> <DialogTitle className="text-popover-foreground">
Delete {selected.size} {selected.size === 1 ? 'Contact' : 'Contacts'} {t('deleteBulkTitle')}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-muted-foreground"> <DialogDescription className="text-muted-foreground">
Are you sure you want to delete{' '} {t('deleteBulkDesc', { count: selected.size })}
<span className="text-popover-foreground font-medium">
{selected.size} {selected.size === 1 ? 'contact' : 'contacts'}
</span>
? This action cannot be undone.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter className="bg-popover border-border"> <DialogFooter className="bg-popover border-border">
@ -818,7 +814,7 @@ export default function ContactsPage() {
onClick={() => setBulkDeleteOpen(false)} onClick={() => setBulkDeleteOpen(false)}
className="border-border text-muted-foreground hover:bg-muted" className="border-border text-muted-foreground hover:bg-muted"
> >
Cancel {t('cancel')}
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
@ -826,7 +822,7 @@ export default function ContactsPage() {
disabled={deleting} disabled={deleting}
> >
{deleting && <Loader2 className="size-4 animate-spin" />} {deleting && <Loader2 className="size-4 animate-spin" />}
Delete {t('deleteBtn')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@ -39,6 +39,7 @@ import {
DollarSign, DollarSign,
LayoutTemplate, LayoutTemplate,
} from 'lucide-react'; } from 'lucide-react';
import { useTranslations } from 'next-intl';
interface ContactDetailViewProps { interface ContactDetailViewProps {
open: boolean; open: boolean;
@ -53,6 +54,7 @@ export function ContactDetailView({
contactId, contactId,
onUpdated, onUpdated,
}: ContactDetailViewProps) { }: ContactDetailViewProps) {
const t = useTranslations('Contacts.detailView');
const supabase = createClient(); const supabase = createClient();
const { accountId, defaultCurrency } = useAuth(); const { accountId, defaultCurrency } = useAuth();
@ -196,7 +198,7 @@ export function ContactDetailView({
async function saveDetails() { async function saveDetails() {
if (!contactId || !editPhone.trim()) { if (!contactId || !editPhone.trim()) {
toast.error('Phone number is required'); toast.error(t('toastPhoneRequired'));
return; return;
} }
@ -213,9 +215,9 @@ export function ContactDetailView({
.eq('id', contactId); .eq('id', contactId);
if (error) { if (error) {
toast.error('Failed to update contact'); toast.error(t('toastUpdateFailed'));
} else { } else {
toast.success('Contact updated'); toast.success(t('toastUpdated'));
fetchContact(); fetchContact();
onUpdated(); onUpdated();
} }
@ -259,7 +261,7 @@ export function ContactDetailView({
} = await supabase.auth.getSession(); } = await supabase.auth.getSession();
const user = session?.user; const user = session?.user;
if (!user || !accountId) { if (!user || !accountId) {
toast.error('Not authenticated'); toast.error(t('toastNotAuthenticated'));
setSavingNote(false); setSavingNote(false);
return; return;
} }
@ -272,11 +274,11 @@ export function ContactDetailView({
}); });
if (error) { if (error) {
toast.error('Failed to add note'); toast.error(t('toastNoteAddFailed'));
} else { } else {
setNewNote(''); setNewNote('');
fetchNotes(); fetchNotes();
toast.success('Note added'); toast.success(t('toastNoteAdded'));
} }
setSavingNote(false); setSavingNote(false);
} }
@ -288,10 +290,10 @@ export function ContactDetailView({
.eq('id', noteId); .eq('id', noteId);
if (error) { if (error) {
toast.error('Failed to delete note'); toast.error(t('toastNoteDeleteFailed'));
} else { } else {
setNotes((prev) => prev.filter((n) => n.id !== noteId)); setNotes((prev) => prev.filter((n) => n.id !== noteId));
toast.success('Note deleted'); toast.success(t('toastNoteDeleted'));
} }
} }
@ -321,9 +323,9 @@ export function ContactDetailView({
if (error) throw error; if (error) throw error;
} }
toast.success('Custom fields saved'); toast.success(t('toastCustomFieldsSaved'));
} catch { } catch {
toast.error('Failed to save custom fields'); toast.error(t('toastCustomFieldsFailed'));
} }
setSavingCustom(false); setSavingCustom(false);
} }
@ -357,11 +359,11 @@ export function ContactDetailView({
const payload = await res.json().catch(() => ({})); const payload = await res.json().catch(() => ({}));
if (!res.ok) { if (!res.ok) {
const reason = payload?.error || `HTTP ${res.status}`; const reason = payload?.error || `HTTP ${res.status}`;
toast.error(`Failed to send template: ${reason}`); toast.error(t('toastTemplateFailed', { reason }));
return; return;
} }
toast.success(`Template "${template.name}" sent`); toast.success(t('toastTemplateSent', { name: template.name }));
} catch (err) { } catch (err) {
const reason = err instanceof Error ? err.message : 'network error'; const reason = err instanceof Error ? err.message : 'network error';
toast.error(`Failed to send template: ${reason}`); toast.error(`Failed to send template: ${reason}`);
@ -403,10 +405,10 @@ export function ContactDetailView({
</Avatar> </Avatar>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<SheetTitle className="text-popover-foreground truncate"> <SheetTitle className="text-popover-foreground truncate">
{contact.name || 'Unknown'} {contact.name || t('unnamed')}
</SheetTitle> </SheetTitle>
<SheetDescription className="text-muted-foreground text-xs mt-0.5"> <SheetDescription className="text-muted-foreground text-xs mt-0.5">
Contact details {t('contactDetailsDesc')}
</SheetDescription> </SheetDescription>
<div className="flex flex-wrap items-center gap-3 mt-1.5 text-xs text-muted-foreground"> <div className="flex flex-wrap items-center gap-3 mt-1.5 text-xs text-muted-foreground">
<button <button
@ -448,7 +450,7 @@ export function ContactDetailView({
) : ( ) : (
<LayoutTemplate className="size-4" /> <LayoutTemplate className="size-4" />
)} )}
Send template {t('sendTemplateBtn')}
</Button> </Button>
</div> </div>
</SheetHeader> </SheetHeader>
@ -460,31 +462,31 @@ export function ContactDetailView({
value="details" value="details"
className="data-active:bg-muted data-active:text-primary text-muted-foreground" className="data-active:bg-muted data-active:text-primary text-muted-foreground"
> >
Details {t('tabs.details')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="tags" value="tags"
className="data-active:bg-muted data-active:text-primary text-muted-foreground" className="data-active:bg-muted data-active:text-primary text-muted-foreground"
> >
Tags {t('tabs.tags', { fallback: 'Tags' })}
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="notes" value="notes"
className="data-active:bg-muted data-active:text-primary text-muted-foreground" className="data-active:bg-muted data-active:text-primary text-muted-foreground"
> >
Notes {t('tabs.notes')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="custom" value="custom"
className="data-active:bg-muted data-active:text-primary text-muted-foreground" className="data-active:bg-muted data-active:text-primary text-muted-foreground"
> >
Custom Fields {t('tabs.custom')}
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="deals" value="deals"
className="data-active:bg-muted data-active:text-primary text-muted-foreground" className="data-active:bg-muted data-active:text-primary text-muted-foreground"
> >
Deals {t('tabs.deals')}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
@ -492,7 +494,7 @@ export function ContactDetailView({
<TabsContent value="details" className="flex-1 overflow-y-auto px-4 py-3"> <TabsContent value="details" className="flex-1 overflow-y-auto px-4 py-3">
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-muted-foreground text-xs">Name</Label> <Label className="text-muted-foreground text-xs">{t('company', { fallback: 'Name' })}</Label>
<Input <Input
value={editName} value={editName}
onChange={(e) => setEditName(e.target.value)} onChange={(e) => setEditName(e.target.value)}
@ -501,7 +503,7 @@ export function ContactDetailView({
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-muted-foreground text-xs"> <Label className="text-muted-foreground text-xs">
Phone <span className="text-red-400">*</span> {t('phone')} <span className="text-red-400">*</span>
</Label> </Label>
<Input <Input
value={editPhone} value={editPhone}
@ -510,7 +512,7 @@ export function ContactDetailView({
/> />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-muted-foreground text-xs">Email</Label> <Label className="text-muted-foreground text-xs">{t('email')}</Label>
<Input <Input
value={editEmail} value={editEmail}
onChange={(e) => setEditEmail(e.target.value)} onChange={(e) => setEditEmail(e.target.value)}
@ -518,7 +520,7 @@ export function ContactDetailView({
/> />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label className="text-muted-foreground text-xs">Company</Label> <Label className="text-muted-foreground text-xs">{t('company')}</Label>
<Input <Input
value={editCompany} value={editCompany}
onChange={(e) => setEditCompany(e.target.value)} onChange={(e) => setEditCompany(e.target.value)}
@ -536,7 +538,7 @@ export function ContactDetailView({
) : ( ) : (
<Save className="size-3.5" /> <Save className="size-3.5" />
)} )}
Save Changes {t('saveChangesBtn')}
</Button> </Button>
</div> </div>
</TabsContent> </TabsContent>
@ -545,11 +547,11 @@ export function ContactDetailView({
<TabsContent value="tags" className="flex-1 overflow-y-auto px-4 py-3"> <TabsContent value="tags" className="flex-1 overflow-y-auto px-4 py-3">
<div className="space-y-3"> <div className="space-y-3">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Click a tag to add or remove it from this contact. {t('tagsTab.clickTagDesc')}
</p> </p>
{allTags.length === 0 ? ( {allTags.length === 0 ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
No tags available. Create tags in Settings. {t('tagsTab.noTagsAvailable')}
</p> </p>
) : ( ) : (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@ -586,7 +588,7 @@ export function ContactDetailView({
<Textarea <Textarea
value={newNote} value={newNote}
onChange={(e) => setNewNote(e.target.value)} onChange={(e) => setNewNote(e.target.value)}
placeholder="Write a note..." placeholder={t('notesTab.placeholder')}
className="bg-muted border-border text-foreground placeholder:text-muted-foreground min-h-[60px] text-sm resize-none" className="bg-muted border-border text-foreground placeholder:text-muted-foreground min-h-[60px] text-sm resize-none"
/> />
<Button <Button
@ -600,7 +602,7 @@ export function ContactDetailView({
) : ( ) : (
<Plus className="size-3.5" /> <Plus className="size-3.5" />
)} )}
Add Note {t('notesTab.save')}
</Button> </Button>
</div> </div>
@ -611,7 +613,7 @@ export function ContactDetailView({
</div> </div>
) : notes.length === 0 ? ( ) : notes.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8"> <p className="text-sm text-muted-foreground text-center py-8">
No notes yet. {t('notesTab.noNotes')}
</p> </p>
) : ( ) : (
notes.map((note) => ( notes.map((note) => (
@ -653,7 +655,7 @@ export function ContactDetailView({
</div> </div>
) : customFields.length === 0 ? ( ) : customFields.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8"> <p className="text-sm text-muted-foreground text-center py-8">
No custom fields defined. Create them in Settings. {t('noCustomFields')}
</p> </p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
@ -670,7 +672,7 @@ export function ContactDetailView({
[field.id]: e.target.value, [field.id]: e.target.value,
})) }))
} }
placeholder={`Enter ${field.field_name}...`} placeholder={t('enterCustomField', { name: field.field_name })}
className="bg-muted border-border text-foreground h-8 text-sm placeholder:text-muted-foreground" className="bg-muted border-border text-foreground h-8 text-sm placeholder:text-muted-foreground"
/> />
</div> </div>
@ -686,7 +688,7 @@ export function ContactDetailView({
) : ( ) : (
<Save className="size-3.5" /> <Save className="size-3.5" />
)} )}
Save Custom Fields {t('saveCustomFieldsBtn')}
</Button> </Button>
</div> </div>
)} )}
@ -699,7 +701,7 @@ export function ContactDetailView({
<Loader2 className="size-5 animate-spin text-primary" /> <Loader2 className="size-5 animate-spin text-primary" />
</div> </div>
) : deals.length === 0 ? ( ) : deals.length === 0 ? (
<p className="text-xs text-muted-foreground">No deals yet</p> <p className="text-xs text-muted-foreground">{t('dealsTab.noDeals')}</p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{deals.map((deal) => ( {deals.map((deal) => (

View File

@ -24,6 +24,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Loader2, AlertTriangle } from 'lucide-react'; import { Loader2, AlertTriangle } from 'lucide-react';
import { useTranslations } from 'next-intl';
interface ContactFormProps { interface ContactFormProps {
open: boolean; open: boolean;
@ -44,6 +45,7 @@ export function ContactForm({
onSaved, onSaved,
onViewExisting, onViewExisting,
}: ContactFormProps) { }: ContactFormProps) {
const t = useTranslations('Contacts.form');
const supabase = createClient(); const supabase = createClient();
const { accountId } = useAuth(); const { accountId } = useAuth();
const isEdit = !!contact; const isEdit = !!contact;
@ -123,14 +125,14 @@ export function ContactForm({
e.preventDefault(); e.preventDefault();
if (!phone.trim()) { if (!phone.trim()) {
toast.error('Phone number is required'); toast.error(t('phoneRequired'));
return; return;
} }
// Hard-block an exact duplicate on create (the DB unique index is // Hard-block an exact duplicate on create (the DB unique index is
// the real backstop; this avoids a round-trip + a raw error toast). // the real backstop; this avoids a round-trip + a raw error toast).
if (!isEdit && dupMatch?.exact) { if (!isEdit && dupMatch?.exact) {
toast.error('A contact with this phone number already exists'); toast.error(t('toastConflict'));
return; return;
} }
@ -194,7 +196,7 @@ export function ContactForm({
} }
} }
toast.success(isEdit ? 'Contact updated' : 'Contact created'); toast.success(isEdit ? t('toastSuccessEdit') : t('toastSuccessAdd'));
onOpenChange(false); onOpenChange(false);
onSaved(); onSaved();
} catch (err: unknown) { } catch (err: unknown) {
@ -203,7 +205,7 @@ export function ContactForm({
// normalizes equal). Surface it as the friendly duplicate notice // normalizes equal). Surface it as the friendly duplicate notice
// and, for new contacts, point the user at the existing record. // and, for new contacts, point the user at the existing record.
if (isUniqueViolation(err)) { if (isUniqueViolation(err)) {
toast.error('A contact with this phone number already exists'); toast.error(t('toastConflict'));
if (!isEdit && accountId) { if (!isEdit && accountId) {
const existing = await findExistingContact( const existing = await findExistingContact(
supabase, supabase,
@ -214,7 +216,7 @@ export function ContactForm({
} }
return; return;
} }
const message = err instanceof Error ? err.message : 'Failed to save contact'; const message = err instanceof Error ? err.message : t('toastError');
toast.error(message); toast.error(message);
} finally { } finally {
setSaving(false); setSaving(false);
@ -226,32 +228,32 @@ export function ContactForm({
<DialogContent className="bg-popover border-border text-popover-foreground sm:max-w-md"> <DialogContent className="bg-popover border-border text-popover-foreground sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-popover-foreground"> <DialogTitle className="text-popover-foreground">
{isEdit ? 'Edit Contact' : 'Add Contact'} {isEdit ? t('editTitle') : t('addTitle')}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-muted-foreground"> <DialogDescription className="text-muted-foreground">
{isEdit {isEdit
? 'Update the contact details below.' ? t('editDesc')
: 'Fill in the details to create a new contact.'} : t('addDesc')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="cf-name" className="text-muted-foreground"> <Label htmlFor="cf-name" className="text-muted-foreground">
Name {t('nameLabel')}
</Label> </Label>
<Input <Input
id="cf-name" id="cf-name"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="John Doe" placeholder={t('namePlaceholder')}
className="bg-muted border-border text-foreground placeholder:text-muted-foreground" className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="cf-phone" className="text-muted-foreground"> <Label htmlFor="cf-phone" className="text-muted-foreground">
Phone <span className="text-red-400">*</span> {t('phoneLabel')} <span className="text-red-400">*</span>
</Label> </Label>
<Input <Input
id="cf-phone" id="cf-phone"
@ -261,7 +263,7 @@ export function ContactForm({
if (dupMatch) setDupMatch(null); if (dupMatch) setDupMatch(null);
}} }}
onBlur={checkDuplicate} onBlur={checkDuplicate}
placeholder="+1 234 567 8900" placeholder={t('phonePlaceholder')}
className="bg-muted border-border text-foreground placeholder:text-muted-foreground" className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/> />
{dupMatch ? ( {dupMatch ? (
@ -276,8 +278,8 @@ export function ContactForm({
<div className="space-y-1"> <div className="space-y-1">
<p> <p>
{dupMatch.exact {dupMatch.exact
? 'A contact with this phone number already exists.' ? t('dupExact')
: 'A contact with a very similar number already exists.'} : t('dupSimilar')}
</p> </p>
{onViewExisting && ( {onViewExisting && (
<button <button
@ -285,55 +287,55 @@ export function ContactForm({
onClick={() => onViewExisting(dupMatch.contact.id)} onClick={() => onViewExisting(dupMatch.contact.id)}
className="font-medium underline underline-offset-2 hover:no-underline" className="font-medium underline underline-offset-2 hover:no-underline"
> >
View {dupMatch.contact.name || dupMatch.contact.phone} {t('viewExisting', { name: dupMatch.contact.name || dupMatch.contact.phone })}
</button> </button>
)} )}
</div> </div>
</div> </div>
) : ( ) : (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Include country code, e.g. +1 for US {t('phoneHint')}
</p> </p>
)} )}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="cf-email" className="text-muted-foreground"> <Label htmlFor="cf-email" className="text-muted-foreground">
Email {t('emailLabel')}
</Label> </Label>
<Input <Input
id="cf-email" id="cf-email"
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="john@example.com" placeholder={t('emailPlaceholder')}
className="bg-muted border-border text-foreground placeholder:text-muted-foreground" className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="cf-company" className="text-muted-foreground"> <Label htmlFor="cf-company" className="text-muted-foreground">
Company {t('companyLabel')}
</Label> </Label>
<Input <Input
id="cf-company" id="cf-company"
value={company} value={company}
onChange={(e) => setCompany(e.target.value)} onChange={(e) => setCompany(e.target.value)}
placeholder="Acme Inc." placeholder={t('companyPlaceholder')}
className="bg-muted border-border text-foreground placeholder:text-muted-foreground" className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-muted-foreground">Tags</Label> <Label className="text-muted-foreground">{t('tagsLabel')}</Label>
{loadingTags ? ( {loadingTags ? (
<div className="flex items-center gap-2 text-muted-foreground text-sm"> <div className="flex items-center gap-2 text-muted-foreground text-sm">
<Loader2 className="size-3 animate-spin" /> <Loader2 className="size-3 animate-spin" />
Loading tags... {t('loadingTags')}
</div> </div>
) : tags.length === 0 ? ( ) : tags.length === 0 ? (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
No tags available. Create tags in Settings. {t('noTagsAvailable')}
</p> </p>
) : ( ) : (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
@ -370,7 +372,7 @@ export function ContactForm({
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
className="border-border text-muted-foreground hover:bg-muted" className="border-border text-muted-foreground hover:bg-muted"
> >
Cancel {t('cancel')}
</Button> </Button>
<Button <Button
type="submit" type="submit"
@ -378,7 +380,7 @@ export function ContactForm({
className="bg-primary hover:bg-primary/90 text-primary-foreground" className="bg-primary hover:bg-primary/90 text-primary-foreground"
> >
{saving && <Loader2 className="size-4 animate-spin" />} {saving && <Loader2 className="size-4 animate-spin" />}
{isEdit ? 'Update' : 'Create'} {isEdit ? t('update') : t('create')}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>

View File

@ -15,6 +15,7 @@ import {
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Loader2, Plus, Trash2 } from 'lucide-react'; import { Loader2, Plus, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
interface CustomFieldsManagerProps { interface CustomFieldsManagerProps {
open: boolean; open: boolean;
@ -31,15 +32,14 @@ export function CustomFieldsManager({
open, open,
onOpenChange, onOpenChange,
}: CustomFieldsManagerProps) { }: CustomFieldsManagerProps) {
const t = useTranslations('Contacts.customFields');
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="border-border bg-popover text-popover-foreground sm:max-w-md"> <DialogContent className="border-border bg-popover text-popover-foreground sm:max-w-md">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-popover-foreground">Custom fields</DialogTitle> <DialogTitle className="text-popover-foreground">{t('title')}</DialogTitle>
<DialogDescription className="text-muted-foreground"> <DialogDescription className="text-muted-foreground">
Define extra contact fields (e.g. ZIP code, lead source). They {t('desc')}
appear on every contact and in the “Update Contact Field” automation
action.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<CustomFieldsPanel /> <CustomFieldsPanel />
@ -55,6 +55,7 @@ export function CustomFieldsManager({
* `custom_fields` RLS also rejects non-admin writes as defense in depth. * `custom_fields` RLS also rejects non-admin writes as defense in depth.
*/ */
export function CustomFieldsPanel() { export function CustomFieldsPanel() {
const t = useTranslations('Contacts.customFields');
const supabase = createClient(); const supabase = createClient();
const { user, accountId } = useAuth(); const { user, accountId } = useAuth();
@ -97,11 +98,11 @@ export function CustomFieldsPanel() {
const name = newName.trim(); const name = newName.trim();
if (!name) return; if (!name) return;
if (!accountId || !user) { if (!accountId || !user) {
toast.error('Your profile is not linked to an account.'); toast.error(t('toastNoAccount'));
return; return;
} }
if (isDuplicate(name)) { if (isDuplicate(name)) {
toast.error(`A field named "${name}" already exists.`); toast.error(t('toastDuplicate', { name }));
return; return;
} }
@ -115,10 +116,10 @@ export function CustomFieldsPanel() {
setCreating(false); setCreating(false);
if (error) { if (error) {
toast.error('Could not create field. You may not have permission.'); toast.error(t('toastCreateFailed'));
return; return;
} }
toast.success(`Created "${name}".`); toast.success(t('toastCreated', { name }));
setNewName(''); setNewName('');
await fetchFields(); await fetchFields();
} }
@ -132,7 +133,7 @@ export function CustomFieldsPanel() {
const name = nextName.trim(); const name = nextName.trim();
if (!name || name === field.field_name) return true; if (!name || name === field.field_name) return true;
if (isDuplicate(name, field.id)) { if (isDuplicate(name, field.id)) {
toast.error(`A field named "${name}" already exists.`); toast.error(t('toastDuplicate', { name }));
return false; return false;
} }
setBusyId(field.id); setBusyId(field.id);
@ -142,7 +143,7 @@ export function CustomFieldsPanel() {
.eq('id', field.id); .eq('id', field.id);
setBusyId(null); setBusyId(null);
if (error) { if (error) {
toast.error('Could not rename field.'); toast.error(t('toastRenameFailed'));
return false; return false;
} }
await fetchFields(); await fetchFields();
@ -152,7 +153,7 @@ export function CustomFieldsPanel() {
async function handleDelete(field: CustomField) { async function handleDelete(field: CustomField) {
if ( if (
!window.confirm( !window.confirm(
`Delete "${field.field_name}"? This also removes its stored value on every contact. This cannot be undone.` t('deleteConfirm', { name: field.field_name })
) )
) { ) {
return; return;
@ -164,10 +165,10 @@ export function CustomFieldsPanel() {
.eq('id', field.id); .eq('id', field.id);
setBusyId(null); setBusyId(null);
if (error) { if (error) {
toast.error('Could not delete field.'); toast.error(t('toastDeleteFailed'));
return; return;
} }
toast.success(`Deleted "${field.field_name}".`); toast.success(t('toastDeleted', { name: field.field_name }));
await fetchFields(); await fetchFields();
} }
@ -184,7 +185,7 @@ export function CustomFieldsPanel() {
void handleCreate(); void handleCreate();
} }
}} }}
placeholder="New field name…" placeholder={t('fieldName')}
className="bg-muted text-foreground" className="bg-muted text-foreground"
/> />
<Button <Button
@ -197,7 +198,7 @@ export function CustomFieldsPanel() {
) : ( ) : (
<Plus className="size-4" /> <Plus className="size-4" />
)} )}
Add {t('addField')}
</Button> </Button>
</div> </div>
@ -206,11 +207,11 @@ export function CustomFieldsPanel() {
{loading ? ( {loading ? (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground"> <div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
Loading… {t('loading')}
</div> </div>
) : fields.length === 0 ? ( ) : fields.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground"> <p className="py-8 text-center text-sm text-muted-foreground">
No custom fields yet. {t('empty')}
</p> </p>
) : ( ) : (
<ul className="divide-y divide-border"> <ul className="divide-y divide-border">
@ -243,6 +244,7 @@ function FieldRow({
onRename: (field: CustomField, name: string) => Promise<boolean>; onRename: (field: CustomField, name: string) => Promise<boolean>;
onDelete: (field: CustomField) => void; onDelete: (field: CustomField) => void;
}) { }) {
const t = useTranslations('Contacts.customFields');
const [name, setName] = useState(field.field_name); const [name, setName] = useState(field.field_name);
async function commit() { async function commit() {
@ -264,7 +266,7 @@ function FieldRow({
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur(); if (e.key === 'Enter') e.currentTarget.blur();
}} }}
aria-label={`Rename ${field.field_name}`} aria-label={t('renameAria', { name: field.field_name })}
className="focus:border-primary h-8 border-transparent bg-transparent text-foreground hover:border-border" className="focus:border-primary h-8 border-transparent bg-transparent text-foreground hover:border-border"
/> />
<Button <Button
@ -272,7 +274,7 @@ function FieldRow({
size="icon-sm" size="icon-sm"
disabled={busy} disabled={busy}
onClick={() => onDelete(field)} onClick={() => onDelete(field)}
title="Delete field" title={t('deleteTitle')}
className="shrink-0 text-muted-foreground hover:text-red-400" className="shrink-0 text-muted-foreground hover:text-red-400"
> >
{busy ? ( {busy ? (

View File

@ -37,6 +37,7 @@ import {
AlertTriangle, AlertTriangle,
Tag, Tag,
} from 'lucide-react'; } from 'lucide-react';
import { useTranslations } from 'next-intl';
const DEFAULT_TAG_COLOR = '#3b82f6'; const DEFAULT_TAG_COLOR = '#3b82f6';
const PREVIEW_LIMIT = 5; const PREVIEW_LIMIT = 5;
@ -79,6 +80,8 @@ function ImportPreviewTags({
tagNames: string[]; tagNames: string[];
tagColorByKey: Map<string, string>; tagColorByKey: Map<string, string>;
}) { }) {
const t = useTranslations('Contacts.importModal');
if (tagNames.length === 0) { if (tagNames.length === 0) {
return <span className="text-muted-foreground">—</span>; return <span className="text-muted-foreground">—</span>;
} }
@ -98,7 +101,7 @@ function ImportPreviewTags({
color, color,
border: `1px solid ${color}${isKnown ? '55' : '30'}`, border: `1px solid ${color}${isKnown ? '55' : '30'}`,
}} }}
title={isKnown ? name : `${name} (will be created on import)`} title={isKnown ? name : t('willBeCreated', { name })}
> >
<span <span
className="size-1.5 shrink-0 rounded-full" className="size-1.5 shrink-0 rounded-full"
@ -123,6 +126,7 @@ export function ImportModal({
onOpenChange, onOpenChange,
onImported, onImported,
}: ImportModalProps) { }: ImportModalProps) {
const t = useTranslations('Contacts.importModal');
const supabase = createClient(); const supabase = createClient();
const { accountId, canEditSettings } = useAuth(); const { accountId, canEditSettings } = useAuth();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@ -172,9 +176,7 @@ export function ImportModal({
} = parseContactCsv(text); } = parseContactCsv(text);
if (rows.length === 0) { if (rows.length === 0) {
toast.error( toast.error(t('toastNoValidRows'));
'No valid rows found. Ensure CSV has a "phone" column header.'
);
setParsedRows([]); setParsedRows([]);
setHasTagsColumn(false); setHasTagsColumn(false);
setHasCompanyColumn(false); setHasCompanyColumn(false);
@ -336,39 +338,31 @@ export function ImportModal({
tagIdByKey tagIdByKey
); );
} catch { } catch {
toast.warning('Contacts imported, but some tag assignments failed.'); toast.warning(t('toastTagsWarning'));
} }
setResult({ imported, skipped, failed, tagsAssigned }); setResult({ imported, skipped, failed, tagsAssigned });
if (imported > 0) { if (imported > 0) {
toast.success( toast.success(t('toastImported', { count: imported }));
`${imported} contact${imported !== 1 ? 's' : ''} imported`
);
onImported(); onImported();
} }
if (tagsAssigned > 0) { if (tagsAssigned > 0) {
toast.success( toast.success(t('toastTagsAssigned', { count: tagsAssigned }));
`${tagsAssigned} tag assignment${tagsAssigned !== 1 ? 's' : ''} applied`
);
} }
if (skippedNames.length > 0) { if (skippedNames.length > 0) {
const sample = skippedNames.slice(0, 3).join(', '); const sample = skippedNames.slice(0, 3).join(', ');
const more = const more =
skippedNames.length > 3 ? ` (+${skippedNames.length - 3} more)` : ''; skippedNames.length > 3 ? ` (+${skippedNames.length - 3} more)` : '';
toast.info( toast.info(t('toastTagsSkipped', { sample, more }));
`Unknown tags skipped (create them in Settings first): ${sample}${more}`
);
} }
if (skipped > 0) { if (skipped > 0) {
toast.info(`${skipped} duplicate${skipped !== 1 ? 's' : ''} skipped`); toast.info(t('toastSkipped', { count: skipped }));
} }
if (failed > 0) { if (failed > 0) {
toast.error( toast.error(t('toastFailed', { count: failed }));
`${failed} contact${failed !== 1 ? 's' : ''} failed to import`
);
} }
} catch (err: unknown) { } catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Import failed'; const message = err instanceof Error ? err.message : t('toastError');
toast.error(message); toast.error(message);
} finally { } finally {
setImporting(false); setImporting(false);
@ -402,31 +396,19 @@ export function ImportModal({
<div className="shrink-0 space-y-4 border-b border-border/80 px-6 pt-6 pb-5"> <div className="shrink-0 space-y-4 border-b border-border/80 px-6 pt-6 pb-5">
<DialogHeader className="gap-1.5"> <DialogHeader className="gap-1.5">
<DialogTitle className="text-lg text-popover-foreground"> <DialogTitle className="text-lg text-popover-foreground">
Import Contacts {t('title')}
</DialogTitle> </DialogTitle>
<DialogDescription className="leading-relaxed text-muted-foreground"> <DialogDescription className="leading-relaxed text-muted-foreground"
Upload a CSV with a required{' '} dangerouslySetInnerHTML={{
<code className="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground"> __html: t.markup('desc', {
phone phoneCode: (chunks) => `<code class="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground">${chunks}</code>`,
</code>{' '} nameCode: (chunks) => `<code class="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground">${chunks}</code>`,
column. Optional:{' '} emailCode: (chunks) => `<code class="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground">${chunks}</code>`,
<code className="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground"> companyCode: (chunks) => `<code class="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground">${chunks}</code>`,
name tagsCode: (chunks) => `<code class="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground">${chunks}</code>`,
</code> })
,{' '} }}
<code className="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground"> />
email
</code>
,{' '}
<code className="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground">
company
</code>
,{' '}
<code className="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground">
tags
</code>{' '}
(comma-separated; quote multi-tag cells).
</DialogDescription>
</DialogHeader> </DialogHeader>
<div <div
@ -456,8 +438,7 @@ export function ImportModal({
{truncateFilename(file.name)} {truncateFilename(file.name)}
</p> </p>
<span className="rounded-full bg-muted px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground"> <span className="rounded-full bg-muted px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
{parsedRows.length} row{parsedRows.length !== 1 ? 's' : ''}{' '} {t('rowsReady', { count: parsedRows.length })}
ready
</span> </span>
</> </>
) : ( ) : (
@ -466,10 +447,10 @@ export function ImportModal({
<Upload className="size-5 text-muted-foreground group-hover:text-foreground" /> <Upload className="size-5 text-muted-foreground group-hover:text-foreground" />
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Click to choose a CSV file {t('uploadDropzone')}
</p> </p>
<p className="text-[11px] text-muted-foreground"> <p className="text-[11px] text-muted-foreground">
.csv up to your browser limit {t('uploadHint')}
</p> </p>
</> </>
)} )}
@ -489,15 +470,13 @@ export function ImportModal({
<div className="space-y-3"> <div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-[11px] font-semibold tracking-[0.14em] text-muted-foreground uppercase"> <p className="text-[11px] font-semibold tracking-[0.14em] text-muted-foreground uppercase">
Preview · first {preview.length} {t('preview', { count: preview.length })}
</p> </p>
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
{tagStats.rowsWithTags > 0 && ( {tagStats.rowsWithTags > 0 && (
<span className="inline-flex items-center gap-1 rounded-md bg-muted/90 px-2 py-0.5 text-[11px] text-muted-foreground"> <span className="inline-flex items-center gap-1 rounded-md bg-muted/90 px-2 py-0.5 text-[11px] text-muted-foreground">
<Tag className="text-primary/80 size-3" /> <Tag className="text-primary/80 size-3" />
{tagStats.unique} tag{tagStats.unique !== 1 ? 's' : ''} ·{' '} {t('previewTags', { tags: tagStats.unique, contacts: tagStats.rowsWithTags })}
{tagStats.rowsWithTags} contact
{tagStats.rowsWithTags !== 1 ? 's' : ''}
</span> </span>
)} )}
</div> </div>
@ -509,22 +488,22 @@ export function ImportModal({
<thead> <thead>
<tr className="border-b border-border bg-background/60"> <tr className="border-b border-border bg-background/60">
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground"> <th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground">
Phone {t('columns.phone')}
</th> </th>
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground"> <th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground">
Name {t('columns.name')}
</th> </th>
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground"> <th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground">
Email {t('columns.email')}
</th> </th>
{previewHasCompany && ( {previewHasCompany && (
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground"> <th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground">
Company {t('columns.company')}
</th> </th>
)} )}
{previewHasTags && ( {previewHasTags && (
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground"> <th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground">
Tags {t('columns.tags')}
</th> </th>
)} )}
</tr> </tr>
@ -579,8 +558,7 @@ export function ImportModal({
{parsedRows.length > PREVIEW_LIMIT && ( {parsedRows.length > PREVIEW_LIMIT && (
<p className="text-center text-[11px] text-muted-foreground"> <p className="text-center text-[11px] text-muted-foreground">
+ {parsedRows.length - PREVIEW_LIMIT} more row {t('moreRows', { count: parsedRows.length - PREVIEW_LIMIT })}
{parsedRows.length - PREVIEW_LIMIT !== 1 ? 's' : ''} not shown
</p> </p>
)} )}
</div> </div>
@ -588,31 +566,30 @@ export function ImportModal({
{result && ( {result && (
<div className="rounded-xl border border-border bg-background/50 p-4"> <div className="rounded-xl border border-border bg-background/50 p-4">
<p className="text-sm font-medium text-popover-foreground">Import complete</p> <p className="text-sm font-medium text-popover-foreground">{t('importComplete')}</p>
<div className="mt-3 flex flex-wrap gap-3"> <div className="mt-3 flex flex-wrap gap-3">
{result.imported > 0 && ( {result.imported > 0 && (
<div className="text-primary flex items-center gap-1.5 text-sm"> <div className="text-primary flex items-center gap-1.5 text-sm">
<CheckCircle className="size-4 shrink-0" /> <CheckCircle className="size-4 shrink-0" />
{result.imported} imported {t('resultImported', { count: result.imported })}
</div> </div>
)} )}
{result.tagsAssigned > 0 && ( {result.tagsAssigned > 0 && (
<div className="flex items-center gap-1.5 text-sm text-cyan-400"> <div className="flex items-center gap-1.5 text-sm text-cyan-400">
<CheckCircle className="size-4 shrink-0" /> <CheckCircle className="size-4 shrink-0" />
{result.tagsAssigned} tag {t('resultTags', { count: result.tagsAssigned })}
{result.tagsAssigned !== 1 ? 's' : ''} assigned
</div> </div>
)} )}
{result.skipped > 0 && ( {result.skipped > 0 && (
<div className="flex items-center gap-1.5 text-sm text-amber-400"> <div className="flex items-center gap-1.5 text-sm text-amber-400">
<AlertTriangle className="size-4 shrink-0" /> <AlertTriangle className="size-4 shrink-0" />
{result.skipped} skipped {t('resultSkipped', { count: result.skipped })}
</div> </div>
)} )}
{result.failed > 0 && ( {result.failed > 0 && (
<div className="flex items-center gap-1.5 text-sm text-red-400"> <div className="flex items-center gap-1.5 text-sm text-red-400">
<XCircle className="size-4 shrink-0" /> <XCircle className="size-4 shrink-0" />
{result.failed} failed {t('resultFailed', { count: result.failed })}
</div> </div>
)} )}
</div> </div>
@ -627,7 +604,7 @@ export function ImportModal({
onClick={() => handleOpenChange(false)} onClick={() => handleOpenChange(false)}
className="border-border text-muted-foreground hover:bg-muted" className="border-border text-muted-foreground hover:bg-muted"
> >
{result ? 'Close' : 'Cancel'} {result ? t('close') : t('cancel')}
</Button> </Button>
{!result && ( {!result && (
<Button <Button
@ -637,8 +614,7 @@ export function ImportModal({
className="bg-primary hover:bg-primary/90 text-primary-foreground" className="bg-primary hover:bg-primary/90 text-primary-foreground"
> >
{importing && <Loader2 className="size-4 animate-spin" />} {importing && <Loader2 className="size-4 animate-spin" />}
Import {parsedRows.length > 0 ? parsedRows.length : ''} contact {parsedRows.length > 0 ? t('importBtn', { count: parsedRows.length }) : t('importBtn', { count: 0 })}
{parsedRows.length !== 1 ? 's' : ''}
</Button> </Button>
)} )}
</DialogFooter> </DialogFooter>