Stepper for the entire process instead of just provider config
This commit is contained in:
parent
b34915c58b
commit
2785f5ee96
|
|
@ -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 }) => (
|
||||
<nav className="flex-1 px-3 pt-5">
|
||||
<div className="space-y-2">
|
||||
{stepItems.map((item) => (
|
||||
<div key={item.id}>
|
||||
<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
|
||||
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[id]}
|
||||
{label}
|
||||
{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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 →
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user