From 3a416832a7441498379c72ec44763608277596fc Mon Sep 17 00:00:00 2001 From: Abhishek Sonwal Date: Mon, 15 Jun 2026 13:00:18 +0530 Subject: [PATCH 01/40] feat(contacts): enhance CSV import functionality with tag assignment - Introduced `parseContactCsv` for improved CSV parsing, replacing the previous inline parsing logic. - Added support for importing tags alongside contacts, allowing users to assign tags during the import process. - Implemented `assignImportedContactTags` to handle tag assignments after contact creation. - Updated the import result state to include the number of tags assigned and added relevant success and info notifications for users. - Enhanced the import modal description to clarify the format for tag input in CSV files. --- src/components/contacts/import-modal.tsx | 156 ++++++++++++--------- src/lib/contacts/parse-contact-csv.test.ts | 65 +++++++++ src/lib/contacts/parse-contact-csv.ts | 100 +++++++++++++ src/lib/contacts/resolve-import-tags.ts | 127 +++++++++++++++++ 4 files changed, 379 insertions(+), 69 deletions(-) create mode 100644 src/lib/contacts/parse-contact-csv.test.ts create mode 100644 src/lib/contacts/parse-contact-csv.ts create mode 100644 src/lib/contacts/resolve-import-tags.ts diff --git a/src/components/contacts/import-modal.tsx b/src/components/contacts/import-modal.tsx index 392a33d..c2d0d66 100644 --- a/src/components/contacts/import-modal.tsx +++ b/src/components/contacts/import-modal.tsx @@ -8,6 +8,12 @@ import { isUniqueViolation, normalizeKey, } from '@/lib/contacts/dedupe'; +import { parseContactCsv } from '@/lib/contacts/parse-contact-csv'; +import { + assignImportedContactTags, + resolveImportTagIds, + type ContactTagAssignment, +} from '@/lib/contacts/resolve-import-tags'; import { toast } from 'sonner'; import { Dialog, @@ -26,75 +32,19 @@ interface ImportModalProps { onImported: () => void; } -interface ParsedRow { - phone: string; - name?: string; - email?: string; - company?: string; -} - -function parseCSV(text: string): ParsedRow[] { - const lines = text.trim().split(/\r?\n/); - if (lines.length < 2) return []; - - const headerLine = lines[0]; - const headers = headerLine.split(',').map((h) => h.trim().toLowerCase().replace(/["']/g, '')); - - const phoneIdx = headers.indexOf('phone'); - if (phoneIdx === -1) return []; - - const nameIdx = headers.indexOf('name'); - const emailIdx = headers.indexOf('email'); - const companyIdx = headers.indexOf('company'); - - const rows: ParsedRow[] = []; - for (let i = 1; i < lines.length; i++) { - const line = lines[i].trim(); - if (!line) continue; - - // Simple CSV parse (handles quoted fields) - const values: string[] = []; - let current = ''; - let inQuotes = false; - for (const char of line) { - if (char === '"') { - inQuotes = !inQuotes; - } else if (char === ',' && !inQuotes) { - values.push(current.trim()); - current = ''; - } else { - current += char; - } - } - values.push(current.trim()); - - const phone = values[phoneIdx]?.replace(/["']/g, '').trim(); - if (!phone) continue; - - rows.push({ - phone, - name: nameIdx >= 0 ? values[nameIdx]?.replace(/["']/g, '').trim() || undefined : undefined, - email: emailIdx >= 0 ? values[emailIdx]?.replace(/["']/g, '').trim() || undefined : undefined, - company: - companyIdx >= 0 ? values[companyIdx]?.replace(/["']/g, '').trim() || undefined : undefined, - }); - } - - return rows; -} - export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps) { const supabase = createClient(); - const { accountId } = useAuth(); + const { accountId, canEditSettings } = useAuth(); const fileInputRef = useRef(null); const [file, setFile] = useState(null); - const [parsedRows, setParsedRows] = useState([]); + const [parsedRows, setParsedRows] = useState>([]); const [importing, setImporting] = useState(false); const [result, setResult] = useState<{ imported: number; skipped: number; failed: number; + tagsAssigned: number; } | null>(null); function reset() { @@ -117,7 +67,7 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps setResult(null); const text = await selected.text(); - const rows = parseCSV(text); + const rows = parseContactCsv(text); if (rows.length === 0) { toast.error('No valid rows found. Ensure CSV has a "phone" column header.'); @@ -168,9 +118,19 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps return true; }); - // 3) Batch insert the genuinely-new rows in chunks of 50. The DB - // unique index is the backstop: a 23505 (race, or a format - // that normalizes equal) counts as skipped, not failed. + // 3) Resolve tag names → ids (create missing tags for admin+). + const allTagNames = toInsert.flatMap((row) => row.tagNames); + const { tagIdByKey, skippedNames } = await resolveImportTagIds(supabase, { + accountId, + userId: user.id, + tagNames: allTagNames, + canCreateTags: canEditSettings, + }); + + const tagAssignments: ContactTagAssignment[] = []; + + // 4) Batch insert contacts in chunks of 50. The DB unique index is + // the backstop: a 23505 counts as skipped, not failed. const chunkSize = 50; for (let i = 0; i < toInsert.length; i += chunkSize) { const chunk = toInsert.slice(i, i + chunkSize); @@ -191,10 +151,23 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps if (error) { // Retry individually so one bad/duplicate row doesn't sink // the whole chunk. - for (const row of rows) { - const { error: singleErr } = await supabase.from('contacts').insert(row); - if (!singleErr) { + for (let j = 0; j < rows.length; j++) { + const row = rows[j]; + const source = chunk[j]; + const { data: singleData, error: singleErr } = await supabase + .from('contacts') + .insert(row) + .select('id') + .single(); + + if (!singleErr && singleData) { imported++; + if (source.tagNames.length > 0) { + tagAssignments.push({ + contactId: singleData.id, + tagNames: source.tagNames, + }); + } } else if (isUniqueViolation(singleErr)) { skipped++; } else { @@ -202,15 +175,44 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps } } } else { - imported += data?.length ?? chunk.length; + const inserted = data ?? []; + imported += inserted.length; + for (let j = 0; j < inserted.length; j++) { + const source = chunk[j]; + if (!source || source.tagNames.length === 0) continue; + tagAssignments.push({ + contactId: inserted[j].id, + tagNames: source.tagNames, + }); + } } } - setResult({ imported, skipped, failed }); + // 5) Wire tags onto the contacts we just created. + const tagsAssigned = await assignImportedContactTags( + supabase, + tagAssignments, + tagIdByKey, + ); + + setResult({ imported, skipped, failed, tagsAssigned }); if (imported > 0) { toast.success(`${imported} contact${imported !== 1 ? 's' : ''} imported`); onImported(); } + if (tagsAssigned > 0) { + toast.success( + `${tagsAssigned} tag assignment${tagsAssigned !== 1 ? 's' : ''} applied`, + ); + } + 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}`, + ); + } if (skipped > 0) { toast.info(`${skipped} duplicate${skipped !== 1 ? 's' : ''} skipped`); } @@ -226,6 +228,7 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps } const preview = parsedRows.slice(0, 5); + const hasTagsColumn = parsedRows.some((row) => row.tagNames.length > 0); return ( @@ -234,7 +237,8 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps Import Contacts Upload a CSV file with a "phone" column (required). Optional columns: - name, email, company. + name, email, company, tags (comma-separated tag names — quote the + cell if a tag list contains commas, e.g. "VIP, Lead"). @@ -287,6 +291,9 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps Name Email Company + {hasTagsColumn && ( + Tags + )} @@ -296,6 +303,11 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps {row.name || '-'} {row.email || '-'} {row.company || '-'} + {hasTagsColumn && ( + + {row.tagNames.length > 0 ? row.tagNames.join(', ') : '-'} + + )} ))} @@ -320,6 +332,12 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps {result.imported} imported )} + {result.tagsAssigned > 0 && ( +
+ + {result.tagsAssigned} tag{result.tagsAssigned !== 1 ? 's' : ''} assigned +
+ )} {result.skipped > 0 && (
diff --git a/src/lib/contacts/parse-contact-csv.test.ts b/src/lib/contacts/parse-contact-csv.test.ts new file mode 100644 index 0000000..06c34e3 --- /dev/null +++ b/src/lib/contacts/parse-contact-csv.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { parseContactCsv, parseTagCell } from "./parse-contact-csv"; + +describe("parseTagCell", () => { + it("splits comma-separated tags and trims whitespace", () => { + expect(parseTagCell(" VIP , Lead , ")).toEqual(["VIP", "Lead"]); + }); + + it("splits semicolon-separated tags", () => { + expect(parseTagCell("VIP; Lead; Customer")).toEqual([ + "VIP", + "Lead", + "Customer", + ]); + }); + + it("de-dupes case-insensitively", () => { + expect(parseTagCell("vip, VIP, Lead")).toEqual(["vip", "Lead"]); + }); + + it("returns empty for blank values", () => { + expect(parseTagCell("")).toEqual([]); + expect(parseTagCell(undefined)).toEqual([]); + }); +}); + +describe("parseContactCsv", () => { + it("parses optional tags column", () => { + const csv = `phone,name,tags ++15551234567,Alice,"VIP, Lead" ++15559876543,Bob,Customer`; + + expect(parseContactCsv(csv)).toEqual([ + { + phone: "+15551234567", + name: "Alice", + email: undefined, + company: undefined, + tagNames: ["VIP", "Lead"], + }, + { + phone: "+15559876543", + name: "Bob", + email: undefined, + company: undefined, + tagNames: ["Customer"], + }, + ]); + }); + + it("returns empty tagNames when tags column is absent", () => { + const csv = `phone,name ++15551234567,Alice`; + + expect(parseContactCsv(csv)).toEqual([ + { + phone: "+15551234567", + name: "Alice", + email: undefined, + company: undefined, + tagNames: [], + }, + ]); + }); +}); diff --git a/src/lib/contacts/parse-contact-csv.ts b/src/lib/contacts/parse-contact-csv.ts new file mode 100644 index 0000000..95381cb --- /dev/null +++ b/src/lib/contacts/parse-contact-csv.ts @@ -0,0 +1,100 @@ +/** + * CSV parsing for the contacts import modal. Shared + unit-tested so + * tag-column handling stays aligned with phone/name/email/company. + */ + +export interface ParsedContactRow { + phone: string; + name?: string; + email?: string; + company?: string; + /** Tag names from the optional `tags` column (comma/semicolon separated). */ + tagNames: string[]; +} + +/** Split a CSV cell into unique tag names (case-insensitive de-dupe). */ +export function parseTagCell(value: string | undefined): string[] { + if (!value?.trim()) return []; + + const seen = new Set(); + const names: string[] = []; + + for (const part of value.split(/[,;]/)) { + const name = part.trim(); + if (!name) continue; + const key = name.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + names.push(name); + } + + return names; +} + +export function parseContactCsv(text: string): ParsedContactRow[] { + const lines = text.trim().split(/\r?\n/); + if (lines.length < 2) return []; + + const headers = lines[0] + .split(",") + .map((h) => h.trim().toLowerCase().replace(/["']/g, "")); + + const phoneIdx = headers.indexOf("phone"); + if (phoneIdx === -1) return []; + + const nameIdx = headers.indexOf("name"); + const emailIdx = headers.indexOf("email"); + const companyIdx = headers.indexOf("company"); + const tagsIdx = headers.indexOf("tags"); + + const rows: ParsedContactRow[] = []; + + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line) continue; + + const values = parseCsvLine(line); + const phone = values[phoneIdx]?.replace(/["']/g, "").trim(); + if (!phone) continue; + + rows.push({ + phone, + name: + nameIdx >= 0 + ? values[nameIdx]?.replace(/["']/g, "").trim() || undefined + : undefined, + email: + emailIdx >= 0 + ? values[emailIdx]?.replace(/["']/g, "").trim() || undefined + : undefined, + company: + companyIdx >= 0 + ? values[companyIdx]?.replace(/["']/g, "").trim() || undefined + : undefined, + tagNames: + tagsIdx >= 0 ? parseTagCell(values[tagsIdx]?.replace(/["']/g, "")) : [], + }); + } + + return rows; +} + +/** Simple CSV line parse (handles quoted fields). */ +function parseCsvLine(line: string): string[] { + const values: string[] = []; + let current = ""; + let inQuotes = false; + + for (const char of line) { + if (char === '"') { + inQuotes = !inQuotes; + } else if (char === "," && !inQuotes) { + values.push(current.trim()); + current = ""; + } else { + current += char; + } + } + values.push(current.trim()); + return values; +} diff --git a/src/lib/contacts/resolve-import-tags.ts b/src/lib/contacts/resolve-import-tags.ts new file mode 100644 index 0000000..9a3f75d --- /dev/null +++ b/src/lib/contacts/resolve-import-tags.ts @@ -0,0 +1,127 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; + +const DEFAULT_TAG_COLOR = "#3b82f6"; + +export interface ResolveImportTagsResult { + /** Lowercase tag name → tag id. */ + tagIdByKey: Map; + /** Names that could not be matched and were not created. */ + skippedNames: string[]; +} + +/** + * Resolve tag names from a CSV import to tag ids. Existing account tags + * are matched case-insensitively. Missing names are created when + * `canCreateTags` is true (admin+); otherwise they are reported in + * `skippedNames`. + */ +export async function resolveImportTagIds( + supabase: SupabaseClient, + params: { + accountId: string; + userId: string; + tagNames: string[]; + canCreateTags: boolean; + defaultColor?: string; + }, +): Promise { + const { accountId, userId, tagNames, canCreateTags } = params; + const defaultColor = params.defaultColor ?? DEFAULT_TAG_COLOR; + + const uniqueNames: string[] = []; + const seen = new Set(); + for (const raw of tagNames) { + const name = raw.trim(); + if (!name) continue; + const key = name.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + uniqueNames.push(name); + } + + const { data: existing, error: fetchError } = await supabase + .from("tags") + .select("id, name") + .eq("account_id", accountId); + + if (fetchError) throw fetchError; + + const tagIdByKey = new Map(); + for (const tag of existing ?? []) { + const key = tag.name.trim().toLowerCase(); + if (!tagIdByKey.has(key)) tagIdByKey.set(key, tag.id); + } + + const skippedNames: string[] = []; + const toCreate: string[] = []; + + for (const name of uniqueNames) { + const key = name.toLowerCase(); + if (tagIdByKey.has(key)) continue; + if (canCreateTags) toCreate.push(name); + else skippedNames.push(name); + } + + if (toCreate.length > 0) { + const { data: created, error: createError } = await supabase + .from("tags") + .insert( + toCreate.map((name) => ({ + user_id: userId, + account_id: accountId, + name, + color: defaultColor, + })), + ) + .select("id, name"); + + if (createError) throw createError; + + for (const tag of created ?? []) { + tagIdByKey.set(tag.name.trim().toLowerCase(), tag.id); + } + } + + return { tagIdByKey, skippedNames }; +} + +export interface ContactTagAssignment { + contactId: string; + tagNames: string[]; +} + +/** Insert contact_tags rows for imported contacts (ignores duplicates). */ +export async function assignImportedContactTags( + supabase: SupabaseClient, + assignments: ContactTagAssignment[], + tagIdByKey: Map, +): Promise { + const rows: { contact_id: string; tag_id: string }[] = []; + + for (const { contactId, tagNames } of assignments) { + const assignedTagIds = new Set(); + for (const name of tagNames) { + const tagId = tagIdByKey.get(name.trim().toLowerCase()); + if (!tagId || assignedTagIds.has(tagId)) continue; + assignedTagIds.add(tagId); + rows.push({ contact_id: contactId, tag_id: tagId }); + } + } + + if (rows.length === 0) return 0; + + const chunkSize = 100; + let assigned = 0; + + for (let i = 0; i < rows.length; i += chunkSize) { + const chunk = rows.slice(i, i + chunkSize); + const { error } = await supabase.from("contact_tags").upsert(chunk, { + onConflict: "contact_id,tag_id", + ignoreDuplicates: true, + }); + if (error) throw error; + assigned += chunk.length; + } + + return assigned; +} From 73062cf06d876b1d74b7c5f615d2e48434249444 Mon Sep 17 00:00:00 2001 From: Abhishek Sonwal Date: Mon, 15 Jun 2026 13:12:00 +0530 Subject: [PATCH 02/40] feat(contacts): enhance import modal with tag preview and CSV parsing improvements - Added `ImportPreviewTags` component to display tags with color coding in the import modal. - Updated `parseContactCsv` to return an object containing both parsed rows and a flag indicating the presence of a tags column. - Modified state management in the import modal to handle tags and their colors, improving user experience during contact import. - Adjusted the import modal to conditionally render the tags column based on the presence of tags in the preview. --- src/components/contacts/import-modal.tsx | 75 ++++++++++++++++++++-- src/lib/contacts/parse-contact-csv.test.ts | 56 ++++++++-------- src/lib/contacts/parse-contact-csv.ts | 14 ++-- 3 files changed, 109 insertions(+), 36 deletions(-) diff --git a/src/components/contacts/import-modal.tsx b/src/components/contacts/import-modal.tsx index c2d0d66..876064b 100644 --- a/src/components/contacts/import-modal.tsx +++ b/src/components/contacts/import-modal.tsx @@ -8,7 +8,7 @@ import { isUniqueViolation, normalizeKey, } from '@/lib/contacts/dedupe'; -import { parseContactCsv } from '@/lib/contacts/parse-contact-csv'; +import { parseContactCsv, type ParsedContactRow } from '@/lib/contacts/parse-contact-csv'; import { assignImportedContactTags, resolveImportTagIds, @@ -26,6 +26,41 @@ import { import { Button } from '@/components/ui/button'; import { Upload, FileText, Loader2, CheckCircle, XCircle, AlertTriangle } from 'lucide-react'; +const DEFAULT_TAG_COLOR = '#3b82f6'; + +function ImportPreviewTags({ + tagNames, + tagColorByKey, +}: { + tagNames: string[]; + tagColorByKey: Map; +}) { + if (tagNames.length === 0) { + return -; + } + + return ( +
+ {tagNames.map((name) => { + const color = tagColorByKey.get(name.trim().toLowerCase()) ?? DEFAULT_TAG_COLOR; + return ( + + {name} + + ); + })} +
+ ); +} + interface ImportModalProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -38,7 +73,9 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps const fileInputRef = useRef(null); const [file, setFile] = useState(null); - const [parsedRows, setParsedRows] = useState>([]); + const [parsedRows, setParsedRows] = useState([]); + const [hasTagsColumn, setHasTagsColumn] = useState(false); + const [tagColorByKey, setTagColorByKey] = useState>(new Map()); const [importing, setImporting] = useState(false); const [result, setResult] = useState<{ imported: number; @@ -50,6 +87,8 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps function reset() { setFile(null); setParsedRows([]); + setHasTagsColumn(false); + setTagColorByKey(new Map()); setResult(null); if (fileInputRef.current) fileInputRef.current.value = ''; } @@ -67,15 +106,34 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps setResult(null); const text = await selected.text(); - const rows = parseContactCsv(text); + const { rows, hasTagsColumn: csvHasTags } = parseContactCsv(text); if (rows.length === 0) { toast.error('No valid rows found. Ensure CSV has a "phone" column header.'); setParsedRows([]); + setHasTagsColumn(false); + setTagColorByKey(new Map()); return; } setParsedRows(rows); + setHasTagsColumn(csvHasTags); + + if (csvHasTags && accountId) { + const { data: tags } = await supabase + .from('tags') + .select('name, color') + .eq('account_id', accountId); + + const colors = new Map(); + for (const tag of tags ?? []) { + const key = tag.name.trim().toLowerCase(); + if (!colors.has(key)) colors.set(key, tag.color); + } + setTagColorByKey(colors); + } else { + setTagColorByKey(new Map()); + } } async function handleImport() { @@ -228,7 +286,7 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps } const preview = parsedRows.slice(0, 5); - const hasTagsColumn = parsedRows.some((row) => row.tagNames.length > 0); + const previewHasTags = hasTagsColumn || preview.some((row) => row.tagNames.length > 0); return ( @@ -291,7 +349,7 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps Name Email Company - {hasTagsColumn && ( + {previewHasTags && ( Tags )} @@ -303,9 +361,12 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps {row.name || '-'} {row.email || '-'} {row.company || '-'} - {hasTagsColumn && ( + {previewHasTags && ( - {row.tagNames.length > 0 ? row.tagNames.join(', ') : '-'} + )} diff --git a/src/lib/contacts/parse-contact-csv.test.ts b/src/lib/contacts/parse-contact-csv.test.ts index 06c34e3..5fcf4a1 100644 --- a/src/lib/contacts/parse-contact-csv.test.ts +++ b/src/lib/contacts/parse-contact-csv.test.ts @@ -30,36 +30,42 @@ describe("parseContactCsv", () => { +15551234567,Alice,"VIP, Lead" +15559876543,Bob,Customer`; - expect(parseContactCsv(csv)).toEqual([ - { - phone: "+15551234567", - name: "Alice", - email: undefined, - company: undefined, - tagNames: ["VIP", "Lead"], - }, - { - phone: "+15559876543", - name: "Bob", - email: undefined, - company: undefined, - tagNames: ["Customer"], - }, - ]); + expect(parseContactCsv(csv)).toEqual({ + hasTagsColumn: true, + rows: [ + { + phone: "+15551234567", + name: "Alice", + email: undefined, + company: undefined, + tagNames: ["VIP", "Lead"], + }, + { + phone: "+15559876543", + name: "Bob", + email: undefined, + company: undefined, + tagNames: ["Customer"], + }, + ], + }); }); it("returns empty tagNames when tags column is absent", () => { const csv = `phone,name +15551234567,Alice`; - expect(parseContactCsv(csv)).toEqual([ - { - phone: "+15551234567", - name: "Alice", - email: undefined, - company: undefined, - tagNames: [], - }, - ]); + expect(parseContactCsv(csv)).toEqual({ + hasTagsColumn: false, + rows: [ + { + phone: "+15551234567", + name: "Alice", + email: undefined, + company: undefined, + tagNames: [], + }, + ], + }); }); }); diff --git a/src/lib/contacts/parse-contact-csv.ts b/src/lib/contacts/parse-contact-csv.ts index 95381cb..9a7b9e3 100644 --- a/src/lib/contacts/parse-contact-csv.ts +++ b/src/lib/contacts/parse-contact-csv.ts @@ -31,16 +31,22 @@ export function parseTagCell(value: string | undefined): string[] { return names; } -export function parseContactCsv(text: string): ParsedContactRow[] { +export interface ParseContactCsvResult { + rows: ParsedContactRow[]; + /** True when the CSV header includes a `tags` column. */ + hasTagsColumn: boolean; +} + +export function parseContactCsv(text: string): ParseContactCsvResult { const lines = text.trim().split(/\r?\n/); - if (lines.length < 2) return []; + if (lines.length < 2) return { rows: [], hasTagsColumn: false }; const headers = lines[0] .split(",") .map((h) => h.trim().toLowerCase().replace(/["']/g, "")); const phoneIdx = headers.indexOf("phone"); - if (phoneIdx === -1) return []; + if (phoneIdx === -1) return { rows: [], hasTagsColumn: false }; const nameIdx = headers.indexOf("name"); const emailIdx = headers.indexOf("email"); @@ -76,7 +82,7 @@ export function parseContactCsv(text: string): ParsedContactRow[] { }); } - return rows; + return { rows, hasTagsColumn: tagsIdx >= 0 }; } /** Simple CSV line parse (handles quoted fields). */ From b780fddb3bb4d0e5edbbbdbef973f3ba5b1f9c2e Mon Sep 17 00:00:00 2001 From: Abhishek Sonwal Date: Mon, 15 Jun 2026 13:27:56 +0530 Subject: [PATCH 03/40] feat(contacts): enhance import modal with company column support and improved CSV parsing - Updated `parseContactCsv` to include a flag for the presence of a company column in the CSV. - Modified the import modal state management to handle the new company column, improving user experience during contact import. - Enhanced the `ImportPreviewTags` component to conditionally render based on the presence of company data. - Adjusted the UI to better display tag and company information in the import preview. --- src/components/contacts/import-modal.tsx | 335 +++++++++++++++------ src/lib/contacts/parse-contact-csv.test.ts | 2 + src/lib/contacts/parse-contact-csv.ts | 16 +- 3 files changed, 251 insertions(+), 102 deletions(-) diff --git a/src/components/contacts/import-modal.tsx b/src/components/contacts/import-modal.tsx index 876064b..8450b0f 100644 --- a/src/components/contacts/import-modal.tsx +++ b/src/components/contacts/import-modal.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useRef } from 'react'; +import { useMemo, useRef, useState } from 'react'; import { createClient } from '@/lib/supabase/client'; import { useAuth } from '@/hooks/use-auth'; import { @@ -14,6 +14,7 @@ import { resolveImportTagIds, type ContactTagAssignment, } from '@/lib/contacts/resolve-import-tags'; +import { cn } from '@/lib/utils'; import { toast } from 'sonner'; import { Dialog, @@ -24,9 +25,45 @@ import { DialogFooter, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; -import { Upload, FileText, Loader2, CheckCircle, XCircle, AlertTriangle } from 'lucide-react'; +import { + Upload, + FileText, + Loader2, + CheckCircle, + XCircle, + AlertTriangle, + Tag, +} from 'lucide-react'; const DEFAULT_TAG_COLOR = '#3b82f6'; +const PREVIEW_LIMIT = 5; + +function truncateFilename(name: string, max = 48): string { + if (name.length <= max) return name; + const ext = name.includes('.') ? name.slice(name.lastIndexOf('.')) : ''; + const base = name.slice(0, name.length - ext.length); + const keep = max - ext.length - 1; + return `${base.slice(0, Math.max(keep, 12))}…${ext}`; +} + +function PreviewCell({ + value, + mono, + maxWidth = 'max-w-[9rem]', +}: { + value: string; + mono?: boolean; + maxWidth?: string; +}) { + return ( + + {value} + + ); +} function ImportPreviewTags({ tagNames, @@ -36,24 +73,30 @@ function ImportPreviewTags({ tagColorByKey: Map; }) { if (tagNames.length === 0) { - return -; + return —; } return ( -
+
{tagNames.map((name) => { const color = tagColorByKey.get(name.trim().toLowerCase()) ?? DEFAULT_TAG_COLOR; + const isKnown = tagColorByKey.has(name.trim().toLowerCase()); return ( - {name} + + {name} ); })} @@ -75,6 +118,7 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps const [file, setFile] = useState(null); const [parsedRows, setParsedRows] = useState([]); const [hasTagsColumn, setHasTagsColumn] = useState(false); + const [hasCompanyColumn, setHasCompanyColumn] = useState(false); const [tagColorByKey, setTagColorByKey] = useState>(new Map()); const [importing, setImporting] = useState(false); const [result, setResult] = useState<{ @@ -88,14 +132,15 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps setFile(null); setParsedRows([]); setHasTagsColumn(false); + setHasCompanyColumn(false); setTagColorByKey(new Map()); setResult(null); if (fileInputRef.current) fileInputRef.current.value = ''; } - function handleOpenChange(open: boolean) { - if (!open) reset(); - onOpenChange(open); + function handleOpenChange(next: boolean) { + if (!next) reset(); + onOpenChange(next); } async function handleFileChange(e: React.ChangeEvent) { @@ -106,18 +151,21 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps setResult(null); const text = await selected.text(); - const { rows, hasTagsColumn: csvHasTags } = parseContactCsv(text); + const { rows, hasTagsColumn: csvHasTags, hasCompanyColumn: csvHasCompany } = + parseContactCsv(text); if (rows.length === 0) { toast.error('No valid rows found. Ensure CSV has a "phone" column header.'); setParsedRows([]); setHasTagsColumn(false); + setHasCompanyColumn(false); setTagColorByKey(new Map()); return; } setParsedRows(rows); setHasTagsColumn(csvHasTags); + setHasCompanyColumn(csvHasCompany); if (csvHasTags && accountId) { const { data: tags } = await supabase @@ -152,12 +200,9 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps let skipped = 0; let failed = 0; - // 1) De-dupe within the file by normalized phone (keep first). const { unique, duplicates: inFileDupes } = dedupeByPhone(parsedRows); skipped += inFileDupes; - // 2) Skip numbers already in this account. One read of the - // generated `phone_normalized` column (migration 022) → Set. const { data: existingRows } = await supabase .from('contacts') .select('phone_normalized') @@ -176,7 +221,6 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps return true; }); - // 3) Resolve tag names → ids (create missing tags for admin+). const allTagNames = toInsert.flatMap((row) => row.tagNames); const { tagIdByKey, skippedNames } = await resolveImportTagIds(supabase, { accountId, @@ -186,10 +230,8 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps }); const tagAssignments: ContactTagAssignment[] = []; - - // 4) Batch insert contacts in chunks of 50. The DB unique index is - // the backstop: a 23505 counts as skipped, not failed. const chunkSize = 50; + for (let i = 0; i < toInsert.length; i += chunkSize) { const chunk = toInsert.slice(i, i + chunkSize); const rows = chunk.map((row) => ({ @@ -207,8 +249,6 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps .select('id'); if (error) { - // Retry individually so one bad/duplicate row doesn't sink - // the whole chunk. for (let j = 0; j < rows.length; j++) { const row = rows[j]; const source = chunk[j]; @@ -246,7 +286,6 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps } } - // 5) Wire tags onto the contacts we just created. const tagsAssigned = await assignImportedContactTags( supabase, tagAssignments, @@ -285,44 +324,90 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps } } - const preview = parsedRows.slice(0, 5); - const previewHasTags = hasTagsColumn || preview.some((row) => row.tagNames.length > 0); + const preview = parsedRows.slice(0, PREVIEW_LIMIT); + const previewHasTags = + hasTagsColumn || preview.some((row) => row.tagNames.length > 0); + const previewHasCompany = + hasCompanyColumn && preview.some((row) => row.company?.trim()); + + const tagStats = useMemo(() => { + const names = new Set(); + let rowsWithTags = 0; + for (const row of parsedRows) { + if (row.tagNames.length === 0) continue; + rowsWithTags++; + for (const name of row.tagNames) names.add(name.trim().toLowerCase()); + } + return { unique: names.size, rowsWithTags }; + }, [parsedRows]); return ( - - - Import Contacts - - Upload a CSV file with a "phone" column (required). Optional columns: - name, email, company, tags (comma-separated tag names — quote the - cell if a tag list contains commas, e.g. "VIP, Lead"). - - + +
+ + Import Contacts + + Upload a CSV with a required{' '} + + phone + {' '} + column. Optional:{' '} + + name + + ,{' '} + + email + + ,{' '} + + company + + ,{' '} + + tags + {' '} + (comma-separated; quote multi-tag cells). + + -
- {/* Upload area */}
fileInputRef.current?.click()} - className="flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-slate-700 p-6 cursor-pointer hover:border-primary/50 transition-colors" + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') fileInputRef.current?.click(); + }} + className={cn( + 'group flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border border-dashed p-5 transition-all', + file + ? 'border-primary/35 bg-primary/[0.04]' + : 'border-slate-700/80 bg-slate-950/40 hover:border-primary/40 hover:bg-slate-950/70', + )} > {file ? ( <> - -

{file.name}

-

- {parsedRows.length} row{parsedRows.length !== 1 ? 's' : ''} detected +

+ +
+

+ {truncateFilename(file.name)}

+ + {parsedRows.length} row{parsedRows.length !== 1 ? 's' : ''} ready + ) : ( <> - -

- Click to upload CSV file -

-

- CSV with "phone" column required -

+
+ +
+

Click to choose a CSV file

+

.csv up to your browser limit

)}
@@ -334,80 +419,131 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps onChange={handleFileChange} className="hidden" /> +
- {/* Preview table */} +
{preview.length > 0 && !result && ( -
-

- Preview (first {preview.length} rows) -

-
- - - - - - - - {previewHasTags && ( - - )} - - - - {preview.map((row, i) => ( - - - - - +
+
+

+ Preview · first {preview.length} +

+
+ {tagStats.rowsWithTags > 0 && ( + + + {tagStats.unique} tag{tagStats.unique !== 1 ? 's' : ''} ·{' '} + {tagStats.rowsWithTags} contact + {tagStats.rowsWithTags !== 1 ? 's' : ''} + + )} +
+
+ +
+
+
PhoneNameEmailCompanyTags
{row.phone}{row.name || '-'}{row.email || '-'}{row.company || '-'}
+ + + + + + {previewHasCompany && ( + + )} {previewHasTags && ( - + )} - ))} - -
+ Phone + + Name + + Email + + Company + - - + Tags +
+ + + {preview.map((row, i) => ( + + + + + + + + + + + {previewHasCompany && ( + + + + )} + {previewHasTags && ( + + + + )} + + ))} + + +
- {parsedRows.length > 5 && ( -

- ...and {parsedRows.length - 5} more rows + + {parsedRows.length > PREVIEW_LIMIT && ( +

+ + {parsedRows.length - PREVIEW_LIMIT} more row + {parsedRows.length - PREVIEW_LIMIT !== 1 ? 's' : ''} not shown

)}
)} - {/* Results */} {result && ( -
-

Import Complete

-
+
+

Import complete

+
{result.imported > 0 && ( -
- +
+ {result.imported} imported
)} {result.tagsAssigned > 0 && ( -
- +
+ {result.tagsAssigned} tag{result.tagsAssigned !== 1 ? 's' : ''} assigned
)} {result.skipped > 0 && ( -
- - {result.skipped} duplicate{result.skipped !== 1 ? 's' : ''} skipped +
+ + {result.skipped} skipped
)} {result.failed > 0 && ( -
- +
+ {result.failed} failed
)} @@ -416,7 +552,7 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps )}
- + )} diff --git a/src/lib/contacts/parse-contact-csv.test.ts b/src/lib/contacts/parse-contact-csv.test.ts index 5fcf4a1..ac9a92e 100644 --- a/src/lib/contacts/parse-contact-csv.test.ts +++ b/src/lib/contacts/parse-contact-csv.test.ts @@ -32,6 +32,7 @@ describe("parseContactCsv", () => { expect(parseContactCsv(csv)).toEqual({ hasTagsColumn: true, + hasCompanyColumn: false, rows: [ { phone: "+15551234567", @@ -57,6 +58,7 @@ describe("parseContactCsv", () => { expect(parseContactCsv(csv)).toEqual({ hasTagsColumn: false, + hasCompanyColumn: false, rows: [ { phone: "+15551234567", diff --git a/src/lib/contacts/parse-contact-csv.ts b/src/lib/contacts/parse-contact-csv.ts index 9a7b9e3..199b42c 100644 --- a/src/lib/contacts/parse-contact-csv.ts +++ b/src/lib/contacts/parse-contact-csv.ts @@ -35,18 +35,24 @@ export interface ParseContactCsvResult { rows: ParsedContactRow[]; /** True when the CSV header includes a `tags` column. */ hasTagsColumn: boolean; + /** True when the CSV header includes a `company` column. */ + hasCompanyColumn: boolean; } export function parseContactCsv(text: string): ParseContactCsvResult { const lines = text.trim().split(/\r?\n/); - if (lines.length < 2) return { rows: [], hasTagsColumn: false }; + if (lines.length < 2) { + return { rows: [], hasTagsColumn: false, hasCompanyColumn: false }; + } const headers = lines[0] .split(",") .map((h) => h.trim().toLowerCase().replace(/["']/g, "")); const phoneIdx = headers.indexOf("phone"); - if (phoneIdx === -1) return { rows: [], hasTagsColumn: false }; + if (phoneIdx === -1) { + return { rows: [], hasTagsColumn: false, hasCompanyColumn: false }; + } const nameIdx = headers.indexOf("name"); const emailIdx = headers.indexOf("email"); @@ -82,7 +88,11 @@ export function parseContactCsv(text: string): ParseContactCsvResult { }); } - return { rows, hasTagsColumn: tagsIdx >= 0 }; + return { + rows, + hasTagsColumn: tagsIdx >= 0, + hasCompanyColumn: companyIdx >= 0, + }; } /** Simple CSV line parse (handles quoted fields). */ From fd2640c89a34098fa1b849fc9bf46635525205b3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:36:41 +0000 Subject: [PATCH 04/40] chore(deps): bump shadcn from 4.10.0 to 4.11.0 Bumps [shadcn](https://github.com/shadcn-ui/ui/tree/HEAD/packages/shadcn) from 4.10.0 to 4.11.0. - [Release notes](https://github.com/shadcn-ui/ui/releases) - [Changelog](https://github.com/shadcn-ui/ui/blob/main/packages/shadcn/CHANGELOG.md) - [Commits](https://github.com/shadcn-ui/ui/commits/shadcn@4.11.0/packages/shadcn) --- updated-dependencies: - dependency-name: shadcn dependency-version: 4.11.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- package-lock.json | 573 +--------------------------------------------- package.json | 2 +- 2 files changed, 10 insertions(+), 565 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8472d5c..75162b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,7 +25,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "recharts": "^3.8.1", - "shadcn": "^4.10.0", + "shadcn": "^4.11.0", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0" @@ -1545,89 +1545,6 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@inquirer/ansi": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", - "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/confirm": { - "version": "5.1.21", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", - "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/core": { - "version": "10.3.2", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", - "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "cli-width": "^4.1.0", - "mute-stream": "^2.0.0", - "signal-exit": "^4.1.0", - "wrap-ansi": "^6.2.0", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/figures": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", - "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/type": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", - "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1735,23 +1652,6 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, - "node_modules/@mswjs/interceptors": { - "version": "0.41.3", - "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", - "integrity": "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==", - "license": "MIT", - "dependencies": { - "@open-draft/deferred-promise": "^2.2.0", - "@open-draft/logger": "^0.3.0", - "@open-draft/until": "^2.0.0", - "is-node-process": "^1.2.0", - "outvariant": "^1.4.3", - "strict-event-emitter": "^0.5.1" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -1993,28 +1893,6 @@ "node": ">=12.4.0" } }, - "node_modules/@open-draft/deferred-promise": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", - "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", - "license": "MIT" - }, - "node_modules/@open-draft/logger": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", - "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", - "license": "MIT", - "dependencies": { - "is-node-process": "^1.2.0", - "outvariant": "^1.4.0" - } - }, - "node_modules/@open-draft/until": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", - "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", - "license": "MIT" - }, "node_modules/@oxc-project/types": { "version": "0.133.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", @@ -3062,7 +2940,7 @@ "version": "25.9.2", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz", "integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "undici-types": ">=7.24.0 <7.24.7" @@ -3088,12 +2966,6 @@ "@types/react": "^19.2.0" } }, - "node_modules/@types/statuses": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", - "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", - "license": "MIT" - }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -3942,6 +3814,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -4476,93 +4349,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "license": "ISC", - "engines": { - "node": ">= 12" - } - }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -4582,6 +4374,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -4594,6 +4387,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, "license": "MIT" }, "node_modules/commander": { @@ -6392,15 +6186,6 @@ "node": ">=6.9.0" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, "node_modules/get-east-asian-width": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", @@ -6570,15 +6355,6 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, - "node_modules/graphql": { - "version": "16.13.2", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", - "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==", - "license": "MIT", - "engines": { - "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" - } - }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -6671,12 +6447,6 @@ "node": ">= 0.4" } }, - "node_modules/headers-polyfill": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", - "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", - "license": "MIT" - }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -7067,15 +6837,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -7176,12 +6937,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-node-process": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", - "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", - "license": "MIT" - }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -8143,72 +7898,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/msw": { - "version": "2.13.3", - "resolved": "https://registry.npmjs.org/msw/-/msw-2.13.3.tgz", - "integrity": "sha512-/F49bxavkNGfreMlrKmTxZs6YorjfMbbDLd89Q3pWi+cXGtQQNXXaHt4MkXN7li91xnQJ24HWXqW9QDm5id33w==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "@inquirer/confirm": "^5.0.0", - "@mswjs/interceptors": "^0.41.2", - "@open-draft/deferred-promise": "^2.2.0", - "@types/statuses": "^2.0.6", - "cookie": "^1.0.2", - "graphql": "^16.12.0", - "headers-polyfill": "^4.0.2", - "is-node-process": "^1.2.0", - "outvariant": "^1.4.3", - "path-to-regexp": "^6.3.0", - "picocolors": "^1.1.1", - "rettime": "^0.11.7", - "statuses": "^2.0.2", - "strict-event-emitter": "^0.5.1", - "tough-cookie": "^6.0.0", - "type-fest": "^5.2.0", - "until-async": "^3.0.2", - "yargs": "^17.7.2" - }, - "bin": { - "msw": "cli/index.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/mswjs" - }, - "peerDependencies": { - "typescript": ">= 4.8.x" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/msw/node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/mute-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", - "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -8653,12 +8342,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/outvariant": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", - "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", - "license": "MIT" - }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -8792,12 +8475,6 @@ "dev": true, "license": "MIT" }, - "node_modules/path-to-regexp": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", - "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", - "license": "MIT" - }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -9292,15 +8969,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -9375,12 +9043,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/rettime": { - "version": "0.11.7", - "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.11.7.tgz", - "integrity": "sha512-DoAm1WjR1eH7z8sHPtvvUMIZh4/CSKkGCz6CxPqOrEAnOGtOuHSnSE9OC+razqxKuf4ub7pAYyl/vZV0vGs5tg==", - "license": "MIT" - }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -9663,9 +9325,9 @@ "license": "ISC" }, "node_modules/shadcn": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/shadcn/-/shadcn-4.10.0.tgz", - "integrity": "sha512-84IJhUsK0xqSCRJx3QxyZe2NpUXj2Nwk8Vc8Ow/tCOND3yz4CT6uU4655vqicNXhzG9Q1cyUt+TBl2SiCJwNgg==", + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/shadcn/-/shadcn-4.11.0.tgz", + "integrity": "sha512-UV0cchFea9hO7poV1CuEP0wvmYjpAqcxCKdy23bndl2Du2ARtDs8A4xdzfhUjDBeOW1nNpJ6lXmsEpsply2SfQ==", "license": "MIT", "dependencies": { "@babel/core": "^7.28.0", @@ -9687,7 +9349,6 @@ "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", - "msw": "^2.10.4", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", @@ -10018,12 +9679,6 @@ "node": ">= 0.4" } }, - "node_modules/strict-event-emitter": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", - "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", - "license": "MIT" - }, "node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -10275,18 +9930,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tagged-tag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", - "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", - "license": "MIT", - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/tailwind-merge": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", @@ -10399,24 +10042,6 @@ "node": ">=14.0.0" } }, - "node_modules/tldts": { - "version": "7.0.28", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", - "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==", - "license": "MIT", - "dependencies": { - "tldts-core": "^7.0.28" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "7.0.28", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz", - "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==", - "license": "MIT" - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -10438,18 +10063,6 @@ "node": ">=0.6" } }, - "node_modules/tough-cookie": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", - "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^7.0.5" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -10527,21 +10140,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-fest": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", - "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", - "license": "(MIT OR CC0-1.0)", - "dependencies": { - "tagged-tag": "^1.0.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", @@ -10695,7 +10293,7 @@ "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/unicorn-magic": { @@ -10763,15 +10361,6 @@ "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, - "node_modules/until-async": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", - "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/kettanaito" - } - }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -11201,61 +10790,6 @@ "node": ">=0.10.0" } }, - "node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/wrap-ansi/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -11278,89 +10812,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "license": "ISC", - "engines": { - "node": ">=10" - } - }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC" }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/yargs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -11401,18 +10858,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/yoctocolors-cjs": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", - "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", diff --git a/package.json b/package.json index 692678a..73532ae 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "recharts": "^3.8.1", - "shadcn": "^4.10.0", + "shadcn": "^4.11.0", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0" From aa38cb4aa394b686b8d96d29825bca58755899bd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:36:56 +0000 Subject: [PATCH 05/40] chore(deps): bump lucide-react from 1.17.0 to 1.18.0 Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 1.17.0 to 1.18.0. - [Release notes](https://github.com/lucide-icons/lucide/releases) - [Commits](https://github.com/lucide-icons/lucide/commits/1.18.0/packages/lucide-react) --- updated-dependencies: - dependency-name: lucide-react dependency-version: 1.18.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8472d5c..a97d172 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.4.0", - "lucide-react": "^1.17.0", + "lucide-react": "^1.18.0", "next": "16.2.6", "react": "19.2.4", "react-dom": "19.2.4", @@ -7993,9 +7993,9 @@ } }, "node_modules/lucide-react": { - "version": "1.17.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.17.0.tgz", - "integrity": "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.18.0.tgz", + "integrity": "sha512-LZDb7H/0YfM+RJncD0hDQRCAu+vSGODqpe35TuVI8EuXaRjkczbsx7p8dY4J87F/MUSj6bpYqeI8nw8qXaAdmA==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" diff --git a/package.json b/package.json index 692678a..35f4905 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.4.0", - "lucide-react": "^1.17.0", + "lucide-react": "^1.18.0", "next": "16.2.6", "react": "19.2.4", "react-dom": "19.2.4", From f39ca606294b04170853abb70ab2ce7fc46de559 Mon Sep 17 00:00:00 2001 From: Abhishek Sonwal Date: Tue, 16 Jun 2026 12:59:03 +0530 Subject: [PATCH 06/40] refactor(contacts): improve code readability and formatting in import modal and CSV parsing - Reformatted code in `import-modal.tsx` for better readability, including consistent indentation and line breaks. - Enhanced `parse-contact-csv.ts` and `parse-contact-csv.test.ts` with consistent string delimiters and improved whitespace handling. - Updated comments for clarity and consistency across the import functionality. - Ensured consistent use of single quotes for strings throughout the files. --- src/components/contacts/import-modal.tsx | 164 +++++++++++++++------ src/lib/contacts/parse-contact-csv.test.ts | 50 +++---- src/lib/contacts/parse-contact-csv.ts | 30 ++-- src/lib/contacts/resolve-import-tags.ts | 39 +++-- 4 files changed, 182 insertions(+), 101 deletions(-) diff --git a/src/components/contacts/import-modal.tsx b/src/components/contacts/import-modal.tsx index 8450b0f..0a05ef7 100644 --- a/src/components/contacts/import-modal.tsx +++ b/src/components/contacts/import-modal.tsx @@ -8,7 +8,10 @@ import { isUniqueViolation, normalizeKey, } from '@/lib/contacts/dedupe'; -import { parseContactCsv, type ParsedContactRow } from '@/lib/contacts/parse-contact-csv'; +import { + parseContactCsv, + type ParsedContactRow, +} from '@/lib/contacts/parse-contact-csv'; import { assignImportedContactTags, resolveImportTagIds, @@ -57,7 +60,11 @@ function PreviewCell({ }) { return ( {value} @@ -77,14 +84,15 @@ function ImportPreviewTags({ } return ( -
+
{tagNames.map((name) => { - const color = tagColorByKey.get(name.trim().toLowerCase()) ?? DEFAULT_TAG_COLOR; + const color = + tagColorByKey.get(name.trim().toLowerCase()) ?? DEFAULT_TAG_COLOR; const isKnown = tagColorByKey.has(name.trim().toLowerCase()); return ( void; } -export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps) { +export function ImportModal({ + open, + onOpenChange, + onImported, +}: ImportModalProps) { const supabase = createClient(); const { accountId, canEditSettings } = useAuth(); const fileInputRef = useRef(null); @@ -119,7 +131,9 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps const [parsedRows, setParsedRows] = useState([]); const [hasTagsColumn, setHasTagsColumn] = useState(false); const [hasCompanyColumn, setHasCompanyColumn] = useState(false); - const [tagColorByKey, setTagColorByKey] = useState>(new Map()); + const [tagColorByKey, setTagColorByKey] = useState>( + new Map() + ); const [importing, setImporting] = useState(false); const [result, setResult] = useState<{ imported: number; @@ -151,11 +165,16 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps setResult(null); const text = await selected.text(); - const { rows, hasTagsColumn: csvHasTags, hasCompanyColumn: csvHasCompany } = - parseContactCsv(text); + const { + rows, + hasTagsColumn: csvHasTags, + hasCompanyColumn: csvHasCompany, + } = parseContactCsv(text); if (rows.length === 0) { - toast.error('No valid rows found. Ensure CSV has a "phone" column header.'); + toast.error( + 'No valid rows found. Ensure CSV has a "phone" column header.' + ); setParsedRows([]); setHasTagsColumn(false); setHasCompanyColumn(false); @@ -194,23 +213,29 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps } = await supabase.auth.getSession(); const user = session?.user; if (!user) throw new Error('Not authenticated'); - if (!accountId) throw new Error('Your profile is not linked to an account.'); + if (!accountId) + throw new Error('Your profile is not linked to an account.'); let imported = 0; let skipped = 0; let failed = 0; + // 1) De-dupe within the file by normalized phone (keep first). const { unique, duplicates: inFileDupes } = dedupeByPhone(parsedRows); skipped += inFileDupes; + // 2) Skip numbers already in this account. One read of the + // generated `phone_normalized` column (migration 022) → Set. const { data: existingRows } = await supabase .from('contacts') .select('phone_normalized') .eq('account_id', accountId); const existing = new Set( (existingRows ?? []) - .map((r) => (r as { phone_normalized: string | null }).phone_normalized) - .filter((p): p is string => !!p), + .map( + (r) => (r as { phone_normalized: string | null }).phone_normalized + ) + .filter((p): p is string => !!p) ); const toInsert = unique.filter((row) => { @@ -221,15 +246,25 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps return true; }); + // 3) Resolve tag names → ids (admin+ may auto-create missing tags). + // Skip the round-trip when the import carries no tag names. const allTagNames = toInsert.flatMap((row) => row.tagNames); - const { tagIdByKey, skippedNames } = await resolveImportTagIds(supabase, { - accountId, - userId: user.id, - tagNames: allTagNames, - canCreateTags: canEditSettings, - }); + let tagIdByKey = new Map(); + let skippedNames: string[] = []; + if (allTagNames.length > 0) { + ({ tagIdByKey, skippedNames } = await resolveImportTagIds(supabase, { + accountId, + userId: user.id, + tagNames: allTagNames, + canCreateTags: canEditSettings, + })); + } const tagAssignments: ContactTagAssignment[] = []; + + // 4) Batch insert the genuinely-new rows in chunks of 50. The DB + // unique index is the backstop: a 23505 (race, or a format + // that normalizes equal) counts as skipped, not failed. const chunkSize = 50; for (let i = 0; i < toInsert.length; i += chunkSize) { @@ -249,6 +284,8 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps .select('id'); if (error) { + // Retry individually so one bad/duplicate row doesn't sink + // the whole chunk. for (let j = 0; j < rows.length; j++) { const row = rows[j]; const source = chunk[j]; @@ -275,6 +312,9 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps } else { const inserted = data ?? []; imported += inserted.length; + // inserted[j] ↔ chunk[j] only holds because a single INSERT + // preserves RETURNING order. If this path is ever split into + // parallel inserts, zip by phone or returned id instead. for (let j = 0; j < inserted.length; j++) { const source = chunk[j]; if (!source || source.tagNames.length === 0) continue; @@ -286,20 +326,29 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps } } - const tagsAssigned = await assignImportedContactTags( - supabase, - tagAssignments, - tagIdByKey, - ); + // 5) Wire tags onto the contacts we just created. Failure here must + // not mask a successful contact import. + let tagsAssigned = 0; + try { + tagsAssigned = await assignImportedContactTags( + supabase, + tagAssignments, + tagIdByKey + ); + } catch { + toast.warning('Contacts imported, but some tag assignments failed.'); + } setResult({ imported, skipped, failed, tagsAssigned }); if (imported > 0) { - toast.success(`${imported} contact${imported !== 1 ? 's' : ''} imported`); + toast.success( + `${imported} contact${imported !== 1 ? 's' : ''} imported` + ); onImported(); } if (tagsAssigned > 0) { toast.success( - `${tagsAssigned} tag assignment${tagsAssigned !== 1 ? 's' : ''} applied`, + `${tagsAssigned} tag assignment${tagsAssigned !== 1 ? 's' : ''} applied` ); } if (skippedNames.length > 0) { @@ -307,14 +356,16 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps const more = skippedNames.length > 3 ? ` (+${skippedNames.length - 3} more)` : ''; toast.info( - `Unknown tags skipped (create them in Settings first): ${sample}${more}`, + `Unknown tags skipped (create them in Settings first): ${sample}${more}` ); } if (skipped > 0) { toast.info(`${skipped} duplicate${skipped !== 1 ? 's' : ''} skipped`); } if (failed > 0) { - toast.error(`${failed} contact${failed !== 1 ? 's' : ''} failed to import`); + toast.error( + `${failed} contact${failed !== 1 ? 's' : ''} failed to import` + ); } } catch (err: unknown) { const message = err instanceof Error ? err.message : 'Import failed'; @@ -325,8 +376,12 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps } const preview = parsedRows.slice(0, PREVIEW_LIMIT); + // Tags: OR — show when the CSV declares a column or preview rows carry + // values, so an all-empty tags column still renders for validation. const previewHasTags = hasTagsColumn || preview.some((row) => row.tagNames.length > 0); + // Company: AND — hide unless the CSV declares it and preview has data, + // avoiding an all-dash column that wastes horizontal space. const previewHasCompany = hasCompanyColumn && preview.some((row) => row.company?.trim()); @@ -346,8 +401,10 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
- Import Contacts - + + Import Contacts + + Upload a CSV with a required{' '} phone @@ -377,19 +434,20 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps tabIndex={0} onClick={() => fileInputRef.current?.click()} onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') fileInputRef.current?.click(); + if (e.key === 'Enter' || e.key === ' ') + fileInputRef.current?.click(); }} className={cn( 'group flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border border-dashed p-5 transition-all', file ? 'border-primary/35 bg-primary/[0.04]' - : 'border-slate-700/80 bg-slate-950/40 hover:border-primary/40 hover:bg-slate-950/70', + : 'hover:border-primary/40 border-slate-700/80 bg-slate-950/40 hover:bg-slate-950/70' )} > {file ? ( <> -
- +
+

- {parsedRows.length} row{parsedRows.length !== 1 ? 's' : ''} ready + {parsedRows.length} row{parsedRows.length !== 1 ? 's' : ''}{' '} + ready ) : ( @@ -406,8 +465,12 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps

-

Click to choose a CSV file

-

.csv up to your browser limit

+

+ Click to choose a CSV file +

+

+ .csv up to your browser limit +

)}
@@ -425,13 +488,13 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps {preview.length > 0 && !result && (
-

+

Preview · first {preview.length}

{tagStats.rowsWithTags > 0 && ( - + {tagStats.unique} tag{tagStats.unique !== 1 ? 's' : ''} ·{' '} {tagStats.rowsWithTags} contact {tagStats.rowsWithTags !== 1 ? 's' : ''} @@ -445,22 +508,22 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps - - - {previewHasCompany && ( - )} {previewHasTags && ( - )} @@ -472,8 +535,12 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps key={i} className="bg-slate-900/40 transition-colors hover:bg-slate-800/30" > -
+ Phone + Name + Email + Company + Tags - + + Import complete

{result.imported > 0 && ( -
+
{result.imported} imported
@@ -532,7 +599,8 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps {result.tagsAssigned > 0 && (
- {result.tagsAssigned} tag{result.tagsAssigned !== 1 ? 's' : ''} assigned + {result.tagsAssigned} tag + {result.tagsAssigned !== 1 ? 's' : ''} assigned
)} {result.skipped > 0 && ( diff --git a/src/lib/contacts/parse-contact-csv.test.ts b/src/lib/contacts/parse-contact-csv.test.ts index ac9a92e..d25670f 100644 --- a/src/lib/contacts/parse-contact-csv.test.ts +++ b/src/lib/contacts/parse-contact-csv.test.ts @@ -1,31 +1,31 @@ -import { describe, expect, it } from "vitest"; -import { parseContactCsv, parseTagCell } from "./parse-contact-csv"; +import { describe, expect, it } from 'vitest'; +import { parseContactCsv, parseTagCell } from './parse-contact-csv'; -describe("parseTagCell", () => { - it("splits comma-separated tags and trims whitespace", () => { - expect(parseTagCell(" VIP , Lead , ")).toEqual(["VIP", "Lead"]); +describe('parseTagCell', () => { + it('splits comma-separated tags and trims whitespace', () => { + expect(parseTagCell(' VIP , Lead , ')).toEqual(['VIP', 'Lead']); }); - it("splits semicolon-separated tags", () => { - expect(parseTagCell("VIP; Lead; Customer")).toEqual([ - "VIP", - "Lead", - "Customer", + it('splits semicolon-separated tags', () => { + expect(parseTagCell('VIP; Lead; Customer')).toEqual([ + 'VIP', + 'Lead', + 'Customer', ]); }); - it("de-dupes case-insensitively", () => { - expect(parseTagCell("vip, VIP, Lead")).toEqual(["vip", "Lead"]); + it('de-dupes case-insensitively', () => { + expect(parseTagCell('vip, VIP, Lead')).toEqual(['vip', 'Lead']); }); - it("returns empty for blank values", () => { - expect(parseTagCell("")).toEqual([]); + it('returns empty for blank values', () => { + expect(parseTagCell('')).toEqual([]); expect(parseTagCell(undefined)).toEqual([]); }); }); -describe("parseContactCsv", () => { - it("parses optional tags column", () => { +describe('parseContactCsv', () => { + it('parses optional tags column', () => { const csv = `phone,name,tags +15551234567,Alice,"VIP, Lead" +15559876543,Bob,Customer`; @@ -35,24 +35,24 @@ describe("parseContactCsv", () => { hasCompanyColumn: false, rows: [ { - phone: "+15551234567", - name: "Alice", + phone: '+15551234567', + name: 'Alice', email: undefined, company: undefined, - tagNames: ["VIP", "Lead"], + tagNames: ['VIP', 'Lead'], }, { - phone: "+15559876543", - name: "Bob", + phone: '+15559876543', + name: 'Bob', email: undefined, company: undefined, - tagNames: ["Customer"], + tagNames: ['Customer'], }, ], }); }); - it("returns empty tagNames when tags column is absent", () => { + it('returns empty tagNames when tags column is absent', () => { const csv = `phone,name +15551234567,Alice`; @@ -61,8 +61,8 @@ describe("parseContactCsv", () => { hasCompanyColumn: false, rows: [ { - phone: "+15551234567", - name: "Alice", + phone: '+15551234567', + name: 'Alice', email: undefined, company: undefined, tagNames: [], diff --git a/src/lib/contacts/parse-contact-csv.ts b/src/lib/contacts/parse-contact-csv.ts index 199b42c..fbeeaff 100644 --- a/src/lib/contacts/parse-contact-csv.ts +++ b/src/lib/contacts/parse-contact-csv.ts @@ -46,18 +46,18 @@ export function parseContactCsv(text: string): ParseContactCsvResult { } const headers = lines[0] - .split(",") - .map((h) => h.trim().toLowerCase().replace(/["']/g, "")); + .split(',') + .map((h) => h.trim().toLowerCase().replace(/["']/g, '')); - const phoneIdx = headers.indexOf("phone"); + const phoneIdx = headers.indexOf('phone'); if (phoneIdx === -1) { return { rows: [], hasTagsColumn: false, hasCompanyColumn: false }; } - const nameIdx = headers.indexOf("name"); - const emailIdx = headers.indexOf("email"); - const companyIdx = headers.indexOf("company"); - const tagsIdx = headers.indexOf("tags"); + const nameIdx = headers.indexOf('name'); + const emailIdx = headers.indexOf('email'); + const companyIdx = headers.indexOf('company'); + const tagsIdx = headers.indexOf('tags'); const rows: ParsedContactRow[] = []; @@ -66,25 +66,25 @@ export function parseContactCsv(text: string): ParseContactCsvResult { if (!line) continue; const values = parseCsvLine(line); - const phone = values[phoneIdx]?.replace(/["']/g, "").trim(); + const phone = values[phoneIdx]?.replace(/["']/g, '').trim(); if (!phone) continue; rows.push({ phone, name: nameIdx >= 0 - ? values[nameIdx]?.replace(/["']/g, "").trim() || undefined + ? values[nameIdx]?.replace(/["']/g, '').trim() || undefined : undefined, email: emailIdx >= 0 - ? values[emailIdx]?.replace(/["']/g, "").trim() || undefined + ? values[emailIdx]?.replace(/["']/g, '').trim() || undefined : undefined, company: companyIdx >= 0 - ? values[companyIdx]?.replace(/["']/g, "").trim() || undefined + ? values[companyIdx]?.replace(/["']/g, '').trim() || undefined : undefined, tagNames: - tagsIdx >= 0 ? parseTagCell(values[tagsIdx]?.replace(/["']/g, "")) : [], + tagsIdx >= 0 ? parseTagCell(values[tagsIdx]?.replace(/["']/g, '')) : [], }); } @@ -98,15 +98,15 @@ export function parseContactCsv(text: string): ParseContactCsvResult { /** Simple CSV line parse (handles quoted fields). */ function parseCsvLine(line: string): string[] { const values: string[] = []; - let current = ""; + let current = ''; let inQuotes = false; for (const char of line) { if (char === '"') { inQuotes = !inQuotes; - } else if (char === "," && !inQuotes) { + } else if (char === ',' && !inQuotes) { values.push(current.trim()); - current = ""; + current = ''; } else { current += char; } diff --git a/src/lib/contacts/resolve-import-tags.ts b/src/lib/contacts/resolve-import-tags.ts index 9a3f75d..618cc60 100644 --- a/src/lib/contacts/resolve-import-tags.ts +++ b/src/lib/contacts/resolve-import-tags.ts @@ -1,6 +1,6 @@ -import type { SupabaseClient } from "@supabase/supabase-js"; +import type { SupabaseClient } from '@supabase/supabase-js'; -const DEFAULT_TAG_COLOR = "#3b82f6"; +const DEFAULT_TAG_COLOR = '#3b82f6'; export interface ResolveImportTagsResult { /** Lowercase tag name → tag id. */ @@ -14,6 +14,9 @@ export interface ResolveImportTagsResult { * are matched case-insensitively. Missing names are created when * `canCreateTags` is true (admin+); otherwise they are reported in * `skippedNames`. + * + * Unlike the manual contact form (existing tags only), import may + * auto-create missing tag definitions for admin+ callers. */ export async function resolveImportTagIds( supabase: SupabaseClient, @@ -23,7 +26,7 @@ export async function resolveImportTagIds( tagNames: string[]; canCreateTags: boolean; defaultColor?: string; - }, + } ): Promise { const { accountId, userId, tagNames, canCreateTags } = params; const defaultColor = params.defaultColor ?? DEFAULT_TAG_COLOR; @@ -39,10 +42,14 @@ export async function resolveImportTagIds( uniqueNames.push(name); } + if (uniqueNames.length === 0) { + return { tagIdByKey: new Map(), skippedNames: [] }; + } + const { data: existing, error: fetchError } = await supabase - .from("tags") - .select("id, name") - .eq("account_id", accountId); + .from('tags') + .select('id, name') + .eq('account_id', accountId); if (fetchError) throw fetchError; @@ -64,16 +71,16 @@ export async function resolveImportTagIds( if (toCreate.length > 0) { const { data: created, error: createError } = await supabase - .from("tags") + .from('tags') .insert( toCreate.map((name) => ({ user_id: userId, account_id: accountId, name, color: defaultColor, - })), + })) ) - .select("id, name"); + .select('id, name'); if (createError) throw createError; @@ -90,11 +97,17 @@ export interface ContactTagAssignment { tagNames: string[]; } -/** Insert contact_tags rows for imported contacts (ignores duplicates). */ +/** + * Insert contact_tags rows for imported contacts (ignores duplicates). + * + * Returns the number of contact–tag pairs *requested* for upsert, not + * rows actually inserted — `ignoreDuplicates` can drop pairs that already + * exist without changing the returned count. + */ export async function assignImportedContactTags( supabase: SupabaseClient, assignments: ContactTagAssignment[], - tagIdByKey: Map, + tagIdByKey: Map ): Promise { const rows: { contact_id: string; tag_id: string }[] = []; @@ -115,8 +128,8 @@ export async function assignImportedContactTags( for (let i = 0; i < rows.length; i += chunkSize) { const chunk = rows.slice(i, i + chunkSize); - const { error } = await supabase.from("contact_tags").upsert(chunk, { - onConflict: "contact_id,tag_id", + const { error } = await supabase.from('contact_tags').upsert(chunk, { + onConflict: 'contact_id,tag_id', ignoreDuplicates: true, }); if (error) throw error; From 5733b6289833215328df5f2f954ce7ce72b857b2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 08:43:33 +0000 Subject: [PATCH 07/40] chore(deps-dev): bump the dev-dependencies group across 1 directory with 4 updates Bumps the dev-dependencies group with 4 updates in the / directory: [@tailwindcss/postcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-postcss), [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node), [prettier](https://github.com/prettier/prettier) and [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest). Updates `@tailwindcss/postcss` from 4.3.0 to 4.3.1 - [Release notes](https://github.com/tailwindlabs/tailwindcss/releases) - [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.3.1/packages/@tailwindcss-postcss) Updates `@types/node` from 25.9.2 to 25.9.3 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node) Updates `prettier` from 3.8.3 to 3.8.4 - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.8.3...3.8.4) Updates `vitest` from 4.1.8 to 4.1.9 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.9/packages/vitest) --- updated-dependencies: - dependency-name: "@tailwindcss/postcss" dependency-version: 4.3.1 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-dependencies - dependency-name: "@types/node" dependency-version: 25.9.3 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-dependencies - dependency-name: prettier dependency-version: 3.8.4 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-dependencies - dependency-name: vitest dependency-version: 4.1.9 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-dependencies ... Signed-off-by: dependabot[bot] --- package-lock.json | 298 +++++++++++++++++++++++++--------------------- package.json | 4 +- 2 files changed, 166 insertions(+), 136 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8fa6a9a..426aa7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,11 +37,11 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.2.6", - "prettier": "^3.8.3", + "prettier": "^3.8.4", "prettier-plugin-tailwindcss": "^0.8.0", "tailwindcss": "^4", "typescript": "^6", - "vitest": "^4.1.8" + "vitest": "^4.1.9" }, "engines": { "node": ">=20.0.0" @@ -2032,6 +2032,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2049,6 +2052,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2066,6 +2072,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2083,6 +2092,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2100,6 +2112,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2117,6 +2132,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2163,14 +2181,14 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.2" }, "funding": { "type": "github", @@ -2378,49 +2396,49 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", - "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", + "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.21.0", - "jiti": "^2.6.1", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.3.0" + "tailwindcss": "4.3.1" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", - "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", + "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.0", - "@tailwindcss/oxide-darwin-arm64": "4.3.0", - "@tailwindcss/oxide-darwin-x64": "4.3.0", - "@tailwindcss/oxide-freebsd-x64": "4.3.0", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", - "@tailwindcss/oxide-linux-x64-musl": "4.3.0", - "@tailwindcss/oxide-wasm32-wasi": "4.3.0", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + "@tailwindcss/oxide-android-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-x64": "4.3.1", + "@tailwindcss/oxide-freebsd-x64": "4.3.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-x64-musl": "4.3.1", + "@tailwindcss/oxide-wasm32-wasi": "4.3.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", - "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", + "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", "cpu": [ "arm64" ], @@ -2435,9 +2453,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", - "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", + "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", "cpu": [ "arm64" ], @@ -2452,9 +2470,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", - "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", + "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", "cpu": [ "x64" ], @@ -2469,9 +2487,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", - "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", + "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", "cpu": [ "x64" ], @@ -2486,9 +2504,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", - "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", + "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", "cpu": [ "arm" ], @@ -2503,13 +2521,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", - "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", + "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2520,13 +2541,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", - "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", + "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2537,13 +2561,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", - "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", + "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2554,13 +2581,16 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", - "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", + "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2571,9 +2601,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", - "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", + "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -2593,7 +2623,7 @@ "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", - "@tybys/wasm-util": "^0.10.1", + "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "engines": { @@ -2650,7 +2680,7 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.10.1", + "version": "0.10.2", "dev": true, "inBundle": true, "license": "MIT", @@ -2667,9 +2697,9 @@ "optional": true }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", - "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", + "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", "cpu": [ "arm64" ], @@ -2684,9 +2714,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", - "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", + "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", "cpu": [ "x64" ], @@ -2701,17 +2731,17 @@ } }, "node_modules/@tailwindcss/postcss": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.0.tgz", - "integrity": "sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.1.tgz", + "integrity": "sha512-dNJuNbdEJT/SWRuXTYP1WSamelsz3ztkUsdtWQPjrexysrTpaEPM40P/71knXiXLYEojqPOEGitVLLpPMS5T6A==", "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.3.0", - "@tailwindcss/oxide": "4.3.0", - "postcss": "^8.5.10", - "tailwindcss": "4.3.0" + "@tailwindcss/node": "4.3.1", + "@tailwindcss/oxide": "4.3.1", + "postcss": "8.5.15", + "tailwindcss": "4.3.1" } }, "node_modules/@ts-morph/common": { @@ -2790,9 +2820,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, "license": "MIT", "optional": true, @@ -2937,9 +2967,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.9.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz", - "integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==", + "version": "25.9.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", + "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", "dev": true, "license": "MIT", "dependencies": { @@ -3543,16 +3573,16 @@ ] }, "node_modules/@vitest/expect": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", - "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -3561,13 +3591,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", - "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.8", + "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -3588,9 +3618,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", - "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", "dev": true, "license": "MIT", "dependencies": { @@ -3601,13 +3631,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", - "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.8", + "@vitest/utils": "4.1.9", "pathe": "^2.0.3" }, "funding": { @@ -3615,14 +3645,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", - "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -3631,9 +3661,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", - "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", "dev": true, "license": "MIT", "funding": { @@ -3641,13 +3671,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", - "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", + "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -5037,9 +5067,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.21.4", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.4.tgz", - "integrity": "sha512-wE4fDO8OjJhrPFH69HUQStq5oKvGRTNXEyW+k5C/pUQLASSsTu7obd2V3GvCDgPcY9AWjhJ4jz9Kh7iRvrxhJg==", + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7221,9 +7251,9 @@ } }, "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", "bin": { @@ -8583,9 +8613,9 @@ } }, "node_modules/prettier": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", - "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", + "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", "dev": true, "license": "MIT", "bin": { @@ -9941,9 +9971,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", - "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", + "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", "dev": true, "license": "MIT" }, @@ -10548,19 +10578,19 @@ } }, "node_modules/vitest": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", - "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.8", - "@vitest/mocker": "4.1.8", - "@vitest/pretty-format": "4.1.8", - "@vitest/runner": "4.1.8", - "@vitest/snapshot": "4.1.8", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -10588,12 +10618,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.8", - "@vitest/browser-preview": "4.1.8", - "@vitest/browser-webdriverio": "4.1.8", - "@vitest/coverage-istanbul": "4.1.8", - "@vitest/coverage-v8": "4.1.8", - "@vitest/ui": "4.1.8", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/package.json b/package.json index 6677dc3..e99faf0 100644 --- a/package.json +++ b/package.json @@ -67,11 +67,11 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.2.6", - "prettier": "^3.8.3", + "prettier": "^3.8.4", "prettier-plugin-tailwindcss": "^0.8.0", "tailwindcss": "^4", "typescript": "^6", - "vitest": "^4.1.8" + "vitest": "^4.1.9" }, "overrides": { "postcss": "^8.5.10", From 4aa329b5278d0d7ec9735e39eadbe39a66efc55a Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Tue, 16 Jun 2026 17:26:29 +0300 Subject: [PATCH 08/40] feat(theme): add light/dark mode toggle (#243) Introduce a light/dark MODE axis orthogonal to the existing accent themes. Neutral surface tokens now live in html[data-mode="light|dark"] blocks; accent tokens (--primary*, --ring, --chart-1, --sidebar-primary*) stay in html[data-theme] blocks, so any accent works in either mode. - globals.css: split neutrals (mode) from accent (theme); add light palette; dark values unchanged (dark stays the default). - lib/themes.ts: Mode type, DEFAULT_MODE=dark, MODE_STORAGE_KEY, isMode. - use-theme.tsx: provider owns mode/setMode/toggleMode + cross-tab sync. - layout.tsx: boot script applies persisted mode before first paint (no flash); colorScheme "dark light". - mode-toggle.tsx (header sun/moon) + themed-toaster.tsx (mode-aware). - appearance-panel.tsx: light/dark control + updated copy. - Tokenize hardcoded neutral colors (slate/white/gray) -> semantic tokens across the app so every surface flips with mode. Flow canvas and message-bubble timestamps made mode-aware too. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app/(auth)/forgot-password/page.tsx | 26 +-- src/app/(auth)/login/page.tsx | 18 +- src/app/(auth)/signup/page.tsx | 38 ++-- .../automations/[id]/logs/page.tsx | 32 ++-- src/app/(dashboard)/automations/page.tsx | 30 +-- src/app/(dashboard)/broadcasts/[id]/page.tsx | 76 ++++---- src/app/(dashboard)/broadcasts/new/page.tsx | 10 +- src/app/(dashboard)/broadcasts/page.tsx | 46 ++--- src/app/(dashboard)/contacts/page.tsx | 92 ++++----- src/app/(dashboard)/dashboard-shell.tsx | 6 +- src/app/(dashboard)/dashboard/page.tsx | 4 +- src/app/(dashboard)/flows/[id]/page.tsx | 4 +- src/app/(dashboard)/flows/[id]/runs/page.tsx | 50 ++--- src/app/(dashboard)/flows/page.tsx | 50 ++--- src/app/(dashboard)/pipelines/page.tsx | 44 ++--- src/app/(dashboard)/settings/page.tsx | 22 +-- src/app/globals.css | 178 +++++++----------- src/app/join/[token]/page.tsx | 44 ++--- src/app/join/layout.tsx | 2 +- src/app/layout.tsx | 71 +++---- .../automations/automation-builder.tsx | 108 +++++------ .../broadcasts/step1-choose-template.tsx | 24 +-- .../broadcasts/step2-select-audience.tsx | 58 +++--- .../broadcasts/step3-personalize.tsx | 38 ++-- .../broadcasts/step4-schedule-send.tsx | 50 ++--- .../contacts/contact-detail-view.tsx | 76 ++++---- src/components/contacts/contact-form.tsx | 36 ++-- .../contacts/custom-fields-manager.tsx | 20 +- src/components/contacts/import-modal.tsx | 74 ++++---- src/components/dashboard/activity-feed.tsx | 28 +-- .../dashboard/conversations-chart.tsx | 24 +-- src/components/dashboard/empty-state.tsx | 8 +- src/components/dashboard/metric-card.tsx | 12 +- src/components/dashboard/pipeline-donut.tsx | 18 +- src/components/dashboard/quick-actions.tsx | 6 +- .../dashboard/response-time-chart.tsx | 14 +- src/components/dashboard/skeleton.tsx | 4 +- src/components/flows/flow-builder.tsx | 54 +++--- src/components/flows/flow-canvas.tsx | 55 +++--- src/components/flows/flow-editor-shell.tsx | 8 +- src/components/flows/forms/fields.tsx | 10 +- .../flows/forms/node-config-form.tsx | 76 ++++---- src/components/flows/header.tsx | 12 +- src/components/flows/shared.tsx | 2 +- src/components/flows/validation-panel.tsx | 10 +- src/components/inbox/contact-sidebar.tsx | 52 ++--- src/components/inbox/conversation-list.tsx | 30 +-- src/components/inbox/message-actions.tsx | 10 +- src/components/inbox/message-bubble.tsx | 39 ++-- src/components/inbox/message-composer.tsx | 8 +- src/components/inbox/message-reactions.tsx | 2 +- src/components/inbox/message-thread.tsx | 54 +++--- src/components/inbox/reply-quote.tsx | 8 +- src/components/inbox/template-picker.tsx | 48 ++--- src/components/layout/header.tsx | 35 ++-- src/components/layout/mode-toggle.tsx | 37 ++++ src/components/layout/sidebar.tsx | 40 ++-- src/components/pipelines/deal-card.tsx | 12 +- src/components/pipelines/deal-form.tsx | 52 ++--- .../pipelines/pipeline-analytics.tsx | 12 +- src/components/pipelines/pipeline-board.tsx | 12 +- .../pipelines/pipeline-settings.tsx | 36 ++-- src/components/settings/appearance-panel.tsx | 143 ++++++++++---- .../settings/custom-fields-settings.tsx | 6 +- src/components/settings/deals-settings.tsx | 12 +- .../settings/invite-member-dialog.tsx | 44 ++--- src/components/settings/members-tab.tsx | 60 +++--- src/components/settings/password-form.tsx | 12 +- src/components/settings/profile-form.tsx | 32 ++-- src/components/settings/sessions-card.tsx | 6 +- src/components/settings/tag-manager.tsx | 44 ++--- src/components/settings/template-manager.tsx | 130 ++++++------- src/components/settings/whatsapp-config.tsx | 120 ++++++------ src/components/themed-toaster.tsx | 30 +++ src/components/tremor/bar-chart.tsx | 32 ++-- src/components/tremor/chart-colors.ts | 16 +- src/components/ui/dialog.tsx | 2 +- src/components/ui/sheet.tsx | 2 +- src/components/ui/switch.tsx | 6 +- src/hooks/use-theme.tsx | 82 ++++++-- src/lib/automations/trigger-meta.ts | 4 +- src/lib/broadcast-status.ts | 4 +- src/lib/template-status.ts | 4 +- src/lib/themes.ts | 26 +++ 84 files changed, 1581 insertions(+), 1391 deletions(-) create mode 100644 src/components/layout/mode-toggle.tsx create mode 100644 src/components/themed-toaster.tsx diff --git a/src/app/(auth)/forgot-password/page.tsx b/src/app/(auth)/forgot-password/page.tsx index 02ff209..e1c5183 100644 --- a/src/app/(auth)/forgot-password/page.tsx +++ b/src/app/(auth)/forgot-password/page.tsx @@ -43,18 +43,18 @@ export default function ForgotPasswordPage() { if (success) { return ( -
- +
+
- + Check your email - + We've sent a password reset link to{" "} - {email}. Please check your + {email}. Please check your inbox.
@@ -62,7 +62,7 @@ export default function ForgotPasswordPage() { @@ -74,14 +74,14 @@ export default function ForgotPasswordPage() { } return ( -
- +
+
- Reset password - + Reset password + Enter your email and we'll send you a reset link
@@ -94,7 +94,7 @@ export default function ForgotPasswordPage() { )}
-
@@ -119,7 +119,7 @@ export default function ForgotPasswordPage() { Back to sign in diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 1a53226..0ffaab2 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -67,8 +67,8 @@ function LoginPageInner() { }; return ( -
- +
+
{inviteToken ? ( @@ -77,10 +77,10 @@ function LoginPageInner() { )}
- + {inviteToken ? "Sign in to accept" : "Welcome back"} - + {inviteToken ? "Sign in and we'll take you to the invitation." : "Sign in to your account"} @@ -95,7 +95,7 @@ function LoginPageInner() { )}
-
-
@@ -141,7 +141,7 @@ function LoginPageInner() { -

+

Don't have an account?{" "} - +

+
- + Check your email - + We've sent a confirmation link to{" "} - {email}. Please check your + {email}. Please check your inbox and click the link to verify your account.
@@ -116,7 +116,7 @@ function SignupPageInner() { > @@ -128,8 +128,8 @@ function SignupPageInner() { } return ( -
- +
+
{inviteToken ? ( @@ -138,10 +138,10 @@ function SignupPageInner() { )}
- + {inviteToken ? "Create account & join" : "Create account"} - + {inviteToken ? "Verify your email, then accept the invitation to join your team." : "Get started with CRM Template for WhatsApp"} @@ -156,7 +156,7 @@ function SignupPageInner() { )}
-
-
-
-
@@ -224,7 +224,7 @@ function SignupPageInner() { -

+

Already have an account?{" "} router.push("/automations")} - className="flex h-8 w-8 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-slate-800 hover:text-white" + className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" aria-label="Back" >

-

{automation.name}

-

Execution logs

+

{automation.name}

+

Execution logs

{logs.length === 0 ? ( -
-

No executions yet

-

+

+

No executions yet

+

Trigger this automation to see runs here.

@@ -112,7 +112,7 @@ export default function AutomationLogsPage({ return (
  • {isOpen && ( -
    +
    {log.error_message && (

    {log.error_message} @@ -150,7 +150,7 @@ export default function AutomationLogsPage({ ))} {(log.steps_executed ?? []).length === 0 && ( -

  • No steps recorded.
  • +
  • No steps recorded.
  • )}
    @@ -196,9 +196,9 @@ function StepRow({ result }: { result: AutomationLogStepResult }) { > {ok ? : } - {result.step_type} + {result.step_type} {result.detail && ( - — {result.detail} + — {result.detail} )} ) diff --git a/src/app/(dashboard)/automations/page.tsx b/src/app/(dashboard)/automations/page.tsx index ac5eaad..28760ae 100644 --- a/src/app/(dashboard)/automations/page.tsx +++ b/src/app/(dashboard)/automations/page.tsx @@ -160,8 +160,8 @@ export default function AutomationsPage() {
    -

    Automations

    -

    +

    Automations

    +

    Build workflows that react to WhatsApp® events automatically.

    @@ -178,7 +178,7 @@ export default function AutomationsPage() { {showTemplates && (
    -

    Quick-start templates

    +

    Quick-start templates

    {TEMPLATE_ORDER.map((slug) => { const t = AUTOMATION_TEMPLATES[slug] @@ -187,13 +187,13 @@ export default function AutomationsPage() { ) })} @@ -202,12 +202,12 @@ export default function AutomationsPage() { )} {automations.length === 0 ? ( -
    +
    -

    No automations yet

    -

    +

    No automations yet

    +

    Pick a template above or create one from scratch.

    @@ -233,7 +233,7 @@ export default function AutomationsPage() { Delete automation This permanently removes{" "} - {pendingDelete?.name} and its execution + {pendingDelete?.name} and its execution history. This cannot be undone. @@ -277,7 +277,7 @@ function AutomationCard({ }) { const meta = triggerMeta(automation.trigger_type) return ( -
  • +
  • - + {automation.name} {automation.is_active && ( @@ -303,9 +303,9 @@ function AutomationCard({ )}
    {automation.description && ( -

    {automation.description}

    +

    {automation.description}

    )} -
    +
    diff --git a/src/app/(dashboard)/broadcasts/[id]/page.tsx b/src/app/(dashboard)/broadcasts/[id]/page.tsx index 7511806..7449d51 100644 --- a/src/app/(dashboard)/broadcasts/[id]/page.tsx +++ b/src/app/(dashboard)/broadcasts/[id]/page.tsx @@ -50,15 +50,15 @@ interface StatCardProps { function StatCard({ label, value, total, icon, color }: StatCardProps) { const pct = total > 0 ? Math.round((value / total) * 100) : 0; return ( -
    +
    {icon}
    - {pct}% + {pct}%
    -

    {value.toLocaleString()}

    -

    {label}

    +

    {value.toLocaleString()}

    +

    {label}

    ); } @@ -77,8 +77,8 @@ interface FunnelStep { function FunnelChart({ steps }: { steps: FunnelStep[] }) { const max = Math.max(...steps.map((s) => s.value), 1); return ( -
    -

    Funnel

    +
    +

    Funnel

    {steps.map((step) => { const pctOfMax = Math.max(5, Math.round((step.value / max) * 100)); @@ -88,17 +88,17 @@ function FunnelChart({ steps }: { steps: FunnelStep[] }) { : 0; return (
    - + {step.label} -
    +
    - + {step.value.toLocaleString()} - + ({pctOfSent}%) @@ -280,20 +280,20 @@ export default function BroadcastDetailPage() { variant="outline" size="icon" onClick={() => router.push('/broadcasts')} - className="border-slate-700" + className="border-border" >
    -

    {broadcast.name}

    +

    {broadcast.name}

    {status.label}
    -
    +
    Template: {broadcast.template_name} - @@ -315,7 +315,7 @@ export default function BroadcastDetailPage() { size="sm" onClick={() => setConfirmDelete(false)} disabled={deleting} - className="h-7 border-slate-700 bg-transparent text-slate-300 hover:bg-slate-800" + className="h-7 border-border bg-transparent text-muted-foreground hover:bg-muted" > Cancel @@ -354,7 +354,7 @@ export default function BroadcastDetailPage() { value={broadcast.total_recipients} total={broadcast.total_recipients} icon={} - color="bg-slate-800 text-slate-300" + color="bg-muted text-muted-foreground" /> {/* Recipients Table */} -
    -
    -

    +
    +
    +

    Recipients ({filteredRecipients.length} {statusFilter !== 'all' ? ` of ${recipients.length}` : ''})

    @@ -409,7 +409,7 @@ export default function BroadcastDetailPage() {