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',
|
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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user