Skip to content
Twilight React Playground
ثيم رائدaren

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:

  1. a theme setting in twilight.json, read with useTheme();
  2. money formatting with useMoney(), placed in JSX;
  3. translations with t(), and Salla's :amount placeholder;
  4. the cart total from Salla SDK events, subscribed in an effect with cleanup;
  5. 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

  1. 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
    }
  ]
}
  1. 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
  }
}
  1. 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;
}
  1. 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} />;
}
  1. 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_START is 'cart:summary.start', rendered first inside CartSummary (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 (the useTwilight() value): twilight.theme.settings is the same object useTheme().settings returns.
  • The SDK's cart::updated payload is the cart summary with sub_total, total, count and more; the SDK also stores total, sub_total, count and the shipping cost under Salla.storage.get('cart.summary'), which is what the hook reads first.
  • pages.cart.free_shipping_alert and pages.cart.has_free_shipping are Salla's shared messages, the ones CartSummary uses; the second argument of t() 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.
Check yourself

The merchant has not filled in free_shipping_min. What does the handler render?