mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
feat: internationalize contact
This commit is contained in:
parent
57a914d547
commit
dcfd66792b
196
messages/en.json
196
messages/en.json
@ -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}\"."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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) => (
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 ? (
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user