import { useState } from 'react'; import { RestApiClient } from 'twenty-client-sdk/rest'; import { defineFrontComponent } from 'twenty-sdk/define'; import { closeSidePanel, enqueueSnackbar, useRecordId, useSelectedRecordIds, } from 'twenty-sdk/front-component'; import { SUPPRESS_LEAD_CLIENT_PATH, SUPPRESS_LEAD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER, } from 'src/constants/gate-queue-identifiers'; import { MAX_SUPPRESSION_NOTE_LENGTH, SUPPRESSION_REASONS, type SuppressionReasonCode, } from 'src/gate/suppression-decision'; /** * Add one person to the do-not-contact list, with a reason on record. * * The shape follows `release-lead.tsx` — a real side panel with a form rather * than a headless confirmation modal — because the reason is the whole point and * a yes/no modal cannot ask for one. What differs is the tone: this panel is * styled as a stop, in red, because a rep who opens it by accident should know * within half a second that this is not a routine action. * * Nothing here is trusted. The panel posts a record id and a reason code; the * logic function re-reads the lead, re-validates the reason against the same * closed list, and takes the reviewer's identity from the authenticated request. */ const STOP = '#B42318'; const PANEL_STYLE: React.CSSProperties = { display: 'flex', flexDirection: 'column', gap: '16px', padding: '20px', fontFamily: 'Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', fontSize: '13px', color: '#333', }; type Phase = 'form' | 'submitting' | 'done'; interface SuppressResponse { readonly outcome?: string; readonly changed?: boolean; readonly message?: string; readonly auditWritten?: boolean; } /** * `RestApiClient` throws on a non-2xx response, carrying the parsed body. The * server writes its refusals for a salesperson, so that sentence is what should * reach them rather than an HTTP status. */ const serverMessage = (error: unknown): string => { const body = (error as { body?: unknown } | null)?.body; if (typeof body === 'object' && body !== null) { const message = (body as Record)['message']; if (typeof message === 'string' && message !== '') { return message; } } return error instanceof Error && error.message !== '' ? error.message : 'Something went wrong.'; }; const SuppressLead = () => { const recordId = useRecordId(); const selectedRecordIds = useSelectedRecordIds(); const targetIds = selectedRecordIds.length > 0 ? selectedRecordIds : recordId === null ? [] : [recordId]; const [reasonCode, setReasonCode] = useState( null, ); const [note, setNote] = useState(''); const [phase, setPhase] = useState('form'); if (targetIds.length === 0) { return (
Nothing selected Open a person, or select one in a list, then run Do not contact again.
); } if (targetIds.length > 1) { return (
One person at a time {targetIds.length} records are selected. Each block is recorded as an individual decision with its own reason, so Greenlight does not apply them in bulk. Import a suppression file instead if you have one.
); } const submit = async () => { if (reasonCode === null) { return; } setPhase('submitting'); try { const response = await new RestApiClient().post( SUPPRESS_LEAD_CLIENT_PATH, { leadObjectNameSingular: 'person', recordId: targetIds[0], reasonCode, note, }, ); enqueueSnackbar({ message: response?.message ?? 'Block processed.', variant: response?.changed === true ? 'success' : 'info', }); setPhase('done'); closeSidePanel(); } catch (error) { // Leave the form open: a refusal is not a reason to lose what they typed. enqueueSnackbar({ message: `Not blocked. ${serverMessage(error)}`, variant: 'error', }); setPhase('form'); } }; const isSubmitting = phase === 'submitting'; return (
Do not contact this person Adds them to Greenlight’s block list. The lead is re-scored, blocked from outreach, and cannot be released by hand afterwards — the only way back is to lift the block, which is also recorded.
Why must we not contact them? {SUPPRESSION_REASONS.map((reason) => ( ))}