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

View File

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

View File

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

View File

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

View File

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