Sidebar better visuals for stepper and resolving template on code level instead of llm

This commit is contained in:
Ritul Jadhav 2026-03-30 16:30:17 +05:30
parent 2785f5ee96
commit ff864a965d
2 changed files with 226 additions and 51 deletions

View File

@ -69,6 +69,7 @@ export default function Sidebar() {
id: 'globalSms', id: 'globalSms',
to: globalSmsPath, to: globalSmsPath,
label: 'Omni-channel SMS', label: 'Omni-channel SMS',
enabled: true,
done: isSetupComplete && !isGlobalSmsRoute, done: isSetupComplete && !isGlobalSmsRoute,
active: isGlobalSmsRoute, active: isGlobalSmsRoute,
expanded: isGlobalSmsRoute, expanded: isGlobalSmsRoute,
@ -78,6 +79,7 @@ export default function Sidebar() {
id: 'events', id: 'events',
to: eventsPath, to: eventsPath,
label: 'Events', label: 'Events',
enabled: isSetupComplete,
done: hasSelectedTemplates && !isEventsRoute, done: hasSelectedTemplates && !isEventsRoute,
active: isEventsRoute, active: isEventsRoute,
}, },
@ -85,6 +87,7 @@ export default function Sidebar() {
id: 'templates', id: 'templates',
to: templatesPath, to: templatesPath,
label: 'Templates', label: 'Templates',
enabled: hasSelectedTemplates,
done: false, done: false,
active: isTemplatesRoute, active: isTemplatesRoute,
}, },
@ -124,56 +127,65 @@ export default function Sidebar() {
<div className="space-y-2"> <div className="space-y-2">
{stepItems.map((item) => ( {stepItems.map((item) => (
<div key={item.id}> <div key={item.id}>
<NavLink {item.enabled ? (
to={item.to} <NavLink
className={`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-semibold transition-colors duration-150 ${ to={item.to}
item.active className={`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-semibold transition-colors duration-150 ${
? 'bg-refresh-hover text-primary-blue' item.active
: 'text-text-muted hover:text-text-primary hover:bg-row-hover' ? 'bg-gray-100/70 text-gray-900'
}`} : 'text-gray-500 hover:text-gray-900 hover:bg-gray-50'
> }`}
{SVG_ICONS[item.id]} >
<span className="flex-1 truncate">{item.label}</span> {SVG_ICONS[item.id]}
<div className="flex items-center gap-3"> <span className="flex-1 truncate">{item.label}</span>
<TopLevelStatus done={item.done} active={item.active} /> <div className="flex items-center gap-3">
{item.substeps && ( {!item.substeps && <TopLevelStatus done={item.done} active={item.active} />}
<svg {item.substeps && (
className={`h-4 w-4 text-gray-400 transition-transform ${item.expanded ? 'rotate-180' : ''}`} <svg
fill="none" className={`h-4 w-4 text-gray-400 transition-transform ${item.expanded ? 'rotate-180' : ''}`}
stroke="currentColor" fill="none"
viewBox="0 0 24 24" stroke="currentColor"
> viewBox="0 0 24 24"
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" /> >
</svg> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
)} </svg>
)}
</div>
</NavLink>
) : (
<div
aria-disabled="true"
className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-semibold text-gray-300 cursor-not-allowed select-none"
>
{SVG_ICONS[item.id]}
<span className="flex-1 truncate">{item.label}</span>
<div className="flex items-center gap-3">
{!item.substeps && <TopLevelStatus done={item.done} active={item.active} />}
</div>
</div> </div>
</NavLink> )}
{item.expanded && item.substeps && ( {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"> <div className="relative mt-2 mb-2 pb-1">
{item.substeps.map((substep) => ( <div className="absolute left-[21.5px] top-1 bottom-3 w-px bg-gray-200" />
<div <div className="space-y-0.5">
key={substep.id} {item.substeps.map((substep) => (
className={`relative rounded-xl px-3 py-2 text-sm font-medium transition-colors ${ <div key={substep.id} className="relative flex items-center pr-3 group cursor-default">
substep.active <div className="w-[44px] flex justify-center items-center shrink-0">
? 'bg-indigo-50 text-indigo-700 shadow-sm' {substep.active && <div className="w-1.5 h-1.5 rounded-full bg-[#8b2bfa] z-10 shadow-[0_0_0_2px_white]" />}
: substep.done </div>
? 'text-gray-700' <div
: 'text-gray-400' className={`flex-1 px-3 py-2.5 rounded-[12px] text-[14px] transition-colors ${
}`} substep.active
> ? 'bg-[#f5eeff] text-[#8b2bfa] font-semibold'
<span : 'text-gray-500 font-medium hover:text-gray-900 hover:bg-gray-50'
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.label}
: substep.done </div>
? 'border-indigo-300 bg-white' </div>
: 'border-gray-300 bg-white' ))}
}`} </div>
/>
{substep.label}
</div>
))}
</div> </div>
)} )}
</div> </div>

View File

@ -200,6 +200,163 @@ function getShipmentToNumber(body) {
return normalizeText(body?.payload?.shipment?.user?.mobile); return normalizeText(body?.payload?.shipment?.user?.mobile);
} }
const DLT_PLACEHOLDER_REGEX = /\{#(?:var|numeric|url|cbn)#\}/g;
function normalizeRenderableValue(value) {
if (typeof value === 'string') return value.replace(/\s+/g, ' ').trim();
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
if (typeof value === 'boolean') return value ? 'true' : 'false';
return '';
}
function toCamelCase(text) {
return String(text || '')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/[^a-zA-Z0-9]+/g, ' ')
.trim()
.split(/\s+/)
.filter(Boolean)
.map((part, index) => {
const lower = part.toLowerCase();
return index === 0 ? lower : lower.charAt(0).toUpperCase() + lower.slice(1);
})
.join('');
}
function setValueIndexEntry(valueIndex, key, value) {
if (!key || valueIndex.has(key)) return;
valueIndex.set(key, value);
}
function indexShipmentValues(value, pathParts = [], valueIndex = new Map()) {
if (Array.isArray(value)) {
value.forEach((entry) => indexShipmentValues(entry, pathParts, valueIndex));
return valueIndex;
}
if (value && typeof value === 'object') {
Object.entries(value).forEach(([key, entry]) => {
indexShipmentValues(entry, [...pathParts, key], valueIndex);
});
return valueIndex;
}
const normalizedValue = normalizeRenderableValue(value);
if (!normalizedValue || pathParts.length === 0) return valueIndex;
const leafKey = toCamelCase(pathParts[pathParts.length - 1]);
const fullKey = toCamelCase(pathParts.join(' '));
setValueIndexEntry(valueIndex, leafKey, normalizedValue);
setValueIndexEntry(valueIndex, fullKey, normalizedValue);
return valueIndex;
}
function buildShipmentValueIndex(shipment) {
const valueIndex = indexShipmentValues(shipment);
const firstBag = shipment?.bags?.[0] || {};
const primaryTrackingUrl = normalizeRenderableValue(
shipment?.delivery_partner_details?.track_url
|| shipment?.meta?.tracking_url
|| firstBag?.meta?.tracking_url
|| shipment?.affiliate_details?.shipment_meta?.tracking_url
|| shipment?.article_details?.dp_details?.track_url
);
const primaryAwbNumber = normalizeRenderableValue(
shipment?.delivery_partner_details?.awb_no
|| shipment?.meta?.awb_number
|| shipment?.article_details?.dp_details?.awb_no
);
const primaryCourierName = normalizeRenderableValue(
shipment?.delivery_partner_details?.display_name
|| shipment?.delivery_partner_details?.name
|| shipment?.meta?.courier_partner_name
|| shipment?.meta?.dp_name
|| firstBag?.meta?.dp_name
);
const brandName = normalizeRenderableValue(
shipment?.bags?.[0]?.brand?.brand_name
|| shipment?.bags?.[0]?.item?.attributes?.brand_name
|| shipment?.affiliate_details?.company_affiliate_tag
);
setValueIndexEntry(valueIndex, 'firstName', normalizeRenderableValue(shipment?.user?.first_name));
setValueIndexEntry(valueIndex, 'lastName', normalizeRenderableValue(shipment?.user?.last_name));
setValueIndexEntry(valueIndex, 'fullName', normalizeRenderableValue(
`${normalizeRenderableValue(shipment?.user?.first_name)} ${normalizeRenderableValue(shipment?.user?.last_name)}`.trim()
));
setValueIndexEntry(valueIndex, 'phone', normalizeRenderableValue(shipment?.user?.mobile || shipment?.delivery_address?.phone));
setValueIndexEntry(valueIndex, 'mobile', normalizeRenderableValue(shipment?.user?.mobile || shipment?.delivery_address?.phone));
setValueIndexEntry(valueIndex, 'toNumber', normalizeRenderableValue(shipment?.user?.mobile || shipment?.delivery_address?.phone));
setValueIndexEntry(valueIndex, 'orderId', normalizeRenderableValue(shipment?.order_id));
setValueIndexEntry(valueIndex, 'shipmentId', normalizeRenderableValue(shipment?.shipment_id));
setValueIndexEntry(valueIndex, 'event', normalizeRenderableValue(shipment?.status));
setValueIndexEntry(valueIndex, 'status', normalizeRenderableValue(shipment?.status));
setValueIndexEntry(valueIndex, 'eventDisplayName', normalizeRenderableValue(shipment?.shipment_status?.display_name));
setValueIndexEntry(valueIndex, 'displayName', normalizeRenderableValue(shipment?.shipment_status?.display_name));
setValueIndexEntry(valueIndex, 'brandName', brandName);
setValueIndexEntry(valueIndex, 'trackingUrl', primaryTrackingUrl);
setValueIndexEntry(valueIndex, 'trackUrl', primaryTrackingUrl);
setValueIndexEntry(valueIndex, 'url', primaryTrackingUrl);
setValueIndexEntry(valueIndex, 'awbNo', primaryAwbNumber);
setValueIndexEntry(valueIndex, 'awbNumber', primaryAwbNumber);
setValueIndexEntry(valueIndex, 'dpName', primaryCourierName);
setValueIndexEntry(valueIndex, 'courierName', primaryCourierName);
setValueIndexEntry(valueIndex, 'deliveryPartnerName', primaryCourierName);
return valueIndex;
}
function validateRenderedPlaceholderValue(token, value, fieldName) {
if (!value) {
throw createHttpError(422, `No shipment value found for placeholder field "${fieldName}"`);
}
if (token === '{#numeric#}' && !/^\d+$/.test(value)) {
throw createHttpError(422, `Field "${fieldName}" resolved to a non-numeric value for ${token}`);
}
if (token === '{#url#}' && !/^https?:\/\//i.test(value)) {
throw createHttpError(422, `Field "${fieldName}" resolved to an invalid URL for ${token}`);
}
if (token === '{#cbn#}' && !/^\+?[0-9][0-9\s-]{5,}$/.test(value)) {
throw createHttpError(422, `Field "${fieldName}" resolved to an invalid callback number for ${token}`);
}
}
function renderShipmentTemplate(template, shipment, variableMap = {}) {
const normalizedTemplate = normalizeText(template);
const placeholderMatches = normalizedTemplate.match(DLT_PLACEHOLDER_REGEX) || [];
if (placeholderMatches.length === 0) {
return normalizedTemplate;
}
if (!variableMap || typeof variableMap !== 'object' || Object.keys(variableMap).length === 0) {
throw createHttpError(422, 'Template has placeholders but no variableMap was found on the stored template');
}
const shipmentValueIndex = buildShipmentValueIndex(shipment);
let placeholderIndex = 0;
return normalizedTemplate.replace(DLT_PLACEHOLDER_REGEX, (token) => {
const mappingKey = `${token}[${placeholderIndex}]`;
const mappedFieldName = normalizeText(variableMap[mappingKey]);
if (!mappedFieldName) {
throw createHttpError(422, `No variable mapping found for placeholder ${mappingKey}`, {
details: { mappingKey, variableMap },
});
}
const resolvedValue = shipmentValueIndex.get(toCamelCase(mappedFieldName)) || '';
validateRenderedPlaceholderValue(token, resolvedValue, mappedFieldName);
placeholderIndex += 1;
return resolvedValue;
});
}
function parseWorkflowPayload(data) { function parseWorkflowPayload(data) {
if (typeof data === 'string') { if (typeof data === 'string') {
const trimmed = data.trim(); const trimmed = data.trim();
@ -214,7 +371,7 @@ function parseWorkflowPayload(data) {
return data && typeof data === 'object' ? data : {}; return data && typeof data === 'object' ? data : {};
} }
async function sendResolveTemplateWorkflow({ template, toNumber, sourcePayload }) { async function sendResolveTemplateWorkflow({ content, toNumber }) {
const workflowUrl = normalizeText(process.env.WORKFLOW_URL_RESOLVE_TEMPLATE); const workflowUrl = normalizeText(process.env.WORKFLOW_URL_RESOLVE_TEMPLATE);
if (!workflowUrl) { if (!workflowUrl) {
throw createHttpError(500, 'WORKFLOW_URL_RESOLVE_TEMPLATE is not configured'); throw createHttpError(500, 'WORKFLOW_URL_RESOLVE_TEMPLATE is not configured');
@ -222,7 +379,7 @@ async function sendResolveTemplateWorkflow({ template, toNumber, sourcePayload }
const response = await axios.post( const response = await axios.post(
workflowUrl, workflowUrl,
{ template, toNumber, sourcePayload }, { content, toNumber },
{ {
timeout: 30000, timeout: 30000,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -589,10 +746,15 @@ router.post('/resolve-template', async (req, res) => {
return res.status(404).json({ error: 'Whitelisted template not found' }); return res.status(404).json({ error: 'Whitelisted template not found' });
} }
const resolvedTemplate = renderShipmentTemplate(
tmpl.selectedTemplate,
shipment,
tmpl.variableMap || {}
);
const workflowResult = await sendResolveTemplateWorkflow({ const workflowResult = await sendResolveTemplateWorkflow({
template: tmpl.selectedTemplate, content: resolvedTemplate,
toNumber, toNumber,
sourcePayload: req.body,
}); });
res.json({ res.json({
@ -603,6 +765,7 @@ router.post('/resolve-template', async (req, res) => {
event: eventSlug, event: eventSlug,
templateId: normalizeText(tmpl.templateId), templateId: normalizeText(tmpl.templateId),
template: tmpl.selectedTemplate, template: tmpl.selectedTemplate,
content: resolvedTemplate,
toNumber, toNumber,
workflowResult, workflowResult,
}); });