@graph8/sdk 0.373.0 → 0.374.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/blocks/_foundation/README.md +47 -0
- package/blocks/_foundation/block.json +13 -0
- package/blocks/_foundation/components/auth-gate.tsx +363 -0
- package/blocks/_foundation/server/auth.ts +815 -0
- package/blocks/campaigns/README.md +1 -1
- package/blocks/campaigns/block.json +34 -1
- package/blocks/campaigns/components/campaign-links.tsx +180 -0
- package/blocks/company-intelligence/components/research-run.tsx +6 -2
- package/blocks/crm/README.md +19 -0
- package/blocks/crm/block.json +205 -6
- package/blocks/crm/components/bulk-update.tsx +119 -0
- package/blocks/crm/components/contact-documents-tab.tsx +144 -0
- package/blocks/crm/components/contact-insights-tab.tsx +118 -0
- package/blocks/crm/components/contact-lookup-link.tsx +47 -6
- package/blocks/crm/components/contact-row-actions.tsx +43 -0
- package/blocks/crm/components/contact-tag-chips.tsx +113 -0
- package/blocks/crm/components/contacts-screen.tsx +46 -5
- package/blocks/crm/components/owner-assign.tsx +132 -0
- package/blocks/crm/components/record-open-links.tsx +79 -0
- package/blocks/crm/components/tag-assign.tsx +112 -0
- package/blocks/crm/components/tag-manager.tsx +178 -0
- package/blocks/crm/components/unlock-info-button.tsx +82 -0
- package/blocks/crm/components/verify-email-button.tsx +93 -0
- package/blocks/crm/hooks/use-contact-tags.ts +29 -0
- package/blocks/crm/lib/record-actions.ts +102 -0
- package/blocks/deals/README.md +1 -0
- package/blocks/deals/block.json +17 -0
- package/blocks/deals/components/create-deal-button.tsx +109 -0
- package/blocks/deals/components/deal-detail.tsx +5 -3
- package/blocks/dialer/components/call-log-screen.tsx +6 -1
- package/blocks/documents/README.md +86 -0
- package/blocks/documents/block.json +378 -0
- package/blocks/documents/components/account-bar.tsx +48 -0
- package/blocks/documents/components/correspondence-form.tsx +95 -0
- package/blocks/documents/components/correspondence-import.tsx +106 -0
- package/blocks/documents/components/correspondence-panel.tsx +191 -0
- package/blocks/documents/components/documents-screen.tsx +76 -0
- package/blocks/documents/components/library-document-detail.tsx +175 -0
- package/blocks/documents/components/library-document-form.tsx +105 -0
- package/blocks/documents/components/library-folder-form.tsx +74 -0
- package/blocks/documents/components/library-panel.tsx +235 -0
- package/blocks/documents/components/library-upload.tsx +160 -0
- package/blocks/documents/components/question-row.tsx +130 -0
- package/blocks/documents/components/questionnaire-detail.tsx +197 -0
- package/blocks/documents/components/questionnaires-panel.tsx +173 -0
- package/blocks/documents/components/record-documents-tab.tsx +109 -0
- package/blocks/documents/lib/documents-types.ts +252 -0
- package/blocks/documents/lib/download.ts +64 -0
- package/blocks/documents/recipes/follow-a-questionnaire.ts +26 -0
- package/blocks/documents/recipes/log-account-correspondence.ts +27 -0
- package/blocks/documents/recipes/organize-the-library.ts +25 -0
- package/blocks/email-signatures/README.md +123 -0
- package/blocks/email-signatures/block.json +341 -0
- package/blocks/email-signatures/components/email-signatures-screen.tsx +166 -0
- package/blocks/email-signatures/components/signature-assign-template.tsx +119 -0
- package/blocks/email-signatures/components/signature-block-editor.tsx +87 -0
- package/blocks/email-signatures/components/signature-campaign-form.tsx +165 -0
- package/blocks/email-signatures/components/signature-campaigns.tsx +75 -0
- package/blocks/email-signatures/components/signature-identity-form.tsx +131 -0
- package/blocks/email-signatures/components/signature-mailboxes.tsx +195 -0
- package/blocks/email-signatures/components/signature-policy.tsx +130 -0
- package/blocks/email-signatures/components/signature-preview.tsx +119 -0
- package/blocks/email-signatures/components/signature-report.tsx +77 -0
- package/blocks/email-signatures/components/signature-template-details.tsx +112 -0
- package/blocks/email-signatures/components/signature-template-form.tsx +109 -0
- package/blocks/email-signatures/hooks/use-signatures-data.ts +93 -0
- package/blocks/email-signatures/lib/signatures-types.ts +392 -0
- package/blocks/email-signatures/recipes/assign-a-signature-to-a-new-hire.ts +32 -0
- package/blocks/email-signatures/recipes/create-a-template-and-make-it-default.ts +35 -0
- package/blocks/email-signatures/recipes/signature-engagement-report.ts +16 -0
- package/blocks/enrichment/README.md +22 -4
- package/blocks/enrichment/block.json +145 -5
- package/blocks/enrichment/components/auto-enrichment-configs.tsx +134 -0
- package/blocks/enrichment/components/auto-enrichment-settings.tsx +82 -0
- package/blocks/enrichment/components/bulk-enrich-panel.tsx +7 -7
- package/blocks/enrichment/components/enrichment-activity.tsx +93 -0
- package/blocks/enrichment/components/enrichment-job-detail.tsx +18 -0
- package/blocks/enrichment/components/enrichment-screen.tsx +34 -3
- package/blocks/enrichment/components/list-pipelines.tsx +97 -0
- package/blocks/enrichment/components/pipeline-detail.tsx +214 -0
- package/blocks/enrichment/components/pipeline-editor.tsx +144 -0
- package/blocks/enrichment/components/pipeline-templates.tsx +90 -0
- package/blocks/enrichment/lib/enrichment-types.ts +12 -0
- package/blocks/enrichment/lib/pipeline-types.ts +234 -0
- package/blocks/forms/README.md +2 -0
- package/blocks/forms/components/forms-screen.tsx +6 -3
- package/blocks/inbox/README.md +40 -4
- package/blocks/inbox/block.json +130 -8
- package/blocks/inbox/components/blocked-senders.tsx +103 -0
- package/blocks/inbox/components/compose-message.tsx +193 -0
- package/blocks/inbox/components/inbox-screen.tsx +54 -6
- package/blocks/inbox/components/inbox-search.tsx +106 -0
- package/blocks/inbox/components/tag-manager.tsx +136 -0
- package/blocks/inbox/components/thread-actions.tsx +133 -0
- package/blocks/inbox/components/thread-view.tsx +2 -0
- package/blocks/inbox/hooks/use-inbox-mailboxes.ts +31 -0
- package/blocks/inbox/lib/inbox-manage.ts +227 -0
- package/blocks/newsletter/components/newsletter-screen.tsx +3 -1
- package/blocks/pages/block.json +10 -1
- package/blocks/pages/hooks/use-landing-page-paths.ts +60 -0
- package/blocks/programmatic-pages/README.md +82 -0
- package/blocks/programmatic-pages/block.json +292 -0
- package/blocks/programmatic-pages/components/programmatic-campaign-form.tsx +136 -0
- package/blocks/programmatic-pages/components/programmatic-generate.tsx +112 -0
- package/blocks/programmatic-pages/components/programmatic-generated-pages.tsx +210 -0
- package/blocks/programmatic-pages/components/programmatic-overview.tsx +84 -0
- package/blocks/programmatic-pages/components/programmatic-pages-screen.tsx +221 -0
- package/blocks/programmatic-pages/components/programmatic-preview.tsx +66 -0
- package/blocks/programmatic-pages/components/programmatic-publish.tsx +102 -0
- package/blocks/programmatic-pages/components/programmatic-settings.tsx +96 -0
- package/blocks/programmatic-pages/components/programmatic-source-fields.tsx +75 -0
- package/blocks/programmatic-pages/components/programmatic-source.tsx +106 -0
- package/blocks/programmatic-pages/components/programmatic-variables.tsx +84 -0
- package/blocks/programmatic-pages/hooks/use-programmatic-workspace.ts +19 -0
- package/blocks/programmatic-pages/lib/programmatic-types.ts +330 -0
- package/blocks/programmatic-pages/recipes/create-a-programmatic-campaign.ts +23 -0
- package/blocks/programmatic-pages/recipes/render-and-follow.ts +21 -0
- package/blocks/programmatic-pages/recipes/review-then-publish.ts +17 -0
- package/blocks/quotes/README.md +25 -1
- package/blocks/quotes/block.json +123 -6
- package/blocks/quotes/components/manual-products.tsx +144 -0
- package/blocks/quotes/components/quote-batch-actions.tsx +86 -0
- package/blocks/quotes/components/quote-documents.tsx +94 -0
- package/blocks/quotes/components/quote-evidence.tsx +95 -0
- package/blocks/quotes/components/quote-list.tsx +23 -5
- package/blocks/quotes/components/quote-settings.tsx +184 -0
- package/blocks/quotes/components/quote-templates.tsx +189 -0
- package/blocks/quotes/components/quote-viewer.tsx +16 -3
- package/blocks/quotes/components/quotes-screen.tsx +15 -5
- package/blocks/quotes/lib/quote-admin.ts +264 -0
- package/blocks/sequencer/README.md +55 -0
- package/blocks/sequencer/block.json +172 -5
- package/blocks/sequencer/components/enrollment-withdraw.tsx +71 -0
- package/blocks/sequencer/components/sequence-builder.tsx +13 -3
- package/blocks/sequencer/components/sequence-campaign-link.tsx +78 -0
- package/blocks/sequencer/components/sequence-detail.tsx +6 -0
- package/blocks/sequencer/components/sequence-edit-form.tsx +111 -0
- package/blocks/sequencer/components/sequence-enrollments.tsx +2 -1
- package/blocks/sequencer/components/sequence-manage-actions.tsx +119 -0
- package/blocks/sequencer/components/sequence-senders-panel.tsx +84 -0
- package/blocks/sequencer/components/sequence-templates.tsx +160 -0
- package/blocks/sequencer/components/sequences-screen.tsx +36 -6
- package/blocks/sequencer/components/step-ai-assist.tsx +196 -0
- package/blocks/sequencer/components/step-editor.tsx +10 -1
- package/blocks/sequencer/components/step-templates.tsx +224 -0
- package/blocks/sequencer/lib/sequencer-manage.ts +235 -0
- package/blocks/sequencer/lib/sequencer-utils.ts +4 -0
- package/blocks/signals/README.md +39 -0
- package/blocks/signals/block.json +287 -0
- package/blocks/signals/components/auto-seed-panel.tsx +111 -0
- package/blocks/signals/components/listener-form.tsx +184 -0
- package/blocks/signals/components/matched-posts.tsx +195 -0
- package/blocks/signals/components/post-engagements.tsx +114 -0
- package/blocks/signals/components/record-social-tab.tsx +49 -0
- package/blocks/signals/components/social-listeners-screen.tsx +250 -0
- package/blocks/signals/components/social-settings.tsx +128 -0
- package/blocks/signals/components/source-lists.tsx +83 -0
- package/blocks/signals/components/top-engagers.tsx +68 -0
- package/blocks/signals/components/x-integration.tsx +129 -0
- package/blocks/signals/lib/signals-types.ts +228 -0
- package/blocks/signals/recipes/preview-and-create-a-listener.ts +16 -0
- package/blocks/signals/recipes/spend-against-caps.ts +14 -0
- package/blocks/signals/recipes/triage-matched-posts.ts +18 -0
- package/blocks/visitors/components/traffic-chart.tsx +3 -2
- package/blocks/web-analytics/README.md +102 -0
- package/blocks/web-analytics/block.json +292 -0
- package/blocks/web-analytics/components/acquisition-tab.tsx +154 -0
- package/blocks/web-analytics/components/analytics-filters.tsx +161 -0
- package/blocks/web-analytics/components/audience-tab.tsx +135 -0
- package/blocks/web-analytics/components/conversions-tab.tsx +128 -0
- package/blocks/web-analytics/components/engagement-tab.tsx +123 -0
- package/blocks/web-analytics/components/overview-tab.tsx +102 -0
- package/blocks/web-analytics/components/pages-tab.tsx +148 -0
- package/blocks/web-analytics/components/ranked-table.tsx +103 -0
- package/blocks/web-analytics/components/realtime-tab.tsx +103 -0
- package/blocks/web-analytics/components/report-card.tsx +71 -0
- package/blocks/web-analytics/components/web-analytics-screen.tsx +90 -0
- package/blocks/web-analytics/hooks/use-report.ts +48 -0
- package/blocks/web-analytics/lib/analytics-scope.ts +155 -0
- package/blocks/web-analytics/lib/page-path.ts +14 -0
- package/blocks/web-analytics/lib/web-analytics-types.ts +187 -0
- package/blocks/web-analytics/recipes/best-landing-pages.ts +26 -0
- package/blocks/web-analytics/recipes/form-conversion-check.ts +19 -0
- package/blocks/web-analytics/recipes/weekly-traffic-report.ts +37 -0
- package/blocks/workbench/README.md +54 -2
- package/blocks/workbench/block.json +234 -5
- package/blocks/workbench/components/workbench-auto-enrich.tsx +88 -0
- package/blocks/workbench/components/workbench-automation.tsx +36 -0
- package/blocks/workbench/components/workbench-connections.tsx +136 -0
- package/blocks/workbench/components/workbench-crm-import.tsx +91 -0
- package/blocks/workbench/components/workbench-datafile-import.tsx +154 -0
- package/blocks/workbench/components/workbench-grid.tsx +70 -7
- package/blocks/workbench/components/workbench-pipelines.tsx +173 -0
- package/blocks/workbench/components/workbench-row-actions.tsx +8 -1
- package/blocks/workbench/components/workbench-schedule.tsx +191 -0
- package/blocks/workbench/components/workbench-screen.tsx +50 -17
- package/blocks/workbench/components/workbench-send-to-sequence.tsx +116 -0
- package/blocks/workbench/components/workbench-table-settings.tsx +53 -0
- package/blocks/workbench/components/workbench-triggers.tsx +178 -0
- package/blocks/workbench/hooks/use-connected-crms.ts +29 -0
- package/blocks/workbench/lib/api-status.ts +8 -0
- package/blocks/workbench/lib/automation-types.ts +257 -0
- package/dist/cli.js +257 -51
- package/dist/index.browser.js +1 -1
- package/dist/index.browser.js.map +1 -1
- package/dist/index.browser.mjs +1 -1
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/dist/react.browser.js +1 -1
- package/dist/react.browser.js.map +1 -1
- package/dist/react.browser.mjs +1 -1
- package/dist/react.browser.mjs.map +1 -1
- package/dist/react.js +1 -1
- package/dist/react.js.map +1 -1
- package/dist/react.mjs +1 -1
- package/dist/react.mjs.map +1 -1
- package/dist/types/blocks/auth.d.ts +45 -0
- package/dist/types/blocks/create.d.ts +5 -2
- package/dist/types/version.d.ts +2 -2
- package/docs/api/forms.md +1 -1
- package/package.json +1 -1
- package/stacks/6sense/pages/contacts.tsx.tmpl +2 -0
- package/stacks/6sense.json +20 -0
- package/stacks/apollo/pages/calls.tsx.tmpl +9 -9
- package/stacks/apollo/pages/campaigns.tsx.tmpl +27 -0
- package/stacks/apollo/pages/contacts.tsx.tmpl +6 -0
- package/stacks/apollo/pages/inbox-contact.tsx.tmpl +3 -3
- package/stacks/apollo/pages/lists.tsx.tmpl +14 -2
- package/stacks/apollo/pages/sequences.tsx.tmpl +8 -2
- package/stacks/apollo.json +33 -2
- package/stacks/clay/pages/contacts.tsx.tmpl +2 -0
- package/stacks/clay/pages/lists.tsx.tmpl +14 -2
- package/stacks/gong/pages/calls.tsx.tmpl +9 -9
- package/stacks/gong/pages/contacts.tsx.tmpl +2 -0
- package/stacks/hootsuite/pages/contacts.tsx.tmpl +2 -0
- package/stacks/hootsuite.json +10 -0
- package/stacks/hubspot/pages/campaigns.tsx.tmpl +30 -0
- package/stacks/hubspot/pages/companies.tsx.tmpl +2 -0
- package/stacks/hubspot/pages/contacts.tsx.tmpl +6 -0
- package/stacks/hubspot/pages/deals.tsx.tmpl +4 -0
- package/stacks/hubspot/pages/forms.tsx.tmpl +36 -0
- package/stacks/hubspot/pages/inbox.tsx.tmpl +2 -2
- package/stacks/hubspot/pages/sequences.tsx.tmpl +8 -2
- package/stacks/hubspot/pages/web-analytics.tsx.tmpl +14 -0
- package/stacks/hubspot.json +45 -2
- package/stacks/intercom/pages/inbox-contact.tsx.tmpl +3 -3
- package/stacks/intercom.json +10 -0
- package/stacks/mailchimp/pages/contacts.tsx.tmpl +2 -0
- package/stacks/mailchimp/pages/forms.tsx.tmpl +36 -0
- package/stacks/mailchimp.json +2 -1
- package/stacks/outreach/pages/calls.tsx.tmpl +9 -9
- package/stacks/outreach/pages/campaigns.tsx.tmpl +27 -0
- package/stacks/outreach/pages/contacts.tsx.tmpl +2 -0
- package/stacks/outreach/pages/inbox-contact.tsx.tmpl +3 -3
- package/stacks/outreach/pages/sequences.tsx.tmpl +8 -2
- package/stacks/outreach.json +13 -2
- package/stacks/pipedrive/pages/contacts.tsx.tmpl +2 -0
- package/stacks/pipedrive/pages/inbox.tsx.tmpl +2 -2
- package/stacks/pipedrive.json +10 -0
- package/stacks/salesforce/pages/companies.tsx.tmpl +2 -0
- package/stacks/salesforce/pages/contacts.tsx.tmpl +6 -0
- package/stacks/salesforce/pages/deals.tsx.tmpl +4 -0
- package/stacks/salesforce.json +10 -0
- package/stacks/zoominfo/pages/contacts.tsx.tmpl +4 -0
- package/stacks/zoominfo/pages/lists.tsx.tmpl +14 -2
- package/stacks/zoominfo.json +20 -0
- package/styles/styles.css +1 -1
- package/templates/auth/auth-route.ts.tmpl +16 -0
- package/templates/auth/g8.auth.anonymous.ts.tmpl +15 -0
- package/templates/auth/g8.auth.basic.ts.tmpl +17 -0
- package/templates/auth/g8.auth.jwt.ts.tmpl +23 -0
- package/templates/auth/g8.auth.password.ts.tmpl +18 -0
- package/templates/auth/g8.auth.session.ts.tmpl +23 -0
- package/templates/auth/middleware.ts.tmpl +14 -0
- package/templates/next-app/README.md.tmpl +5 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TagManager: the org's contact tags in one sheet. Create, rename, recolour, archive and restore a tag, delete one
|
|
5
|
+
* (optionally moving its contacts to another tag first) and merge several tags into one.
|
|
6
|
+
*
|
|
7
|
+
* SDK operations, in order:
|
|
8
|
+
* 1. listContactTags GET /contacts/tags?include_archived=true (read; every tag at once, with contact counts)
|
|
9
|
+
* 2. createContactTag POST /contacts/tags { name, color, description } (write; a duplicate name is refused by the API)
|
|
10
|
+
* 3. updateContactTag PATCH /contacts/tags/{tag_id} { name, color, description, archived } (write; omitted fields stay)
|
|
11
|
+
* 4. deleteContactTag DELETE /contacts/tags/{tag_id}?reassign_to=... (destructive, ConfirmSpendDialog)
|
|
12
|
+
* Without `reassign_to` the API drops the assignments together with the tag; the contacts themselves stay.
|
|
13
|
+
* 5. mergeContactTags POST /contacts/tags/merge { source_tag_ids, target_tag_id } (destructive: the sources are
|
|
14
|
+
* removed and their contacts get the target, ConfirmSpendDialog)
|
|
15
|
+
*
|
|
16
|
+
* Edit sends only what changed. A tag's description cannot be cleared (the API leaves a null field untouched), so
|
|
17
|
+
* the description is not offered as an editable field here; only name, colour and archive state are.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { useMemo, useState } from "react";
|
|
21
|
+
import { Button, cn } from "@graph8/sdk/ui";
|
|
22
|
+
import { Archive, GitMerge, Pencil, Plus, Trash2 } from "lucide-react";
|
|
23
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
24
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
25
|
+
import { FormSheet } from "@/g8/components/screen/form-sheet";
|
|
26
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
27
|
+
import { g8Delete, g8Patch, g8Post } from "@/g8/lib/g8-client";
|
|
28
|
+
import { plural } from "@/g8/lib/plural";
|
|
29
|
+
import { useContactTags } from "../hooks/use-contact-tags";
|
|
30
|
+
import { HEX_COLOR, TAG_COLORS, isArchivedTag } from "../lib/record-actions";
|
|
31
|
+
import type { ContactTag } from "../lib/record-actions";
|
|
32
|
+
import { TagChip } from "./contact-tag-chips";
|
|
33
|
+
|
|
34
|
+
export interface TagManagerProps {
|
|
35
|
+
open: boolean;
|
|
36
|
+
onOpenChange: (open: boolean) => void;
|
|
37
|
+
/** Called after any change that can alter what the contact list shows. */
|
|
38
|
+
onChanged?: () => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function Swatches({ value, onChange, label }: { value: string; onChange: (c: string) => void; label: string }) {
|
|
42
|
+
return (
|
|
43
|
+
<span className="flex gap-1" role="radiogroup" aria-label={label}>
|
|
44
|
+
{TAG_COLORS.map((c) => <button key={c} type="button" role="radio" aria-checked={value === c} aria-label={`Colour ${c}`} onClick={() => onChange(c)} className={cn("size-5 rounded-full border-2", value === c ? "border-foreground" : "border-transparent")} style={{ backgroundColor: c }} />)}
|
|
45
|
+
</span>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function TagManager({ open, onOpenChange, onChanged }: TagManagerProps) {
|
|
50
|
+
const all = useContactTags({ enabled: open, includeArchived: true });
|
|
51
|
+
const [name, setName] = useState("");
|
|
52
|
+
const [color, setColor] = useState<string>(TAG_COLORS[0]);
|
|
53
|
+
const [editing, setEditing] = useState<{ id: string; name: string; color: string } | null>(null);
|
|
54
|
+
const [picked, setPicked] = useState<ReadonlySet<string>>(new Set());
|
|
55
|
+
const [mergeInto, setMergeInto] = useState("");
|
|
56
|
+
const [confirmMerge, setConfirmMerge] = useState(false);
|
|
57
|
+
const [deleting, setDeleting] = useState<ContactTag | null>(null);
|
|
58
|
+
const [moveTo, setMoveTo] = useState("");
|
|
59
|
+
const done = () => { void all.refetch(); onChanged?.(); };
|
|
60
|
+
const keys = [["contact-tags"], ["contacts", "tags"], ["contacts", "list"]] as const;
|
|
61
|
+
|
|
62
|
+
const create = useG8Mutation((v: { name: string; color: string }) => g8Post("/contacts/tags", v), { invalidates: keys, onSuccess: () => { setName(""); done(); } });
|
|
63
|
+
const update = useG8Mutation((v: { id: string; body: Record<string, unknown> }) => g8Patch(`/contacts/tags/${v.id}`, v.body), { invalidates: keys, onSuccess: () => { setEditing(null); done(); } });
|
|
64
|
+
const remove = useG8Mutation((v: { id: string; reassignTo: string }) => g8Delete(`/contacts/tags/${v.id}`, v.reassignTo ? { query: { reassign_to: v.reassignTo } } : {}), {
|
|
65
|
+
invalidates: keys,
|
|
66
|
+
onSuccess: (_r, v) => { setDeleting(null); setMoveTo(""); setPicked((s) => { const n = new Set(s); n.delete(v.id); return n; }); done(); },
|
|
67
|
+
});
|
|
68
|
+
const merge = useG8Mutation((v: { sources: string[]; target: string }) => g8Post("/contacts/tags/merge", { source_tag_ids: v.sources, target_tag_id: v.target }), {
|
|
69
|
+
invalidates: keys,
|
|
70
|
+
onSuccess: () => { setConfirmMerge(false); setPicked(new Set()); setMergeInto(""); done(); },
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const byId = useMemo(() => new Map(all.tags.map((t) => [t.id, t])), [all.tags]);
|
|
74
|
+
const sources = [...picked].filter((id) => byId.has(id));
|
|
75
|
+
const targets = all.tags.filter((t) => !picked.has(t.id) && !isArchivedTag(t));
|
|
76
|
+
const sourceContacts = sources.reduce((n, id) => n + (byId.get(id)?.contact_count ?? 0), 0);
|
|
77
|
+
const togglePick = (id: string) => setPicked((s) => { const n = new Set(s); if (!n.delete(id)) n.add(id); return n; });
|
|
78
|
+
const saveEdit = (t: ContactTag) => {
|
|
79
|
+
if (!editing) return;
|
|
80
|
+
const body: Record<string, unknown> = {};
|
|
81
|
+
if (editing.name.trim() && editing.name.trim() !== t.name) body.name = editing.name.trim();
|
|
82
|
+
if (HEX_COLOR.test(editing.color) && editing.color !== t.color) body.color = editing.color;
|
|
83
|
+
if (Object.keys(body).length === 0) { setEditing(null); return; }
|
|
84
|
+
void update.mutate({ id: t.id, body });
|
|
85
|
+
};
|
|
86
|
+
const deleteTargets = all.tags.filter((t) => t.id !== deleting?.id && !isArchivedTag(t));
|
|
87
|
+
const allCountsKnown = sources.every((id) => byId.get(id)?.contact_count != null);
|
|
88
|
+
const targetName = byId.get(mergeInto)?.name ?? "";
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<FormSheet open={open} onOpenChange={onOpenChange} title="Manage tags" description="Tags group contacts outside lists." width="lg" footer={<Button variant="ghost" onClick={() => onOpenChange(false)}>Close</Button>}>
|
|
92
|
+
<form className="flex flex-wrap items-center gap-2 border-b pb-3" onSubmit={(e) => { e.preventDefault(); if (name.trim()) void create.mutate({ name: name.trim(), color }); }}>
|
|
93
|
+
<input aria-label="Tag name" maxLength={50} placeholder="New tag" value={name} onChange={(e) => setName(e.target.value)} className="h-9 w-44 rounded-md border bg-background px-3 text-sm" />
|
|
94
|
+
<Swatches value={color} onChange={setColor} label="Tag colour" />
|
|
95
|
+
<Button type="submit" size="sm" loading={create.loading} disabled={!name.trim()}><Plus className="size-4" aria-hidden />Create tag</Button>
|
|
96
|
+
</form>
|
|
97
|
+
{create.error ? <ApiError error={create.error} title="Could not create the tag" /> : null}
|
|
98
|
+
{update.error ? <ApiError error={update.error} title="Could not save the tag" /> : null}
|
|
99
|
+
{all.error ? <ApiError error={all.error} title="Could not load tags" onRetry={() => void all.refetch()} /> : null}
|
|
100
|
+
{all.loading ? <p className="text-sm text-muted-foreground" role="status">Loading tags</p> : null}
|
|
101
|
+
{!all.loading && !all.error && all.tags.length === 0 ? <p className="py-6 text-center text-sm text-muted-foreground">No tags yet. Create the first one above.</p> : null}
|
|
102
|
+
|
|
103
|
+
{sources.length > 0 ? (
|
|
104
|
+
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/40 p-2 text-sm" role="status">
|
|
105
|
+
<span>{plural(sources.length, "tag")} selected</span>
|
|
106
|
+
<select aria-label="Merge into" className="h-8 rounded-md border bg-background px-2" value={mergeInto} onChange={(e) => setMergeInto(e.target.value)}>
|
|
107
|
+
<option value="">Merge into...</option>
|
|
108
|
+
{targets.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
|
|
109
|
+
</select>
|
|
110
|
+
<Button size="sm" variant="outline" disabled={!mergeInto} onClick={() => setConfirmMerge(true)}><GitMerge className="size-4" aria-hidden />Merge</Button>
|
|
111
|
+
<Button size="sm" variant="ghost" onClick={() => setPicked(new Set())}>Clear</Button>
|
|
112
|
+
</div>
|
|
113
|
+
) : null}
|
|
114
|
+
{merge.error ? <ApiError error={merge.error} title="Could not merge the tags" /> : null}
|
|
115
|
+
{remove.error ? <ApiError error={remove.error} title="Could not delete the tag" /> : null}
|
|
116
|
+
|
|
117
|
+
<ul className="flex flex-col divide-y">
|
|
118
|
+
{all.tags.map((t) => (
|
|
119
|
+
<li key={t.id} className="flex flex-wrap items-center gap-2 py-2">
|
|
120
|
+
<input type="checkbox" aria-label={`Select ${t.name}`} checked={picked.has(t.id)} onChange={() => togglePick(t.id)} />
|
|
121
|
+
{editing?.id === t.id ? (
|
|
122
|
+
<form className="flex flex-wrap items-center gap-2" onSubmit={(e) => { e.preventDefault(); saveEdit(t); }}>
|
|
123
|
+
<input aria-label="Rename tag" maxLength={50} value={editing.name} onChange={(e) => setEditing({ ...editing, name: e.target.value })} className="h-8 w-40 rounded-md border bg-background px-2 text-sm" />
|
|
124
|
+
<Swatches value={editing.color} onChange={(c) => setEditing({ ...editing, color: c })} label="Edit colour" />
|
|
125
|
+
<Button type="submit" size="sm" loading={update.loading} disabled={!editing.name.trim()}>Save</Button>
|
|
126
|
+
<Button type="button" size="sm" variant="ghost" onClick={() => setEditing(null)}>Cancel</Button>
|
|
127
|
+
</form>
|
|
128
|
+
) : (
|
|
129
|
+
<>
|
|
130
|
+
<span className={cn(isArchivedTag(t) && "opacity-60")}><TagChip tag={t} /></span>
|
|
131
|
+
{isArchivedTag(t) ? <span className="text-xs text-muted-foreground">Archived</span> : null}
|
|
132
|
+
<span className="text-xs tabular-nums text-muted-foreground">{t.contact_count == null ? "-" : plural(t.contact_count, "contact")}</span>
|
|
133
|
+
<span className="ml-auto flex gap-1">
|
|
134
|
+
<Button size="sm" variant="ghost" aria-label={`Edit ${t.name}`} onClick={() => setEditing({ id: t.id, name: t.name, color: t.color && HEX_COLOR.test(t.color) ? t.color : TAG_COLORS[0] })}><Pencil className="size-4" aria-hidden /></Button>
|
|
135
|
+
<Button size="sm" variant="ghost" aria-label={`${isArchivedTag(t) ? "Restore" : "Archive"} ${t.name}`} onClick={() => void update.mutate({ id: t.id, body: { archived: !isArchivedTag(t) } })}><Archive className="size-4" aria-hidden /></Button>
|
|
136
|
+
<Button size="sm" variant="ghost" aria-label={`Delete ${t.name}`} onClick={() => { setDeleting(t); setMoveTo(""); }}><Trash2 className="size-4" aria-hidden /></Button>
|
|
137
|
+
</span>
|
|
138
|
+
</>
|
|
139
|
+
)}
|
|
140
|
+
</li>
|
|
141
|
+
))}
|
|
142
|
+
</ul>
|
|
143
|
+
|
|
144
|
+
<ConfirmSpendDialog
|
|
145
|
+
open={confirmMerge}
|
|
146
|
+
tier="destructive"
|
|
147
|
+
title={`Merge ${plural(sources.length, "tag")} into ${targetName}`}
|
|
148
|
+
description={`${sources.map((id) => byId.get(id)?.name).filter(Boolean).join(", ")} will be removed. ${allCountsKnown ? `Their ${plural(sourceContacts, "contact")} get` : "Their contacts get"} ${targetName} instead; contacts that already have it keep one copy.`}
|
|
149
|
+
affected={allCountsKnown ? sourceContacts : undefined}
|
|
150
|
+
confirmLabel="Merge tags"
|
|
151
|
+
confirming={merge.loading}
|
|
152
|
+
onConfirm={() => void merge.mutate({ sources, target: mergeInto })}
|
|
153
|
+
onCancel={() => setConfirmMerge(false)}
|
|
154
|
+
/>
|
|
155
|
+
<ConfirmSpendDialog
|
|
156
|
+
open={deleting != null}
|
|
157
|
+
tier="destructive"
|
|
158
|
+
title={`Delete the tag ${deleting?.name ?? ""}`}
|
|
159
|
+
description={
|
|
160
|
+
<div className="flex flex-col gap-2">
|
|
161
|
+
<p>{moveTo ? `${deleting?.contact_count == null ? "Its contacts" : `Its ${plural(deleting.contact_count, "contact")}`} get ${byId.get(moveTo)?.name ?? "the other tag"} first.` : `It is removed from ${deleting?.contact_count == null ? "its contacts" : plural(deleting.contact_count, "contact")}. The contacts themselves stay.`}</p>
|
|
162
|
+
<label className="flex items-center gap-2 text-sm">Move its contacts to
|
|
163
|
+
<select aria-label="Move contacts to" className="h-8 rounded-md border bg-background px-2" value={moveTo} onChange={(e) => setMoveTo(e.target.value)}>
|
|
164
|
+
<option value="">No other tag</option>
|
|
165
|
+
{deleteTargets.map((t) => <option key={t.id} value={t.id}>{t.name}</option>)}
|
|
166
|
+
</select>
|
|
167
|
+
</label>
|
|
168
|
+
</div>
|
|
169
|
+
}
|
|
170
|
+
affected={deleting?.contact_count ?? undefined}
|
|
171
|
+
confirmLabel="Delete tag"
|
|
172
|
+
confirming={remove.loading}
|
|
173
|
+
onConfirm={() => deleting && void remove.mutate({ id: deleting.id, reassignTo: moveTo })}
|
|
174
|
+
onCancel={() => setDeleting(null)}
|
|
175
|
+
/>
|
|
176
|
+
</FormSheet>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* UnlockInfoButton: unlock the locked contacts of a selection. Counts what it will charge for, shows that count and
|
|
5
|
+
* cost in ConfirmSpendDialog, and sends exactly that set.
|
|
6
|
+
*
|
|
7
|
+
* SDK operations, behind ConfirmSpendDialog (billable, scope enrichment:run):
|
|
8
|
+
* 1. unlockContactInfo POST /contacts/unlock-info { contact_ids } 1 credit per newly unlocked contact
|
|
9
|
+
* Only locked contacts are counted and sent (a masked email or phone). The answer's `credits_charged` is shown.
|
|
10
|
+
* At most 1000 per call, so a larger selection unlocks the first 1000 and says so before the confirm.
|
|
11
|
+
*
|
|
12
|
+
* The confirm is disabled while its request runs, and a failed call keeps the selection so the person can retry.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { useState } from "react";
|
|
16
|
+
import { Button } from "@graph8/sdk/ui";
|
|
17
|
+
import { Unlock } from "lucide-react";
|
|
18
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
19
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
20
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
21
|
+
import { g8Post } from "@/g8/lib/g8-client";
|
|
22
|
+
import { plural } from "@/g8/lib/plural";
|
|
23
|
+
import type { ContactRecord } from "../lib/crm-types";
|
|
24
|
+
import { MAX_PAID_BATCH, UNLOCK_CREDITS_PER_CONTACT, isLockedContact, unlockTargets } from "../lib/record-actions";
|
|
25
|
+
|
|
26
|
+
export interface PaidActionProps {
|
|
27
|
+
rows: ReadonlyArray<ContactRecord>;
|
|
28
|
+
onDone?: () => void;
|
|
29
|
+
variant?: "default" | "outline" | "secondary" | "ghost";
|
|
30
|
+
size?: "default" | "sm";
|
|
31
|
+
className?: string;
|
|
32
|
+
/** Start with the confirm open and render no trigger (the row actions dialog opens it). */
|
|
33
|
+
defaultOpen?: boolean;
|
|
34
|
+
onClosed?: () => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function UnlockInfoButton({ rows, onDone, variant = "outline", size = "sm", className, defaultOpen = false, onClosed }: PaidActionProps) {
|
|
38
|
+
const [open, setOpen] = useState(defaultOpen);
|
|
39
|
+
const [charged, setCharged] = useState<number | null | undefined>(undefined);
|
|
40
|
+
const targets = unlockTargets(rows);
|
|
41
|
+
const lockedTotal = rows.filter(isLockedContact).length;
|
|
42
|
+
const already = rows.length - lockedTotal;
|
|
43
|
+
|
|
44
|
+
const unlock = useG8Mutation(async (ids: number[]) => {
|
|
45
|
+
const res = await g8Post<{ credits_charged?: number }>("/contacts/unlock-info", { contact_ids: ids });
|
|
46
|
+
return typeof res.data?.credits_charged === "number" ? res.data.credits_charged : null;
|
|
47
|
+
}, {
|
|
48
|
+
invalidates: [["contacts", "list"], ["contacts", "detail"], ["contacts", "record"]],
|
|
49
|
+
onSuccess: (n) => { setOpen(false); setCharged(n); onDone?.(); onClosed?.(); },
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<>
|
|
54
|
+
{defaultOpen ? null : (
|
|
55
|
+
<Button className={className} variant={variant} size={size} disabled={targets.length === 0} title={targets.length === 0 ? "None of the selected contacts is locked" : undefined} onClick={() => { setCharged(undefined); unlock.reset(); setOpen(true); }}>
|
|
56
|
+
<Unlock className="size-4" aria-hidden />Unlock info
|
|
57
|
+
</Button>
|
|
58
|
+
)}
|
|
59
|
+
{charged !== undefined ? <span role="status" className="text-xs text-muted-foreground">{charged == null ? "Unlocked." : `Unlocked. ${charged.toLocaleString()} ${charged === 1 ? "credit" : "credits"} charged.`}</span> : null}
|
|
60
|
+
{unlock.error && !open ? <ApiError compact error={unlock.error} title="Could not unlock the contacts" /> : null}
|
|
61
|
+
<ConfirmSpendDialog
|
|
62
|
+
open={open}
|
|
63
|
+
tier="billable"
|
|
64
|
+
title={`Unlock ${plural(targets.length, "contact")}`}
|
|
65
|
+
description={
|
|
66
|
+
<>
|
|
67
|
+
Reveals the withheld email and phone numbers. Each newly unlocked contact costs {UNLOCK_CREDITS_PER_CONTACT} credit; contacts that are already unlocked are not charged again.
|
|
68
|
+
{already > 0 ? ` ${plural(already, "selected contact")} ${already === 1 ? "is" : "are"} already unlocked and left out.` : ""}
|
|
69
|
+
{lockedTotal > MAX_PAID_BATCH ? ` Only the first ${MAX_PAID_BATCH.toLocaleString()} locked contacts are unlocked in one go.` : ""}
|
|
70
|
+
{unlock.error ? <span role="alert" className="mt-2 block text-destructive">The unlock failed. You can try again.</span> : null}
|
|
71
|
+
</>
|
|
72
|
+
}
|
|
73
|
+
estimatedCredits={targets.length * UNLOCK_CREDITS_PER_CONTACT}
|
|
74
|
+
affected={targets.length}
|
|
75
|
+
confirmLabel="Unlock"
|
|
76
|
+
confirming={unlock.loading}
|
|
77
|
+
onConfirm={() => void unlock.mutate(targets.map((t) => t.id))}
|
|
78
|
+
onCancel={() => { setOpen(false); onClosed?.(); }}
|
|
79
|
+
/>
|
|
80
|
+
</>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* VerifyEmailButton: verify the work emails of a selection. Counts what it will charge for, shows that count and cost
|
|
5
|
+
* in ConfirmSpendDialog, and sends exactly that set.
|
|
6
|
+
*
|
|
7
|
+
* SDK operations, behind ConfirmSpendDialog (billable, scope enrichment:run):
|
|
8
|
+
* 1. verifyContactEmails POST /contacts/verify-email { contact_pks } about 1 credit per verified email
|
|
9
|
+
* Only contacts with a real (not locked, not empty) work email are counted and sent: the "***" placeholder is never
|
|
10
|
+
* sent. Already verified emails come back as `already_verified` and inconclusive checks are not stored or
|
|
11
|
+
* charged; the result line states what was charged (`charged_credits`) rather than the estimate. At most 1000.
|
|
12
|
+
*
|
|
13
|
+
* The confirm is disabled while its request runs, and a failed call keeps the selection so the person can retry.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useState } from "react";
|
|
17
|
+
import { Button } from "@graph8/sdk/ui";
|
|
18
|
+
import { MailCheck } from "lucide-react";
|
|
19
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
20
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
21
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
22
|
+
import { g8Post } from "@/g8/lib/g8-client";
|
|
23
|
+
import { plural } from "@/g8/lib/plural";
|
|
24
|
+
import type { ContactRecord } from "../lib/crm-types";
|
|
25
|
+
import { MAX_PAID_BATCH, VERIFY_CREDITS_PER_EMAIL, verifySummary, verifyTargets } from "../lib/record-actions";
|
|
26
|
+
import type { VerifyRow } from "../lib/record-actions";
|
|
27
|
+
|
|
28
|
+
export interface PaidActionProps {
|
|
29
|
+
rows: ReadonlyArray<ContactRecord>;
|
|
30
|
+
onDone?: () => void;
|
|
31
|
+
variant?: "default" | "outline" | "secondary" | "ghost";
|
|
32
|
+
size?: "default" | "sm";
|
|
33
|
+
className?: string;
|
|
34
|
+
/** Start with the confirm open and render no trigger (the row actions dialog opens it). */
|
|
35
|
+
defaultOpen?: boolean;
|
|
36
|
+
onClosed?: () => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A list answer that is a bare array or an envelope with the list under a common key. */
|
|
40
|
+
function anyRows<T>(data: unknown): T[] {
|
|
41
|
+
if (Array.isArray(data)) return data as T[];
|
|
42
|
+
const o = data as Record<string, unknown> | null | undefined;
|
|
43
|
+
for (const k of ["items", "results", "contacts", "data"]) if (Array.isArray(o?.[k])) return o![k] as T[];
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function VerifyEmailButton({ rows, onDone, variant = "outline", size = "sm", className, defaultOpen = false, onClosed }: PaidActionProps) {
|
|
48
|
+
const [open, setOpen] = useState(defaultOpen);
|
|
49
|
+
const [summary, setSummary] = useState<string | null>(null);
|
|
50
|
+
// One filter, used for the count, the cost and the request body.
|
|
51
|
+
const targets = verifyTargets(rows);
|
|
52
|
+
const left = rows.length - targets.length;
|
|
53
|
+
|
|
54
|
+
const verify = useG8Mutation(async (ids: number[]) => {
|
|
55
|
+
const res = await g8Post<unknown>("/contacts/verify-email", { contact_pks: ids });
|
|
56
|
+
return anyRows<VerifyRow>(res.data);
|
|
57
|
+
}, {
|
|
58
|
+
invalidates: [["contacts", "list"], ["contacts", "detail"], ["contacts", "record"]],
|
|
59
|
+
onSuccess: (out) => { setOpen(false); setSummary(verifySummary(out)); onDone?.(); onClosed?.(); },
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<>
|
|
64
|
+
{defaultOpen ? null : (
|
|
65
|
+
<Button className={className} variant={variant} size={size} disabled={targets.length === 0} title={targets.length === 0 ? "None of the selected contacts has an unlocked work email" : undefined} onClick={() => { setSummary(null); verify.reset(); setOpen(true); }}>
|
|
66
|
+
<MailCheck className="size-4" aria-hidden />Verify email
|
|
67
|
+
</Button>
|
|
68
|
+
)}
|
|
69
|
+
{summary ? <span role="status" className="text-xs text-muted-foreground">{summary}</span> : null}
|
|
70
|
+
{verify.error && !open ? <ApiError compact error={verify.error} title="Could not verify the emails" /> : null}
|
|
71
|
+
<ConfirmSpendDialog
|
|
72
|
+
open={open}
|
|
73
|
+
tier="billable"
|
|
74
|
+
title={`Verify ${plural(targets.length, "email")}`}
|
|
75
|
+
description={
|
|
76
|
+
<>
|
|
77
|
+
Checks whether each address can receive mail and stores the answer on the contact. Already verified emails and inconclusive checks are not charged.
|
|
78
|
+
{left > 0 ? ` ${plural(left, "selected contact")} ${left === 1 ? "has" : "have"} no unlocked email and ${left === 1 ? "is" : "are"} left out.` : ""}
|
|
79
|
+
{rows.filter((r) => !!r.work_email).length > MAX_PAID_BATCH ? ` Only the first ${MAX_PAID_BATCH.toLocaleString()} are verified in one go.` : ""}
|
|
80
|
+
{verify.error ? <span role="alert" className="mt-2 block text-destructive">The check failed. Nothing was charged for the failed call; you can try again.</span> : null}
|
|
81
|
+
</>
|
|
82
|
+
}
|
|
83
|
+
estimatedCredits={targets.length * VERIFY_CREDITS_PER_EMAIL}
|
|
84
|
+
affected={targets.length}
|
|
85
|
+
confirmLabel="Verify"
|
|
86
|
+
confirming={verify.loading}
|
|
87
|
+
onConfirm={() => void verify.mutate(targets.map((t) => t.id))}
|
|
88
|
+
onCancel={() => { setOpen(false); onClosed?.(); }}
|
|
89
|
+
/>
|
|
90
|
+
</>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useContactTags: the org's tag definitions (`GET /contacts/tags`), cached under ["contact-tags"] so every chip row, the
|
|
5
|
+
* assign dialog and the tag manager share one request and refresh together after a write.
|
|
6
|
+
*
|
|
7
|
+
* SDK operations: listContactTags (read). The list is not paged by the API (it answers every tag at once with a
|
|
8
|
+
* contact count), so there is no "first N" to disclose. Archived tags are left out unless asked for.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useMemo } from "react";
|
|
12
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
13
|
+
import { g8Get } from "@/g8/lib/g8-client";
|
|
14
|
+
import { isArchivedTag, rowsOf } from "../lib/record-actions";
|
|
15
|
+
import type { ContactTag } from "../lib/record-actions";
|
|
16
|
+
|
|
17
|
+
export function useContactTags(options: { enabled?: boolean; includeArchived?: boolean } = {}) {
|
|
18
|
+
const { enabled = true, includeArchived = false } = options;
|
|
19
|
+
const query = useG8Query(
|
|
20
|
+
(signal) => g8Get<unknown>("/contacts/tags", includeArchived ? { include_archived: true } : undefined, { signal }),
|
|
21
|
+
[includeArchived],
|
|
22
|
+
{ enabled, key: ["contact-tags", includeArchived ? "all" : "active"] },
|
|
23
|
+
);
|
|
24
|
+
const tags = useMemo(() => {
|
|
25
|
+
const rows = rowsOf<ContactTag>(query.data?.data, "tags").filter((t) => t && typeof t.id === "string" && t.id !== "");
|
|
26
|
+
return includeArchived ? rows : rows.filter((t) => !isArchivedTag(t));
|
|
27
|
+
}, [query.data, includeArchived]);
|
|
28
|
+
return { tags, loading: query.loading && !query.data, error: query.error, refetch: query.refetch, loaded: query.data != null };
|
|
29
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared helpers for the CRM record actions (owner, tags, bulk update, verify, unlock, AI documents, next best step).
|
|
5
|
+
* Pure functions and types only: no operations of its own.
|
|
6
|
+
*
|
|
7
|
+
* Why the sets are computed here: a confirm dialog must COUNT exactly the set the request SENDS (#25642). Every paid
|
|
8
|
+
* action filters its selection once with one of these helpers and uses the SAME array for the count, the cost line
|
|
9
|
+
* and the request body.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { isMasked } from "./crm-types";
|
|
13
|
+
import type { ContactRecord } from "./crm-types";
|
|
14
|
+
|
|
15
|
+
/** One contact tag definition (`GET /contacts/tags`). The handler sends `archived_at`; the contract types `archived`. */
|
|
16
|
+
export interface ContactTag {
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
color?: string | null;
|
|
20
|
+
description?: string | null;
|
|
21
|
+
contact_count?: number | null;
|
|
22
|
+
archived?: boolean | null;
|
|
23
|
+
archived_at?: string | null;
|
|
24
|
+
is_default?: boolean | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isArchivedTag(tag: ContactTag): boolean {
|
|
28
|
+
return tag.archived === true || (tag.archived_at != null && tag.archived_at !== "");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The API takes six-digit hex colours only (`^#[0-9A-Fa-f]{6}$`). */
|
|
32
|
+
export const HEX_COLOR = /^#[0-9A-Fa-f]{6}$/;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The tag palette offered in the tag manager. Free choice of a hex colour is also allowed; these are only the
|
|
36
|
+
* one-click choices, so no screen ever shows a raw colour input as its only control.
|
|
37
|
+
*/
|
|
38
|
+
export const TAG_COLORS = ["#2563EB", "#0F766E", "#B45309", "#B91C1C", "#6D28D9", "#475569"] as const;
|
|
39
|
+
|
|
40
|
+
/** `POST /contacts/unlock-info` takes at most 1000 contact ids; `POST /contacts/verify-email` at most 1000 too. */
|
|
41
|
+
export const MAX_PAID_BATCH = 1000;
|
|
42
|
+
|
|
43
|
+
/** One credit per newly unlocked contact (contacts/interfaces/fastapi/router.py: "estimated_credits ... 1 credit per contact"). */
|
|
44
|
+
export const UNLOCK_CREDITS_PER_CONTACT = 1;
|
|
45
|
+
/** One credit per verified email (the same price the enrichment block shows for a single verification). */
|
|
46
|
+
export const VERIFY_CREDITS_PER_EMAIL = 1;
|
|
47
|
+
|
|
48
|
+
/** A contact with any masked email or phone: unlocking reveals it. */
|
|
49
|
+
export function isLockedContact(row: Pick<ContactRecord, "work_email" | "direct_phone" | "mobile_phone">): boolean {
|
|
50
|
+
return isMasked(row.work_email) || isMasked(row.direct_phone) || isMasked(row.mobile_phone);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The contacts an unlock would charge for: only the locked ones, capped at the API's batch size. */
|
|
54
|
+
export function unlockTargets(rows: ReadonlyArray<ContactRecord>): ContactRecord[] {
|
|
55
|
+
return rows.filter(isLockedContact).slice(0, MAX_PAID_BATCH);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The contacts with a real work email. A locked or empty email cannot be verified, and the "***" placeholder is never sent. */
|
|
59
|
+
export function verifyTargets(rows: ReadonlyArray<ContactRecord>): ContactRecord[] {
|
|
60
|
+
return rows.filter((r) => !!r.work_email && r.work_email.trim() !== "" && !isMasked(r.work_email)).slice(0, MAX_PAID_BATCH);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Fields the bulk updater offers. Names are the contact's own columns (`bulk-update` only accepts `CONTACT_*` and `udo_*` keys); anything else, including the owner (own action) and tags (own action), is not editable here.
|
|
65
|
+
*/
|
|
66
|
+
export const BULK_FIELDS = [
|
|
67
|
+
{ key: "CONTACT_JOB_TITLE", label: "Job title" },
|
|
68
|
+
{ key: "CONTACT_JOB_DEPARTMENT", label: "Department" },
|
|
69
|
+
{ key: "CONTACT_SENIORITY_LEVEL", label: "Seniority" },
|
|
70
|
+
{ key: "CONTACT_CITY", label: "City" },
|
|
71
|
+
{ key: "CONTACT_STATE", label: "State" },
|
|
72
|
+
{ key: "CONTACT_COUNTRY", label: "Country" },
|
|
73
|
+
] as const;
|
|
74
|
+
export type BulkFieldKey = (typeof BULK_FIELDS)[number]["key"];
|
|
75
|
+
|
|
76
|
+
/** The result rows of a verify call. */
|
|
77
|
+
export interface VerifyRow {
|
|
78
|
+
contact_pk?: number;
|
|
79
|
+
outcome?: "verified" | "already_verified" | "inconclusive" | "not_verified" | null;
|
|
80
|
+
verification_result?: string | null;
|
|
81
|
+
charged_credits?: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** "Verified 3, already verified 1, inconclusive 1 (not charged). 3 credits charged." */
|
|
85
|
+
export function verifySummary(rows: ReadonlyArray<VerifyRow>): string {
|
|
86
|
+
const n = (o: string) => rows.filter((r) => r.outcome === o).length;
|
|
87
|
+
const parts = [
|
|
88
|
+
`Verified ${n("verified")}`,
|
|
89
|
+
n("already_verified") ? `already verified ${n("already_verified")}` : "",
|
|
90
|
+
n("inconclusive") ? `inconclusive ${n("inconclusive")} (not charged)` : "",
|
|
91
|
+
n("not_verified") ? `no answer for ${n("not_verified")} (not charged)` : "",
|
|
92
|
+
].filter(Boolean);
|
|
93
|
+
const charged = rows.reduce((max, r) => Math.max(max, r.charged_credits ?? 0), 0);
|
|
94
|
+
return `${parts.join(", ")}. ${charged.toLocaleString()} ${charged === 1 ? "credit" : "credits"} charged.`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The array inside an envelope `data` that is either the array itself or `{ <key>: [...] }`. */
|
|
98
|
+
export function rowsOf<T>(data: unknown, key: string): T[] {
|
|
99
|
+
if (Array.isArray(data)) return data as T[];
|
|
100
|
+
const inner = (data as Record<string, unknown> | null | undefined)?.[key];
|
|
101
|
+
return Array.isArray(inner) ? (inner as T[]) : [];
|
|
102
|
+
}
|
package/blocks/deals/README.md
CHANGED
|
@@ -125,3 +125,4 @@ create), `deal.id` + `deal.revision` (card -> detail -> update/delete).
|
|
|
125
125
|
## Cross-block glue
|
|
126
126
|
|
|
127
127
|
- `RecordDealsTab` (`components/record-deals-tab.tsx`): the deals of one contact or company (`GET /contacts/{id}/deals`, `GET /companies/{id}/deals`), each opening in the deals screen. Use it as an `extraDetailTabs` entry of `ContactsScreen` / `CompaniesScreen`. `DealsScreen` also takes optional `onOpenContact` / `onOpenCompany`: the Contacts tab gains "Open in contacts" and the contact sheet gains "Open company".
|
|
128
|
+
- `CreateDealButton` (`components/create-deal-button.tsx`): "Create deal" for one contact. A sheet with the deal form already filled in from the contact and their company (linked contact, name "<company> - <contact>", owner from the key's person or a picker). `onCreated(dealId)` routes to the new deal. The company comes from the contact (the API derives it), and a company that already has a deal is refused until "Create another deal for this company" is ticked. Use it in `ContactsScreen.renderDetailActions`.
|
package/blocks/deals/block.json
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"delete_pipeline_pipelines__pipeline_id__delete",
|
|
32
32
|
"delete_stage_pipelines__pipeline_id__stages__stage_id__delete",
|
|
33
33
|
"get_company_deals_companies__company_id__deals_get",
|
|
34
|
+
"get_contact_contacts__contact_id__get",
|
|
34
35
|
"get_contact_deals_contacts__contact_id__deals_get",
|
|
35
36
|
"get_deal_analytics_deals_analytics_get",
|
|
36
37
|
"get_deal_deals__deal_id__get",
|
|
@@ -261,6 +262,22 @@
|
|
|
261
262
|
"tiers": [
|
|
262
263
|
"read"
|
|
263
264
|
]
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
"name": "CreateDealButton",
|
|
268
|
+
"file": "components/create-deal-button.tsx",
|
|
269
|
+
"purpose": "\"Create deal\" for one contact: a sheet with the deal form prefilled from the contact and their company (linked contact, name \"<company> - <contact>\", owner, pipeline stage). Calls onCreated(dealId) so you can route to the new deal.",
|
|
270
|
+
"operations": [
|
|
271
|
+
"create_deal_deals_post",
|
|
272
|
+
"get_contact_contacts__contact_id__get",
|
|
273
|
+
"get_my_team_member_team_members_me_get",
|
|
274
|
+
"list_pipelines_deals_pipelines_get",
|
|
275
|
+
"list_team_members_team_members_get"
|
|
276
|
+
],
|
|
277
|
+
"tiers": [
|
|
278
|
+
"read",
|
|
279
|
+
"write"
|
|
280
|
+
]
|
|
264
281
|
}
|
|
265
282
|
],
|
|
266
283
|
"hooks": [
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CreateDealButton: "Create deal" for one contact. Opens a sheet with the deal form already filled in from the
|
|
5
|
+
* contact and their company: the deal is linked to the contact (the API derives the company from its contacts),
|
|
6
|
+
* named "<company> - <contact>", and owned by the API key's person (or a member you pick). Use it in
|
|
7
|
+
* `ContactsScreen.renderDetailActions`:
|
|
8
|
+
* <CreateDealButton contactId={c.id} onCreated={(dealId) => router.push(`/deals?id=${dealId}`)} />
|
|
9
|
+
*
|
|
10
|
+
* SDK operations, in order:
|
|
11
|
+
* 1. getContact GET /contacts/{contact_id} (read) the name and company shown and used as the default deal name
|
|
12
|
+
* 2. getMyTeamMember GET /team-members/me (read) the owner; listTeamMembers (read) when the key has no person
|
|
13
|
+
* 3. listPipelines GET /deals/pipelines (read) the stage picker
|
|
14
|
+
* 4. createDeal POST /deals (write) once, retry-safe (a second save edits the same deal)
|
|
15
|
+
* A company that already has a deal answers 409; the sheet then offers "Create another deal for this company".
|
|
16
|
+
* Nothing here is billable, external or destructive.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { useState } from "react";
|
|
20
|
+
import { Button, Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, Skeleton } from "@graph8/sdk/ui";
|
|
21
|
+
import { Handshake } from "lucide-react";
|
|
22
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
23
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
24
|
+
import { g8Get } from "@/g8/lib/g8-client";
|
|
25
|
+
import { useDefaultOwner } from "../hooks/use-default-owner";
|
|
26
|
+
import { dataOf } from "../lib/deals-types";
|
|
27
|
+
import { DealDetail } from "./deal-detail";
|
|
28
|
+
|
|
29
|
+
export interface CreateDealButtonProps {
|
|
30
|
+
/** The CRM contact the deal is for. */
|
|
31
|
+
contactId: number;
|
|
32
|
+
/** Called with the new deal's id after it was created (route to your deals screen). */
|
|
33
|
+
onCreated?: (dealId: string) => void;
|
|
34
|
+
label?: string;
|
|
35
|
+
variant?: "default" | "outline" | "secondary" | "ghost";
|
|
36
|
+
size?: "default" | "sm";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface ContactBits {
|
|
40
|
+
first_name?: string | null;
|
|
41
|
+
last_name?: string | null;
|
|
42
|
+
full_name?: string | null;
|
|
43
|
+
work_email?: string | null;
|
|
44
|
+
company_name?: string | null;
|
|
45
|
+
company?: { name?: string | null; domain?: string | null } | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const contactLabel = (c: ContactBits | undefined | null, id: number): string =>
|
|
49
|
+
c?.full_name?.trim() || [c?.first_name, c?.last_name].filter(Boolean).join(" ").trim() || c?.work_email || `Contact ${id}`;
|
|
50
|
+
export const companyLabelOf = (c: ContactBits | undefined | null): string => (c?.company?.name || c?.company_name || "").trim();
|
|
51
|
+
|
|
52
|
+
function Form({ contactId, onCreated }: { contactId: number; onCreated?: (dealId: string) => void }) {
|
|
53
|
+
const owner = useDefaultOwner();
|
|
54
|
+
const contact = useG8Query(async (signal) => dataOf<ContactBits>(await g8Get(`/contacts/${contactId}`, undefined, { signal })), [contactId], { key: ["contacts", "deal-prefill", contactId] });
|
|
55
|
+
const [ownerId, setOwnerId] = useState<string | null>(null);
|
|
56
|
+
const [another, setAnother] = useState(false);
|
|
57
|
+
if (owner.loading || (contact.loading && !contact.data)) return <Skeleton className="h-24 w-full" aria-label="Loading the deal form" />;
|
|
58
|
+
if (contact.error) return <ApiError error={contact.error} title="Could not load the contact" onRetry={() => void contact.refetch()} />;
|
|
59
|
+
const effective = ownerId ?? owner.defaultOwner?.id ?? "";
|
|
60
|
+
if (!effective) return <ApiError error={owner.error ?? new Error("No team member found to own this deal.")} title="A deal needs an owner" onRetry={() => void owner.reload()} />;
|
|
61
|
+
const company = companyLabelOf(contact.data);
|
|
62
|
+
const person = contactLabel(contact.data, contactId);
|
|
63
|
+
return (
|
|
64
|
+
<div className="space-y-3">
|
|
65
|
+
<dl className="grid grid-cols-[auto,1fr] gap-x-3 gap-y-1 rounded-md border bg-muted/40 p-3 text-sm">
|
|
66
|
+
<dt className="text-muted-foreground">Contact</dt>
|
|
67
|
+
<dd className="font-medium">{person}</dd>
|
|
68
|
+
<dt className="text-muted-foreground">Company</dt>
|
|
69
|
+
<dd className="font-medium">{company || "None on this contact"}</dd>
|
|
70
|
+
</dl>
|
|
71
|
+
{owner.source === "first-member" ? (
|
|
72
|
+
<div className="space-y-1">
|
|
73
|
+
<label htmlFor="create-deal-owner" className="text-sm font-medium">Owner</label>
|
|
74
|
+
<select id="create-deal-owner" className="h-9 w-full rounded-md border bg-background px-2 text-sm" value={effective} onChange={(e) => setOwnerId(e.target.value)}>
|
|
75
|
+
{owner.owners.map((o) => <option key={o.id} value={o.id}>{o.name}{o.email && o.email !== o.name ? ` (${o.email})` : ""}</option>)}
|
|
76
|
+
</select>
|
|
77
|
+
<p className="text-xs text-muted-foreground">This key has no person of its own, so choose who owns the deal.</p>
|
|
78
|
+
</div>
|
|
79
|
+
) : null}
|
|
80
|
+
<label className="flex items-center gap-2 text-sm">
|
|
81
|
+
<input type="checkbox" checked={another} onChange={(e) => setAnother(e.target.checked)} />
|
|
82
|
+
Create another deal for this company
|
|
83
|
+
</label>
|
|
84
|
+
<p className="text-xs text-muted-foreground">Leave this off and a company that already has a deal is refused, so you never create a duplicate by accident.</p>
|
|
85
|
+
<DealDetail ownerId={effective} contactIds={[contactId]} allowDuplicate={another} defaultName={[company, person].filter(Boolean).join(" - ")} onSaved={(deal) => { if (deal.id) onCreated?.(deal.id); }} />
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function CreateDealButton({ contactId, onCreated, label = "Create deal", variant = "outline", size = "sm" }: CreateDealButtonProps) {
|
|
91
|
+
const [open, setOpen] = useState(false);
|
|
92
|
+
return (
|
|
93
|
+
<>
|
|
94
|
+
<Button size={size} variant={variant} onClick={() => setOpen(true)}>
|
|
95
|
+
<Handshake className="mr-1 size-4" aria-hidden />
|
|
96
|
+
{label}
|
|
97
|
+
</Button>
|
|
98
|
+
<Sheet open={open} onOpenChange={setOpen}>
|
|
99
|
+
<SheetContent className="w-full overflow-y-auto sm:max-w-xl">
|
|
100
|
+
<SheetHeader>
|
|
101
|
+
<SheetTitle>Create deal</SheetTitle>
|
|
102
|
+
<SheetDescription>The deal is linked to this contact and their company.</SheetDescription>
|
|
103
|
+
</SheetHeader>
|
|
104
|
+
<div className="p-4">{open ? <Form key={contactId} contactId={contactId} onCreated={onCreated} /> : null}</div>
|
|
105
|
+
</SheetContent>
|
|
106
|
+
</Sheet>
|
|
107
|
+
</>
|
|
108
|
+
);
|
|
109
|
+
}
|