Stepper for the entire process instead of just provider config

This commit is contained in:
Ritul Jadhav 2026-03-30 15:35:07 +05:30
parent b34915c58b
commit 2785f5ee96
4 changed files with 218 additions and 70 deletions

View File

@ -1,12 +1,7 @@
import { NavLink, useNavigate } from 'react-router-dom'; import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import { useBusiness } from '../context/BusinessContext'; import { useBusiness } from '../context/BusinessContext';
const SVG_ICONS = { const SVG_ICONS = {
providers: (
<svg className="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
),
globalSms: ( globalSms: (
<svg className="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
@ -24,14 +19,75 @@ const SVG_ICONS = {
), ),
}; };
export default function Sidebar() { function TopLevelStatus({ done, active }) {
const { activeBusiness, activeBusinessId, clearBusiness } = useBusiness(); if (done) {
const navigate = useNavigate(); return (
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-indigo-600 text-white shadow-sm">
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M5 13l4 4L19 7" />
</svg>
</span>
);
}
const navItems = [ if (active) {
{ id: 'globalSms', to: `/${activeBusinessId}/global-sms`, label: 'Omni-channel SMS' }, return <span className="inline-block h-2.5 w-2.5 rounded-full bg-indigo-600 shadow-sm" />;
{ id: 'events', to: `/${activeBusinessId}/events`, label: 'Events' }, }
{ id: 'templates', to: `/${activeBusinessId}/templates`, label: 'Templates' },
return <span className="inline-block h-2.5 w-2.5 rounded-full bg-gray-200" />;
}
export default function Sidebar() {
const {
activeBusiness,
activeBusinessId,
clearBusiness,
hasGlobalSms,
isSetupComplete,
hasSelectedTemplates,
} = useBusiness();
const navigate = useNavigate();
const location = useLocation();
const globalSmsPath = `/${activeBusinessId}/global-sms`;
const eventsPath = `/${activeBusinessId}/events`;
const templatesPath = `/${activeBusinessId}/templates`;
const isGlobalSmsRoute = location.pathname === globalSmsPath;
const isEventsRoute = location.pathname === eventsPath;
const isTemplatesRoute = location.pathname === templatesPath;
const omniSubsteps = [
{ id: 'profile', label: 'Add / Select Profile', done: hasGlobalSms, active: isGlobalSmsRoute && !hasGlobalSms },
{ id: 'validate', label: 'Validate cURL', done: hasGlobalSms, active: false },
{ id: 'fields', label: 'Complete Fields', done: isSetupComplete, active: isGlobalSmsRoute && hasGlobalSms && !isSetupComplete },
{ id: 'ready', label: 'Ready', done: isSetupComplete, active: isGlobalSmsRoute && isSetupComplete },
];
const stepItems = [
{
id: 'globalSms',
to: globalSmsPath,
label: 'Omni-channel SMS',
done: isSetupComplete && !isGlobalSmsRoute,
active: isGlobalSmsRoute,
expanded: isGlobalSmsRoute,
substeps: omniSubsteps,
},
{
id: 'events',
to: eventsPath,
label: 'Events',
done: hasSelectedTemplates && !isEventsRoute,
active: isEventsRoute,
},
{
id: 'templates',
to: templatesPath,
label: 'Templates',
done: false,
active: isTemplatesRoute,
},
]; ];
function handleSwitch() { function handleSwitch() {
@ -64,22 +120,65 @@ export default function Sidebar() {
</div> </div>
{/* Nav */} {/* Nav */}
<nav className="flex-1 px-3 pt-5 space-y-1"> <nav className="flex-1 px-3 pt-5">
{navItems.map(({ id, to, label }) => ( <div className="space-y-2">
{stepItems.map((item) => (
<div key={item.id}>
<NavLink <NavLink
key={id} to={item.to}
to={to} className={`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-semibold transition-colors duration-150 ${
className={({ isActive }) => item.active
`flex items-center gap-3 px-3 py-2.5 rounded-md text-sm font-medium transition-colors duration-150 ${isActive
? 'bg-refresh-hover text-primary-blue' ? 'bg-refresh-hover text-primary-blue'
: 'text-text-muted hover:text-text-primary hover:bg-row-hover' : 'text-text-muted hover:text-text-primary hover:bg-row-hover'
}` }`}
}
> >
{SVG_ICONS[id]} {SVG_ICONS[item.id]}
{label} <span className="flex-1 truncate">{item.label}</span>
<div className="flex items-center gap-3">
<TopLevelStatus done={item.done} active={item.active} />
{item.substeps && (
<svg
className={`h-4 w-4 text-gray-400 transition-transform ${item.expanded ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
</svg>
)}
</div>
</NavLink> </NavLink>
{item.expanded && item.substeps && (
<div className="relative ml-8 mt-2 space-y-2 pl-6 before:absolute before:left-[5px] before:top-2 before:bottom-3 before:w-px before:bg-indigo-100">
{item.substeps.map((substep) => (
<div
key={substep.id}
className={`relative rounded-xl px-3 py-2 text-sm font-medium transition-colors ${
substep.active
? 'bg-indigo-50 text-indigo-700 shadow-sm'
: substep.done
? 'text-gray-700'
: 'text-gray-400'
}`}
>
<span
className={`absolute -left-[24px] top-1/2 h-2.5 w-2.5 -translate-y-1/2 rounded-full border-2 ${
substep.active
? 'border-indigo-100 bg-indigo-600'
: substep.done
? 'border-indigo-300 bg-white'
: 'border-gray-300 bg-white'
}`}
/>
{substep.label}
</div>
))} ))}
</div>
)}
</div>
))}
</div>
</nav> </nav>
<div className="px-5 py-4 border-t border-gray-100"> <div className="px-5 py-4 border-t border-gray-100">

View File

@ -11,17 +11,49 @@ export function BusinessProvider({ children }) {
const [activeBusiness, setActiveBusinessState] = useState(null); const [activeBusiness, setActiveBusinessState] = useState(null);
const [hasGlobalSms, setHasGlobalSms] = useState(false); const [hasGlobalSms, setHasGlobalSms] = useState(false);
const [isSetupComplete, setIsSetupComplete] = useState(false); const [isSetupComplete, setIsSetupComplete] = useState(false);
const [hasSelectedTemplates, setHasSelectedTemplates] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const updateReadyState = useCallback((activeProfile) => { const updateReadyState = useCallback((activeProfile, templates = []) => {
const hasProfile = !!activeProfile; const hasProfile = !!activeProfile;
setHasGlobalSms(hasProfile); setHasGlobalSms(hasProfile);
const p = activeProfile?.provider || {}; const p = activeProfile?.provider || {};
const nextIsSetupComplete = hasProfile && !!p.providerName && !!p.senderId && !!p.dltEntityId; const nextIsSetupComplete = hasProfile && !!p.providerName && !!p.senderId && !!p.dltEntityId;
setIsSetupComplete(nextIsSetupComplete); setIsSetupComplete(nextIsSetupComplete);
return nextIsSetupComplete; const nextHasSelectedTemplates = Array.isArray(templates)
? templates.some((template) => !!template?.selectedTemplate)
: false;
setHasSelectedTemplates(nextHasSelectedTemplates);
return {
hasGlobalSms: hasProfile,
isSetupComplete: nextIsSetupComplete,
hasSelectedTemplates: nextHasSelectedTemplates,
};
}, []); }, []);
const refreshOnboardingState = useCallback(async (businessIdOverride) => {
const targetBusinessId = businessIdOverride || activeBusiness?.businessId;
if (!targetBusinessId) {
setHasGlobalSms(false);
setIsSetupComplete(false);
setHasSelectedTemplates(false);
return {
hasGlobalSms: false,
isSetupComplete: false,
hasSelectedTemplates: false,
};
}
const [smsRes, templatesRes] = await Promise.all([
apiClient.get(`/api/businesses/${targetBusinessId}/global-sms/active`).catch(() => ({ data: {} })),
apiClient.get(`/api/businesses/${targetBusinessId}/templates`).catch(() => ({ data: { templates: [] } })),
]);
return updateReadyState(smsRes.data?.activeProfile, templatesRes.data?.templates || []);
}, [activeBusiness?.businessId, updateReadyState]);
// On mount: rehydrate from sessionStorage and refresh from API // On mount: rehydrate from sessionStorage and refresh from API
useEffect(() => { useEffect(() => {
async function rehydrate() { async function rehydrate() {
@ -37,10 +69,13 @@ export function BusinessProvider({ children }) {
const [bizRes, smsRes] = await Promise.all([ const [bizRes, smsRes] = await Promise.all([
apiClient.get(`/api/businesses/${businessId}`), apiClient.get(`/api/businesses/${businessId}`),
apiClient.get(`/api/businesses/${businessId}/global-sms/active`).catch(() => ({ data: {} })) Promise.all([
apiClient.get(`/api/businesses/${businessId}/global-sms/active`).catch(() => ({ data: {} })),
apiClient.get(`/api/businesses/${businessId}/templates`).catch(() => ({ data: { templates: [] } })),
]),
]); ]);
setActiveBusinessState(bizRes.data); setActiveBusinessState(bizRes.data);
updateReadyState(smsRes.data?.activeProfile); updateReadyState(smsRes[0].data?.activeProfile, smsRes[1].data?.templates || []);
sessionStorage.setItem(SESSION_KEY, JSON.stringify({ sessionStorage.setItem(SESSION_KEY, JSON.stringify({
businessId, businessId,
companyId: runtimeCompanyId || companyId || '', companyId: runtimeCompanyId || companyId || '',
@ -51,6 +86,7 @@ export function BusinessProvider({ children }) {
setActiveBusinessState(null); setActiveBusinessState(null);
setHasGlobalSms(false); setHasGlobalSms(false);
setIsSetupComplete(false); setIsSetupComplete(false);
setHasSelectedTemplates(false);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -65,19 +101,21 @@ export function BusinessProvider({ children }) {
companyId: getRuntimeCompanyId(), companyId: getRuntimeCompanyId(),
})); }));
try { try {
const smsRes = await apiClient.get(`/api/businesses/${business.businessId}/global-sms/active`); const progress = await refreshOnboardingState(business.businessId);
return updateReadyState(smsRes.data?.activeProfile); return progress.isSetupComplete;
} catch { } catch {
setHasGlobalSms(false); setHasGlobalSms(false);
setIsSetupComplete(false); setIsSetupComplete(false);
setHasSelectedTemplates(false);
return false; return false;
} }
}, [updateReadyState]); }, [refreshOnboardingState]);
const clearBusiness = useCallback(() => { const clearBusiness = useCallback(() => {
setActiveBusinessState(null); setActiveBusinessState(null);
setHasGlobalSms(false); setHasGlobalSms(false);
setIsSetupComplete(false); setIsSetupComplete(false);
setHasSelectedTemplates(false);
sessionStorage.removeItem(SESSION_KEY); sessionStorage.removeItem(SESSION_KEY);
}, []); }, []);
@ -85,7 +123,18 @@ export function BusinessProvider({ children }) {
return ( return (
<BusinessContext.Provider value={{ <BusinessContext.Provider value={{
activeBusiness, activeBusinessId, setActiveBusiness, clearBusiness, loading, hasGlobalSms, setHasGlobalSms, isSetupComplete, setIsSetupComplete activeBusiness,
activeBusinessId,
setActiveBusiness,
clearBusiness,
loading,
hasGlobalSms,
setHasGlobalSms,
isSetupComplete,
setIsSetupComplete,
hasSelectedTemplates,
setHasSelectedTemplates,
refreshOnboardingState,
}}> }}>
{children} {children}
</BusinessContext.Provider> </BusinessContext.Provider>

View File

@ -1,6 +1,7 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import apiClient from '../api/client'; import apiClient from '../api/client';
import { useBusiness } from '../context/BusinessContext';
const MAX_SMS_LENGTH = 160; const MAX_SMS_LENGTH = 160;
const DLT_VARIABLE_OPTIONS = [ const DLT_VARIABLE_OPTIONS = [
@ -99,6 +100,8 @@ function buildTemplateUiState(templates = []) {
export default function Events() { export default function Events() {
const { businessId } = useParams(); const { businessId } = useParams();
const navigate = useNavigate();
const { hasSelectedTemplates, refreshOnboardingState } = useBusiness();
const [events, setEvents] = useState([]); const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [newLabel, setNewLabel] = useState(''); const [newLabel, setNewLabel] = useState('');
@ -259,16 +262,21 @@ export default function Events() {
async function handleSelect(slug, variant, variantIndex) { async function handleSelect(slug, variant, variantIndex) {
const variantKey = getVariantKey(slug, variantIndex); const variantKey = getVariantKey(slug, variantIndex);
const shouldAutoAdvance = !hasSelectedTemplates;
setSelectingVariantKey(variantKey); setSelectingVariantKey(variantKey);
setError(''); setError('');
try { try {
await apiClient.post(`/api/businesses/${businessId}/templates/${slug}/select`, { selectedVariant: variant }); await apiClient.post(`/api/businesses/${businessId}/templates/${slug}/select`, { selectedVariant: variant });
await refreshOnboardingState(businessId).catch(() => null);
setVariants((currentVariants) => ({ ...currentVariants, [slug]: [] })); setVariants((currentVariants) => ({ ...currentVariants, [slug]: [] }));
setVariantDrafts((currentDrafts) => removeDraftsForSlug(currentDrafts, slug)); setVariantDrafts((currentDrafts) => removeDraftsForSlug(currentDrafts, slug));
setOpenVariableMenuKey(''); setOpenVariableMenuKey('');
setActiveCaretVariantKey(''); setActiveCaretVariantKey('');
setGenState((state) => ({ ...state, [slug]: 'selected' })); setGenState((state) => ({ ...state, [slug]: 'selected' }));
if (shouldAutoAdvance) {
navigate(`/${businessId}/templates`);
}
} catch (err) { } catch (err) {
setError(err.response?.data?.error || 'Failed to select template'); setError(err.response?.data?.error || 'Failed to select template');
} finally { } finally {

View File

@ -24,6 +24,7 @@ export default function GlobalSms() {
// Form state for Missing Provider Fields // Form state for Missing Provider Fields
const [providerForm, setProviderForm] = useState({ providerName: '', senderId: '', dltEntityId: '' }); const [providerForm, setProviderForm] = useState({ providerName: '', senderId: '', dltEntityId: '' });
const [savingProvider, setSavingProvider] = useState(false); const [savingProvider, setSavingProvider] = useState(false);
const eventsPath = `/${businessId}/events`;
const loadProfiles = useCallback(async () => { const loadProfiles = useCallback(async () => {
try { try {
@ -47,8 +48,13 @@ export default function GlobalSms() {
senderId: p.senderId || '', senderId: p.senderId || '',
dltEntityId: p.dltEntityId || '', dltEntityId: p.dltEntityId || '',
}); });
return { activeProfile, hasProfile, complete };
} catch { } catch {
setError('Failed to load cURL profiles'); setError('Failed to load cURL profiles');
setHasGlobalSms(false);
setIsSetupComplete(false);
return { activeProfile: null, hasProfile: false, complete: false };
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -89,6 +95,7 @@ export default function GlobalSms() {
setSaving(true); setSaving(true);
setError(''); setError('');
setSuccess(''); setSuccess('');
const shouldAutoAdvance = !isSetupComplete;
try { try {
if (editingId) { if (editingId) {
@ -105,10 +112,13 @@ export default function GlobalSms() {
}); });
setSuccess('Profile created successfully.'); setSuccess('Profile created successfully.');
} }
await loadProfiles(); const nextState = await loadProfiles();
setFormName(''); setFormName('');
setFormCurl(''); setFormCurl('');
setEditingId(null); setEditingId(null);
if (shouldAutoAdvance && nextState.complete) {
navigate(eventsPath);
}
} catch (err) { } catch (err) {
setError(err.response?.data?.error || 'Failed to save cURL profile'); setError(err.response?.data?.error || 'Failed to save cURL profile');
} finally { } finally {
@ -127,9 +137,13 @@ export default function GlobalSms() {
} }
async function handleActivate(id) { async function handleActivate(id) {
const shouldAutoAdvance = !isSetupComplete;
try { try {
await apiClient.post(`/api/businesses/${businessId}/global-sms/profiles/${id}/activate`); await apiClient.post(`/api/businesses/${businessId}/global-sms/profiles/${id}/activate`);
await loadProfiles(); const nextState = await loadProfiles();
if (shouldAutoAdvance && nextState.complete) {
navigate(eventsPath);
}
} catch (err) { } catch (err) {
setError(err.response?.data?.error || 'Failed to activate profile'); setError(err.response?.data?.error || 'Failed to activate profile');
} }
@ -141,6 +155,7 @@ export default function GlobalSms() {
setSavingProvider(true); setSavingProvider(true);
setError(''); setError('');
setSuccess(''); setSuccess('');
const shouldAutoAdvance = !isSetupComplete;
try { try {
await apiClient.patch(`/api/businesses/${businessId}/global-sms/profiles/${activeProfileId}`, { await apiClient.patch(`/api/businesses/${businessId}/global-sms/profiles/${activeProfileId}`, {
@ -151,7 +166,10 @@ export default function GlobalSms() {
} }
}); });
setSuccess('Provider details saved successfully!'); setSuccess('Provider details saved successfully!');
await loadProfiles(); const nextState = await loadProfiles();
if (shouldAutoAdvance && nextState.complete) {
navigate(eventsPath);
}
} catch (err) { } catch (err) {
setError(err.response?.data?.error || 'Failed to save provider details'); setError(err.response?.data?.error || 'Failed to save provider details');
} finally { } finally {
@ -169,37 +187,11 @@ export default function GlobalSms() {
return ( return (
<div className="max-w-4xl mx-auto space-y-8 pb-12"> <div className="max-w-4xl mx-auto space-y-8 pb-12">
{/* Header & Stepper */}
<div> <div>
<h2 className="text-2xl font-bold text-text-primary mb-2">Setup configuration</h2> <h2 className="text-2xl font-bold text-text-primary mb-2">Omni-channel SMS</h2>
<p className="text-sm text-text-muted mb-8"> <p className="text-sm text-text-muted">
Complete this flow to configure your cURL profile and brand provider data. You must finish setup before generating templates. Complete this flow to configure your cURL profile and brand provider data. You must finish setup before generating templates.
</p> </p>
{/* CSS Stepper */}
<div className="flex items-center w-full justify-between relative before:absolute before:top-4 before:left-0 before:h-[2px] before:w-full before:bg-border-soft before:-z-10">
{[
{ label: 'Add / Select Profile', done: !!activeProfile, active: !activeProfile },
{ label: 'Validate cURL', done: !!activeProfile, active: false },
{ label: 'Complete Fields', done: isSetupComplete, active: !!activeProfile && !isSetupComplete },
{ label: 'Ready', done: isSetupComplete, active: isSetupComplete }
].map((step, idx) => (
<div key={idx} className="flex flex-col items-center">
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm transition-colors border-2 ${step.done
? 'bg-primary-blue border-primary-blue text-white'
: step.active
? 'bg-white border-primary-blue text-primary-blue'
: 'bg-white border-border-main text-text-muted'
}`}>
{step.done ? '✓' : idx + 1}
</div>
<span className={`mt-2 text-[11px] uppercase tracking-wide font-bold ${step.done || step.active ? 'text-primary-blue' : 'text-text-muted'
}`}>
{step.label}
</span>
</div>
))}
</div>
</div> </div>
{error && ( {error && (
@ -300,7 +292,7 @@ export default function GlobalSms() {
<div className="flex flex-col justify-center items-center h-full space-y-4"> <div className="flex flex-col justify-center items-center h-full space-y-4">
<p className="text-center text-sm font-medium text-text-muted">Your active cURL profile is fully configured.</p> <p className="text-center text-sm font-medium text-text-muted">Your active cURL profile is fully configured.</p>
<button <button
onClick={() => navigate(`/${businessId}/events`)} onClick={() => navigate(eventsPath)}
className="px-6 py-3 bg-primary-blue hover:bg-primary-dark text-white rounded-lg shadow font-semibold text-sm transition w-full" className="px-6 py-3 bg-primary-blue hover:bg-primary-dark text-white rounded-lg shadow font-semibold text-sm transition w-full"
> >
Continue to Events Continue to Events