@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,71 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* WithdrawContactButton: take ONE contact out of ONE sequence for good (they stop receiving its steps), recording why.
|
|
5
|
+
*
|
|
6
|
+
* SDK operations:
|
|
7
|
+
* POST /contacts/withdraw-from-sequences (contacts.withdrawContactsFromSequences) tier `write`, but it ends the contact's
|
|
8
|
+
* place in the sequence, so it asks first through ConfirmSpendDialog as a DESTRUCTIVE action. The body is always the one
|
|
9
|
+
* `sequence_id` and the one `contact_id`; `remove_all` (every sequence the contact is in) is never sent from here.
|
|
10
|
+
* The outcome to record is the person's choice: removed, completed, or not interested.
|
|
11
|
+
*/
|
|
12
|
+
import { useState } from "react";
|
|
13
|
+
import { UserMinus } from "lucide-react";
|
|
14
|
+
import { Button } from "@graph8/sdk/ui";
|
|
15
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
16
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
17
|
+
import { g8Post } from "@/g8/lib/g8-client";
|
|
18
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
19
|
+
|
|
20
|
+
export interface WithdrawContactButtonProps {
|
|
21
|
+
sequenceId: string;
|
|
22
|
+
contactId: number;
|
|
23
|
+
/** Shown in the dialog. */
|
|
24
|
+
contactName?: string;
|
|
25
|
+
/** Called after the contact was withdrawn so the list can refresh. */
|
|
26
|
+
onWithdrawn?: () => void;
|
|
27
|
+
className?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const OUTCOMES = [
|
|
31
|
+
{ value: "removed", label: "Removed (no reason recorded)" },
|
|
32
|
+
{ value: "completed", label: "Completed (they did what we wanted)" },
|
|
33
|
+
{ value: "not_interested", label: "Not interested" },
|
|
34
|
+
] as const;
|
|
35
|
+
|
|
36
|
+
export function WithdrawContactButton({ sequenceId, contactId, contactName, onWithdrawn, className }: WithdrawContactButtonProps) {
|
|
37
|
+
const [open, setOpen] = useState(false);
|
|
38
|
+
const [outcome, setOutcome] = useState<(typeof OUTCOMES)[number]["value"]>("removed");
|
|
39
|
+
const withdraw = useG8Mutation(async () => {
|
|
40
|
+
await g8Post("/contacts/withdraw-from-sequences", { sequence_id: sequenceId, contact_ids: [contactId], target_state: outcome, remove_all: false });
|
|
41
|
+
setOpen(false);
|
|
42
|
+
onWithdrawn?.();
|
|
43
|
+
}, { invalidates: ["sequences"], onError: () => setOpen(false) });
|
|
44
|
+
const who = contactName || `contact ${contactId}`;
|
|
45
|
+
return (
|
|
46
|
+
<span className={className}>
|
|
47
|
+
<Button size="sm" variant="ghost" onClick={() => setOpen(true)}><UserMinus className="mr-1 size-4" aria-hidden />Remove from sequence</Button>
|
|
48
|
+
{withdraw.error ? <ApiError error={withdraw.error} title="Could not remove the contact" /> : null}
|
|
49
|
+
<ConfirmSpendDialog
|
|
50
|
+
open={open}
|
|
51
|
+
tier="destructive"
|
|
52
|
+
title={`Remove ${who} from this sequence?`}
|
|
53
|
+
description={
|
|
54
|
+
<div className="space-y-2">
|
|
55
|
+
<p>They stop receiving its steps. They stay in your contacts and in every other sequence.</p>
|
|
56
|
+
<label className="block text-sm">Record it as
|
|
57
|
+
<select aria-label="Outcome" className="mt-1 h-9 w-full rounded-md border border-border bg-background px-2" value={outcome} onChange={(e) => setOutcome(e.target.value as typeof outcome)}>
|
|
58
|
+
{OUTCOMES.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
|
59
|
+
</select>
|
|
60
|
+
</label>
|
|
61
|
+
</div>
|
|
62
|
+
}
|
|
63
|
+
confirmLabel="Remove from sequence"
|
|
64
|
+
affected={1}
|
|
65
|
+
confirming={withdraw.loading}
|
|
66
|
+
onConfirm={() => void withdraw.mutate(undefined)}
|
|
67
|
+
onCancel={() => setOpen(false)}
|
|
68
|
+
/>
|
|
69
|
+
</span>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -38,7 +38,9 @@ import { ListSelect } from "@/g8/crm/components/list-select";
|
|
|
38
38
|
import { FirstNotice } from "@/g8/components/paging-notices";
|
|
39
39
|
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
40
40
|
import type { ConfirmTier } from "@/g8/components/confirm-spend-dialog";
|
|
41
|
+
import { StepAiAssist } from "./step-ai-assist";
|
|
41
42
|
import { StepEditor } from "./step-editor";
|
|
43
|
+
import { StepTemplatePicker } from "./step-templates";
|
|
42
44
|
import {
|
|
43
45
|
fromApiStep,
|
|
44
46
|
KIND_LABEL,
|
|
@@ -65,6 +67,10 @@ export interface SequenceBuilderProps {
|
|
|
65
67
|
/** Optional owner email sent as `user_email` on create. */
|
|
66
68
|
ownerEmail?: string;
|
|
67
69
|
onSaved?: (sequenceId: string) => void;
|
|
70
|
+
/** New sequence only: start with this name (for example from a template). */
|
|
71
|
+
initialName?: string;
|
|
72
|
+
/** New sequence only: start with these unsaved steps (for example from a template). Sender and audience are still asked for. */
|
|
73
|
+
initialSteps?: DraftStep[];
|
|
68
74
|
className?: string;
|
|
69
75
|
}
|
|
70
76
|
|
|
@@ -133,16 +139,17 @@ function describe(p: Pending, live: boolean): { tier: ConfirmTier; title: string
|
|
|
133
139
|
};
|
|
134
140
|
}
|
|
135
141
|
|
|
136
|
-
export function SequenceBuilder({ sequenceId, defaultListId, ownerEmail, onSaved, className }: SequenceBuilderProps) {
|
|
142
|
+
export function SequenceBuilder({ sequenceId, defaultListId, ownerEmail, onSaved, initialName, initialSteps, className }: SequenceBuilderProps) {
|
|
137
143
|
const brand = useBrand();
|
|
138
144
|
const editing = Boolean(sequenceId);
|
|
139
|
-
const [name, setName] = useState("");
|
|
145
|
+
const [name, setName] = useState(initialName ?? "");
|
|
140
146
|
const [mailboxId, setMailboxId] = useState("");
|
|
141
147
|
const [aiPhoneId, setAiPhoneId] = useState("");
|
|
142
148
|
const [dialerPhoneId, setDialerPhoneId] = useState("");
|
|
143
149
|
const [linkedinId, setLinkedinId] = useState("");
|
|
144
150
|
const [listId, setListId] = useState(defaultListId ? String(defaultListId) : "");
|
|
145
|
-
const [steps, setSteps] = useState<DraftStep[]>([]);
|
|
151
|
+
const [steps, setSteps] = useState<DraftStep[]>(() => (sequenceId ? [] : (initialSteps ?? [])));
|
|
152
|
+
const [pickingTemplate, setPickingTemplate] = useState(false);
|
|
146
153
|
const [loadedFor, setLoadedFor] = useState<string | null>(null);
|
|
147
154
|
const [pending, setPending] = useState<Pending | null>(null);
|
|
148
155
|
const [saved, setSaved] = useState<string | null>(null);
|
|
@@ -392,6 +399,7 @@ export function SequenceBuilder({ sequenceId, defaultListId, ownerEmail, onSaved
|
|
|
392
399
|
index={i + 1}
|
|
393
400
|
onChange={(next) => setSteps((all) => all.map((x) => (x.key === s.key ? next : x)))}
|
|
394
401
|
onRemove={() => (s.id ? setPending({ kind: "delete", step: s }) : setSteps((all) => all.filter((x) => x.key !== s.key)))}
|
|
402
|
+
renderAssist={(step, change) => <StepAiAssist step={step} onChange={change} sequenceId={sequenceId} stepOrder={i + 1} />}
|
|
395
403
|
/>
|
|
396
404
|
<div className="flex gap-2 text-xs">
|
|
397
405
|
{!s.id ? (
|
|
@@ -421,7 +429,9 @@ export function SequenceBuilder({ sequenceId, defaultListId, ownerEmail, onSaved
|
|
|
421
429
|
Add {KIND_LABEL[k].split(" ").map((w) => (/^[A-Z]{2,}$/.test(w) ? w : w.toLowerCase())).join(" ")}
|
|
422
430
|
</Button>
|
|
423
431
|
))}
|
|
432
|
+
<Button size="sm" variant="ghost" onClick={() => setPickingTemplate((p) => !p)}>{pickingTemplate ? "Hide step templates" : "From a step template"}</Button>
|
|
424
433
|
</div>
|
|
434
|
+
{pickingTemplate ? <StepTemplatePicker onPick={(step) => { setSteps((s) => [...s, step]); setPickingTemplate(false); }} /> : null}
|
|
425
435
|
|
|
426
436
|
{editing ? (
|
|
427
437
|
<Button
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SequenceCampaignLink: which campaign a sequence belongs to. Shows the linked campaign with an "Open campaign"
|
|
5
|
+
* button, lets you unlink it, or, when there is none, pick a campaign and link it. Use it in
|
|
6
|
+
* `SequencesScreen.renderDetailActions` (the row it passes is the full sequence, so it carries the campaign id):
|
|
7
|
+
* renderDetailActions={(s) => <SequenceCampaignLink sequence={s} onOpenCampaign={(id) => router.push(`/campaigns?id=${id}`)} />}
|
|
8
|
+
*
|
|
9
|
+
* SDK operations, in order:
|
|
10
|
+
* 1. getCampaign GET /campaigns/{id} (read; the linked campaign's name)
|
|
11
|
+
* listCampaignsForPicker GET /campaigns/picker (read; the choices, only while the sequence has no campaign)
|
|
12
|
+
* 2. linkSequenceCampaign PUT /sequences/{id}/campaign (write)
|
|
13
|
+
* 3. unlinkSequenceCampaign DELETE /sequences/{id}/campaign (DESTRUCTIVE tier -> ConfirmSpendDialog; the campaign is kept)
|
|
14
|
+
* Linking never replaces silently: while a campaign is linked, the picker is hidden, so you unlink first.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { useState } from "react";
|
|
18
|
+
import { Button, Skeleton } from "@graph8/sdk/ui";
|
|
19
|
+
import { ExternalLink, Link2, Unlink } from "lucide-react";
|
|
20
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
21
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
22
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
23
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
24
|
+
import { g8Delete, g8Get, g8Put } from "@/g8/lib/g8-client";
|
|
25
|
+
import { listOf, objectOf } from "../lib/sequencer-utils";
|
|
26
|
+
|
|
27
|
+
export interface SequenceCampaignLinkProps {
|
|
28
|
+
/** The full sequence (from `renderDetailActions`): its id and `campaign_builder_campaign_id`. */
|
|
29
|
+
sequence: { id: string; name?: string | null; campaign_builder_campaign_id?: string | null };
|
|
30
|
+
/** Route to the campaign in your app. Without it the campaign's name is shown without a button. */
|
|
31
|
+
onOpenCampaign?: (campaignId: string) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface Choice { id: string; name?: string | null }
|
|
35
|
+
|
|
36
|
+
export function SequenceCampaignLink({ sequence, onOpenCampaign }: SequenceCampaignLinkProps) {
|
|
37
|
+
const linked = sequence.campaign_builder_campaign_id ? String(sequence.campaign_builder_campaign_id) : "";
|
|
38
|
+
const [open, setOpen] = useState(false);
|
|
39
|
+
const [choice, setChoice] = useState("");
|
|
40
|
+
const [confirm, setConfirm] = useState(false);
|
|
41
|
+
const campaign = useG8Query((signal) => g8Get(`/campaigns/${encodeURIComponent(linked)}`, undefined, { signal }), [linked], { enabled: Boolean(linked), key: ["campaigns", "sequence-link", linked] });
|
|
42
|
+
const choices = useG8Query((signal) => g8Get("/campaigns/picker", undefined, { signal }), [], { enabled: open && !linked, key: ["sequences", "campaign-choices"] });
|
|
43
|
+
const refresh = [["sequences", "detail", sequence.id], ["sequences", "list"]] as const;
|
|
44
|
+
const link = useG8Mutation((id: string) => g8Put(`/sequences/${encodeURIComponent(sequence.id)}/campaign`, { campaign_id: id }), { invalidates: refresh, onSuccess: () => { setOpen(false); setChoice(""); } });
|
|
45
|
+
const unlink = useG8Mutation(async () => {
|
|
46
|
+
try { await g8Delete(`/sequences/${encodeURIComponent(sequence.id)}/campaign`); } catch (err) { if ((err as { status?: number }).status !== 404) throw err; }
|
|
47
|
+
}, { invalidates: refresh, onSuccess: () => setConfirm(false), onError: () => setConfirm(false) });
|
|
48
|
+
|
|
49
|
+
const name = (objectOf(campaign.data).name as string | undefined) || `Campaign ${linked}`;
|
|
50
|
+
return (
|
|
51
|
+
<>
|
|
52
|
+
{linked ? (
|
|
53
|
+
<>
|
|
54
|
+
{campaign.loading && !campaign.data ? <Skeleton className="h-8 w-28" aria-label="Loading campaign" /> : onOpenCampaign ? (
|
|
55
|
+
<Button size="sm" variant="outline" onClick={() => onOpenCampaign(linked)} aria-label={`Open campaign ${name}`}><ExternalLink className="mr-1 h-4 w-4" aria-hidden />Open campaign</Button>
|
|
56
|
+
) : <span className="text-sm text-muted-foreground">Campaign: {name}</span>}
|
|
57
|
+
<Button size="sm" variant="ghost" onClick={() => setConfirm(true)}><Unlink className="mr-1 h-4 w-4" aria-hidden />Unlink campaign</Button>
|
|
58
|
+
</>
|
|
59
|
+
) : open ? (
|
|
60
|
+
<span className="flex items-center gap-2">
|
|
61
|
+
{choices.error ? <ApiError error={choices.error} title="Could not load campaigns" onRetry={() => void choices.refetch()} /> : (
|
|
62
|
+
<select aria-label="Campaign to link" className="h-8 rounded-md border bg-background px-2 text-sm" value={choice} onChange={(e) => setChoice(e.target.value)}>
|
|
63
|
+
<option value="">{choices.loading && !choices.data ? "Loading campaigns" : listOf<Choice>(choices.data).length ? "Choose a campaign" : "No campaigns yet"}</option>
|
|
64
|
+
{listOf<Choice>(choices.data).map((c) => <option key={c.id} value={c.id}>{c.name ?? c.id}</option>)}
|
|
65
|
+
</select>
|
|
66
|
+
)}
|
|
67
|
+
<Button size="sm" disabled={!choice} loading={link.loading} onClick={() => void link.mutate(choice)}>Link</Button>
|
|
68
|
+
<Button size="sm" variant="ghost" onClick={() => setOpen(false)}>Cancel</Button>
|
|
69
|
+
</span>
|
|
70
|
+
) : (
|
|
71
|
+
<Button size="sm" variant="outline" onClick={() => setOpen(true)}><Link2 className="mr-1 h-4 w-4" aria-hidden />Link to campaign</Button>
|
|
72
|
+
)}
|
|
73
|
+
{link.error ? <ApiError error={link.error} title="Could not link the campaign" onRetry={link.reset} /> : null}
|
|
74
|
+
{unlink.error ? <ApiError error={unlink.error} title="Could not unlink the campaign" onRetry={unlink.reset} /> : null}
|
|
75
|
+
<ConfirmSpendDialog open={confirm} tier="destructive" title={`Unlink "${sequence.name ?? "this sequence"}" from ${name}?`} description="The campaign and the sequence are both kept. Only the tie between them is removed." confirmLabel="Unlink" affected={1} confirming={unlink.loading} onCancel={() => setConfirm(false)} onConfirm={() => void unlink.mutate(undefined)} />
|
|
76
|
+
</>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
* 5. archiveSequence POST /sequences/{id}/archive (destructive: confirmed)
|
|
18
18
|
* Enroll contacts: EnrollSheet (ListPicker + ContactTable, or the whole list) then EnrollDialog (external: confirmed).
|
|
19
19
|
* Steps, Contacts and Results tabs compose SequenceStepsPanel, SequenceEnrollments and SequenceStats.
|
|
20
|
+
* Manage (SequenceManageActions): edit and rename (external, confirmed), sync the audience (external, confirmed), save as
|
|
21
|
+
* template, delete (destructive, confirmed). The Senders tab (SequenceSendersPanel) lists the senders and removes one (destructive, confirmed).
|
|
20
22
|
*/
|
|
21
23
|
|
|
22
24
|
import { useState } from "react";
|
|
@@ -39,6 +41,8 @@ import { fieldItems, launchCheck, listOf, objectOf, statusGroup } from "../lib/s
|
|
|
39
41
|
import type { SequenceRow } from "../lib/sequencer-utils";
|
|
40
42
|
import { EnrollDialog } from "./enroll-dialog";
|
|
41
43
|
import { SequenceEnrollments } from "./sequence-enrollments";
|
|
44
|
+
import { SequenceManageActions } from "./sequence-manage-actions";
|
|
45
|
+
import { SequenceSendersPanel } from "./sequence-senders-panel";
|
|
42
46
|
import { SequenceStats } from "./sequence-stats";
|
|
43
47
|
import { SequenceStepsPanel } from "./sequence-steps-panel";
|
|
44
48
|
import { pluralWord } from "@/g8/lib/plural";
|
|
@@ -203,6 +207,7 @@ export function SequenceDetail(props: SequenceDetailProps) {
|
|
|
203
207
|
<Button size="sm" variant="outline" onClick={() => setEnrolling(true)}><UserPlus className="mr-1 h-4 w-4" aria-hidden /> Enroll contacts</Button>
|
|
204
208
|
<Button size="sm" variant="ghost" aria-label="Duplicate sequence" loading={duplicate.loading} onClick={() => void duplicate.mutate(undefined)}><Copy className="h-4 w-4" aria-hidden /></Button>
|
|
205
209
|
{group !== "ended" ? <Button size="sm" variant="ghost" onClick={() => void prepare.mutate("archive")}>Archive sequence</Button> : null}
|
|
210
|
+
<SequenceManageActions sequence={row} onChanged={changed} onDeleted={() => onClose?.()} />
|
|
206
211
|
{props.renderActions?.(row)}
|
|
207
212
|
{enrolling ? <EnrollSheet sequence={row} onClose={() => setEnrolling(false)} onEnrolled={() => { setEnrolling(false); setStatsKey((k) => k + 1); changed(); }} /> : null}
|
|
208
213
|
{p ? (
|
|
@@ -253,6 +258,7 @@ export function SequenceDetail(props: SequenceDetailProps) {
|
|
|
253
258
|
},
|
|
254
259
|
{ id: "steps", label: "Steps", count: row.step_count ?? undefined, content: <SequenceStepsPanel sequenceId={id} stepId={props.stepId} onStepChange={props.onStepChange} onChanged={changed} /> },
|
|
255
260
|
{ id: "contacts", label: "Contacts", count: row.contact_count ?? undefined, content: <SequenceEnrollments key={statsKey} sequenceId={id} contactId={props.contactId} onContactChange={props.onContactChange} onOpenContact={props.onOpenContact} /> },
|
|
261
|
+
{ id: "senders", label: "Senders", content: <SequenceSendersPanel sequenceId={id} onChanged={changed} /> },
|
|
256
262
|
{ id: "results", label: "Results", content: <SequenceStats key={`${id}:${statsKey}`} sequenceId={id} refreshSeconds={group === "live" ? 60 : undefined} /> },
|
|
257
263
|
...(props.extraTabs ?? []).map((t) => ({ id: t.id, label: t.label, content: t.render(row) })),
|
|
258
264
|
]}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SequenceEditForm: rename a sequence and change its own settings (description, stop on reply, same email thread, pick
|
|
5
|
+
* up new contacts).
|
|
6
|
+
*
|
|
7
|
+
* SDK operations, in order:
|
|
8
|
+
* 1. getSequence GET /sequences/{id} (read) the record the form is seeded from
|
|
9
|
+
* 2. updateSequence PATCH /sequences/{id} (EXTERNAL: tier `external`, because changing a live sequence changes what
|
|
10
|
+
* real people receive) -> ConfirmSpendDialog first, naming each change and the contacts in flight
|
|
11
|
+
*
|
|
12
|
+
* The form never saves over a record it has not loaded: nothing renders until the sequence has loaded, Save stays
|
|
13
|
+
* disabled until something differs from what was loaded, and the request carries ONLY the changed fields (a partial
|
|
14
|
+
* PATCH, so a field this form does not show is never reset). A 409 (the sequence is mid-transition: launching, pausing)
|
|
15
|
+
* is shown as the API words it. The audience list, mailboxes and steps are not edited here: the API cannot change the
|
|
16
|
+
* list after creation, senders are SequenceSendersPanel, steps are SequenceStepsPanel.
|
|
17
|
+
*/
|
|
18
|
+
import { useState } from "react";
|
|
19
|
+
import { Button, Input, Skeleton, Textarea } from "@graph8/sdk/ui";
|
|
20
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
21
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
22
|
+
import { g8Get, g8Patch } from "@/g8/lib/g8-client";
|
|
23
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
24
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
25
|
+
import { changeSummary, editableSequence, sequenceChanges } from "../lib/sequencer-manage";
|
|
26
|
+
import type { EditableSequence } from "../lib/sequencer-manage";
|
|
27
|
+
import { objectOf, statusGroup } from "../lib/sequencer-utils";
|
|
28
|
+
|
|
29
|
+
export interface SequenceEditFormProps {
|
|
30
|
+
sequenceId: string;
|
|
31
|
+
/** Called after the change was saved. */
|
|
32
|
+
onSaved?: () => void;
|
|
33
|
+
onCancel?: () => void;
|
|
34
|
+
className?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function Fields({ loaded, contacts, live, sequenceId, onSaved, onCancel }: { loaded: EditableSequence; contacts: number; live: boolean; sequenceId: string; onSaved?: () => void; onCancel?: () => void }) {
|
|
38
|
+
const [draft, setDraft] = useState<EditableSequence>(loaded);
|
|
39
|
+
const [confirming, setConfirming] = useState(false);
|
|
40
|
+
const changes = sequenceChanges(loaded, draft);
|
|
41
|
+
const dirty = Object.keys(changes).length > 0;
|
|
42
|
+
const save = useG8Mutation(async () => {
|
|
43
|
+
await g8Patch(`/sequences/${encodeURIComponent(sequenceId)}`, changes);
|
|
44
|
+
}, { invalidates: ["sequences"] });
|
|
45
|
+
const set = <K extends keyof EditableSequence>(key: K, value: EditableSequence[K]) => setDraft((d) => ({ ...d, [key]: value }));
|
|
46
|
+
const confirm = async () => {
|
|
47
|
+
try {
|
|
48
|
+
await save.mutateAsync(undefined);
|
|
49
|
+
setConfirming(false);
|
|
50
|
+
onSaved?.();
|
|
51
|
+
} catch {
|
|
52
|
+
setConfirming(false);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const check = (key: "finish_on_reply" | "send_in_same_thread" | "wait_for_new_contacts", label: string) => (
|
|
56
|
+
<label className="flex items-center gap-2 text-sm">
|
|
57
|
+
<input type="checkbox" checked={draft[key]} onChange={(e) => set(key, e.target.checked)} />
|
|
58
|
+
{label}
|
|
59
|
+
</label>
|
|
60
|
+
);
|
|
61
|
+
return (
|
|
62
|
+
<form className="space-y-3" onSubmit={(e) => { e.preventDefault(); if (dirty && draft.name.trim()) setConfirming(true); }}>
|
|
63
|
+
<label className="block space-y-1 text-sm"><span className="font-medium">Name</span><Input aria-label="Sequence name" value={draft.name} onChange={(e) => set("name", e.target.value)} /></label>
|
|
64
|
+
<label className="block space-y-1 text-sm"><span className="font-medium">Description</span><Textarea aria-label="Description" rows={3} value={draft.description} onChange={(e) => set("description", e.target.value)} /></label>
|
|
65
|
+
<div className="space-y-2">
|
|
66
|
+
{check("finish_on_reply", "Stop sending to a contact once they reply")}
|
|
67
|
+
{check("send_in_same_thread", "Send follow-up emails in the same thread")}
|
|
68
|
+
{check("wait_for_new_contacts", "Keep picking up contacts added to the audience list")}
|
|
69
|
+
</div>
|
|
70
|
+
{live ? <p role="note" className="text-xs text-warning-foreground">This sequence is live. Changes apply to contacts already in it.</p> : null}
|
|
71
|
+
{save.error ? <ApiError error={save.error} title="Could not save the sequence" /> : null}
|
|
72
|
+
<div className="flex justify-end gap-2">
|
|
73
|
+
{onCancel ? <Button type="button" variant="outline" onClick={onCancel}>Cancel</Button> : null}
|
|
74
|
+
<Button type="submit" disabled={!dirty || !draft.name.trim() || save.loading}>Save changes</Button>
|
|
75
|
+
</div>
|
|
76
|
+
<ConfirmSpendDialog
|
|
77
|
+
open={confirming}
|
|
78
|
+
tier="external"
|
|
79
|
+
title={`Save changes to "${loaded.name}"?`}
|
|
80
|
+
description={<ul className="list-disc pl-5">{changeSummary(changes).map((c) => <li key={c}>{c}</li>)}</ul>}
|
|
81
|
+
recipients={contacts > 0 ? contacts : undefined}
|
|
82
|
+
confirmLabel="Save changes"
|
|
83
|
+
confirming={save.loading}
|
|
84
|
+
onConfirm={() => void confirm()}
|
|
85
|
+
onCancel={() => setConfirming(false)}
|
|
86
|
+
/>
|
|
87
|
+
</form>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function SequenceEditForm({ sequenceId, onSaved, onCancel, className }: SequenceEditFormProps) {
|
|
92
|
+
const [version, setVersion] = useState(0);
|
|
93
|
+
// No refetch on window focus: it would reseed the form and wipe what the person is typing.
|
|
94
|
+
const seq = useG8Query((signal) => g8Get(`/sequences/${encodeURIComponent(sequenceId)}`, undefined, { signal }), [sequenceId], { key: ["sequences", "edit", sequenceId], keepPrevious: false, refetchOnFocus: false });
|
|
95
|
+
if (seq.error) return <ApiError error={seq.error} title="Could not load the sequence" onRetry={() => void seq.refetch()} className={className} />;
|
|
96
|
+
if (!seq.data || seq.isPlaceholderData) return <Skeleton aria-busy="true" className={`h-48 w-full ${className ?? ""}`} />;
|
|
97
|
+
const record = objectOf(seq.data);
|
|
98
|
+
return (
|
|
99
|
+
<div className={className}>
|
|
100
|
+
<Fields
|
|
101
|
+
key={`${sequenceId}:${version}`}
|
|
102
|
+
loaded={editableSequence(record)}
|
|
103
|
+
contacts={Number(record.contact_count ?? 0) || 0}
|
|
104
|
+
live={statusGroup(typeof record.status === "string" ? record.status : null) === "live"}
|
|
105
|
+
sequenceId={sequenceId}
|
|
106
|
+
onSaved={() => { void seq.refetch().then(() => setVersion((v) => v + 1)); onSaved?.(); }}
|
|
107
|
+
onCancel={onCancel}
|
|
108
|
+
/>
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -24,6 +24,7 @@ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
|
24
24
|
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
25
25
|
import { g8Get, g8Post } from "@/g8/lib/g8-client";
|
|
26
26
|
import { ContactDetail } from "@/g8/crm/components/contact-detail";
|
|
27
|
+
import { WithdrawContactButton } from "./enrollment-withdraw";
|
|
27
28
|
import { fieldItems, listOf } from "../lib/sequencer-utils";
|
|
28
29
|
|
|
29
30
|
export interface SequenceEnrollmentsProps {
|
|
@@ -91,7 +92,7 @@ export function SequenceEnrollments({ sequenceId, contactId, onContactChange, on
|
|
|
91
92
|
<Button size="sm" variant="outline" disabled={rows.length < pageSize} onClick={() => setPage((p) => p + 1)}>Next</Button>
|
|
92
93
|
</div>
|
|
93
94
|
{open ? (
|
|
94
|
-
<SectionCard title={`${nameOf(open)} in this sequence`} actions={<>{actions(open)}{onOpenContact && open.contact_id != null ? <Button size="sm" variant="outline" onClick={() => onOpenContact(open.contact_id as number)}>Open in contacts</Button> : null}<Button size="sm" variant="ghost" onClick={() => onContactChange?.(null)}>Close</Button></>}>
|
|
95
|
+
<SectionCard title={`${nameOf(open)} in this sequence`} actions={<>{actions(open)}{onOpenContact && open.contact_id != null ? <Button size="sm" variant="outline" onClick={() => onOpenContact(open.contact_id as number)}>Open in contacts</Button> : null}{open.contact_id != null ? <WithdrawContactButton sequenceId={sequenceId} contactId={open.contact_id} contactName={nameOf(open)} onWithdrawn={() => { onContactChange?.(null); void list.refetch(); }} /> : null}<Button size="sm" variant="ghost" onClick={() => onContactChange?.(null)}>Close</Button></>}>
|
|
95
96
|
<KeyValueList columns={2} items={fieldItems(open as Record<string, unknown>)} />
|
|
96
97
|
{open.contact_id != null ? (
|
|
97
98
|
<SectionCard title="Contact" description="The full contact record.">
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SequenceManageActions: the buttons that manage a sequence itself, for a sequence detail's action row: Edit (rename and
|
|
5
|
+
* settings), Sync audience, Save as template, Delete.
|
|
6
|
+
*
|
|
7
|
+
* SDK operations, in order:
|
|
8
|
+
* Edit SequenceEditForm in a sheet: getSequence (read) then updateSequence PATCH /sequences/{id} (EXTERNAL, confirmed there)
|
|
9
|
+
* Sync audience POST /sequences/{id}/sync (sequences.syncSequence) EXTERNAL -> ConfirmSpendDialog first.
|
|
10
|
+
* Re-pulls the audience list so contacts added to it since launch are ENROLLED (real recipients).
|
|
11
|
+
* Save as template GET /sequences/{id}/steps (sequenceLifecycle.listSequenceSteps) read
|
|
12
|
+
* POST /sequencer/templates/sequences (sequences.createSequenceTemplate) write; the steps are copied, not linked
|
|
13
|
+
* Delete DELETE /sequences/{id} (sequenceLifecycle.deleteSequence) DESTRUCTIVE -> ConfirmSpendDialog first.
|
|
14
|
+
* The API's DELETE is a SOFT delete, the same as archive: the sequence stops, leaves every list, and cannot be
|
|
15
|
+
* restored through the API. The Archive button in SequenceDetail stays for the same effect.
|
|
16
|
+
*/
|
|
17
|
+
import { useState } from "react";
|
|
18
|
+
import { BookmarkPlus, Pencil, RefreshCw, Trash2 } from "lucide-react";
|
|
19
|
+
import { Button, Input } from "@graph8/sdk/ui";
|
|
20
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
21
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
22
|
+
import { FormSheet } from "@/g8/components/screen/form-sheet";
|
|
23
|
+
import { g8Delete, g8Get, g8Post } from "@/g8/lib/g8-client";
|
|
24
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
25
|
+
import { templateBodyFromSteps } from "../lib/sequencer-manage";
|
|
26
|
+
import { listOf, statusGroup } from "../lib/sequencer-utils";
|
|
27
|
+
import type { ApiStep, SequenceRow } from "../lib/sequencer-utils";
|
|
28
|
+
import { SequenceEditForm } from "./sequence-edit-form";
|
|
29
|
+
|
|
30
|
+
export interface SequenceManageActionsProps {
|
|
31
|
+
sequence: SequenceRow;
|
|
32
|
+
/** Called after any change so the list and detail can refresh. */
|
|
33
|
+
onChanged?: () => void;
|
|
34
|
+
/** Called after the sequence was deleted (close its detail here). */
|
|
35
|
+
onDeleted?: () => void;
|
|
36
|
+
className?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type Dialog = "sync" | "delete" | "template" | null;
|
|
40
|
+
|
|
41
|
+
export function SequenceManageActions({ sequence, onChanged, onDeleted, className }: SequenceManageActionsProps) {
|
|
42
|
+
const [dialog, setDialog] = useState<Dialog>(null);
|
|
43
|
+
const [editing, setEditing] = useState(false);
|
|
44
|
+
const [templateName, setTemplateName] = useState("");
|
|
45
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
46
|
+
const name = sequence.name ?? `Sequence ${sequence.id}`;
|
|
47
|
+
const group = statusGroup(sequence.status);
|
|
48
|
+
const id = encodeURIComponent(sequence.id);
|
|
49
|
+
|
|
50
|
+
const sync = useG8Mutation(async () => {
|
|
51
|
+
await g8Post(`/sequences/${id}/sync`);
|
|
52
|
+
setDialog(null);
|
|
53
|
+
setNotice("The audience list was synced.");
|
|
54
|
+
onChanged?.();
|
|
55
|
+
}, { invalidates: ["sequences"], onError: () => setDialog(null) });
|
|
56
|
+
const remove = useG8Mutation(async () => {
|
|
57
|
+
await g8Delete(`/sequences/${id}`);
|
|
58
|
+
setDialog(null);
|
|
59
|
+
onChanged?.();
|
|
60
|
+
onDeleted?.();
|
|
61
|
+
}, { invalidates: ["sequences"], onError: () => setDialog(null) });
|
|
62
|
+
const saveTemplate = useG8Mutation(async () => {
|
|
63
|
+
const steps = listOf<ApiStep>(await g8Get(`/sequences/${id}/steps`));
|
|
64
|
+
if (steps.length === 0) throw new Error("This sequence has no steps to save.");
|
|
65
|
+
await g8Post("/sequencer/templates/sequences", templateBodyFromSteps(templateName, "", steps));
|
|
66
|
+
setDialog(null);
|
|
67
|
+
setNotice(`Saved "${templateName.trim()}" as a template.`);
|
|
68
|
+
}, { onError: () => undefined });
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<>
|
|
72
|
+
<span className={`contents ${className ?? ""}`}>
|
|
73
|
+
<Button size="sm" variant="outline" onClick={() => setEditing(true)}><Pencil className="mr-1 h-4 w-4" aria-hidden />Edit</Button>
|
|
74
|
+
{group === "live" || group === "paused" ? <Button size="sm" variant="outline" onClick={() => setDialog("sync")}><RefreshCw className="mr-1 h-4 w-4" aria-hidden />Sync audience</Button> : null}
|
|
75
|
+
<Button size="sm" variant="ghost" onClick={() => { setTemplateName(`${name} template`); setDialog("template"); }}><BookmarkPlus className="mr-1 h-4 w-4" aria-hidden />Save as template</Button>
|
|
76
|
+
<Button size="sm" variant="ghost" onClick={() => setDialog("delete")}><Trash2 className="mr-1 h-4 w-4" aria-hidden />Delete sequence</Button>
|
|
77
|
+
</span>
|
|
78
|
+
{notice ? <p role="status" className="basis-full text-xs text-muted-foreground">{notice}</p> : null}
|
|
79
|
+
{[sync.error, remove.error].map((e, i) => (e ? <ApiError key={i} error={e} title="Could not update the sequence" /> : null))}
|
|
80
|
+
|
|
81
|
+
<FormSheet open={editing} onOpenChange={setEditing} title={`Edit ${name}`} description="Rename it or change how it behaves. Only what you change is saved.">
|
|
82
|
+
{editing ? <SequenceEditForm sequenceId={sequence.id} onCancel={() => setEditing(false)} onSaved={() => { setEditing(false); onChanged?.(); }} /> : null}
|
|
83
|
+
</FormSheet>
|
|
84
|
+
|
|
85
|
+
<FormSheet
|
|
86
|
+
open={dialog === "template"}
|
|
87
|
+
onOpenChange={(o) => !o && setDialog(null)}
|
|
88
|
+
title="Save as template"
|
|
89
|
+
description="Copies the steps so you can start new sequences from them. The template does not change when this sequence does."
|
|
90
|
+
footer={<><Button variant="outline" onClick={() => setDialog(null)}>Cancel</Button><Button loading={saveTemplate.loading} disabled={!templateName.trim()} onClick={() => void saveTemplate.mutate(undefined)}>Save template</Button></>}
|
|
91
|
+
>
|
|
92
|
+
<Input aria-label="Template name" value={templateName} onChange={(e) => setTemplateName(e.target.value)} />
|
|
93
|
+
{saveTemplate.error ? <ApiError error={saveTemplate.error} title="Could not save the template" /> : null}
|
|
94
|
+
</FormSheet>
|
|
95
|
+
|
|
96
|
+
<ConfirmSpendDialog
|
|
97
|
+
open={dialog === "sync"}
|
|
98
|
+
tier="external"
|
|
99
|
+
title={`Sync the audience of "${name}"?`}
|
|
100
|
+
description="Contacts added to the audience list since the last sync are enrolled now and start receiving the steps."
|
|
101
|
+
confirmLabel="Sync audience"
|
|
102
|
+
confirming={sync.loading}
|
|
103
|
+
onConfirm={() => void sync.mutate(undefined)}
|
|
104
|
+
onCancel={() => setDialog(null)}
|
|
105
|
+
/>
|
|
106
|
+
<ConfirmSpendDialog
|
|
107
|
+
open={dialog === "delete"}
|
|
108
|
+
tier="destructive"
|
|
109
|
+
title={`Delete "${name}"?`}
|
|
110
|
+
description="The sequence stops sending and disappears from your lists. The API cannot restore a deleted sequence. Contacts already messaged keep what they received."
|
|
111
|
+
confirmLabel="Delete sequence"
|
|
112
|
+
affected={1}
|
|
113
|
+
confirming={remove.loading}
|
|
114
|
+
onConfirm={() => void remove.mutate(undefined)}
|
|
115
|
+
onCancel={() => setDialog(null)}
|
|
116
|
+
/>
|
|
117
|
+
</>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SequenceSendersPanel: the senders a sequence sends from (mailboxes, phone numbers, LinkedIn accounts) and a way to
|
|
5
|
+
* take one off.
|
|
6
|
+
*
|
|
7
|
+
* SDK operations, in order:
|
|
8
|
+
* 1. listSequenceChannels GET /sequences/{id}/channels (read)
|
|
9
|
+
* 2. removeSequenceChannel DELETE /sequences/{id}/channels/{sequence_channel_id} (DESTRUCTIVE) -> ConfirmSpendDialog first
|
|
10
|
+
* Removing the only sender of a step type means those steps can no longer send and the sequence cannot launch (or keeps
|
|
11
|
+
* a live step stuck); the dialog says which step types lose their sender. Adding senders to an existing sequence is
|
|
12
|
+
* done where its steps are added (SequenceBuilder in edit mode), which asks for the sender each new step type needs.
|
|
13
|
+
*/
|
|
14
|
+
import { useState } from "react";
|
|
15
|
+
import { Trash2 } from "lucide-react";
|
|
16
|
+
import { Button, Skeleton } from "@graph8/sdk/ui";
|
|
17
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
18
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
19
|
+
import { EmptyState } from "@/g8/components/empty-state";
|
|
20
|
+
import { StatusPill } from "@/g8/components/screen/status-pill";
|
|
21
|
+
import { g8Delete, g8Get } from "@/g8/lib/g8-client";
|
|
22
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
23
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
24
|
+
import { listOf } from "../lib/sequencer-utils";
|
|
25
|
+
|
|
26
|
+
export interface SequenceSendersPanelProps {
|
|
27
|
+
sequenceId: string;
|
|
28
|
+
/** Called after a sender was removed so a detail can recheck whether the sequence can launch. */
|
|
29
|
+
onChanged?: () => void;
|
|
30
|
+
className?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface Channel { id?: string | number; channel_id?: number; channel_type?: string | null; channel_value?: string | null }
|
|
34
|
+
|
|
35
|
+
const LABEL: Record<string, string> = { GMAIL: "Gmail mailbox", INBOXKIT: "Managed mailbox", SMTP: "SMTP mailbox", PHONE: "Phone number", LINKEDIN: "LinkedIn account", LINKEDIN_NETRION: "LinkedIn account", SMS: "SMS number", WHATSAPP: "WhatsApp number" };
|
|
36
|
+
const STEPS_LOST: Record<string, string> = { GMAIL: "email", INBOXKIT: "email", SMTP: "email", PHONE: "call", LINKEDIN: "LinkedIn", LINKEDIN_NETRION: "LinkedIn", SMS: "SMS", WHATSAPP: "WhatsApp" };
|
|
37
|
+
const typeOf = (c: Channel) => (c.channel_type ?? "").toUpperCase();
|
|
38
|
+
|
|
39
|
+
export function SequenceSendersPanel({ sequenceId, onChanged, className }: SequenceSendersPanelProps) {
|
|
40
|
+
const [removing, setRemoving] = useState<Channel | null>(null);
|
|
41
|
+
const list = useG8Query((signal) => g8Get(`/sequences/${encodeURIComponent(sequenceId)}/channels`, undefined, { signal }), [sequenceId], { key: ["sequences", "channels", sequenceId] });
|
|
42
|
+
const rows = listOf<Channel>(list.data);
|
|
43
|
+
const remove = useG8Mutation(async (c: Channel) => {
|
|
44
|
+
await g8Delete(`/sequences/${encodeURIComponent(sequenceId)}/channels/${encodeURIComponent(String(c.id))}`);
|
|
45
|
+
setRemoving(null);
|
|
46
|
+
await list.refetch();
|
|
47
|
+
onChanged?.();
|
|
48
|
+
}, { invalidates: ["sequences"], onError: () => setRemoving(null) });
|
|
49
|
+
|
|
50
|
+
if (list.error) return <ApiError error={list.error} title="Could not load the senders" onRetry={() => void list.refetch()} className={className} />;
|
|
51
|
+
if (list.loading && !list.data) return <Skeleton aria-busy="true" className={`h-24 w-full ${className ?? ""}`} />;
|
|
52
|
+
const others = removing ? rows.filter((c) => c.id !== removing.id && STEPS_LOST[typeOf(c)] === STEPS_LOST[typeOf(removing)]).length : 0;
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className={`space-y-3 ${className ?? ""}`}>
|
|
56
|
+
<h3 className="font-medium">Senders</h3>
|
|
57
|
+
{rows.length === 0 ? (
|
|
58
|
+
<EmptyState compact title="No sender yet" description="Steps need a sender to go out: a mailbox for emails, a number for calls, an account for LinkedIn. Add steps with their sender in the Steps tab." />
|
|
59
|
+
) : (
|
|
60
|
+
<ul aria-label="Senders" className="divide-y rounded-md border border-border text-sm">
|
|
61
|
+
{rows.map((c) => (
|
|
62
|
+
<li key={String(c.id ?? `${c.channel_type}:${c.channel_id}`)} className="flex items-center gap-3 p-2">
|
|
63
|
+
<StatusPill tone="neutral">{LABEL[typeOf(c)] ?? (c.channel_type || "Sender")}</StatusPill>
|
|
64
|
+
<span className="min-w-0 flex-1 truncate">{c.channel_value || `#${c.channel_id ?? ""}`}</span>
|
|
65
|
+
<Button size="sm" variant="ghost" aria-label={`Remove sender ${c.channel_value ?? c.channel_id ?? ""}`} disabled={c.id == null} onClick={() => setRemoving(c)}><Trash2 className="size-4" aria-hidden /></Button>
|
|
66
|
+
</li>
|
|
67
|
+
))}
|
|
68
|
+
</ul>
|
|
69
|
+
)}
|
|
70
|
+
{remove.error ? <ApiError error={remove.error} title="Could not remove the sender" /> : null}
|
|
71
|
+
<ConfirmSpendDialog
|
|
72
|
+
open={removing != null}
|
|
73
|
+
tier="destructive"
|
|
74
|
+
title={`Remove ${removing?.channel_value ?? "this sender"} from the sequence?`}
|
|
75
|
+
description={others === 0 && removing ? `It is the only ${STEPS_LOST[typeOf(removing)] ?? ""} sender, so those steps can no longer send until you add another.` : "Other senders of the same kind keep the sequence sending."}
|
|
76
|
+
confirmLabel="Remove sender"
|
|
77
|
+
affected={1}
|
|
78
|
+
confirming={remove.loading}
|
|
79
|
+
onConfirm={() => removing && void remove.mutate(removing)}
|
|
80
|
+
onCancel={() => setRemoving(null)}
|
|
81
|
+
/>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|