Liquid Template API Reference
Complete developer reference for all variables, objects, and data available inside Liquid templates. Every section has a config.json and a name.liquid example you can copy into the code editor.
Overview
Every storefront page is assembled from components stored in the database. Each component has two parts:
config.json— declares component metadata, version, and the fields it needs (data sources, editor inputs)name.liquid— the HTML + Liquid template that renders the final output
Global variables
shop and component are injected into every template automatically. You never need to declare them in config.json.How It Works
- The rendering engine reads
config.jsonand inspects thefieldsobject - API fields (products, categories, brands, etc.) are fetched automatically from the backend based on their
type/apiType - Static fields (text, color, number, boolean, image, etc.) are pulled from the visual editor's saved data
- Conditional fields with a
conditionproperty are only fetched when the condition is met - All data is merged into one context:
{ shop, component, ...blockVars, ...staticFields, ...apiData } - Icon paths starting with
/assets/icons/are auto-converted to full backend URLs - The context is passed to LiquidJS which renders the
.liquidtemplate into HTML
File Structure
Components live at a path like category/component-name (e.g. hero/hero-1, products/product-grid-1). The path is auto-generated from the category and name when you create a component.
{
"name": "My Component",
"version": "1.0.0",
"description": "A reusable storefront component",
"fields": {
"title": {
"type": "text",
"label": "Section Title",
"default": "Welcome"
},
"products": {
"type": "products",
"label": "Products"
}
}
}<section class="py-12 px-4">
<h2 class="text-2xl font-bold text-center mb-8">{{ title }}</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 max-w-6xl mx-auto">
{% for product in products %}
<a href="/products/{{ product.slug }}" class="border rounded-lg overflow-hidden hover:shadow-md transition">
{% if product.coverImage %}
<img src="{{ product.coverImage }}" alt="{{ product.name }}" class="w-full aspect-square object-cover">
{% endif %}
<div class="p-3">
<h3 class="text-sm font-medium line-clamp-2">{{ product.name }}</h3>
<span class="text-sm font-bold mt-1 block">{{ shop.priceUnit }} {{ product.price }}</span>
</div>
</a>
{% endfor %}
</div>
</section>Data Flow Diagram
Database (ShopWebsiteTemplate)
├─ pages[] → Which components to render on which page
├─ components[] → .liquid content + config.json for each component
└─ blocks[] → Reusable blocks (e.g. product-card) with customization data
│
▼
renderPageComponents()
1. Parse page JSON → list of { path, data }
2. For each component:
a. Read config.json → detect API fields
b. Fetch API data (products, categories, etc.) from tRPC
c. Transform data (array_object wrapping, "automatic" → null)
d. Merge: { shop, component, ...blockVars, ...editorData, ...apiData }
e. Convert icon paths to full URLs
f. LiquidJS.parseAndRender(template, mergedData)
3. Join all rendered HTML
│
▼
Final HTML → sent to browserDefault Component Template
When you create a new component in the developer panel, it starts with this default:
{
"name": "My Component",
"version": "1.0.0",
"description": "Your custom component",
"fields": {
"text": {
"type": "string",
"label": "Your Text",
"value": "Welcome to our store",
"placeholder": "Enter the text"
}
}
}<h2>Your code goes here {{ text }}</h2>shop Object (Global)
The shop object is available in every component and partial. No config needed. It contains all the store's information.
| Property | Type | Description |
|---|---|---|
| shop._id | string | MongoDB shop ID |
| shop.slug | string | Shop subdomain slug (e.g. "my-store") |
| shop.shopName | string | Store display name |
| shop.shopLogo | string | null | Logo image URL |
| shop.shopDescription | string | Store description (max 1000 chars) |
| shop.location | string | Store location / primary address |
| shop.publicEmail | string | Public contact email |
| shop.publicContactNumber | string | Public phone number |
| shop.priceUnit | string | Currency symbol: "Rs", "$", "USD", etc. |
| shop.brandColor | string | Primary brand color (hex) |
| shop.textOverBrandColor | string | Text color to use over brand color |
| shop.brandFont | string | Brand font family name |
| shop.socialMediaLinks | array | Social media links (see below) |
| shop.footerLogo | string | null | Footer logo URL (can differ from main) |
| shop.footerQuickLinks | array | Footer navigation links (see below) |
| shop.panVatNumber | string | null | PAN/VAT registration number |
| shop.ecommerceRegNumber | string | null | Ecommerce platform registration ID |
| shop.privacyPolicy | string | null | Privacy policy (HTML) |
| shop.termsAndConditions | string | null | Terms & conditions (HTML) |
| shop.returnPolicy | string | null | Return policy (HTML) |
| shop.refundPolicy | string | null | Refund policy (HTML) |
| shop.about | string | null | About page (HTML) |
| shop.branches | array | Store branches (see below) |
| shop.paymentMethods | array | Payment method name strings |
| shop.paymentMethodConfig | object | Payment method config (internal) |
| shop.loginEnabled | boolean | Customer login is on (see Custom Login Page) |
| shop.emailLoginEnabled | boolean | Customers can sign in with an emailed code |
| shop.googleLoginEnabled | boolean | Customers can sign in with Google |
| shop.guestCheckoutAllowed | boolean | Customers can check out without signing in |
shop.branches[]
| Property | Type | Description |
|---|---|---|
| name | string | Branch name |
| location | string | Branch address |
| googleMapLink | string | Google Maps embed or link URL |
| isMainBranch | boolean | Whether this is the main/HQ branch |
{% for branch in shop.branches %}
<div class="p-4 border rounded-lg">
<div class="flex items-center gap-2">
<strong>{{ branch.name }}</strong>
{% if branch.isMainBranch %}<span class="text-[10px] bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full">HQ</span>{% endif %}
</div>
<p class="text-sm text-gray-500 mt-1">{{ branch.location }}</p>
{% if branch.googleMapLink %}
<a href="{{ branch.googleMapLink }}" target="_blank" class="text-sm text-blue-500 mt-2 inline-block">Open in Maps →</a>
{% endif %}
</div>
{% endfor %}Policies & About
Policy fields contain HTML content. On privacy, refund, return, and terms pages, policyDesc automatically contains the policy for the current page.
{% if policyDesc %}
<div class="prose max-w-3xl mx-auto py-12">
{{ policyDesc }}
</div>
{% else %}
<p class="text-center text-gray-400 py-16">No policy has been set.</p>
{% endif %}
{%- comment -%}
policyDesc matches the current policy route.
Individual shop fields remain available:
Available policy fields:
- shop.privacyPolicy
- shop.termsAndConditions
- shop.returnPolicy
- shop.refundPolicy
- shop.about
{%- endcomment -%}Payment Methods
shop.paymentMethods is an array of strings like ["COD", "eSewa", "Khalti"].
<div class="flex flex-wrap gap-2">
{% for method in shop.paymentMethods %}
<span class="px-3 py-1 bg-gray-100 text-xs rounded-full">{{ method }}</span>
{% endfor %}
</div>Full Example: Header Component
{
"name": "Simple Header",
"version": "1.0.0",
"description": "Basic header with logo, search, and brand colors",
"fields": {
"showSearch": { "type": "boolean", "label": "Show Search Bar", "default": true },
"announcementText": { "type": "text", "label": "Announcement Bar Text", "default": "" }
}
}{% if announcementText != blank %}
<div class="text-center py-1.5 text-xs font-medium"
style="background-color: {{ shop.brandColor }}; color: {{ shop.textOverBrandColor }};">
{{ announcementText }}
</div>
{% endif %}
<header class="border-b">
<div class="max-w-7xl mx-auto flex items-center justify-between px-4 py-3">
{% if shop.shopLogo %}
<a href="/"><img src="{{ shop.shopLogo }}" alt="{{ shop.shopName }}" class="h-10 object-contain"></a>
{% else %}
<a href="/" class="text-xl font-bold" style="font-family: {{ shop.brandFont }};">{{ shop.shopName }}</a>
{% endif %}
{% if showSearch %}
<div class="hidden md:block flex-1 max-w-md mx-8">
<input type="text" placeholder="Search products..."
class="w-full px-4 py-2 rounded-full bg-gray-100 text-sm outline-none">
</div>
{% endif %}
<div class="flex items-center gap-3 text-sm">
<a href="mailto:{{ shop.publicEmail }}" class="hidden md:block text-gray-600 hover:text-gray-900">Contact</a>
<button onclick="window.openCart && window.openCart()"
class="px-4 py-2 rounded-full text-sm font-medium"
style="background: {{ shop.brandColor }}; color: {{ shop.textOverBrandColor }};">
Cart
</button>
</div>
</div>
</header>component Object (Global)
Metadata about the current component being rendered. Available in every template.
| Property | Type | Description |
|---|---|---|
| component.path | string | Component path, e.g. "hero/hero-1", "products/product-grid-1" |
{%- comment -%} Useful for debugging: show which component is rendering {%- endcomment -%}
<!-- Component: {{ component.path }} -->Block Customization Variables (Global)
These top-level variables come from the active product card block's data field. They control product card rendering and are available in every component context (no config needed). They're merged last, so they override any conflicting keys.
| Property | Type | Description |
|---|---|---|
| borderRadius | string | Card border radius CSS value (e.g. "8px", "0.5rem") |
| imageRounding | string | Product image border radius |
| hoverEffect | string | Hover animation type (e.g. "scale", "shadow", "none") |
| showAddToCart | boolean | Whether to show the Add to Cart button on cards |
| cardPadding | string | Card inner padding CSS value |
| titleSize | string | Product title font size CSS value |
| buttonStyle | string | Button styling variant/class name |
How blocks work
Blocks are reusable sub-components (like a product card design). The active block's customization data is automatically injected into every component's context. When you use{% render 'product-card', product: product %}, the block variables are available in that partial too because LiquidJS shares parent scope.<div class="product-card group" style="border-radius: {{ borderRadius }}; padding: {{ cardPadding }};">
<div class="overflow-hidden" style="border-radius: {{ imageRounding }};">
<img src="{{ product.coverImage }}" alt="{{ product.name }}"
class="w-full aspect-square object-cover {% if hoverEffect == 'scale' %}group-hover:scale-105{% endif %} transition-transform">
</div>
<h3 style="font-size: {{ titleSize }};" class="font-medium mt-2 line-clamp-2">{{ product.name }}</h3>
<div class="flex items-center gap-2 mt-1">
{% if product.compareAtPrice and product.compareAtPrice > product.price %}
<span class="text-xs line-through text-gray-400">{{ shop.priceUnit }} {{ product.compareAtPrice }}</span>
{% endif %}
<span class="font-bold text-sm">{{ shop.priceUnit }} {{ product.price }}</span>
</div>
{% if showAddToCart %}
<button class="{{ buttonStyle }} mt-3 w-full py-2 rounded text-sm font-medium"
style="background: {{ shop.brandColor }}; color: {{ shop.textOverBrandColor }};">
Add to Cart
</button>
{% endif %}
</div>products (Array)
Returns an array of published products from the shop (up to 100). Config type: "products".
{
"name": "Product Grid",
"version": "1.0.0",
"description": "Responsive grid of all shop products",
"fields": {
"title": { "type": "text", "label": "Section Title", "default": "Our Products" },
"columns": { "type": "number", "label": "Columns (desktop)", "default": 4 },
"products": { "type": "products", "label": "Products" }
}
}Product Object Fields
| Property | Type | Description |
|---|---|---|
| name | string | Product name (2-255 chars) |
| slug | string | URL-friendly identifier for routing |
| description | string | Product description (may contain HTML, max 50000 chars) |
| price | number | Current selling price (min 0) |
| compareAtPrice | number | null | Original/strikethrough price for showing discounts |
| flashDeal | object | undefined | Present only while a flash deal prices this product - price is ALREADY the deal price and compareAtPrice the normal one. { id, title, slug, shortDescription, coverImage, startsAt, endsAt (ISO), discountType, value, priceBase ('selling' | 'compare' - what the discount is taken off), percentOff (% saved vs compareAtPrice - matches the strike-through), originalPrice (price before the deal) }. value 0 = in the deal at the price it already sells at: a deal set to list products at the normal price, or a compare-at deal on a product that is already marked down that far. |
| costPerItem | number | null | Cost per item (internal, may be exposed) |
| coverImage | string | null | Main product image URL |
| images | string[] | Additional image URLs array |
| quantity | number | Available stock quantity |
| unlimitedStock | boolean | If true, stock is infinite (ignore quantity) |
| trackQuantity | boolean | Whether stock tracking is enabled |
| allowBackorder | boolean | Allow orders when out of stock |
| sku | string | null | Stock keeping unit code |
| barcode | string | null | Product barcode |
| categoryId | string | Category ID reference |
| subcategoryId | string | null | Subcategory ID reference |
| brandId | string | null | Brand ID reference |
| avgReviews | number | Average star rating (0–5, cached) |
| reviewsCount | number | Total number of reviews (cached) |
| productSold | number | Total units sold (cached) |
| tags | string[] | Array of tag strings |
| features | array | Array of feature objects (title, description, image) |
| featuresSectionTitle | string | null | Custom heading for features section |
| specifications | array | Specification groups (see below) |
| extraFields | array | Custom field definitions (see below) |
| isPublished | boolean | Publication status |
| freeDelivery | boolean | Whether delivery is free |
| deliveryTime | string | null | Estimated delivery time text |
| weight | number | null | Product weight value |
| weightUnit | string | "kg", "g", "lb", "oz" |
| isComboProduct | boolean | Whether this is a combo/bundle product |
| metaTitle | string | null | SEO meta title |
| metaDescription | string | null | SEO meta description |
| createdAt | string | Creation date (ISO string) |
| updatedAt | string | Last update date (ISO string) |
<section class="py-12 px-4">
<h2 class="text-2xl font-bold text-center mb-8">{{ title }}</h2>
<div class="grid grid-cols-2 md:grid-cols-{{ columns }} gap-4 max-w-7xl mx-auto">
{% for product in products %}
<a href="/products/{{ product.slug }}" class="group border rounded-lg overflow-hidden hover:shadow-md transition">
{% if product.coverImage %}
<div class="aspect-square bg-gray-50 overflow-hidden">
<img src="{{ product.coverImage }}" alt="{{ product.name }}"
class="w-full h-full object-cover group-hover:scale-105 transition-transform" loading="lazy">
</div>
{% endif %}
<div class="p-3">
<h3 class="text-sm font-medium line-clamp-2">{{ product.name }}</h3>
<div class="flex items-center gap-2 mt-1.5">
{% if product.compareAtPrice and product.compareAtPrice > product.price %}
<span class="text-xs line-through text-gray-400">{{ shop.priceUnit }} {{ product.compareAtPrice }}</span>
{% assign saved = product.compareAtPrice | minus: product.price | times: 100 | divided_by: product.compareAtPrice %}
<span class="text-[10px] bg-red-100 text-red-600 px-1.5 py-0.5 rounded">-{{ saved }}%</span>
{% endif %}
<span class="font-bold text-sm">{{ shop.priceUnit }} {{ product.price }}</span>
</div>
{% if product.avgReviews > 0 %}
<div class="flex items-center gap-1 mt-1 text-xs text-yellow-500">
{% for i in (1..5) %}{% if i <= product.avgReviews %}★{% else %}☆{% endif %}{% endfor %}
<span class="text-gray-400">({{ product.reviewsCount }})</span>
</div>
{% endif %}
{% if product.freeDelivery %}<span class="text-[10px] text-green-600 mt-1 block">Free Delivery</span>{% endif %}
{% if product.quantity == 0 and product.unlimitedStock == false %}
<span class="text-[10px] text-red-500 mt-1 block">Out of Stock</span>
{% endif %}
</div>
</a>
{% endfor %}
</div>
</section>Product Variants
Variants are available on single product objects (see product type). Each variant has flexible options.
| Property | Type | Description |
|---|---|---|
| options | object | Map of option names to value arrays: { "Color": ["Red", "Blue"], "Size": ["S", "M", "L"] } |
| price | number | null | Variant-specific price (overrides product price if set) |
| compareAtPrice | number | null | Variant compare/original price |
| sku | string | null | Variant-specific SKU |
| barcode | string | null | Variant barcode |
| quantity | number | Variant stock count |
| coverImage | string | null | Variant-specific image (e.g. different color) |
| weight | number | null | Variant weight |
| weightUnit | string | "kg", "g", "lb", "oz" |
| isActive | boolean | Whether variant is active/visible |
Product Specifications
Specifications are organized in groups. Each group has a title, optional image, and an array of key-value data rows.
{% if product.specifications.size > 0 %}
<div class="mt-8">
<h2 class="text-xl font-bold mb-4">{{ product.featuresSectionTitle | default: "Specifications" }}</h2>
{% for spec in product.specifications %}
<div class="mb-6">
<h3 class="font-semibold text-sm mb-2 flex items-center gap-2">
{% if spec.image %}<img src="{{ spec.image }}" class="w-5 h-5">{% endif %}
{{ spec.specification }}
</h3>
<table class="w-full text-sm border-collapse">
{% for row in spec.data %}
<tr class="border-b">
<td class="py-2 pr-4 font-medium text-gray-600 w-1/3">{{ row.title }}</td>
<td class="py-2 text-gray-800">{{ row.value }}</td>
</tr>
{% endfor %}
</table>
</div>
{% endfor %}
</div>
{% endif %}Product Extra Fields
Custom fields defined by the shop owner (e.g. "Engraving text", "Gift message"). These are field definitions, not values.
| Property | Type | Description |
|---|---|---|
| label | string | Field label shown to customer |
| fieldType | string | "text", "textarea", or "imageUpload" |
| isRequired | boolean | Whether the customer must fill this |
| placeholder | string | null | Input placeholder text |
{% if product.extraFields.size > 0 %}
<div class="space-y-3 mt-4">
{% for field in product.extraFields %}
<div>
<label class="text-sm font-medium">
{{ field.label }}{% if field.isRequired %}<span class="text-red-500"> *</span>{% endif %}
</label>
{% if field.fieldType == "textarea" %}
<textarea placeholder="{{ field.placeholder }}" class="w-full mt-1 border rounded p-2 text-sm"></textarea>
{% elsif field.fieldType == "imageUpload" %}
<input type="file" accept="image/*" class="mt-1 text-sm">
{% else %}
<input type="text" placeholder="{{ field.placeholder }}" class="w-full mt-1 border rounded px-3 py-2 text-sm">
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}Product Add-ons
product.addOnProducts is available on a single product detail object. It contains up to 20 products selected by the merchant, in the merchant's chosen order. Only published, active products from the same shop are returned.
| Property | Type | Description |
|---|---|---|
| _id | string | Add-on product ID |
| name | string | Add-on product name |
| slug | string | Slug used by the product details URL |
| coverImage | string | null | Main product image URL |
| price | number | Current selling price |
| compareAtPrice | number | null | Optional crossed-out original price |
| quantity | number | Product quantity summary; variants are not included in this compact object |
| unlimitedStock | boolean | Whether stock is unlimited |
| isPublished | boolean | Publication status; returned add-ons are published |
| isActive | boolean | Active status; returned add-ons are active |
| isDeleted | boolean | Deletion status; always false for returned add-ons |
| regionPricing | object | null | Converted currency marker when Multi Region pricing applies |
Add-ons are separate products
Add-ons are recommendations, not combo or bundle lines. They are not automatically included with the main product and must not be silently added to its cart line. Link each one to/products/{{ addOn.slug }} so customers can choose that product's own variants, quantity, and required extra fields. Keep the exact data-zalient-product-add-ons attribute on your outer section; it tells the storefront not to append the default add-ons fallback a second time.{% if product.addOnProducts and product.addOnProducts.size > 0 %}
<section data-zalient-product-add-ons class="mt-10">
<h2 class="text-xl font-bold mb-4">Add-ons</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
{% for addOn in product.addOnProducts %}
<a href="/products/{{ addOn.slug }}" class="block rounded-lg border overflow-hidden hover:shadow-md transition">
{% if addOn.coverImage %}
<img src="{{ addOn.coverImage }}" alt="{{ addOn.name }}" class="w-full aspect-square object-cover" loading="lazy">
{% endif %}
<div class="p-3">
<h3 class="text-sm font-medium line-clamp-2">{{ addOn.name }}</h3>
<div class="flex items-baseline gap-2 mt-1">
<span class="font-semibold">{{ shop.priceUnit }} {{ addOn.price }}</span>
{% if addOn.compareAtPrice and addOn.compareAtPrice > addOn.price %}
<span class="text-xs text-gray-400 line-through">{{ shop.priceUnit }} {{ addOn.compareAtPrice }}</span>
{% endif %}
</div>
</div>
</a>
{% endfor %}
</div>
</section>
{% endif %}Product Features
Features are reusable shop-wide highlights (e.g. "Free Returns", "1 Year Warranty") assigned to products.
| Property | Type | Description |
|---|---|---|
| title | string | Feature name |
| description | string | null | Feature description |
| image | string | null | Feature icon/image URL |
{% if product.features.size > 0 %}
<div class="flex flex-wrap gap-4 mt-4">
{% for feature in product.features %}
<div class="flex items-center gap-2 text-sm text-gray-600">
{% if feature.image %}<img src="{{ feature.image }}" class="w-5 h-5">{% endif %}
<span>{{ feature.title }}</span>
</div>
{% endfor %}
</div>
{% endif %}product (Single)
Returns a single product with populated relations. Config type: "product". Supports automatic mode (current page context on /product/:slug) or a hardcoded slug.
{
"name": "Product Detail",
"version": "1.0.0",
"description": "Full product detail page component",
"fields": {
"product": { "type": "product", "label": "Product" }
}
}Automatic vs. Manual
Set to "automatic" in the visual editor to use the current page's product (on /product/:slug pages). Set to a specific slug like "blue-shirt" to show that product on other pages. On a product page the field always receives that page's product, even when a slug is set.
The single product includes all fields from the products array above, plus these populated relations:
| Property | Type | Description |
|---|---|---|
| category | object | null | Populated category: { name, slug, image, description } |
| subcategory | object | null | Populated subcategory: { name, slug, image } |
| brand | object | null | Populated brand: { name, slug, image } |
| similarProducts | array | Array of related products (same category) |
| addOnProducts | array | Merchant-selected add-on product summaries; separate from similarProducts and combo items |
Flash deal running on this product
While a flash deal prices the product, product.price already is the deal price and product.compareAtPrice the normal price - your usual strike-through works with no extra code. product.flashDeal names the deal (title, slug, coverImage, shortDescription) and when it ends (endsAt), for a "deal running" label and a countdown beside the price. Never compute a deal price in Liquid - checkout charges the server's price.
{% if product.flashDeal %}
<a href="/deals/{{ product.flashDeal.slug }}" class="mt-3 flex items-center gap-2 rounded-lg px-3 py-2 text-sm" style="background:#fef2f2;color:#b91c1c;">
{% if product.flashDeal.coverImage %}<img src="{{ product.flashDeal.coverImage }}" alt="" class="h-8 w-8 rounded object-cover">{% endif %}
<strong>{{ product.flashDeal.title }}</strong>
{% if product.flashDeal.percentOff > 0 %}<span>· {{ product.flashDeal.percentOff }}% off</span>{% endif %}
<span>· ends in <span data-deal-ends="{{ product.flashDeal.endsAt }}" class="font-mono tabular-nums"></span></span>
</a>
{% endif %}
<script>
document.querySelectorAll('[data-deal-ends]:not([data-deal-bound])').forEach(function (el) {
el.setAttribute('data-deal-bound', '1');
var end = new Date(el.getAttribute('data-deal-ends')).getTime();
var timer;
function tick() {
var s = Math.max(0, Math.floor((end - Date.now()) / 1000));
var d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
el.textContent = s === 0 ? 'ended' : (d ? d + 'd ' : '') + [h, m, sec].map(function (n) { return String(n).padStart(2, '0'); }).join(':');
if (s === 0) clearInterval(timer);
}
tick();
timer = setInterval(tick, 1000);
});
</script>{% if product %}
<div class="max-w-6xl mx-auto grid md:grid-cols-2 gap-8 py-8 px-4">
<!-- Images -->
<div>
<img src="{{ product.coverImage }}" alt="{{ product.name }}" class="w-full rounded-lg">
{% if product.images.size > 0 %}
<div class="flex gap-2 mt-4 overflow-x-auto">
{% for img in product.images %}
<img src="{{ img }}" class="w-20 h-20 rounded border object-cover cursor-pointer hover:border-blue-500" loading="lazy">
{% endfor %}
</div>
{% endif %}
</div>
<!-- Info -->
<div>
{% if product.brand %}<a href="/brand/{{ product.brand.slug }}" class="text-sm text-blue-500">{{ product.brand.name }}</a>{% endif %}
<h1 class="text-2xl font-bold mt-1">{{ product.name }}</h1>
{% if product.avgReviews > 0 %}
<div class="flex items-center gap-2 mt-2 text-sm">
<span class="text-yellow-500">{% for i in (1..5) %}{% if i <= product.avgReviews %}★{% else %}☆{% endif %}{% endfor %}</span>
<span class="text-gray-400">({{ product.reviewsCount }} reviews)</span>
<span class="text-gray-400">· {{ product.productSold }} sold</span>
</div>
{% endif %}
<div class="flex items-baseline gap-3 mt-4">
{% if product.compareAtPrice and product.compareAtPrice > product.price %}
<span class="text-lg line-through text-gray-400">{{ shop.priceUnit }} {{ product.compareAtPrice }}</span>
{% endif %}
<span class="text-3xl font-bold" style="color: {{ shop.brandColor }};">{{ shop.priceUnit }} {{ product.price }}</span>
</div>
{% if product.freeDelivery %}<p class="text-sm text-green-600 mt-2">✓ Free Delivery</p>{% endif %}
{% if product.deliveryTime %}<p class="text-sm text-gray-500">Delivery: {{ product.deliveryTime }}</p>{% endif %}
{% if product.quantity == 0 and product.unlimitedStock == false %}
<p class="text-red-500 font-medium mt-4">Out of Stock</p>
{% endif %}
<div class="mt-6 prose prose-sm max-w-none">{{ product.description }}</div>
{% if product.category %}
<p class="text-sm text-gray-500 mt-4">Category: <a href="/category/{{ product.category.slug }}" class="text-blue-500">{{ product.category.name }}</a></p>
{% endif %}
{% if product.tags.size > 0 %}
<div class="flex flex-wrap gap-2 mt-3">
{% for tag in product.tags %}<span class="text-xs bg-gray-100 px-2 py-1 rounded">{{ tag }}</span>{% endfor %}
</div>
{% endif %}
</div>
</div>
{% if product.similarProducts.size > 0 %}
<div class="max-w-6xl mx-auto px-4 pb-12">
<h2 class="text-xl font-bold mb-4">You may also like</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
{% for item in product.similarProducts %}{% render 'product-card', product: item %}{% endfor %}
</div>
</div>
{% endif %}
{% endif %}categories
Returns an array of active categories. Config: "type": "api", "apiType": "categories".
{
"name": "Category Grid",
"version": "1.0.0",
"fields": {
"categories": { "type": "api", "apiType": "categories", "label": "Categories" }
}
}| Property | Type | Description |
|---|---|---|
| name | string | Category name |
| slug | string | URL-friendly identifier |
| description | string | null | Category description |
| image | string | null | Category image URL |
| isActive | boolean | Active status |
| order | number | Display order (lower = first) |
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6 max-w-6xl mx-auto py-8 px-4">
{% for category in categories %}
<a href="/category/{{ category.slug }}" class="group block rounded-xl overflow-hidden border hover:shadow-lg transition">
{% if category.image %}
<div class="aspect-[4/3] overflow-hidden bg-gray-100">
<img src="{{ category.image }}" alt="{{ category.name }}" class="w-full h-full object-cover group-hover:scale-105 transition-transform" loading="lazy">
</div>
{% else %}
<div class="aspect-[4/3] bg-gray-100 flex items-center justify-center">
<span class="text-3xl font-bold text-gray-300">{{ category.name | slice: 0, 1 | upcase }}</span>
</div>
{% endif %}
<div class="p-4">
<h3 class="font-semibold">{{ category.name }}</h3>
{% if category.description %}<p class="text-sm text-gray-500 mt-1 line-clamp-2">{{ category.description }}</p>{% endif %}
</div>
</a>
{% endfor %}
</div>categories-expanded
Returns categories with their subcategories and products nested inside. Config type: "categories-expanded". This is the data type used for mega menus and navigation.
{
"name": "Mega Menu",
"version": "1.0.0",
"fields": {
"categoryMenu": { "type": "categories-expanded", "label": "Category Navigation" }
}
}| Property | Type | Description |
|---|---|---|
| name | string | Category name |
| slug | string | URL-friendly identifier |
| subcategories | array | Subcategories with products nested (see below) |
| products | array | Products directly under this category (no subcategory) |
Subcategory Object
| Property | Type | Description |
|---|---|---|
| name | string | Subcategory name |
| slug | string | URL slug (or category slug for virtual "All") |
| products | array | Up to 5 products: { name, slug, parentProductName, coverImage } |
Category Direct Products
Virtual "All" subcategory
If a category has products but its subcategories have no products, a virtual subcategory named "All" is automatically prepended to the subcategories array with those direct products. This ensures your mega menu template always has data to render.Each product in the expanded data has:
| Property | Type | Description |
|---|---|---|
| name | string | Product name (or parent product name if variant) |
| slug | string | Product slug for URL |
| parentProductName | string | null | Parent product name (for child/variant products) |
| coverImage | string | null | Product cover image URL |
{% for cat in categoryMenu %}
<div class="category-section">
<a href="/categories/{{ cat.slug }}" class="text-sm font-bold">{{ cat.name }}</a>
{% if cat.subcategories.size > 0 %}
{% for sub in cat.subcategories %}
{% if sub.products.size > 0 %}
<div class="mt-3">
<a href="/categories/{{ cat.slug }}/{{ sub.slug }}" class="text-xs font-semibold text-gray-700">
{{ sub.name }}
</a>
<div class="grid grid-cols-5 gap-2 mt-2">
{% for product in sub.products %}
<a href="/products/{{ product.slug }}" class="block">
{% if product.coverImage %}
<img src="{{ product.coverImage }}" alt="{{ product.name }}"
class="w-full aspect-square object-cover rounded" loading="lazy">
{% endif %}
<p class="text-[11px] mt-1 line-clamp-2">{{ product.name }}</p>
</a>
{% endfor %}
</div>
</div>
{% endif %}
{% endfor %}
{% elsif cat.products.size > 0 %}
{%- comment -%} Fallback: direct category products {%- endcomment -%}
<div class="grid grid-cols-5 gap-2 mt-2">
{% for product in cat.products %}
<a href="/products/{{ product.slug }}" class="block">
{% if product.coverImage %}
<img src="{{ product.coverImage }}" alt="{{ product.name }}"
class="w-full aspect-square object-cover rounded" loading="lazy">
{% endif %}
<p class="text-[11px] mt-1 line-clamp-2">{{ product.name }}</p>
</a>
{% endfor %}
</div>
{% else %}
<p class="text-xs text-gray-400 mt-2">No products found</p>
{% endif %}
</div>
{% endfor %}brands
Returns { brands: [...] }. Note the nested structure: iterate brands.brands. Config: "type": "api", "apiType": "brands".
{ "name": "Brand Logos", "version": "1.0.0", "fields": {
"brands": { "type": "api", "apiType": "brands", "label": "Brands" }
}}| Property | Type | Description |
|---|---|---|
| name | string | Brand name |
| slug | string | null | URL slug |
| image | string | null | Brand logo URL |
| isActive | boolean | Active status |
| order | number | Display order |
<div class="flex flex-wrap gap-6 items-center justify-center py-8">
{% for brand in brands.brands %}
<a href="/brand/{{ brand.slug }}" class="p-4 border rounded-lg hover:shadow-md transition">
{% if brand.image %}<img src="{{ brand.image }}" alt="{{ brand.name }}" class="h-12 object-contain">
{% else %}<span class="text-sm font-medium">{{ brand.name }}</span>{% endif %}
</a>
{% endfor %}
</div>promotions
Array of active promotional offers. Config type: "promotion". Value can be "all" or a specific offer ID.
{ "name": "Promo Banners", "version": "1.0.0", "fields": {
"promotions": { "type": "promotion", "label": "Promotions" }
}}| Property | Type | Description |
|---|---|---|
| title | string | Offer title |
| slug | string | URL slug |
| description | string | null | Offer description |
| coverImage | string | null | Banner image URL |
| video | string | null | Video URL |
| products | array | Product ID array |
| order | number | Display order |
| isActive | boolean | Active status |
{% for promo in promotions %}
<a href="/promotions/{{ promo.slug }}" class="relative block rounded-xl overflow-hidden">
{% if promo.coverImage %}<img src="{{ promo.coverImage }}" alt="{{ promo.title }}" class="w-full h-64 object-cover">{% endif %}
<div class="absolute inset-0 bg-gradient-to-t from-black/70 flex items-end p-6">
<div><h2 class="text-2xl font-bold text-white">{{ promo.title }}</h2>
{% if promo.description %}<p class="text-white/80 mt-1 text-sm">{{ promo.description }}</p>{% endif %}</div>
</div>
</a>
{% endfor %}promotional-products
Single promotional offer with products populated as full objects. Config type: "promotional-products". Supports "automatic" on /promotions/:slug pages.
{ "name": "Promo Detail", "version": "1.0.0", "fields": {
"promotion": { "type": "promotional-products", "label": "Promotion" }
}}{% if promotion %}
<div class="text-center py-8">
<h1 class="text-3xl font-bold">{{ promotion.title }}</h1>
{% if promotion.description %}<p class="text-gray-500 mt-2 max-w-xl mx-auto">{{ promotion.description }}</p>{% endif %}
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 max-w-6xl mx-auto">
{% for product in promotion.products %}{% render 'product-card', product: product %}{% endfor %}
</div>
{% endif %}featured-products
Array of featured product sections. Config type: "featured-products". Value: "all" or a specific section ID.
{ "name": "Featured Sections", "version": "1.0.0", "fields": {
"featuredProducts": { "type": "featured-products", "label": "Featured Sections" }
}}| Property | Type | Description |
|---|---|---|
| title | string | Section title |
| slug | string | null | URL slug |
| mode | string | "manual" or "automatic" |
| automaticType | string | null | "top_selling", "discounted", "most_popular", "category", "brand" |
| limit | number | Max products |
| products | array | Product objects |
| image1, image2, image3 | string | null | Section banner images |
| order | number | Display order |
{% for section in featuredProducts %}
<section class="py-8 max-w-6xl mx-auto px-4">
<div class="flex items-center justify-between mb-4">
<h2 class="text-2xl font-bold">{{ section.title }}</h2>
{% if section.slug %}<a href="/featured/{{ section.slug }}" class="text-sm" style="color:{{ shop.brandColor }};">View All →</a>{% endif %}
</div>
{% if section.image1 %}<img src="{{ section.image1 }}" class="w-full rounded-lg mb-4" loading="lazy">{% endif %}
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
{% for product in section.products %}{% render 'product-card', product: product %}{% endfor %}
</div>
</section>
{% endfor %}featured-section (Single)
Single featured section. Config type: "featured-section". Supports "automatic" on /featured/:slug pages or a specific slug/ID.
{ "name": "Featured Detail", "version": "1.0.0", "fields": {
"featured": { "type": "featured-section", "label": "Featured Section" }
}}{% if featured %}
<div class="max-w-6xl mx-auto py-8 px-4">
<h1 class="text-3xl font-bold">{{ featured.title }}</h1>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
{% for product in featured.products %}{% render 'product-card', product: product %}{% endfor %}
</div>
</div>
{% endif %}deals
Array of live flash deals, each with its products (the same product objects a featured section returns, already deal-priced), ending soonest first. Config type: "deals". Value: "all" or one deal's slug/ID - a chosen deal only appears while it is live, so a deals section never advertises a price the checkout won't give.
{ "name": "Flash Deals", "version": "1.0.0", "fields": {
"deals": { "type": "deals", "label": "Flash Deals" }
}}| Property | Type | Description |
|---|---|---|
| _id | string | Deal ID |
| title | string | Deal name |
| slug | string | Deal page: /deals/{{ deal.slug }} |
| shortDescription | string | null | Short description |
| coverImage | string | null | Deal image |
| state | string | "live" in this list ("scheduled" / "ended" only on a single deal) |
| startsAt, endsAt | string (ISO) | Deal window - use endsAt for a countdown |
| productCount | number | Products shown |
| products | array | Hand-picked products first (merchant's order), then products of whole groups the deal puts on sale (all products, categories, brands, collections…); each carries product.flashDeal while in a live deal |
{% for deal in deals %}
<section class="py-8 max-w-6xl mx-auto px-4">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-2xl font-bold">{{ deal.title }}</h2>
{% if deal.shortDescription %}<p class="text-sm text-gray-500">{{ deal.shortDescription }}</p>{% endif %}
</div>
<a href="/deals/{{ deal.slug }}" class="text-sm" style="color:{{ shop.brandColor }};">
Ends in <span data-deal-ends="{{ deal.endsAt }}" class="font-mono"></span> →
</a>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
{% for product in deal.products %}{% render 'product-card', product: product %}{% endfor %}
</div>
</section>
{% endfor %}Editor preview
The visual editor also previews deals that are scheduled but not started yet (their products at normal prices,state: "scheduled") so a section can be designed before a deal goes live. The storefront lists live deals only.deal (Single)
One flash deal with its products. Config type: "deal". Value: "automatic" or a deal slug/ID. On /deals/:slug pages the field always receives that page's deal. Elsewhere, "automatic" is the live deal ending soonest (or null when none is live), and a slug returns that deal in any state - check deal.state (live, scheduled, ended); products are only discounted while it is live. Same object as one entry of deals, plus metaTitle, metaDescription, metaKeywords.
{ "name": "Deal Detail", "version": "1.0.0", "fields": {
"deal": { "type": "deal", "label": "Flash Deal" }
}}{% if deal %}
<div class="max-w-6xl mx-auto py-8 px-4">
{% if deal.coverImage %}<img src="{{ deal.coverImage }}" alt="{{ deal.title }}" class="w-full rounded-lg mb-6">{% endif %}
<h1 class="text-3xl font-bold">{{ deal.title }}</h1>
{% if deal.state == "live" %}
<p class="mt-2 text-sm">Ends in <span data-deal-ends="{{ deal.endsAt }}" class="font-mono"></span></p>
{% elsif deal.state == "scheduled" %}
<p class="mt-2 text-sm">Starts {{ deal.startsAt | date: "%b %d, %H:%M" }}</p>
{% else %}
<p class="mt-2 text-sm text-gray-500">This deal has ended.</p>
{% endif %}
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
{% for product in deal.products %}{% render 'product-card', product: product %}{% endfor %}
</div>
</div>
{% endif %}/deals/:slug page
The storefront renders a deal with the template's/deals/:slug page when it has sections, otherwise with the /collections/:slug page. Either way every section gets the deal: deal fields receive it, promotional-products fields receive it as a collection (title, description, coverImage, products), and {{ flashDeal }} is available as a global.faqs
Array of active FAQs. Config: "type": "api", "apiType": "faqs".
{ "name": "FAQ Section", "version": "1.0.0", "fields": {
"title": { "type": "text", "label": "Title", "default": "Frequently Asked Questions" },
"faqs": { "type": "api", "apiType": "faqs", "label": "FAQs" }
}}| Property | Type | Description |
|---|---|---|
| question | string | FAQ question |
| answer | string | FAQ answer (may contain HTML) |
| order | number | Display order |
<section class="max-w-3xl mx-auto py-12 px-4">
<h2 class="text-2xl font-bold mb-6 text-center">{{ title }}</h2>
{% for faq in faqs %}
<details class="border-b py-4 group">
<summary class="cursor-pointer font-medium flex justify-between items-center">
{{ faq.question }}
<span class="text-gray-400 group-open:rotate-45 transition-transform text-xl">+</span>
</summary>
<div class="mt-3 text-gray-600 text-sm prose prose-sm">{{ faq.answer }}</div>
</details>
{% endfor %}
</section>branches
Array of shop branches. Config: "type": "api", "apiType": "branches". Same data as shop.branches.
{ "name": "Store Locator", "version": "1.0.0", "fields": {
"branches": { "type": "api", "apiType": "branches", "label": "Branches" }
}}<div class="grid md:grid-cols-2 gap-6 max-w-4xl mx-auto py-8 px-4">
{% for branch in branches %}
<div class="border rounded-lg p-6">
<div class="flex items-center gap-2">
<h3 class="font-bold text-lg">{{ branch.name }}</h3>
{% if branch.isMainBranch %}<span class="text-[10px] bg-blue-100 text-blue-700 px-2 py-0.5 rounded-full">Main</span>{% endif %}
</div>
<p class="text-gray-600 mt-2">{{ branch.location }}</p>
{% if branch.googleMapLink %}<a href="{{ branch.googleMapLink }}" target="_blank" class="text-blue-500 text-sm mt-2 inline-block">View on Maps →</a>{% endif %}
</div>
{% endfor %}
</div>blog (Single Article)
Single shop article. Config type: "blog". Supports "automatic" on /blogs/:slug pages or a specific slug.
{ "name": "Blog Article", "version": "1.0.0", "fields": {
"article": { "type": "blog", "label": "Article" }
}}| Property | Type | Description |
|---|---|---|
| title | string | Article title |
| shortDesc | string | Brief excerpt |
| content | string | Full content (HTML) |
| coverImage | string | null | Cover image URL |
| slug | string | URL slug |
| tags | string[] | Tag strings |
| seoTitle | string | null | SEO title |
| seoDescription | string | null | SEO description |
| publishedAt | string | Publish date (ISO string) |
{% if article %}
<article class="max-w-3xl mx-auto py-8 px-4">
{% if article.coverImage %}<img src="{{ article.coverImage }}" alt="{{ article.title }}" class="w-full rounded-xl mb-6">{% endif %}
<h1 class="text-4xl font-bold">{{ article.title }}</h1>
<p class="text-gray-500 mt-2">{{ article.shortDesc }}</p>
<div class="flex flex-wrap gap-2 mt-4">
{% for tag in article.tags %}<span class="bg-gray-100 text-xs px-3 py-1 rounded-full">{{ tag }}</span>{% endfor %}
</div>
<div class="prose prose-lg mt-8 max-w-none">{{ article.content }}</div>
</article>
{% endif %}blog-list
Paginated list of published articles. Config type: "blog-list".
{ "name": "Blog List", "version": "1.0.0", "fields": {
"blogList": { "type": "blog-list", "label": "Blog Articles" }
}}| Property | Type | Description |
|---|---|---|
| articles | array | Article objects (same as single blog) |
| total | number | Total articles count |
| totalPages | number | Total pages |
| page | number | Current page number |
| limit | number | Items per page |
| search | string | undefined | Current search query |
<div class="grid md:grid-cols-3 gap-6 max-w-6xl mx-auto py-8 px-4">
{% for article in blogList.articles %}
<a href="/blogs/{{ article.slug }}" class="block border rounded-lg overflow-hidden hover:shadow-md transition">
{% if article.coverImage %}<img src="{{ article.coverImage }}" alt="{{ article.title }}" class="w-full h-48 object-cover">{% endif %}
<div class="p-4">
<h3 class="font-semibold line-clamp-2">{{ article.title }}</h3>
<p class="text-sm text-gray-500 mt-1 line-clamp-2">{{ article.shortDesc }}</p>
</div>
</a>
{% endfor %}
</div>
{% if blogList.totalPages > 1 %}
<p class="text-center text-sm text-gray-400 pb-8">Page {{ blogList.page }} of {{ blogList.totalPages }} ({{ blogList.total }} articles)</p>
{% endif %}order-details
Order object for tracking pages. Config: "type": "api", "apiType": "order-details". Typically via page context.
{ "name": "Order Status", "version": "1.0.0", "fields": {
"order": { "type": "api", "apiType": "order-details", "label": "Order" }
}}| Property | Type | Description |
|---|---|---|
| orderNumber | string | Unique order number |
| status | string | "pending", "confirmed", "approved", "packaging", "shipping", "shipped", "delivering", "delivered", "canceled" |
| fullName | string | Customer name |
| string | null | Customer email | |
| mobileNumber | string | Customer phone |
| city | string | null | Delivery city |
| address | string | null | Delivery address |
| items | array | Line items: { productName, variantName, quantity, price, totalPrice, extraFields } |
| subtotal | number | Subtotal |
| tax | number | Tax |
| shippingCost | number | Shipping cost |
| discount | number | Discount |
| totalAmount | number | Final total |
| paymentMethod | string | Payment method |
| paidStatus | string | "unpaid", "paid", "partial" |
| trackingUrl | string | null | Tracking URL |
| trackingNumber | string | null | Tracking number |
| promocode | string | null | Applied promo code |
| statusHistory | array | Array of { status, changedAt } |
| notes | string | null | Order notes |
| createdAt | string | Order date (ISO) |
{% if order %}
<div class="max-w-2xl mx-auto py-8 px-4">
<h1 class="text-2xl font-bold">Order #{{ order.orderNumber }}</h1>
<div class="flex gap-2 mt-2">
<span class="px-3 py-1 rounded-full text-xs font-medium
{% if order.status == 'delivered' %}bg-green-100 text-green-700
{% elsif order.status == 'canceled' %}bg-red-100 text-red-700
{% else %}bg-blue-100 text-blue-700{% endif %}">{{ order.status | capitalize }}</span>
<span class="px-3 py-1 rounded-full text-xs font-medium
{% if order.paidStatus == 'paid' %}bg-green-100 text-green-700
{% else %}bg-yellow-100 text-yellow-700{% endif %}">{{ order.paidStatus | capitalize }}</span>
</div>
<table class="w-full text-sm mt-6">
<thead><tr class="border-b text-left"><th class="py-2">Product</th><th class="py-2 text-right">Qty</th><th class="py-2 text-right">Price</th><th class="py-2 text-right">Total</th></tr></thead>
<tbody>{% for item in order.items %}
<tr class="border-b"><td class="py-2">{{ item.productName }}{% if item.variantName %}<br><small class="text-gray-400">{{ item.variantName }}</small>{% endif %}</td>
<td class="py-2 text-right">{{ item.quantity }}</td><td class="py-2 text-right">{{ shop.priceUnit }} {{ item.price }}</td><td class="py-2 text-right">{{ shop.priceUnit }} {{ item.totalPrice }}</td></tr>
{% endfor %}</tbody>
</table>
<div class="mt-4 text-right text-sm space-y-1">
<div>Subtotal: {{ shop.priceUnit }} {{ order.subtotal }}</div>
{% if order.tax > 0 %}<div>Tax: {{ shop.priceUnit }} {{ order.tax }}</div>{% endif %}
{% if order.shippingCost > 0 %}<div>Shipping: {{ shop.priceUnit }} {{ order.shippingCost }}</div>{% endif %}
{% if order.discount > 0 %}<div class="text-green-600">Discount: -{{ shop.priceUnit }} {{ order.discount }}</div>{% endif %}
<div class="text-lg font-bold">Total: {{ shop.priceUnit }} {{ order.totalAmount }}</div>
</div>
<div class="mt-6 text-sm"><p class="font-medium">{{ order.fullName }}</p><p class="text-gray-500">{{ order.address }}{% if order.city %}, {{ order.city }}{% endif %}</p><p class="text-gray-500">{{ order.mobileNumber }}</p></div>
{% if order.trackingUrl %}<a href="{{ order.trackingUrl }}" target="_blank" class="mt-4 inline-block px-4 py-2 rounded text-white text-sm" style="background:{{ shop.brandColor }};">Track Shipment</a>{% endif %}
</div>
{% endif %}reviews
The newest visible reviews, at most 50, resolved with the page. On a product page they are that product's reviews; everywhere else the whole shop's. Config: "type": "api", "apiType": "reviews". Optional: "max" (1–50), "withText": true (skip rating-only reviews), "minRating" (2–5), "scope": "shop" (whole shop even on product pages).
{ "name": "Recent reviews", "version": "1.0.0", "fields": {
"reviews": { "type": "api", "apiType": "reviews", "label": "Reviews", "withText": true }
}}| Property | Type | Description |
|---|---|---|
| items | array | Newest first (see below) |
| total | number | Every visible review in scope, not just the listed ones |
| average | number | Their average rating, 1 decimal (0 when none) |
| items[].reviewerName | string | Reviewer name |
| items[].review | string | Review text (may be empty) |
| items[].rating | number | Rating 1–5 |
| items[].createdAt | string | Review date |
| items[].verified | boolean | A customer's review of a delivered order (false = added by the merchant) |
| items[].product | object | null | { _id, name, slug, image }, or null when the product is no longer on sale |
{% if reviews.total > 0 %}<p>{{ reviews.average }} / 5 from {{ reviews.total }} reviews</p>{% endif %}
{% for r in reviews.items %}
<div class="border rounded p-4">
<span class="font-medium text-sm">{{ r.reviewerName | escape }}</span>
<span class="text-yellow-500 text-sm">{% for i in (1..5) %}{% if i <= r.rating %}★{% else %}☆{% endif %}{% endfor %}</span>
{% if r.review != blank %}<p class="text-sm mt-1">{{ r.review | escape }}</p>{% endif %}
{% if r.product %}<a href="/products/{{ r.product.slug }}">{{ r.product.name | escape }}</a>{% endif %}
</div>
{% endfor %}product-ratings
Product review ratings. Config: "type": "api", "apiType": "product-ratings".
{ "name": "Reviews", "version": "1.0.0", "fields": {
"productRatings": { "type": "api", "apiType": "product-ratings", "label": "Ratings" }
}}| Property | Type | Description |
|---|---|---|
| rating | number | Rating 1–5 |
| name | string | Reviewer name |
| review | string | null | Review text |
| createdAt | string | Review date (ISO) |
{% for r in productRatings %}
<div class="border-b py-4">
<div class="flex items-center gap-2">
<span class="font-medium text-sm">{{ r.name | escape }}</span>
<span class="text-yellow-500 text-sm">{% for i in (1..5) %}{% if i <= r.rating %}★{% else %}☆{% endif %}{% endfor %}</span>
</div>
{% if r.review %}<p class="text-gray-600 text-sm mt-1">{{ r.review | escape }}</p>{% endif %}
</div>
{% endfor %}searchData
Auto-available on search pages via page context. Config type: "searchData".
{ "name": "Search Results", "version": "1.0.0", "fields": {
"searchData": { "type": "searchData", "label": "Search Data" }
}}| Property | Type | Description |
|---|---|---|
| q | string | undefined | Search query |
| brandSlug | string | undefined | Brand filter slug |
| categorySlug | string | undefined | Category filter slug |
| subcategorySlug | string | undefined | Subcategory filter slug |
| sortBy | string | Sort field |
| sortOrder | string | "asc" or "desc" |
| products | array | Matching product objects |
<section class="max-w-6xl mx-auto py-8 px-4">
{% if searchData.q %}<h1 class="text-2xl font-bold">Results for “{{ searchData.q | escape }}”</h1>
{% else %}<h1 class="text-2xl font-bold">All Products</h1>{% endif %}
{% if searchData.products.size > 0 %}
<p class="text-sm text-gray-500 mt-1">{{ searchData.products.size }} products found</p>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
{% for product in searchData.products %}{% render 'product-card', product: product %}{% endfor %}
</div>
{% else %}
<div class="text-center py-16"><p class="text-gray-400 text-lg">No products found.</p><a href="/" class="text-blue-500 mt-2 inline-block">Back to Home</a></div>
{% endif %}
</section>Config Field Types
Complete reference of all field types for config.json. The field key becomes the variable name in your Liquid template.
Static / Editor Fields
These fields are set by the user in the visual editor and passed directly to the template.
| Type | Widget | Liquid Value |
|---|---|---|
| text / string | Text input | string |
| textarea | Multi-line text | string |
| number | Number input | number |
| boolean | Toggle switch | true / false |
| color | Color picker | hex string ("#ff0000") |
| date | Date picker | date string |
| time | Time picker | time string |
| url | URL input | URL string |
| option | Dropdown select | selected value string |
| image | Image uploader | image URL string |
| icon | Icon picker | icon path (auto-converted to full URL) |
| toggle | Toggle selector | string value |
| tabs | Tab selector | string value |
| alignment | Alignment picker | "left", "center", "right" |
| text-align | Text alignment | "left", "center", "right", "justify" |
| font-style | Font style picker | string |
| padding | Padding controls | CSS value string |
| border-radius | Border radius | CSS value string |
| size | Size selector | string |
| code | Code editor | raw HTML/CSS string |
| wysiwyg | Rich text editor | HTML string |
| array_object | Repeatable items | array or { items: [...] } |
API Data Fields Summary
| Config Type | Mode | Returns |
|---|---|---|
| products | Always fetches | Array of product objects (max 100) |
| product | "automatic" or slug | Single product or null |
| api { apiType: "categories" } | Always fetches | Array of categories |
| categories-expanded | Always fetches | Categories + subcategories + products nested |
| api { apiType: "brands" } | Always fetches | { brands: [...] } (note: nested!) |
| promotion | "all" or ID | Array of promotional offers |
| promotional-products | "automatic" or slug | Offer with populated products |
| featured-products | "all" or ID | Array of featured sections |
| featured-section | "automatic" or slug | Single featured section or null |
| deals | "all" or slug/ID | Array of live deals, each with products |
| deal | "automatic" or slug/ID | Single deal with products, or null |
| api { apiType: "faqs" } | Always fetches | Array of FAQs |
| api { apiType: "branches" } | Always fetches | Array of branches |
| blog | "automatic" or slug | Single article or null |
| blog-list | Always fetches | { articles, total, totalPages, page, limit } |
| api { apiType: "order-details" } | Page context | Order object |
| api { apiType: "product-ratings" } | Page context / fetch | Array of ratings |
| api { apiType: "reviews" } | Page product, else whole shop | { items (max 50), total, average } |
| searchData | Page context | { q, products, filters... } |
Conditional Fetching
API fields can have a condition to only fetch when another field matches a value. Saves unnecessary API calls.
{
"name": "Flexible Section",
"version": "1.0.0",
"fields": {
"showBrands": { "type": "boolean", "label": "Show Brands", "default": false },
"brands": {
"type": "api",
"apiType": "brands",
"label": "Brands",
"condition": { "field": "showBrands", "value": true }
},
"showFaqs": { "type": "boolean", "label": "Show FAQs", "default": false },
"faqs": {
"type": "api",
"apiType": "faqs",
"condition": { "field": "showFaqs", "value": true }
}
}
}{% if showBrands and brands %}
<h2>Our Brands</h2>
<div class="flex flex-wrap gap-4">
{% for brand in brands.brands %}<span class="px-3 py-1 border rounded">{{ brand.name }}</span>{% endfor %}
</div>
{% endif %}
{% if showFaqs and faqs %}
<h2 class="mt-8">FAQs</h2>
{% for faq in faqs %}<details class="border-b py-3"><summary class="cursor-pointer font-medium">{{ faq.question }}</summary><p class="mt-2 text-sm text-gray-600">{{ faq.answer }}</p></details>{% endfor %}
{% endif %}Array Object Fields (Repeatable Items)
Use "array_object" or "array-object" for repeatable user-defined lists (slides, testimonials, links, etc.).
{
"name": "Testimonials",
"version": "1.0.0",
"fields": {
"testimonials": {
"type": "array_object",
"label": "Testimonials",
"fields": {
"name": { "type": "string", "label": "Name", "placeholder": "Customer name" },
"quote": { "type": "textarea", "label": "Quote" },
"avatar": { "type": "image", "label": "Photo" },
"rating": { "type": "number", "label": "Stars (1-5)", "default": 5 }
},
"value": [
{ "name": "John Doe", "quote": "Amazing product!", "avatar": "", "rating": 5 }
]
}
}
}items wrapper
If your config declares"value": { "items": [...] }, the data is wrapped as { items: [...] }. If it's a flat array, you iterate directly. Check how you define value.<div class="grid md:grid-cols-3 gap-6">
{% for t in testimonials %}
<div class="border rounded-lg p-6 text-center">
{% if t.avatar %}<img src="{{ t.avatar }}" class="w-16 h-16 rounded-full mx-auto mb-3 object-cover">{% endif %}
<p class="text-sm italic text-gray-600">“{{ t.quote }}”</p>
<p class="font-semibold text-sm mt-3">{{ t.name }}</p>
<div class="text-yellow-500 text-sm mt-1">{% for i in (1..5) %}{% if i <= t.rating %}★{% else %}☆{% endif %}{% endfor %}</div>
</div>
{% endfor %}
</div>Option / Tabs Fields
Dropdowns and tab selectors let users pick from predefined values.
{
"fields": {
"layout": {
"type": "option",
"label": "Layout Style",
"default": "grid",
"options": [
{ "label": "Grid", "value": "grid" },
{ "label": "List", "value": "list" },
{ "label": "Carousel", "value": "carousel" }
]
},
"theme": {
"type": "tabs",
"label": "Theme",
"default": "light",
"options": [
{ "label": "Light", "value": "light" },
{ "label": "Dark", "value": "dark" }
]
}
}
}{% if layout == "grid" %}
<div class="grid grid-cols-4 gap-4">...</div>
{% elsif layout == "list" %}
<div class="space-y-4">...</div>
{% elsif layout == "carousel" %}
<div class="flex overflow-x-auto gap-4">...</div>
{% endif %}
<section class="{% if theme == 'dark' %}bg-gray-900 text-white{% else %}bg-white text-gray-900{% endif %} py-8">
...
</section>Custom Liquid Filters
In addition to all standard LiquidJS filters, these custom filters are registered:
| Filter | Usage | Description |
|---|---|---|
| inline_svg | {{ path | inline_svg }} | Reads SVG file, returns raw SVG HTML. Falls back to a placeholder on error. |
| svg_class | {{ svg | svg_class: "w-5 h-5" }} | Adds CSS classes to an SVG element |
| svg_size | {{ svg | svg_size: 24 }} | Sets width and height attributes on SVG |
{%- comment -%} Chain: inline SVG, add Tailwind classes, set dimensions {%- endcomment -%}
{{ iconPath | inline_svg | svg_class: "w-6 h-6 text-gray-500" | svg_size: 24 }}
{%- comment -%} Use an icon field value {%- endcomment -%}
{% if slide.icon %}
<span class="w-6 h-6 shrink-0 inline-block"
style="background:var(--brand-color);
-webkit-mask-image:url({{ slide.icon }});mask-image:url({{ slide.icon }});
-webkit-mask-size:contain;mask-size:contain;
-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;
-webkit-mask-position:center;mask-position:center;">
</span>
{% endif %}Auto icon URL conversion
All values starting with/assets/icons/ are automatically converted to full backend URLs (e.g. http://backend:4000/assets/icons/heart.svg) before template rendering. You don't need to prepend the backend URL yourself.Rendering Limits
To prevent infinite loops and abuse, the LiquidJS engine enforces strict DoS limits on every render:
| Limit | Value | Description |
|---|---|---|
| parseLimit | 1 MB | Maximum template source size |
| renderLimit | 10 seconds | Maximum render time before timeout |
| memoryLimit | 10 million | Maximum object allocations during render |
Exceeding limits
If a template exceeds these limits, it returns empty HTML and logs an error. Deeply nested loops, recursive renders, or very large data sets can trigger this. Keep templates lean and uselimit on {% for %} loops.Liquid Syntax Reference
Variables & Filters
{%- comment -%} Output {%- endcomment -%}
{{ shop.shopName }}
{{ product.price }}
{{ title | default: "Untitled" }}
{%- comment -%} String filters {%- endcomment -%}
{{ "hello" | upcase }} => HELLO
{{ "HELLO" | downcase }} => hello
{{ "hello world" | capitalize }} => Hello world
{{ "Hello World" | truncate: 10 }} => Hello W...
{{ "Hello World" | truncatewords: 1 }} => Hello...
{{ product.name | slice: 0, 1 | upcase }} => first letter uppercase
{{ product.description | strip_html }} => plain text
{{ " hello " | strip }} => "hello"
{{ "hello-world" | split: "-" }} => ["hello", "world"]
{{ "hello" | prepend: "say " }} => "say hello"
{{ "hello" | append: " world" }} => "hello world"
{{ "hello" | replace: "hello", "hi" }} => "hi"
{%- comment -%} Number filters {%- endcomment -%}
{{ 4.56 | round }} => 5
{{ 4.56 | round: 1 }} => 4.6
{{ 100 | plus: 13 }} => 113
{{ 100 | minus: 13 }} => 87
{{ 10 | times: 1.13 }} => 11.3
{{ 100 | divided_by: 3 }} => 33
{{ 100 | modulo: 3 }} => 1
{%- comment -%} Array filters {%- endcomment -%}
{{ products | size }} => count
{{ products | first }} => first item
{{ products | last }} => last item
{{ tags | join: ", " }} => "tag1, tag2"
{{ tags | sort }} => alphabetical
{{ tags | uniq }} => deduplicated
{{ tags | reverse }} => reversed
{{ products | where: "freeDelivery", true }} => filter array
{%- comment -%} Date filters {%- endcomment -%}
{{ product.createdAt | date: "%B %d, %Y" }} => "January 15, 2026"
{{ "now" | date: "%Y" }} => current yearConditionals
{%- comment -%} if / elsif / else {%- endcomment -%}
{% if product.compareAtPrice and product.compareAtPrice > product.price %}
<span class="badge-sale">On Sale!</span>
{% elsif product.freeDelivery %}
<span class="badge-free">Free Delivery</span>
{% else %}
<span>{{ shop.priceUnit }} {{ product.price }}</span>
{% endif %}
{%- comment -%} unless (negation of if) {%- endcomment -%}
{% unless product.quantity == 0 %}
<button>Add to Cart</button>
{% endunless %}
{%- comment -%} case / when {%- endcomment -%}
{% case order.status %}
{% when "delivered" %}<span class="text-green-600">Delivered</span>
{% when "canceled" %}<span class="text-red-600">Canceled</span>
{% else %}<span class="text-blue-600">{{ order.status | capitalize }}</span>
{% endcase %}
{%- comment -%} Operators: == != > < >= <= or and contains {%- endcomment -%}
{% if product.tags contains "new" %}
<span class="badge">New Arrival</span>
{% endif %}
{%- comment -%} Blank check (nil, empty string, empty array) {%- endcomment -%}
{% if shop.shopLogo != blank %}
<img src="{{ shop.shopLogo }}" alt="{{ shop.shopName }}">
{% endif %}
{% unless description == blank %}
<div class="prose">{{ description }}</div>
{% endunless %}Loops
{%- comment -%} Basic {%- endcomment -%}
{% for product in products %}
<div>{{ product.name }}</div>
{% endfor %}
{%- comment -%} With limit and offset {%- endcomment -%}
{% for product in products limit: 4 %}...{% endfor %}
{% for product in products limit: 4 offset: 4 %}...{% endfor %}
{%- comment -%} Loop variables {%- endcomment -%}
{% for product in products %}
{{ forloop.index }} => 1, 2, 3, ...
{{ forloop.index0 }} => 0, 1, 2, ...
{{ forloop.first }} => true on first
{{ forloop.last }} => true on last
{{ forloop.length }} => total count
{{ forloop.rindex }} => reverse: 3, 2, 1
{% endfor %}
{%- comment -%} Empty fallback (else) {%- endcomment -%}
{% for product in products %}
<div>{{ product.name }}</div>
{% else %}
<p>No products found.</p>
{% endfor %}
{%- comment -%} Range loop {%- endcomment -%}
{% for i in (1..5) %}
<span>Star {{ i }}</span>
{% endfor %}
{%- comment -%} break and continue {%- endcomment -%}
{% for product in products %}
{% if product.quantity == 0 %}{% continue %}{% endif %}
{% if forloop.index > 10 %}{% break %}{% endif %}
<div>{{ product.name }}</div>
{% endfor %}Render Partials
{% render %} includes other components as partials. Parent scope variables are available in rendered templates.
{%- comment -%} Render product card, passing product variable {%- endcomment -%}
{% render 'product-card', product: product %}
{%- comment -%} Inside a loop {%- endcomment -%}
{% for product in products %}
{% render 'product-card', product: product %}
{% endfor %}
{%- comment -%} Render layout partials {%- endcomment -%}
{% render 'header' %}
{% render 'footer' %}
{%- comment -%} Pass multiple variables {%- endcomment -%}
{% render 'promo-badge', text: "SALE", color: "#ff0000" %}Parent scope sharing
In LiquidJS, when you use{% render %}, the parent scope variables (like borderRadius, shop, etc.) are available in the rendered partial. You only need to explicitly pass variables that are loop-specific or renamed.Assign & Capture
{%- comment -%} Assign a variable {%- endcomment -%}
{% assign discountPercent = product.compareAtPrice | minus: product.price | times: 100 | divided_by: product.compareAtPrice %}
<span>{{ discountPercent }}% off</span>
{%- comment -%} Assign with default {%- endcomment -%}
{% assign heroTitle = title | default: "Welcome to " | append: shop.shopName %}
{%- comment -%} Capture multi-line content into a variable {%- endcomment -%}
{% capture priceHtml %}
<span class="font-bold">{{ shop.priceUnit }} {{ product.price }}</span>
{% if product.compareAtPrice > product.price %}
<span class="line-through text-gray-400 text-sm">{{ shop.priceUnit }} {{ product.compareAtPrice }}</span>
{% endif %}
{% endcapture %}
{%- comment -%} Use it later {%- endcomment -%}
<div class="price-display">{{ priceHtml }}</div>
{%- comment -%} Increment / Decrement (independent counters) {%- endcomment -%}
{% increment myCounter %} => 0
{% increment myCounter %} => 1
{% increment myCounter %} => 2Whitespace Control
Use {%- and -%} (with hyphens) to strip whitespace around Liquid tags. Useful for keeping clean HTML output.
{%- comment -%} Without whitespace control: adds blank lines {%- endcomment -%}
{% if product.freeDelivery %}
Free Delivery
{% endif %}
{%- comment -%} With whitespace control: clean output {%- endcomment -%}
{%- if product.freeDelivery -%}
Free Delivery
{%- endif -%}
{%- comment -%} Raw: escape all Liquid syntax {%- endcomment -%}
{% raw %}
This {{ will not }} be parsed. Useful for showing Liquid code examples.
{% endraw %}
{%- comment -%} Comments are invisible in output {%- endcomment -%}
{% comment %}
This entire block is hidden from the rendered HTML.
Use it for notes, TODOs, or documentation.
{% endcomment %}Page Contexts
Certain pages auto-inject data. Components using "automatic" mode receive this data.
| Page URL | Variable | Description |
|---|---|---|
| /product/:slug | product | Full product object with relations |
| /collections/:slug | promotion | Promotional offer with populated products |
| /featured/:slug | featured | Featured section with products |
| /deals/:slug | deal | Flash deal with products (also given to promotional-products fields as a collection) |
| /order-status/:id | order | Order details with items and status history |
| /search?q=... | searchData | Search query, filters, and matching products |
| /blogs/:slug | article | Blog article with full HTML content |
| /blogs | blogData | Paginated blog list with search/pagination |
| (review pages) | productRatings | Rating data for the product |
Automatic vs. Manual
When a field is"automatic", it receives the page context data. On pages without that context, the value is null (a deal field falls back to the live deal ending soonest). A specific slug fetches that item on other pages - but on a detail page (/product/:slug, /collections/:slug, /featured/:slug, /blogs/:slug, /deals/:slug) the matching field type alwaysreceives that page's own entity, even when a slug is set, so a section pinned by mistake still shows the page it is on.JavaScript API (window functions)
The storefront puts a set of functions on window so the <script> in a component can sign customers in, read their account, and work with the cart, wishlist and compare list. These functions are the supported way to do those things from a template.
Rules for every function
- Check before you call. The functions exist on the storefront, not inside the editor preview. Use
typeof window.addToCart === "function"so your component never crashes there. - Wire each element once. A component's script can run more than once on the same page. Mark the element you attached listeners to and skip it the next time (the login example below does this).
- Never call the storefront's
/api/...addresses yourself and never read or keep the customer's sign-in token. The functions handle the token for you. - Prices you pass are for display. The checkout always charges the shop's real price.
Custom Login Page
A theme can replace the built-in sign-in form with its own design. Add your components to the theme's /login page (visual editor → page switcher → Login Page). While that page is empty, the built-in form is used.
Customer login must be on
Sign-in exists only for shops that turned on Apps → Checkout Customizer → Checkout Login. When it is off,/login and /account do not exist.Where your login page is shown (to customers who are not signed in):
/login/account- the checkout, before the order form
The sign-in functions below exist only in those three places. Anywhere else (a header, a popup on the home page) link to /login instead, and use window.isLoggedIn() to decide whether to show "Sign in" or "My account".
How signing in works
- The customer types their email. Call
loginWithEmail(email): we email them a 6-digit code. - The customer types the code. Call
verifyOtp(code). - If the answer says
needsRegistration, this is their first visit: ask for their name and callregisterShopUser(tempToken, fullName). Otherwise they are already signed in.
Or the customer clicks your Google button and you call LoginWithGoogle().
| Function | Returns | What it does |
|---|---|---|
| loginWithEmail(email) | Promise<{ ok: true, data: { channel: "email" } }> | Emails a 6-digit code to that address. The code works for 10 minutes. |
| verifyOtp(code) | Promise<{ ok: true, data: { signedIn, needsRegistration, tempToken? } }> | Checks the code for the email you last passed to loginWithEmail on this page. A returning customer comes back with signedIn: true. A new one comes back with needsRegistration: true and a tempToken. You can also name the email yourself: verifyOtp(email, code) or verifyOtp({ email, otp }). |
| registerShopUser(tempToken, fullName, image?) | Promise<{ ok: true }> | Creates the account for a first-time customer and signs them in. fullName is 1–255 characters. image is an optional picture URL. The tempToken works for 15 minutes. |
| LoginWithGoogle(returnPath?) | Promise<{ ok: true }> | Sends the customer to Google, then back to returnPath signed in (default: the page they were on). A first-time customer gets an account automatically, with their Google name. Note the capital L. |
| continueCheckoutAsGuest() | void | Checkout only, and only when the shop allows guest checkout: closes your login page and shows the order form. |
Every call can fail: read the answer
On failure the same functions resolve to{ ok: false, message } instead. Show message to the customer as it is. If the customer has no connection, the call rejects, so add a .catch() too.What your page does after sign-in: nothing. As soon as a call signs the customer in, the storefront takes over: /login moves to /account, /account shows the account, and the checkout shows the order form. The items already in their cart are kept. Just show a short "Signing you in…" message.
Show only what the shop turned on. These are available on the shop object in Liquid:
| Property | Type | Description |
|---|---|---|
| shop.emailLoginEnabled | boolean | The shop accepts sign-in by emailed code |
| shop.googleLoginEnabled | boolean | The shop accepts sign-in with Google |
| shop.guestCheckoutAllowed | boolean | Customers may check out without signing in |
| shop.loginEnabled | boolean | Customer login is on for this shop at all |
Limits and messages you will see
| Situation | What happens |
|---|---|
| Wrong or expired code | verifyOtp answers ok: false, "Invalid OTP or this code has expired." |
| 5 wrong codes in a row | That code stops working. Call loginWithEmail again for a new one. |
| The code is not 6 digits | verifyOtp answers ok: false, "Provide a valid 6-digit code." |
| More than 15 minutes on the name step | registerShopUser answers ok: false, "Invalid or expired registration token." Start again from the email step. |
| Too many tries from one visitor | The call answers ok: false. Limits per hour: 20 codes sent, 20 codes checked. Per day: 10 new accounts. |
Full example. A complete login component: email, code, first-time name, Google, and guest checkout. Copy both files into a new component and add it to the /login page.
{
"name": "Custom Login",
"version": "1.0.0",
"description": "Sign-in form for the /login page",
"fields": {
"title": {
"type": "text",
"label": "Heading",
"default": "Sign in"
}
}
}<section data-login class="max-w-sm mx-auto px-4 py-16">
<h1 class="text-2xl font-bold">{{ title | default: "Sign in" }}</h1>
<p data-login-error class="hidden mt-3 text-sm text-red-600"></p>
<!-- Step 1: how to sign in -->
<form data-login-step="start" class="mt-6 space-y-3">
{% if shop.emailLoginEnabled %}
<input name="email" type="email" required autocomplete="email"
placeholder="[email protected]" class="w-full border rounded-lg px-3 py-2.5" />
<button type="submit" class="w-full rounded-lg py-2.5 font-medium"
style="background: {{ shop.brandColor }}; color: {{ shop.textOverBrandColor }};">
Email me a code
</button>
{% endif %}
{% if shop.googleLoginEnabled %}
<button type="button" data-login-google class="w-full border rounded-lg py-2.5 font-medium">
Continue with Google
</button>
{% endif %}
{% if shop.guestCheckoutAllowed %}
<button type="button" data-login-guest class="hidden w-full text-sm underline">
Continue as guest
</button>
{% endif %}
</form>
<!-- Step 2: the code from the email -->
<form data-login-step="code" class="hidden mt-6 space-y-3">
<p class="text-sm">Enter the 6-digit code we sent to <strong data-login-sent-to></strong>.</p>
<input name="code" required inputmode="numeric" autocomplete="one-time-code"
maxlength="6" placeholder="123456" class="w-full border rounded-lg px-3 py-2.5 tracking-widest" />
<button type="submit" class="w-full rounded-lg py-2.5 font-medium"
style="background: {{ shop.brandColor }}; color: {{ shop.textOverBrandColor }};">
Verify
</button>
<button type="button" data-login-back class="w-full text-sm underline">Use a different email</button>
</form>
<!-- Step 3: first visit only - the customer's name -->
<form data-login-step="name" class="hidden mt-6 space-y-3">
<p class="text-sm">Welcome! What should we call you?</p>
<input name="fullName" required maxlength="255" autocomplete="name"
placeholder="Your name" class="w-full border rounded-lg px-3 py-2.5" />
<button type="submit" class="w-full rounded-lg py-2.5 font-medium"
style="background: {{ shop.brandColor }}; color: {{ shop.textOverBrandColor }};">
Create account
</button>
</form>
<!-- Signed in: the storefront takes over from here -->
<p data-login-step="done" class="hidden mt-6 text-sm">Signing you in...</p>
</section>
<script>
(function () {
var root = document.querySelector("[data-login]");
// A component's script can run more than once: wire each page once.
if (!root || root.getAttribute("data-login-wired")) return;
root.setAttribute("data-login-wired", "1");
var errorEl = root.querySelector("[data-login-error]");
var startForm = root.querySelector('[data-login-step="start"]');
var codeForm = root.querySelector('[data-login-step="code"]');
var nameForm = root.querySelector('[data-login-step="name"]');
var tempToken = "";
function fail(message) {
errorEl.textContent = message || "";
errorEl.classList.toggle("hidden", !message);
}
function show(step) {
var steps = root.querySelectorAll("[data-login-step]");
for (var i = 0; i < steps.length; i++) {
steps[i].classList.toggle("hidden", steps[i].getAttribute("data-login-step") !== step);
}
fail("");
}
// Make one sign-in call: lock the button, show the error if it fails.
function run(form, name, args) {
var button = form.querySelector('[type="submit"]');
if (typeof window[name] !== "function") {
fail("Sign-in is not available on this page.");
return Promise.resolve({ ok: false });
}
if (button) button.disabled = true;
fail("");
return window[name]
.apply(window, args)
.catch(function () {
return { ok: false, message: "Could not reach the shop. Check your connection and try again." };
})
.then(function (result) {
if (button) button.disabled = false;
if (!result.ok) fail(result.message);
return result;
});
}
// 1. Send the code.
startForm.addEventListener("submit", function (event) {
event.preventDefault();
var email = startForm.elements.email.value.trim();
run(startForm, "loginWithEmail", [email]).then(function (result) {
if (!result.ok) return;
root.querySelector("[data-login-sent-to]").textContent = email;
show("code");
codeForm.elements.code.focus();
});
});
// 2. Check the code. It belongs to the email passed to loginWithEmail above.
codeForm.addEventListener("submit", function (event) {
event.preventDefault();
var code = codeForm.elements.code.value.trim();
run(codeForm, "verifyOtp", [code]).then(function (result) {
if (!result.ok) return;
if (result.data.needsRegistration) {
tempToken = result.data.tempToken; // first visit: ask for a name next
show("name");
nameForm.elements.fullName.focus();
} else {
show("done"); // signed in - nothing else to do
}
});
});
// 3. First visit only: create the account.
nameForm.addEventListener("submit", function (event) {
event.preventDefault();
var fullName = nameForm.elements.fullName.value.trim();
run(nameForm, "registerShopUser", [tempToken, fullName]).then(function (result) {
if (result.ok) show("done");
});
});
root.querySelector("[data-login-back]").addEventListener("click", function () {
codeForm.reset();
show("start");
});
// Google: leaves the page and comes back signed in.
var google = root.querySelector("[data-login-google]");
if (google) {
google.addEventListener("click", function () {
run(startForm, "LoginWithGoogle", []);
});
}
// Guest checkout exists only inside the checkout.
var guest = root.querySelector("[data-login-guest]");
if (guest && window.location.pathname.indexOf("/checkout") === 0) {
guest.classList.remove("hidden");
guest.addEventListener("click", function () {
if (typeof window.continueCheckoutAsGuest === "function") window.continueCheckoutAsGuest();
});
}
})();
</script>Testing your login page
The editor preview shows your design but cannot sign anyone in. To test for real, open/login on the storefront while signed out.Customer Account
Available on every storefront page. Use them for a header greeting, an order list, or a full account page. A null answer always means "not signed in": show your signed-out design and link to /login. Any other failure throws an error.
| Function | Returns | What it does |
|---|---|---|
| isLoggedIn() | boolean | Whether a customer is signed in. Answers straight away (no await). |
| getMyProfile() | Promise<profile | null> | The customer: _id, fullName, email, image, deliveryDetails[], loyalty. |
| getMyOrders({ page?, limit?, search? }) | Promise<{ items, total, page, limit, categories } | null> | The customer's orders, newest first. limit is 20 by default, 50 at most. categories lists the categories found in those orders, for tabs. Also available as getMyPurchases. |
| getMyAccount({ page?, limit?, search? }) | Promise<{ profile, purchases, deliveryDetails, loyalty } | null> | Profile, orders, saved addresses and loyalty in one call. |
| getMyLoyalty() | Promise<loyalty | null> | The customer's points, tier and rewards. null when the shop has no loyalty program. |
| getOrderKeys(orderNumber) | Promise<{ authorized, paid, items, ... } | null> | Digital keys and instructions of one order. Keys are included only when the order belongs to this customer (authorized) and is paid (paid). |
| updateMyProfile({ fullName?, image?, deliveryDetails? }) | Promise<profile | null> | Saves changes and returns the updated profile. Also available as editProfile. |
| uploadMyProfileImage(file) | Promise<string> | Uploads a picture (a File from a file input) and returns its URL. |
| setMyProfileImage(file) | Promise<profile | null> | Uploads a picture and saves it as the customer's photo. |
| signOut() | void | Signs the customer out on this device. Use it for your "Log out" button. |
Saved delivery addresses. A customer can keep up to 5. An address is { fullName, phoneNumber, email?, city, address, isDefault? }; saved ones also have an _id. Each function returns the full list after the change.
| Function | Returns | What it does |
|---|---|---|
| getDeliveryDetails() | Promise<address[]> | The saved addresses. An empty list when signed out. |
| addDeliveryDetail(address) | Promise<address[]> | Adds one address. |
| updateDeliveryDetail(id, changes) | Promise<address[]> | Changes the fields you pass on one address. |
| deleteDeliveryDetail(id) | Promise<address[]> | Removes one address. |
| setDefaultDeliveryDetail(id) | Promise<address[]> | Makes one address the default. |
| replaceDeliveryDetails(addresses) | Promise<address[]> | Replaces the whole list at once. |
| getDeliveryLocations(countryCode?) | Promise<location[]> | The cities the shop delivers to, for the city picker of an address form. Works signed out too. |
<a data-account-link href="/login">Sign in</a>
<script>
(function () {
var link = document.querySelector("[data-account-link]");
if (!link || typeof window.getMyProfile !== "function") return;
window.getMyProfile().then(function (profile) {
if (!profile) return; // signed out: keep "Sign in"
link.href = "/account";
link.textContent = "Hi, " + profile.fullName;
}).catch(function () {}); // could not load: keep "Sign in"
})();
</script>Designing the /account page
Components you add to the theme's/account page replace the built-in account view for signed-in customers. To keep the built-in view (orders, addresses, profile, loyalty) and only add your own design around it, put the attribute data-zalient-native-account on any element of your component.Cart
| Function | Returns | What it does |
|---|---|---|
| addToCart(productId, productName, price, image, quantity?, variantId?, variantName?, extraFields?, installationPresetId?, options?) | Promise<void> | Adds a product to the cart. quantity defaults to 1. Pass variantId and variantName when the product has variants. extraFields is the customer's answers to the product's extra fields: [{ label, fieldType, value }]. The cart drawer opens after an add; pass { openCart: false } as options to add without opening it. |
| buyNow(...) | Promise<void> | Same arguments as addToCart. Takes the customer straight to the checkout with only this item; their cart is left as it is. |
| openCart() / closeCart() | void | Opens or closes the cart drawer. |
| clearCart() | void | Empties the cart. |
| getCartCount() | number | How many items are in the cart, counting quantities. |
| getCartItems() | item[] | The cart lines: productId, productName, price, image, quantity, variantId, variantName, extraFields. |
| getTotalCartPrice() | number | Price × quantity of every line, added up. Delivery and discounts are worked out at the checkout. |
| searchProducts(query, limit?) | Promise<product[]> | Products matching the text, for a live search box. limit defaults to 20. |
<button data-add
data-id="{{ product._id }}" data-name="{{ product.name | escape }}"
data-price="{{ product.price }}" data-image="{{ product.coverImage }}">
Add to cart
</button>
<script>
(function () {
var buttons = document.querySelectorAll("[data-add]:not([data-add-wired])");
for (var i = 0; i < buttons.length; i++) {
buttons[i].setAttribute("data-add-wired", "1");
buttons[i].addEventListener("click", function () {
if (typeof window.addToCart !== "function") return;
window.addToCart(this.dataset.id, this.dataset.name, Number(this.dataset.price), this.dataset.image, 1);
});
}
})();
</script>Wishlist, Compare & Share
The wishlist page is /wishlists and the compare page is /compare.
| Function | Returns | What it does |
|---|---|---|
| addToWishlist(productId, slug, productName, price, image, variantId?, variantName?) | void | Saves a product to the wishlist. |
| toggleWishlist(...) | void | Same arguments as addToWishlist. Adds the product, or removes it if it is already saved. Use it for a heart button. |
| removeFromWishlist(productId, variantId?) | void | Removes a product from the wishlist. |
| isInWishlist(slug) | boolean | Whether the product with that slug is saved. |
| getWishlistCount() / getWishlists() | number / item[] | How many products are saved, and the list itself. |
| clearWishlist() | void | Empties the wishlist. |
| compareProduct(productId, slug, name, price, image) | void | Adds a product to the compare list. |
| removeCompareProduct(productId) | void | Removes a product from the compare list. |
| getCompareCount() / getCompareProducts() | number / item[] | How many products are being compared, and the list itself. |
| clearCompareProducts() | void | Empties the compare list. |
| shareProduct(slug) | Promise<{ success, sharedCount?, message? }> | Counts one share of the product (once a day per visitor) and returns the new total. Open your own share links or the browser's share sheet alongside it. |
Events
The storefront fires these on window when something changes, so a header badge can stay up to date. Read the values from event.detail.
| Property | Type | Description |
|---|---|---|
| cart:updated | { totalItems } | The number of items in the cart changed |
| cart:item-added | { productName, quantity } | A product was added to the cart |
| cart:add-error | { error } | A product could not be added |
| cart:upload-error | { field, error } | A file the customer attached to a product could not be uploaded |
| wishlist:updated | { totalItems } | The number of saved products changed |
| wishlist:item-added | { productId, slug, productName } | A product was saved to the wishlist |
| wishlist:item-removed | { productId } | A product was removed from the wishlist |
| compare:updated | { count } | The compare list changed |
| product:shared | { slug, sharedCount? } | A product was shared |
<span data-cart-badge>0</span>
<script>
(function () {
var badge = document.querySelector("[data-cart-badge]");
if (!badge || badge.getAttribute("data-wired")) return;
badge.setAttribute("data-wired", "1");
if (typeof window.getCartCount === "function") badge.textContent = window.getCartCount();
window.addEventListener("cart:updated", function (event) {
badge.textContent = event.detail.totalItems;
});
})();
</script>Real-World Examples
Production-ready component examples showing real config + liquid patterns used on live stores.
Product Card Block
This is the reusable product card block that other components render via {% render 'product-card', product: product %}. It uses the global block variables for customization.
{
"name": "Product Card",
"version": "1.0.0",
"description": "Reusable product card block",
"fields": {}
}<a href="/products/{{ product.slug }}" class="group block border rounded-lg overflow-hidden hover:shadow-md transition"
style="border-radius: {{ borderRadius | default: '8px' }};">
<div class="aspect-square bg-gray-50 overflow-hidden" style="border-radius: {{ imageRounding | default: '0' }};">
{% if product.coverImage %}
<img src="{{ product.coverImage }}" alt="{{ product.name }}"
class="w-full h-full object-cover {% if hoverEffect == 'scale' %}group-hover:scale-105{% endif %} transition-transform duration-300"
loading="lazy">
{% else %}
<div class="w-full h-full flex items-center justify-center text-gray-300 text-2xl font-bold">
{{ product.name | slice: 0, 1 | upcase }}
</div>
{% endif %}
</div>
<div style="padding: {{ cardPadding | default: '12px' }};">
<h3 style="font-size: {{ titleSize | default: '14px' }};" class="font-medium line-clamp-2 text-gray-800 group-hover:text-gray-900">
{{ product.name }}
</h3>
<div class="flex items-center gap-2 mt-1.5">
{% if product.compareAtPrice and product.compareAtPrice > product.price %}
<span class="text-xs line-through text-gray-400">{{ shop.priceUnit }} {{ product.compareAtPrice }}</span>
{% endif %}
<span class="font-bold text-sm" style="color: {{ shop.brandColor }};">{{ shop.priceUnit }} {{ product.price }}</span>
</div>
{% if product.avgReviews > 0 %}
<div class="text-xs text-yellow-500 mt-1">
{% for i in (1..5) %}{% if i <= product.avgReviews %}★{% else %}☆{% endif %}{% endfor %}
<span class="text-gray-400 ml-1">({{ product.reviewsCount }})</span>
</div>
{% endif %}
{% if product.quantity == 0 and product.unlimitedStock == false %}
<span class="text-[10px] text-red-500 mt-1 block">Out of Stock</span>
{% elsif product.freeDelivery %}
<span class="text-[10px] text-green-600 mt-1 block">Free Delivery</span>
{% endif %}
{% if showAddToCart %}
<button class="{{ buttonStyle | default: '' }} mt-3 w-full py-2 rounded text-sm font-medium transition-colors"
style="background: {{ shop.brandColor }}; color: {{ shop.textOverBrandColor }};">
Add to Cart
</button>
{% endif %}
</div>
</a>Best Practices
Performance
- Always add
loading="lazy"to images inside loops to defer off-screen images - Use
{% for product in products limit: 8 %}to cap rendered items instead of looping all 100 - Avoid deeply nested loops (e.g. products inside categories inside sections) — they multiply render cost
- Keep
<script>blocks minimal. Use event delegation instead of per-elementonclickhandlers for large lists - Use conditional fetching (
conditionin config) to avoid loading data that won't be rendered - The 10-second render limit means your template should render fast — avoid computing heavy values in Liquid
Responsive Design
- Use Tailwind responsive prefixes:
grid-cols-2 md:grid-cols-3 lg:grid-cols-4 - Always test mobile layout — use the viewport switcher in the visual editor preview
- Use
hidden lg:block/lg:hiddento show/hide elements by breakpoint (e.g. mobile menu vs desktop nav) - Images should use
class="w-full h-auto"oraspect-square object-coverfor consistent sizing - Keep touch targets at least 44x44px on mobile (buttons, links)
SEO
- Always include
altattributes on images:alt="{{ product.name }}" - Use semantic HTML:
<header>,<nav>,<main>,<article>,<footer> - Use heading hierarchy: one
<h1>per page, then<h2>,<h3>etc. - Add
rel="noopener noreferrer"andtarget="_blank"on external links - Use
line-clamp-2for truncation instead of| truncate(preserves full text for search engines) - Product descriptions should use
<div class="prose">for proper content styling
Liquid Template API Reference — Zalient Ecommerce Platform
Built with LiquidJS · Tailwind CSS supported
