Putting it together: a free-shipping progress bar
Beginner15 min
Combine a theme setting, money formatting, translations, cart events and a hook slot into one feature.
Goal: in the cart's order summary, show "Add 180 SAR more for free shipping" with a progress bar, at an amount the merchant chooses in the theme settings, and keep it current as the shopper changes the cart.
It takes one piece from almost every lesson:
- a theme setting in
twilight.json, read withuseTheme(); - money formatting with
useMoney(), placed in JSX; - translations with
t(), and Salla's:amountplaceholder; - the cart total from Salla SDK events, subscribed in an effect with cleanup;
- a hook slot, so the engine's cart page needs no copy.
Try it
This store has no free_shipping_min setting, so the first control stands in for it. With my guest cart, add products in Changing data with the SDK or the ProductCard demo and come back; with a total I type, move the amount past the threshold. Switch the ar / en pill to see the translated message.
Reads your own guest cart on the demo storesettings.free_shipping_min on this store: not set
Live items total: waiting for the SDK…
the cart summary's start slot
The files
- Declare the setting, so the merchant can fill it in.
twilight.json (excerpt)
{
"settings": [
{
"id": "free_shipping_min",
"type": "string",
"format": "text",
"icon": "sicon-shipping-fast",
"label": "Free shipping from (SAR). Leave empty to hide the bar.",
"value": "",
"required": false
}
]
}
- Tell TypeScript about it.
app/types/theme-settings.d.ts
// app/types/theme-settings.d.ts
export {}; // keeps this file a module, so the block below adds to the engine's type
declare module '@salla.sa/twilight-theme-engine/types' {
interface ThemeSettings {
free_shipping_min?: string; // the id from twilight.json
}
}
- Follow the cart total in the browser.
app/hooks/useCartSubtotal.ts
// app/hooks/useCartSubtotal.ts
import { useEffect, useState } from 'react';
import { getSallaSDK } from '@salla.sa/twilight-theme-engine/utils';
export function useCartSubtotal() {
const [subtotal, setSubtotal] = useState<number | null>(null);
useEffect(() => {
const salla = getSallaSDK(); // undefined on the server
if (!salla) return;
let active = true;
const onUpdated = (...args: unknown[]) => {
const summary = args[0] as { sub_total?: number } | undefined;
if (typeof summary?.sub_total === 'number') setSubtotal(summary.sub_total);
};
salla
.onReady()
.then(() => {
if (!active) return;
const stored = salla.storage.get('cart.summary') as { sub_total?: number } | undefined;
setSubtotal(typeof stored?.sub_total === 'number' ? stored.sub_total : 0);
salla.cart.event?.onUpdated?.(onUpdated);
})
.catch(() => undefined);
return () => {
active = false;
salla.cart.event?.offUpdated?.(onUpdated); // the same function
};
}, []);
return subtotal;
}
- Draw the bar.
app/components/cart/FreeShipping.tsx
// app/components/cart/FreeShipping.tsx
import { useMoney } from '@salla.sa/twilight-theme-engine/hooks/useMoney';
import { useTheme } from '@salla.sa/twilight-theme-engine/hooks/useTheme';
import { useTranslation } from '@salla.sa/twilight-theme-engine/i18n';
import { useCartSubtotal } from '../../hooks/useCartSubtotal';
export function FreeShippingBar({ threshold, subtotal }: { threshold: number; subtotal: number }) {
const { format } = useMoney();
const { t } = useTranslation();
const { color } = useTheme();
const remaining = Math.max(0, threshold - subtotal);
const percent = Math.min(100, Math.round((subtotal / threshold) * 100));
// Salla's message is HTML with a :amount placeholder: keep the words, put the price in JSX.
const [before, after = ''] = t('pages.cart.free_shipping_alert', 'Add :amount more for free shipping')
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, ' ')
.split(':amount');
return (
<div className="free-shipping">
<p>
<i className="sicon-shipping-fast" />{' '}
{remaining === 0 ? (
t('pages.cart.has_free_shipping', 'You have free shipping!')
) : (
<>
{before}
<strong>{format(remaining)}</strong>
{after}
</>
)}
</p>
<div role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={percent}>
<div style={{ width: `${percent}%`, background: color.primary }} />
</div>
</div>
);
}
export function CartFreeShipping({ threshold }: { threshold: number }) {
const subtotal = useCartSubtotal();
if (subtotal === null) return null; // the server, and the moment before the SDK is ready
return <FreeShippingBar threshold={threshold} subtotal={subtotal} />;
}
- Hang it in the cart summary's slot, once.
app/hooks/index.tsx
// app/hooks/index.tsx
import { hookRegistry, HookName, type HookContext } from '@salla.sa/twilight-theme-engine/hooks';
import { CartFreeShipping } from '../components/cart/FreeShipping';
let registered = false;
export function registerThemeHooks() {
if (registered) return;
registered = true;
hookRegistry.register(HookName.CART_SUMMARY_START, ({ twilight }: HookContext) => {
const threshold = Number(twilight.theme.settings.free_shipping_min);
if (!threshold) return null; // not set by the merchant: show nothing
return <CartFreeShipping threshold={threshold} />;
});
}
In engine terms
HookName.CART_SUMMARY_STARTis'cart:summary.start', rendered first insideCartSummary(src/components/cart/CartSummary.tsx) on the engine cart page. The live lab uses a playground slot name with the same handler.- The handler receives
twilight(theuseTwilight()value):twilight.theme.settingsis the same objectuseTheme().settingsreturns. - The SDK's
cart::updatedpayload is the cart summary withsub_total,total,countand more; the SDK also storestotal,sub_total,countand the shipping cost underSalla.storage.get('cart.summary'), which is what the hook reads first. pages.cart.free_shipping_alertandpages.cart.has_free_shippingare Salla's shared messages, the onesCartSummaryuses; the second argument oft()covers a store without them.- Next steps: the hooks reference, the recipe Add content to every page with a hook slot, and the Theme type for declaring settings.