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 { UNSUPPRESS_LEAD_CLIENT_PATH, UNSUPPRESS_LEAD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER, } from 'src/constants/gate-queue-identifiers'; import { MAX_SUPPRESSION_NOTE_LENGTH, MIN_UNSUPPRESSION_NOTE_LENGTH, UNSUPPRESSION_REASONS, type UnsuppressionReasonCode, } from 'src/gate/suppression-decision'; /** * Lift a suppression — the more sensitive of the two block-list actions. * * Everything that makes this harder than its counterpart is enforced on the * server (`parseUnsuppressRequest`), not here. This panel's job is to make the * requirements visible *before* somebody types, so the friction reads as * deliberate rather than as a form that keeps rejecting them: * * - a reason from a list whose every entry is a claim that the suppression was * wrong or has been superseded; * - a written note, minimum length shown live; * - an explicit acknowledgement that contact is being re-enabled. * * The button stays disabled until all three are satisfied. That is a duplicate * of the server rules, and duplicating them is correct here: a disabled button * teaches, a 400 response scolds. */ const CAUTION = '#B54708'; 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 UnsuppressResponse { readonly outcome?: string; readonly changed?: boolean; readonly message?: string; readonly auditWritten?: boolean; } 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 UnsuppressLead = () => { 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 [acknowledged, setAcknowledged] = useState(false); const [phase, setPhase] = useState('form'); if (targetIds.length === 0) { return (
Nothing selected Open a person, or select one in the Do Not Contact list, then run Allow contact again.
); } if (targetIds.length > 1) { return (
One person at a time {targetIds.length} records are selected. Lifting a do-not-contact entry is an individual decision that has to be justified individually, so Greenlight never does it in bulk.
); } const noteRemaining = MIN_UNSUPPRESSION_NOTE_LENGTH - note.trim().length; const canSubmit = reasonCode !== null && acknowledged && noteRemaining <= 0 && phase === 'form'; const submit = async () => { if (!canSubmit) { return; } setPhase('submitting'); try { const response = await new RestApiClient().post( UNSUPPRESS_LEAD_CLIENT_PATH, { leadObjectNameSingular: 'person', recordId: targetIds[0], reasonCode, note, acknowledged: true, }, ); enqueueSnackbar({ message: response?.message ?? 'Removal processed.', variant: response?.changed === true ? 'success' : 'info', }); setPhase('done'); closeSidePanel(); } catch (error) { enqueueSnackbar({ message: `Not removed. ${serverMessage(error)}`, variant: 'error', }); setPhase('form'); } }; const isSubmitting = phase === 'submitting'; return (
Allow contact again This removes the person from Greenlight’s block list and re-enables outreach to somebody who is on record asking us to stop. Your name, the reason, and what they were originally blocked for all go into the audit log and cannot be edited afterwards.
Why should the block be lifted? {UNSUPPRESSION_REASONS.map((reason) => ( ))}