Sidebar better visuals for stepper and resolving template on code level instead of llm
This commit is contained in:
parent
2785f5ee96
commit
ff864a965d
|
|
@ -69,6 +69,7 @@ export default function Sidebar() {
|
|||
id: 'globalSms',
|
||||
to: globalSmsPath,
|
||||
label: 'Omni-channel SMS',
|
||||
enabled: true,
|
||||
done: isSetupComplete && !isGlobalSmsRoute,
|
||||
active: isGlobalSmsRoute,
|
||||
expanded: isGlobalSmsRoute,
|
||||
|
|
@ -78,6 +79,7 @@ export default function Sidebar() {
|
|||
id: 'events',
|
||||
to: eventsPath,
|
||||
label: 'Events',
|
||||
enabled: isSetupComplete,
|
||||
done: hasSelectedTemplates && !isEventsRoute,
|
||||
active: isEventsRoute,
|
||||
},
|
||||
|
|
@ -85,6 +87,7 @@ export default function Sidebar() {
|
|||
id: 'templates',
|
||||
to: templatesPath,
|
||||
label: 'Templates',
|
||||
enabled: hasSelectedTemplates,
|
||||
done: false,
|
||||
active: isTemplatesRoute,
|
||||
},
|
||||
|
|
@ -124,56 +127,65 @@ export default function Sidebar() {
|
|||
<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>
|
||||
)}
|
||||
{item.enabled ? (
|
||||
<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-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>
|
||||
<div className="flex items-center gap-3">
|
||||
{!item.substeps && <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>
|
||||
) : (
|
||||
<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>
|
||||
</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 className="relative mt-2 mb-2 pb-1">
|
||||
<div className="absolute left-[21.5px] top-1 bottom-3 w-px bg-gray-200" />
|
||||
<div className="space-y-0.5">
|
||||
{item.substeps.map((substep) => (
|
||||
<div key={substep.id} className="relative flex items-center pr-3 group cursor-default">
|
||||
<div className="w-[44px] flex justify-center items-center shrink-0">
|
||||
{substep.active && <div className="w-1.5 h-1.5 rounded-full bg-[#8b2bfa] z-10 shadow-[0_0_0_2px_white]" />}
|
||||
</div>
|
||||
<div
|
||||
className={`flex-1 px-3 py-2.5 rounded-[12px] text-[14px] transition-colors ${
|
||||
substep.active
|
||||
? 'bg-[#f5eeff] text-[#8b2bfa] font-semibold'
|
||||
: 'text-gray-500 font-medium hover:text-gray-900 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{substep.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -200,6 +200,163 @@ function getShipmentToNumber(body) {
|
|||
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) {
|
||||
if (typeof data === 'string') {
|
||||
const trimmed = data.trim();
|
||||
|
|
@ -214,7 +371,7 @@ function parseWorkflowPayload(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);
|
||||
if (!workflowUrl) {
|
||||
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(
|
||||
workflowUrl,
|
||||
{ template, toNumber, sourcePayload },
|
||||
{ content, toNumber },
|
||||
{
|
||||
timeout: 30000,
|
||||
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' });
|
||||
}
|
||||
|
||||
const resolvedTemplate = renderShipmentTemplate(
|
||||
tmpl.selectedTemplate,
|
||||
shipment,
|
||||
tmpl.variableMap || {}
|
||||
);
|
||||
|
||||
const workflowResult = await sendResolveTemplateWorkflow({
|
||||
template: tmpl.selectedTemplate,
|
||||
content: resolvedTemplate,
|
||||
toNumber,
|
||||
sourcePayload: req.body,
|
||||
});
|
||||
|
||||
res.json({
|
||||
|
|
@ -603,6 +765,7 @@ router.post('/resolve-template', async (req, res) => {
|
|||
event: eventSlug,
|
||||
templateId: normalizeText(tmpl.templateId),
|
||||
template: tmpl.selectedTemplate,
|
||||
content: resolvedTemplate,
|
||||
toNumber,
|
||||
workflowResult,
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user