sms-extension-1777874553/client/src/pages/GlobalSms.jsx

647 lines
28 KiB
JavaScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import apiClient from '../api/client';
import { useBusiness } from '../context/BusinessContext';
const BASE_PROFILE_KEYS = new Set(['providerName', 'senderId', 'dltEntityId']);
const PENDING_SENDER_ID_PROFILE_NAME = 'Pending Sender ID';
function formatUpdatedAt(value) {
if (!value) return 'Not updated yet';
try {
return new Date(value).toLocaleString();
} catch {
return 'Not updated yet';
}
}
function buildProfilePatchPayload(inputs = [], values = {}) {
const provider = {};
const profileInputValues = {};
inputs.forEach((input) => {
const rawValue = String(values[input.key] ?? '').trim();
if (!rawValue) return;
if (BASE_PROFILE_KEYS.has(input.key)) {
provider[input.key] = input.key === 'senderId' ? rawValue.toUpperCase() : rawValue;
return;
}
profileInputValues[input.key] = rawValue;
});
return {
...(Object.keys(provider).length > 0 ? { provider } : {}),
...(Object.keys(profileInputValues).length > 0 ? { profileInputValues } : {}),
};
}
function getInputInitialValues(inputs = []) {
return inputs.reduce((accumulator, input) => {
accumulator[input.key] = input.value || '';
return accumulator;
}, {});
}
function getProfileSummary(profile) {
const parts = [];
const provider = profile?.provider || {};
const missingCount = profile?.executionReadiness?.missingProfileInputs?.length || 0;
if (provider.providerName) parts.push(provider.providerName);
if (provider.senderId) parts.push(`Sender ${provider.senderId}`);
if (provider.dltEntityId) parts.push('DLT ready');
if (missingCount > 0) parts.push(`${missingCount} required field${missingCount === 1 ? '' : 's'} pending`);
return parts.join(' • ') || 'Profile saved. Complete the remaining setup fields to continue.';
}
function isPendingSenderIdProfile(profile) {
const normalizedName = String(profile?.name || '').trim();
const senderId = String(profile?.provider?.senderId || '').trim();
return (profile?.isAutoNamed === true && !senderId) || normalizedName === PENDING_SENDER_ID_PROFILE_NAME;
}
function DeleteProfileModal({ preview, deleting, onCancel, onConfirm }) {
if (!preview) return null;
const impactedTemplates = Array.isArray(preview.impactedTemplates) ? preview.impactedTemplates : [];
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-900/55 p-4 backdrop-blur-sm">
<div className="w-full max-w-xl rounded-2xl border border-gray-200 bg-white shadow-xl">
<div className="border-b border-gray-200 px-6 py-5">
<h3 className="text-lg font-bold text-gray-900">Delete cURL Profile</h3>
<p className="mt-1 text-sm text-gray-500">
{preview.profile?.name || 'This profile'} will be deleted. Bound templates will be removed, but event definitions will stay.
</p>
</div>
<div className="space-y-4 px-6 py-5">
{impactedTemplates.length > 0 ? (
<>
<p className="text-sm font-semibold text-gray-900">Affected templates</p>
<div className="max-h-72 space-y-3 overflow-y-auto">
{impactedTemplates.map((template) => (
<div key={`${template.eventSlug}-${template.status}`} className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-3">
<div className="flex items-center justify-between gap-3">
<p className="font-medium text-gray-900">{template.eventLabel || template.eventSlug}</p>
<span className="rounded-full border border-gray-200 bg-white px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-gray-500">
{template.status || 'generated'}
</span>
</div>
{template.templateId && (
<p className="mt-2 font-mono text-xs text-gray-500">DLT Template ID: {template.templateId}</p>
)}
</div>
))}
</div>
</>
) : (
<div className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-600">
No templates are currently bound to this profile.
</div>
)}
</div>
<div className="flex gap-3 border-t border-gray-200 px-6 py-4">
<button
type="button"
onClick={onCancel}
disabled={deleting}
className="flex-1 rounded-lg border border-gray-300 px-4 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 disabled:opacity-50"
>
Cancel
</button>
<button
type="button"
onClick={onConfirm}
disabled={deleting}
className="flex-1 rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-red-700 disabled:opacity-50"
>
{deleting ? 'Deleting…' : 'Delete Profile'}
</button>
</div>
</div>
</div>
);
}
export default function GlobalSms() {
const { businessId } = useParams();
const navigate = useNavigate();
const { isSetupComplete, setHasGlobalSms, setIsSetupComplete } = useBusiness();
const hasLoadedProfilesRef = useRef(false);
const [initialLoading, setInitialLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [profiles, setProfiles] = useState([]);
const [activeProfileId, setActiveProfileId] = useState(null);
const [saving, setSaving] = useState(false);
const [savingInputs, setSavingInputs] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [formCurl, setFormCurl] = useState('');
const [formSetActive, setFormSetActive] = useState(true);
const [inputForm, setInputForm] = useState({});
const [revealedProfiles, setRevealedProfiles] = useState({});
const [visibleProfileIds, setVisibleProfileIds] = useState({});
const [deletePreview, setDeletePreview] = useState(null);
const [deletingProfileId, setDeletingProfileId] = useState('');
const activeProfile = useMemo(
() => profiles.find((profile) => profile.id === activeProfileId) || null,
[profiles, activeProfileId],
);
const missingInputs = useMemo(
() => activeProfile?.executionReadiness?.missingProfileInputs || [],
[activeProfile?.executionReadiness?.missingProfileInputs],
);
const hasProfiles = profiles.length > 0;
const eventsPath = `/${businessId}/events`;
const loadProfiles = useCallback(async ({ background = false } = {}) => {
try {
if (background) {
setRefreshing(true);
} else {
setInitialLoading(true);
}
const res = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles`);
const fetchedProfiles = res.data?.profiles || [];
const nextActiveProfileId = res.data?.activeProfileId || null;
const nextActiveProfile = fetchedProfiles.find((profile) => profile.id === nextActiveProfileId) || null;
const nextIsSetupComplete = nextActiveProfile?.executionReadiness?.isSetupComplete === true;
setProfiles(fetchedProfiles);
setActiveProfileId(nextActiveProfileId);
setHasGlobalSms(fetchedProfiles.length > 0);
setIsSetupComplete(nextIsSetupComplete);
setError('');
return {
activeProfile: nextActiveProfile,
hasProfile: !!nextActiveProfile,
complete: nextIsSetupComplete,
};
} catch {
setError('Failed to load cURL profiles');
setHasGlobalSms(false);
setIsSetupComplete(false);
return { activeProfile: null, hasProfile: false, complete: false };
} finally {
if (background) {
setRefreshing(false);
} else {
hasLoadedProfilesRef.current = true;
setInitialLoading(false);
}
}
}, [businessId, setHasGlobalSms, setIsSetupComplete]);
useEffect(() => {
loadProfiles({ background: hasLoadedProfilesRef.current });
}, [loadProfiles]);
useEffect(() => {
setInputForm(getInputInitialValues(missingInputs));
}, [activeProfileId, missingInputs]);
const ensureRevealData = useCallback(async (profileId) => {
if (revealedProfiles[profileId]) return revealedProfiles[profileId];
const res = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles/${profileId}/reveal`);
setRevealedProfiles((current) => ({ ...current, [profileId]: res.data }));
return res.data;
}, [businessId, revealedProfiles]);
async function handleSubmit(event) {
event.preventDefault();
if (!formCurl.trim()) return;
setSaving(true);
setError('');
setSuccess('');
const shouldAutoAdvance = !isSetupComplete;
try {
await apiClient.post(`/api/businesses/${businessId}/global-sms/profiles`, {
rawCurl: formCurl.trim(),
setActive: formSetActive,
});
setFormCurl('');
setFormSetActive(true);
setSuccess('Profile created successfully.');
const nextState = await loadProfiles({ background: true });
if (shouldAutoAdvance && nextState.complete) {
navigate(eventsPath);
}
} catch (err) {
setError(err.response?.data?.error || 'Failed to save cURL profile');
} finally {
setSaving(false);
}
}
async function handleActivate(profileId) {
const shouldAutoAdvance = !isSetupComplete;
setError('');
setSuccess('');
try {
await apiClient.post(`/api/businesses/${businessId}/global-sms/profiles/${profileId}/activate`);
const nextState = await loadProfiles({ background: true });
setSuccess('Active profile updated.');
if (shouldAutoAdvance && nextState.complete) {
navigate(eventsPath);
}
} catch (err) {
setError(err.response?.data?.error || 'Failed to activate profile');
}
}
async function handleCopyCurl(profile) {
try {
const revealData = await ensureRevealData(profile.id);
const textToCopy = revealData?.rawCurl || profile.maskedCurl || '';
if (!textToCopy) return;
await navigator.clipboard.writeText(textToCopy);
setSuccess(`Copied ${profile.name} cURL.`);
} catch {
setError('Failed to copy the cURL command.');
}
}
async function handleToggleCurl(profile) {
setError('');
if (!visibleProfileIds[profile.id]) {
try {
await ensureRevealData(profile.id);
} catch (err) {
setError(err.response?.data?.error || 'Failed to reveal stored cURL');
return;
}
}
setVisibleProfileIds((current) => ({
...current,
[profile.id]: !current[profile.id],
}));
}
async function handleProviderSubmit(event) {
event.preventDefault();
if (!activeProfileId || missingInputs.length === 0) return;
setSavingInputs(true);
setError('');
setSuccess('');
const shouldAutoAdvance = !isSetupComplete;
try {
const payload = buildProfilePatchPayload(missingInputs, inputForm);
await apiClient.patch(`/api/businesses/${businessId}/global-sms/profiles/${activeProfileId}`, payload);
setSuccess('Required profile fields saved.');
const nextState = await loadProfiles({ background: true });
if (shouldAutoAdvance && nextState.complete) {
navigate(eventsPath);
}
} catch (err) {
setError(err.response?.data?.error || 'Failed to save required profile fields');
} finally {
setSavingInputs(false);
}
}
async function handleDeleteRequest(profile) {
setError('');
setSuccess('');
try {
const res = await apiClient.get(`/api/businesses/${businessId}/global-sms/profiles/${profile.id}/delete-impact`);
setDeletePreview(res.data);
} catch (err) {
setError(err.response?.data?.error || 'Failed to load delete impact');
}
}
async function handleDeleteConfirm() {
if (!deletePreview?.profile?.id) return;
setDeletingProfileId(deletePreview.profile.id);
setError('');
setSuccess('');
try {
await apiClient.delete(`/api/businesses/${businessId}/global-sms/profiles/${deletePreview.profile.id}`);
setDeletePreview(null);
setVisibleProfileIds((current) => {
const nextState = { ...current };
delete nextState[deletePreview.profile.id];
return nextState;
});
setRevealedProfiles((current) => {
const nextState = { ...current };
delete nextState[deletePreview.profile.id];
return nextState;
});
await loadProfiles({ background: true });
setSuccess('Profile deleted successfully.');
} catch (err) {
setError(err.response?.data?.error || 'Failed to delete profile');
} finally {
setDeletingProfileId('');
}
}
if (initialLoading) {
return (
<div className="flex h-64 items-center justify-center">
<span className="h-8 w-8 animate-spin rounded-full border-4 border-spinner-track border-t-primary-blue" />
</div>
);
}
return (
<>
<DeleteProfileModal
preview={deletePreview}
deleting={deletingProfileId === deletePreview?.profile?.id}
onCancel={() => setDeletePreview(null)}
onConfirm={handleDeleteConfirm}
/>
<div className="mx-auto max-w-4xl space-y-8 pb-12">
<div>
<div className="mb-2 flex flex-wrap items-center gap-3">
<h2 className="text-2xl font-bold text-text-primary">Omni-channel SMS</h2>
{refreshing && (
<span className="inline-flex items-center gap-2 rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-semibold text-gray-500">
<span className="h-2 w-2 animate-pulse rounded-full bg-primary-blue" />
Refreshing profiles
</span>
)}
</div>
<p className="text-sm text-text-muted">
Add and activate a validated provider cURL, complete the required profile fields, and then continue to event template setup.
</p>
</div>
{error && (
<div className="flex items-center justify-between rounded-md border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-error-text">
{error}
<button type="button" onClick={() => setError('')} className="font-bold text-error-text hover:text-red-900">
&times;
</button>
</div>
)}
{success && (
<div className="flex items-center justify-between rounded-md border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700">
{success}
<button type="button" onClick={() => setSuccess('')} className="font-bold text-gray-700 hover:opacity-75">
&times;
</button>
</div>
)}
{activeProfile ? (
<div className={`rounded-lg border p-5 ${activeProfile.executionReadiness?.isSetupComplete ? 'border-primary-blue bg-white' : 'border-gray-200 bg-white'}`}>
<div className="mb-4 flex items-center gap-3">
<h3 className="text-lg font-bold text-text-primary">
Active Setup:{' '}
<span className={isPendingSenderIdProfile(activeProfile) ? 'text-error-text' : 'text-text-primary'}>
{activeProfile.name}
</span>
</h3>
<span className="rounded-full border border-gray-200 bg-white px-3 py-1 text-xs font-bold uppercase tracking-wide text-gray-700">
{activeProfile.executionReadiness?.isSetupComplete ? 'Setup Complete' : 'Missing Information'}
</span>
</div>
<div className="grid gap-5 md:grid-cols-2">
<div className="space-y-3">
<p className="text-sm font-medium text-text-primary">Current Profile Summary</p>
<ul className="space-y-2 text-sm">
<li className="flex items-center justify-between rounded border border-border-soft bg-surface-white p-2">
<span className="text-text-muted">Provider</span>
<span className="font-bold text-text-primary">
{activeProfile.provider?.providerName || <span className="text-xs uppercase text-error-text">Missing</span>}
</span>
</li>
<li className="flex items-center justify-between rounded border border-border-soft bg-surface-white p-2">
<span className="text-text-muted">Sender ID</span>
<span className="font-bold text-text-primary">
{activeProfile.provider?.senderId || <span className="text-xs uppercase text-error-text">Missing</span>}
</span>
</li>
<li className="flex items-center justify-between rounded border border-border-soft bg-surface-white p-2">
<span className="text-text-muted">DLT Entity ID</span>
<span className="font-bold text-text-primary">
{activeProfile.provider?.dltEntityId || <span className="text-xs uppercase text-error-text">Missing</span>}
</span>
</li>
<li className="rounded border border-border-soft bg-surface-white p-3">
<p className="text-xs font-semibold uppercase tracking-wide text-text-muted">Setup Status</p>
<p className="mt-2 text-sm text-text-primary">{getProfileSummary(activeProfile)}</p>
</li>
</ul>
</div>
{!activeProfile.executionReadiness?.isSetupComplete ? (
<div className="rounded-lg border border-border-main bg-surface-white p-4">
<p className="mb-3 text-sm font-semibold text-text-primary">Complete the required fields</p>
<form onSubmit={handleProviderSubmit} className="space-y-3">
{missingInputs.map((input) => (
<div key={input.key}>
<label className="mb-1.5 block text-sm font-semibold text-text-primary">{input.label}</label>
<input
type={input.secret ? 'password' : 'text'}
value={inputForm[input.key] || ''}
onChange={(event) => setInputForm((current) => ({
...current,
[input.key]: input.key === 'senderId'
? event.target.value.toUpperCase()
: event.target.value,
}))}
className="w-full rounded border border-border-main bg-page-bg px-3 py-2 text-sm text-text-primary focus:ring-1 focus:ring-primary-blue"
placeholder={input.label}
required={input.required !== false}
/>
</div>
))}
<button
type="submit"
disabled={savingInputs || missingInputs.some((input) => !String(inputForm[input.key] || '').trim())}
className="w-full rounded bg-primary-blue py-2 text-sm font-bold text-white transition hover:bg-primary-dark disabled:opacity-50"
>
{savingInputs ? 'Saving...' : 'Save Required Details'}
</button>
</form>
</div>
) : (
<div className="flex h-full flex-col items-center justify-center space-y-4">
<p className="text-center text-sm font-medium text-text-muted">Your active cURL profile is fully configured.</p>
<button
type="button"
onClick={() => navigate(eventsPath)}
className="w-full rounded-lg bg-primary-blue px-6 py-2 text-sm font-semibold text-white transition hover:bg-primary-dark"
>
Continue to Events
</button>
</div>
)}
</div>
</div>
) : hasProfiles ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
Select an active cURL profile to continue. Your saved profiles are still available below.
</div>
) : null}
{hasProfiles && (
<div className="space-y-4 border-t border-border-soft pt-4">
<h3 className="text-lg font-bold text-text-primary">Saved Profiles</h3>
{profiles.map((profile) => {
const isActive = profile.id === activeProfileId;
const isVisible = visibleProfileIds[profile.id] === true;
const revealedProfile = revealedProfiles[profile.id];
const displayCurl = isVisible ? (revealedProfile?.rawCurl || profile.maskedCurl) : profile.maskedCurl;
return (
<div
key={profile.id}
className={`rounded-xl border p-5 transition-colors ${isActive ? 'border-primary-blue bg-white' : 'border-border-main bg-surface-white'}`}
>
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 flex-1">
<div className="mb-3 flex flex-wrap items-center gap-3">
<h3 className={`truncate text-base font-bold ${isPendingSenderIdProfile(profile) ? 'text-error-text' : 'text-text-primary'}`}>{profile.name}</h3>
{isActive && (
<span className="rounded-full border border-indigo-200 bg-indigo-50 px-2.5 py-1 text-[11px] font-bold uppercase tracking-wider text-primary-dark">
Active Profile
</span>
)}
<span className={`rounded-full border px-2.5 py-1 text-[11px] font-bold uppercase tracking-wider ${profile.executionReadiness?.isSetupComplete ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-amber-200 bg-amber-50 text-amber-700'}`}>
{profile.executionReadiness?.isSetupComplete ? 'Ready' : 'Needs Fields'}
</span>
</div>
<p className="mb-2 text-xs font-medium text-text-muted">Updated: {formatUpdatedAt(profile.updatedAt)}</p>
<p className="mb-3 text-sm text-text-muted">{getProfileSummary(profile)}</p>
<div className="overflow-hidden rounded-xl border border-gray-200 bg-gray-950">
<div className="flex items-center justify-between border-b border-gray-800 px-4 py-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-gray-400">Stored cURL</p>
<button
type="button"
onClick={() => handleToggleCurl(profile)}
className="text-xs font-semibold text-gray-300 transition hover:text-white"
>
{isVisible ? 'Hide' : 'Show'}
</button>
</div>
<pre className="max-h-56 overflow-auto px-4 py-4 text-xs leading-relaxed text-gray-100">
<code>{displayCurl || 'No cURL stored.'}</code>
</pre>
</div>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2 lg:justify-end">
{!isActive && (
<button
type="button"
onClick={() => handleActivate(profile.id)}
className="rounded-lg bg-primary-blue px-4 py-2 text-sm font-semibold text-white transition hover:bg-primary-dark"
>
Use this cURL
</button>
)}
<button
type="button"
onClick={() => handleCopyCurl(profile)}
className="rounded-lg border border-border-main px-3 py-2 text-sm font-medium text-text-muted transition hover:border-primary-blue hover:bg-page-bg hover:text-primary-blue"
>
Copy
</button>
<button
type="button"
onClick={() => handleDeleteRequest(profile)}
className="rounded-lg border border-border-main px-3 py-2 text-sm font-medium text-text-muted transition hover:border-error-text hover:bg-red-50 hover:text-error-text"
>
Delete
</button>
</div>
</div>
</div>
);
})}
</div>
)}
<div className={`overflow-hidden rounded-xl border ${!hasProfiles ? 'border-indigo-100 bg-white shadow-sm' : 'border-border-main bg-surface-white'}`}>
<div className={`flex items-start justify-between gap-4 px-6 py-5 ${!hasProfiles ? 'border-b border-indigo-100 bg-indigo-50/60' : 'border-b border-border-main bg-table-header'}`}>
<div>
<h3 className="text-md font-bold text-text-primary">Add New Profile</h3>
<p className="mt-1 text-sm text-text-muted">
Paste a provider cURL exactly once. After validation, the stored cURL becomes immutable and can only be replaced by creating a new profile.
</p>
</div>
</div>
<div className="p-6">
{!hasProfiles && (
<div className="mb-6 rounded-xl border border-indigo-100 bg-indigo-50/50 px-4 py-4">
<p className="text-sm font-semibold text-text-primary">Start by adding a cURL profile</p>
<p className="mt-1 text-sm text-text-muted">
This becomes the base for validating provider details and unlocking event template generation.
</p>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="mb-1.5 block text-sm font-semibold text-text-primary">Provider cURL Command</label>
<textarea
value={formCurl}
onChange={(event) => setFormCurl(event.target.value)}
placeholder="curl --request POST --url ..."
className="h-48 w-full resize-none rounded-lg border border-border-main bg-white px-4 py-3 font-mono text-sm leading-relaxed text-text-primary transition focus:outline-none focus:ring-2 focus:ring-primary-blue"
required
spellCheck="false"
/>
<p className="mt-2 text-xs font-medium text-text-muted">
Profile name is generated automatically from Sender ID. If Sender ID is not detected yet, it stays as <span className="text-error-text">{PENDING_SENDER_ID_PROFILE_NAME}</span> until completed.
</p>
</div>
<label className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
className="h-4 w-4 rounded border-border-main text-primary-blue focus:ring-primary-blue"
checked={formSetActive}
onChange={(event) => setFormSetActive(event.target.checked)}
/>
<span className="text-sm font-semibold text-text-primary">Set as active profile immediately</span>
</label>
<div className="flex gap-3 pt-2">
<button
type="submit"
disabled={saving}
className="flex items-center justify-center gap-2 rounded-lg bg-primary-blue px-6 py-2 text-sm font-semibold text-white transition hover:bg-primary-dark disabled:opacity-50"
>
{saving ? 'Saving…' : 'Save Profile'}
</button>
</div>
</form>
</div>
</div>
</div>
</>
);
}