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';
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: (
<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" />
@ -24,14 +19,75 @@ const SVG_ICONS = {
),
};
export default function Sidebar() {
const { activeBusiness, activeBusinessId, clearBusiness } = useBusiness();
const navigate = useNavigate();
function TopLevelStatus({ done, active }) {
if (done) {
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 = [
{ id: 'globalSms', to: `/${activeBusinessId}/global-sms`, label: 'Omni-channel SMS' },
{ id: 'events', to: `/${activeBusinessId}/events`, label: 'Events' },
{ id: 'templates', to: `/${activeBusinessId}/templates`, label: 'Templates' },
if (active) {
return <span className="inline-block h-2.5 w-2.5 rounded-full bg-indigo-600 shadow-sm" />;
}
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() {
@ -64,22 +120,65 @@ export default function Sidebar() {
</div>
{/* Nav */}
<nav className="flex-1 px-3 pt-5 space-y-1">
{navItems.map(({ id, to, label }) => (
<NavLink
key={id}
to={to}
className={({ isActive }) =>
`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'
: 'text-text-muted hover:text-text-primary hover:bg-row-hover'
}`
}
>
{SVG_ICONS[id]}
{label}
</NavLink>
))}
<nav className="flex-1 px-3 pt-5">
<div className="space-y-2">
{stepItems.map((item) => (
<div key={item.id}>
<NavLink
to={item.to}
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-semibold transition-colors duration-150 ${
item.active
? 'bg-refresh-hover text-primary-blue'
: 'text-text-muted hover:text-text-primary hover:bg-row-hover'
}`}
>
{SVG_ICONS[item.id]}
<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>
{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>
<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 [hasGlobalSms, setHasGlobalSms] = useState(false);
const [isSetupComplete, setIsSetupComplete] = useState(false);
const [hasSelectedTemplates, setHasSelectedTemplates] = useState(false);
const [loading, setLoading] = useState(true);
const updateReadyState = useCallback((activeProfile) => {
const updateReadyState = useCallback((activeProfile, templates = []) => {
const hasProfile = !!activeProfile;
setHasGlobalSms(hasProfile);
const p = activeProfile?.provider || {};
const nextIsSetupComplete = hasProfile && !!p.providerName && !!p.senderId && !!p.dltEntityId;
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
useEffect(() => {
async function rehydrate() {
@ -37,10 +69,13 @@ export function BusinessProvider({ children }) {
const [bizRes, smsRes] = await Promise.all([
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);
updateReadyState(smsRes.data?.activeProfile);
updateReadyState(smsRes[0].data?.activeProfile, smsRes[1].data?.templates || []);
sessionStorage.setItem(SESSION_KEY, JSON.stringify({
businessId,
companyId: runtimeCompanyId || companyId || '',
@ -51,6 +86,7 @@ export function BusinessProvider({ children }) {
setActiveBusinessState(null);
setHasGlobalSms(false);
setIsSetupComplete(false);
setHasSelectedTemplates(false);
} finally {
setLoading(false);
}
@ -65,19 +101,21 @@ export function BusinessProvider({ children }) {
companyId: getRuntimeCompanyId(),
}));
try {
const smsRes = await apiClient.get(`/api/businesses/${business.businessId}/global-sms/active`);
return updateReadyState(smsRes.data?.activeProfile);
const progress = await refreshOnboardingState(business.businessId);
return progress.isSetupComplete;
} catch {
setHasGlobalSms(false);
setIsSetupComplete(false);
setHasSelectedTemplates(false);
return false;
}
}, [updateReadyState]);
}, [refreshOnboardingState]);
const clearBusiness = useCallback(() => {
setActiveBusinessState(null);
setHasGlobalSms(false);
setIsSetupComplete(false);
setHasSelectedTemplates(false);
sessionStorage.removeItem(SESSION_KEY);
}, []);
@ -85,7 +123,18 @@ export function BusinessProvider({ children }) {
return (
<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}
</BusinessContext.Provider>

View File

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

View File

@ -24,6 +24,7 @@ export default function GlobalSms() {
// Form state for Missing Provider Fields
const [providerForm, setProviderForm] = useState({ providerName: '', senderId: '', dltEntityId: '' });
const [savingProvider, setSavingProvider] = useState(false);
const eventsPath = `/${businessId}/events`;
const loadProfiles = useCallback(async () => {
try {
@ -47,8 +48,13 @@ export default function GlobalSms() {
senderId: p.senderId || '',
dltEntityId: p.dltEntityId || '',
});
return { activeProfile, hasProfile, complete };
} catch {
setError('Failed to load cURL profiles');
setHasGlobalSms(false);
setIsSetupComplete(false);
return { activeProfile: null, hasProfile: false, complete: false };
} finally {
setLoading(false);
}
@ -89,6 +95,7 @@ export default function GlobalSms() {
setSaving(true);
setError('');
setSuccess('');
const shouldAutoAdvance = !isSetupComplete;
try {
if (editingId) {
@ -105,10 +112,13 @@ export default function GlobalSms() {
});
setSuccess('Profile created successfully.');
}
await loadProfiles();
const nextState = await loadProfiles();
setFormName('');
setFormCurl('');
setEditingId(null);
if (shouldAutoAdvance && nextState.complete) {
navigate(eventsPath);
}
} catch (err) {
setError(err.response?.data?.error || 'Failed to save cURL profile');
} finally {
@ -127,9 +137,13 @@ export default function GlobalSms() {
}
async function handleActivate(id) {
const shouldAutoAdvance = !isSetupComplete;
try {
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) {
setError(err.response?.data?.error || 'Failed to activate profile');
}
@ -141,6 +155,7 @@ export default function GlobalSms() {
setSavingProvider(true);
setError('');
setSuccess('');
const shouldAutoAdvance = !isSetupComplete;
try {
await apiClient.patch(`/api/businesses/${businessId}/global-sms/profiles/${activeProfileId}`, {
@ -151,7 +166,10 @@ export default function GlobalSms() {
}
});
setSuccess('Provider details saved successfully!');
await loadProfiles();
const nextState = await loadProfiles();
if (shouldAutoAdvance && nextState.complete) {
navigate(eventsPath);
}
} catch (err) {
setError(err.response?.data?.error || 'Failed to save provider details');
} finally {
@ -169,37 +187,11 @@ export default function GlobalSms() {
return (
<div className="max-w-4xl mx-auto space-y-8 pb-12">
{/* Header & Stepper */}
<div>
<h2 className="text-2xl font-bold text-text-primary mb-2">Setup configuration</h2>
<p className="text-sm text-text-muted mb-8">
<h2 className="text-2xl font-bold text-text-primary mb-2">Omni-channel SMS</h2>
<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.
</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>
{error && (
@ -300,7 +292,7 @@ export default function GlobalSms() {
<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>
<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"
>
Continue to Events