The default storefront theme for Zid's Vitrin platform. Built with TailwindCSS v4, Vite 7, and Jinja2 templates.
TailwindCSS v4 | Vite 7 | Jinja2 | RTL-first (Arabic)
- Quick Start
- Build and Deploy
- Project Structure
- Template System
- CSS Architecture
- JavaScript Architecture
- Event System
- Cart System
- Product System
- Platform Integration
- Window Globals and Platform Callbacks
- Component Patterns
- Localization and RTL
- External Dependencies
- Secrets and Configuration
- Non-Obvious Behaviors
- Resources
npm install
npm run dev # Watch mode (CSS + JS)
vitrin push -s <store-email> -a # Push and activate theme| Command | Description |
|---|---|
npm run dev |
Parallel CSS watch + JS watch (via npm-run-all) |
npm run build |
Production build: minified CSS + both JS bundles (sequential) |
npm run format |
Prettier with Jinja + Tailwind plugins |
The build has two separate pipelines that run sequentially:
1. CSS (@tailwindcss/cli):
assets/tailwindcss.css → @tailwindcss/cli → assets/styles.css
2. JavaScript (Vite, two bundles):
ENTRY=main vite build → assets/dist/theme.js (global: VitrinTheme)
ENTRY=cart vite build → assets/dist/cart-controller.js (global: CartController)
The ENTRY environment variable controls which bundle Vite builds. The main bundle empties dist/ first (emptyOutDir: entry === "main"), so order matters -- main must build before cart.
In development mode (npm run build:dev), sourcemaps are enabled and minification is disabled.
npm install -g @zidsa/vitrin-cli
vitrin login # One-time auth (stored globally by CLI)
vitrin push -s <store-email> -a # Push and activate theme
vitrin preview -s <store-id> # Preview in browserThe vitrin push command pushes the entire theme directory. The -a flag activates the theme immediately after pushing. There are no .env files, API keys, or secrets in this repo -- all authentication is handled by the Vitrin CLI's global login session.
assets/styles.css-- compiled CSS output (do not edit, gitignored)assets/dist/theme.js-- main JS bundle (gitignored)assets/dist/cart-controller.js-- cart page JS bundle (gitignored)
Always run npm run build before vitrin push.
├── layout.jinja # Base HTML wrapper (all templates extend this)
├── layout.schema.json # Global theme settings (colors, fonts, radius)
├── header.jinja / footer.jinja # Header and footer partials
├── templates/ # Page templates (12 pages)
├── sections/ # Homepage blocks with .schema.json settings
├── components/
│ ├── ui/ # Base UI (breadcrumb, disclosure, quantity-input, phone-input)
│ ├── products/ # Product components (card, gallery, variants, reviews)
│ │ ├── filters/ # Product filter components
│ │ └── headless/ # Unstyled product components (options, fields, filters)
│ ├── cart/ # Cart page components (coupon, gift, loyalty, summary)
│ ├── categories/ # Category page components
│ ├── header/ # Header sub-components (logo, nav, search, drawer)
│ ├── shipping-payment/ # Shipping and payment components
│ └── shared/ # Shared components (metafields)
├── assets/
│ ├── tailwindcss.css # Main CSS source (theme config + imports)
│ ├── styles.css # Compiled output (do not edit)
│ ├── css/ # Extracted component stylesheets
│ │ ├── components.css # @layer components (buttons, forms, badges)
│ │ ├── product-options.css # Headless variant selector styles
│ │ ├── custom-fields.css # Headless custom input field styles
│ │ ├── product-filters.css # Headless product filter styles
│ │ ├── price-slider.css # noUiSlider overrides
│ │ └── lightbox.css # PhotoSwipe overrides
│ ├── js/ # JavaScript source modules
│ │ ├── main.js # Theme entry point (imports all modules)
│ │ ├── cart/ # Cart modules (controller, add-to-cart, coupon, etc.)
│ │ ├── product/ # Product features (variants, gallery, lightbox, sticky-bar, quick-view)
│ │ ├── features/ # Self-initializing modules (wishlist, search, filters, etc.)
│ │ ├── lib/ # Utilities (carousel wrapper)
│ │ ├── utils/ # Shared helpers (events, loading)
│ │ └── data/ # Static data (countries list)
│ └── dist/ # Vite build output (theme.js, cart-controller.js)
├── locale/ar/LC_MESSAGES/ # Arabic translations (messages.po)
└── docs/ # Component and architecture documentation
12 page templates in templates/:
home · product · products · cart · category · categories · page · faqs · reviews · questions · shipping_payment · 404_not_found
11 homepage sections in sections/, each a .jinja + .schema.json pair:
hero · carousel · products · categories · gallery · video · benefits · partners · testimonials · logo-social · countdown
Every template extends layout.jinja:
{% extends "layout.jinja" %}
{% block content %}
<!-- Page content -->
{% endblock %}Page-specific scripts use the footer_scripts block:
{% block footer_scripts %}
<script src="{{ 'assets/dist/cart-controller.js' | asset_url }}"></script>
<script>
window.CartPage.init({ /* config */ });
</script>
{% endblock %}Every page must include these Vitrin tags in layout.jinja:
<head>
{% vitrin_head %}
</head>
<body>
...
{% vitrin_body %}
</body>{% vitrin_head %} injects platform CSS, analytics, and meta tags. {% vitrin_body %} injects platform scripts (auth dialogs, Zid SDK, tracking). The loyalty rewards script (layout-loyalty.js) must be loaded after {% vitrin_body %} because it depends on platform globals that vitrin_body injects.
Each homepage section is a pair:
sections/
├── hero.jinja # Template with {{ section.settings.title }}
└── hero.schema.json # Defines editable fields for merchants
The schema defines groups, fields, and defaults that merchants configure through the visual Theme Editor. Settings are accessed via section.settings.<field_name>.
Templates receive data from the platform as context variables. Key variables available per page:
| Template | Key Context Variables |
|---|---|
product |
product, product.attributes, product.images, product.variants |
products |
products (paginated list), filters, sort_options |
cart |
cart, cart.products, cart.totals, cart.coupon |
category |
category, category.products |
page |
page.title, page.content (HTML) |
Global variables (available in all templates via layout.jinja):
settings.*-- merchant theme settings fromlayout.schema.jsonstore-- store info (name, logo, currency, etc.)session-- current session inforequest-- HTTP request context
TailwindCSS v4 with @theme inline -- no tailwind.config.js needed.
assets/tailwindcss.css is the main source file. It contains:
@theme inlineblock (maps CSS variables to Tailwind tokens):rootvariables (layout, fixed colors, typography)- Prose typography utility
- Base styles (body, container)
- Announcement marquee animation
- Platform widget overrides
It imports component stylesheets from assets/css/:
| File | Content |
|---|---|
css/components.css |
@layer components: buttons, forms, badges, qty-input, button-groups |
css/product-options.css |
Headless variant selector styles (.product-options__*) |
css/custom-fields.css |
Headless custom input field styles (.custom-field__*, .file-upload__*) |
css/product-filters.css |
Headless product filter styles (.product-filter__*, .filters--sidebar) |
css/price-slider.css |
noUiSlider theme overrides |
css/lightbox.css |
PhotoSwipe theme overrides |
This is the core theming mechanism. Merchant settings flow through three layers:
layout.schema.json (merchant picks colors/fonts in Theme Editor)
→ layout.jinja <style> block (sets CSS custom properties on :root)
→ @theme inline in tailwindcss.css (maps to Tailwind tokens)
→ Tailwind utility classes (bg-primary, text-foreground, etc.)
Step 1: layout.jinja reads merchant settings and sets CSS variables:
<style>
:root {
--background: {{ settings.background_color }};
--foreground: {{ settings.foreground_color }};
--primary: {{ settings.primary_color }};
--primary-foreground: {{ settings.primary_foreground_color }};
--secondary: {{ settings.secondary_color }};
--muted: {{ settings.muted_color }};
--accent: {{ settings.accent_color }};
--border: {{ settings.border_color }};
--input: {{ settings.input_color }};
--ring: {{ settings.ring_color }};
--radius: {{ settings.border_radius }}px;
--font-family: {{ settings.font_family }};
}
</style>Step 2: tailwindcss.css maps these to Tailwind:
@theme inline {
--color-background: var(--background);
--color-primary: var(--primary);
--radius-sm: calc(var(--radius) * 0.5);
/* ... */
}Step 3: Templates use Tailwind classes: bg-primary, text-foreground, rounded-lg, etc.
These are NOT merchant-configurable. They're set in :root in tailwindcss.css:
| Token | Hex | Usage |
|---|---|---|
--destructive |
#C22B51 |
Error states |
--success |
#649560 |
Success states |
--warning |
#FFB400 |
Warning states |
Typography is defined as CSS custom properties consumed by component classes in css/components.css:
| Variable Prefix | Size | Weight | Used by |
|---|---|---|---|
--font-body1-- |
16px | 400 | .btn-lg, .form-input |
--font-subtitle2-- |
14px | 600 | SemiBold small titles |
--font-body2-- |
14px | 400 | .btn-md, .qty-input |
--font-caption-- |
12px | 400 | .btn-sm, .form-caption |
--font-tagline-- |
16px | 400 | .form-label, .badge |
Each has --size, --line-height, and --weight suffixes.
The css/product-options.css, css/custom-fields.css, and css/product-filters.css files provide styles for the unstyled "headless" components in components/products/headless/. They use BEM naming (.product-options__group, .custom-field__input, .product-filter__checkbox). These are independent of Tailwind and can be completely restyled.
Vite builds two IIFE bundles (not ES modules) for direct <script> tag inclusion:
| Bundle | Entry Point | Global Variable | Loaded On |
|---|---|---|---|
theme.js |
assets/js/main.js |
VitrinTheme |
Every page (in layout.jinja) |
cart-controller.js |
assets/js/cart/controller.js |
CartController |
Cart page only (in templates/cart.jinja) |
assets/js/
├── main.js # Entry point: imports everything, inits on DOMContentLoaded
│
├── cart/ # Cart system (split between both bundles)
│ ├── controller.js # Cart PAGE controller (separate bundle)
│ ├── add-to-cart.js # Add-to-cart buttons (theme.js bundle)
│ ├── badge.js # Header cart count badge
│ ├── coupon.js # Coupon code apply/remove
│ ├── gift.js # Gift card operations
│ ├── loyalty.js # Loyalty points redemption
│ ├── quantity.js # Cart page quantity update/remove
│ ├── refresh.js # AJAX cart page refresh
│ └── totals.js # Cart totals rendering + payment widgets
│
├── product/ # Product page features
│ ├── variants.js # Variant selection and SKU switching
│ ├── gallery.js # Product image carousel (Embla)
│ ├── lightbox.js # Full-screen image viewer (PhotoSwipe)
│ ├── quick-view.js # Quick view modal with LRU cache
│ └── sticky-bar.js # Sticky add-to-cart bar on scroll
│
├── features/ # Self-initializing feature modules
│ ├── layout.js # Auth visibility, locale nav, announcement bar
│ ├── wishlist.js # Wishlist toggle (heart button)
│ ├── search.js # Search drawer/overlay
│ ├── product-filter.js # AJAX product filtering with History API
│ ├── price-slider.js # noUiSlider initialization
│ ├── qty-input.js # Quantity +/- input component
│ ├── phone-input.js # Phone number input with country selector
│ ├── notify-me.js # Back-in-stock notification form
│ ├── bundle-offers.js # Bundle offer badges on product cards
│ └── loyalty-rewards.js # Floating loyalty rewards button
│
├── lib/
│ └── carousel.js # Embla Carousel wrapper (createCarousel helper)
│
├── utils/
│ ├── events.js # Custom event dispatch/listen helpers
│ └── loading.js # Spinner show/hide utilities (WeakMap-backed)
│
├── data/
│ └── countries.js # Country data with dial codes + phone validation
│
├── time-ago.js # Relative time display ("2 days ago")
├── question-form.js # Product question form handler
├── shipping-payment.js # Shipping page cities dialog + copy buttons
└── layout-loyalty.js # Standalone loyalty popup (loaded AFTER vitrin_body)
Self-initializing modules (in features/): Each module auto-initializes on DOMContentLoaded and re-initializes on dynamic content events. Pattern:
// Track initialized elements with WeakSet (prevents double-init)
const initialized = new WeakSet();
function initModule() {
document.querySelectorAll("[data-my-component]").forEach((el) => {
if (initialized.has(el)) return;
initialized.add(el);
// Setup...
});
}
export function init() {
initModule();
window.addEventListener("content:loaded", initModule);
window.addEventListener("products-updated", initModule);
}
// Self-initialize
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}Key patterns used throughout:
- WeakSet/WeakMap for element tracking (prevents double-initialization, auto-cleans on DOM removal)
- Data attributes for component binding (
data-qty-input,data-cart-badge,data-phone-input, etc.) - Event delegation where appropriate (cart controller uses
[data-action]clicks) - Exponential backoff for SDK availability (
waitForZid()in add-to-cart.js)
The theme uses custom DOM events for communication between modules. This is critical for AJAX content updates -- when new HTML is injected, modules must re-initialize on the new elements.
| Event | Dispatched By | Listened By | Purpose |
|---|---|---|---|
content:loaded |
Quick view, AJAX content | Carousels, galleries, wishlist, qty-inputs, phone-input, notify-me | Re-initialize components after dynamic content injection |
products-updated |
Product filter (AJAX) | Cart buttons, qty-inputs, bundle offers, wishlist | Re-initialize after product listing update |
cart:updated |
Cart refresh module | Loyalty module | Cart data changed |
product:variant-changed |
Variant selector | Notify-me form | Update product ID when variant changes |
qty:change |
Quantity input | Cart quantity module | Quantity value changed (bubbles) |
qty:remove |
Quantity input | Cart quantity module | Delete button clicked (bubbles) |
phone:change |
Phone input | Notify-me form | Phone number value changed |
phone:country-change |
Phone input | (External listeners) | Country/dial code changed |
toast:show |
Notify-me fallback | (External toast handler) | Show toast notification |
| Event | Source | Handled By | Purpose |
|---|---|---|---|
vitrin:auth:success |
Platform auth dialog | Layout module, cart controller | User completed login (OTP verified) |
vitrin:gift:submitted |
Platform gift dialog | Gift card module | Gift card form submitted |
vitrin:bundle-selections:updated |
Platform bundle UI | Add-to-cart module | Bundle product selections changed |
zidcart:loading |
Platform cart SDK | Cart refresh module | Cart operation started |
zidcart:updated |
Platform cart SDK | Cart refresh module | Cart operation completed |
zidcart:error |
Platform cart SDK | Cart refresh module | Cart operation failed |
zid-customer-fetched |
Platform auth | Layout module, notify-me | Customer data available after login |
User clicks filter checkbox
→ productFilter.applyFilter({ attributes: [...] })
→ History API pushState (URL updated)
→ fetch() current URL with X-Requested-With: XMLHttpRequest
→ DOMParser extracts #products-content from response
→ innerHTML swap
→ window.dispatchEvent("products-updated")
→ add-to-cart: syncCartState() + re-init buttons
→ qty-input: initAll() on new elements
→ bundle-offers: reload badges
→ wishlist: re-init hearts
User clicks "Add to cart" button
→ addToCart(productId, quantity, customFields)
→ waitForZid() (exponential backoff until window.zid ready)
→ zid.cart.addProduct({ product_id, quantity, ... })
→ On success:
→ showQuantityInput(button) — swap button to qty input
→ refreshBadge() — update header cart count
→ If Buy Now: redirect to cart
The cart has two separate JavaScript entry points:
Runs on every page. Handles:
- "Add to cart" buttons on product cards and product pages
- Cart badge count in header
- Cart state sync (showing qty inputs vs add buttons based on what's in cart)
- Buy Now functionality
- Bundle product add-to-cart
Key behaviors:
syncCartState(): On page load, fetches current cart and updates all product cards to show either the "Add to cart" button or a quantity input based on what's already in cart- ID normalization: Cart product IDs from the API may differ in format (hyphens, case). The
findCartItem()function normalizes IDs by stripping hyphens and lowercasing for comparison waitForZid(): The Zid SDK loads asynchronously.waitForZid()uses exponential backoff (100ms, 200ms, 400ms...) up to 10 retries to wait forwindow.zidto be available
Runs on the cart page only. Orchestrates all cart page features:
CartPage.init(config)
├── setupEventDelegation() # [data-action] click handlers
├── setupZidCartEventListeners() # zidcart:* platform events
├── setupCouponInput() # Coupon code apply
├── setupGiftEventListener() # Gift card events
├── initLoyaltyProgram() # Loyalty points
└── setupAuthSuccessListener() # Post-login redirect
Event delegation: All cart actions use data-action attributes:
<button data-action="apply-coupon">Apply</button>
<button data-action="remove-coupon">Remove</button>
<button data-action="gift-card">Add Gift</button>
<button data-action="edit-gift">Edit</button>
<button data-action="delete-gift">Delete</button>
<button data-action="apply-loyalty">Apply</button>
<button data-action="remove-loyalty">Remove</button>When cart data changes, the page refreshes via AJAX (no full reload):
Cart operation (add/remove/update)
→ zidcart:updated event fires
→ refreshCartPage():
→ fetch(window.location.href) with X-Requested-With header
→ DOMParser extracts sections from response HTML
→ swapElement() replaces: products list, totals, coupon, loyalty, shipping bar, payment widgets
→ Re-init: qty inputs, coupon handlers, cart badge, payment widgets (Tamara/Tabby)
→ Dispatch "cart-updated" event
If the cart becomes empty after a removal, it falls back to window.location.reload() to show the empty state.
After cart total changes, the theme updates BNPL (Buy Now Pay Later) widgets:
- Tamara: Updates
tamara-widgetamount attribute and callsTamaraWidgetV2.refresh() - Tabby: Reconstructs
TabbyPromowith new price
Handles product variant/option selection on the product page. When a customer selects options (size, color, etc.):
- Calls
zid.products.getProductOptions(productId, { attributes: true, option_fields: true, input_fields: true }) - Matches selected attributes to find the correct variant
- Dispatches
product:variant-changedevent with variant data - Updates: price display, stock status, images, SKU, add-to-cart button state
The platform function window.productOptionsChanged(product) is called as a callback. This is a platform requirement -- the Vitrin platform expects this function to exist and calls it after variant changes.
Uses Embla Carousel for product image galleries:
- Main image carousel with thumbnail navigation
- Thumbnail carousel synced to main
- Responsive: horizontal thumbnails on mobile, vertical on desktop
- Gallery re-initializes on
content:loadedfor quick view support
Modal that shows product details without navigating to the product page:
- Hover prefetch: When hovering over a product card, the quick view content is prefetched via
fetch()(3-second delay) - LRU cache: Cached responses are stored in a Map. When cache exceeds 20 entries, oldest entries are evicted
- Content injection: Response HTML is parsed and injected into a dialog element
- After injection, dispatches
content:loadedto re-init carousels, galleries, and other components
PhotoSwipe integration for full-screen image viewing:
- Initializes on product gallery click events
- Supports dynamic slide data from
data-pswp-*attributes - Re-initializes on
content:loadedfor quick view modals
Shows a sticky add-to-cart bar at the bottom when the main add-to-cart button scrolls out of view:
- Uses IntersectionObserver to detect when the add-to-cart section leaves the viewport
- Shows/hides the sticky bar with a slide-up animation
AJAX-based filtering on the products listing page:
- Uses History API (
pushState) for clean URLs - Fetches filtered results via
fetch()withX-Requested-With: XMLHttpRequestheader - Parses response HTML with DOMParser and swaps
#products-content - Supports: sort, price range, availability, attribute filters
- Global instance at
window.productFilter
Bundle offer data is NOT included in product listing API responses. This module:
- Collects all
data-bundle-offer-product-idelements on the page - Batch-fetches bundle offers via
/api/v1/products/bundle-offers?product_ids=... - Displays bundle offer badges on matching product cards
- Uses a
Mapto track loaded offers (avoids re-fetching) - Global instance at
window.bundleOffersLoader
The Zid SDK is loaded by {% vitrin_body %} and provides client-side APIs. It loads asynchronously -- always check for availability or use waitForZid():
// Cart
await zid.cart.get();
await zid.cart.addProduct({ product_id, quantity, custom_fields });
await zid.cart.updateProduct({ id, quantity });
await zid.cart.removeProduct({ id });
await zid.cart.applyCoupon({ coupon_code });
await zid.cart.removeCoupon();
await zid.cart.getCalculatedPoints(total);
await zid.cart.getCustomerLoyaltyPoints();
await zid.cart.getRedemptionMethods(currencyCode);
await zid.cart.addRedemptionMethod({ id });
await zid.cart.removeRedemptionMethod();
await zid.cart.removeGiftCard();
// Products
const product = await zid.products.get(product_id);
const options = await zid.products.getProductOptions(product_id, {
attributes: true,
option_fields: true,
input_fields: true
});
await zid.products.createQuestion(productId, { question, name, email, is_anonymous });
// Account
await zid.account.addToWishList(product_id);
await zid.account.removeFromWishList(product_id);
// Customer
zid.customer.login.open({ redirectTo });
// Store
zid.store.showMessage(message, type);The Vitrin platform injects an authentication dialog. The theme opens it via:
window.auth_dialog.open();After successful OTP verification, the platform dispatches vitrin:auth:success. The theme's layout module handles this by:
- Updating
window.customerAuthState - Redirecting to any
pendingAuthRedirectURL
{% vitrin_body %} injects several HTML elements into the page:
- Auth dialog
- Gift card dialog (
window.gift_dialog) - Loyalty rewards popup (
.loyalty-rewards-popup-init) - Payment widgets (Tamara, Tabby)
- Toast notification system (
window.toastr)
These are exposed for platform integration and must be maintained:
| Global | Set By | Purpose |
|---|---|---|
window.productFilter |
product-filter.js |
Product filter instance (applyFilter, clearFilters, etc.) |
window.bundleOffersLoader |
bundle-offers.js |
Bundle offers loader (reload()) |
window.PhoneInput |
phone-input.js |
Phone input API (init, initElement, get) |
window.CartPage |
cart/controller.js |
Cart page controller (init) |
window.cartManager |
cart/add-to-cart.js |
Cart manager (refreshBadge) |
window.handleLoginAction |
features/layout.js |
Login trigger with optional redirect |
window.initQtyInputs |
features/qty-input.js |
Re-init quantity inputs (used by cart refresh) |
window.initPriceSliders |
features/price-slider.js |
Re-init price sliders |
window.updateQtyMax |
features/qty-input.js |
Update max quantity for an input |
window.updateTimeAgoElements |
time-ago.js |
Re-render relative time displays |
window.selectMobileCountry |
features/layout.js |
Locale navigation (mobile) |
window.selectMobileLanguage |
features/layout.js |
Locale navigation (mobile) |
window.popupLoyaltyReward |
layout-loyalty.js / loyalty-rewards.js |
Open loyalty rewards popup |
window.closeLoyaltyRewardsWindow |
layout-loyalty.js / loyalty-rewards.js |
Close loyalty rewards popup |
window.CountriesData |
data/countries.js |
Countries list with phone validation |
The Vitrin platform calls these functions directly. Removing them will break platform features:
| Callback | Called By | Purpose |
|---|---|---|
window.productOptionsChanged(product) |
Platform variant system | Called when product options/variant data changes |
window.cartProductsHtmlChanged() |
Platform cart system | Called when cart HTML is updated by platform |
window.toggleBundleItems(item, btn) |
Platform bundle UI | Toggle bundle item expand/collapse |
window.refreshCartPage() |
Platform cart system | Trigger cart page AJAX refresh |
These are set as inline <script> blocks in the layout:
window.layoutConfig = {
profileUrl: "{{ store_url }}/account-profile",
defaultCountryCode: "{{ store.country_code }}",
currentLanguage: "{{ request.language }}",
currentCountry: "{{ request.country }}"
};
window.productTranslations = {
addedToCart: "{{ _('Added to cart') }}",
outOfStock: "{{ _('Out of stock') }}",
// ...
};
window.notifyMeTranslations = {
success: "{{ _('You will be notified...') }}",
// ...
};
window.customerAuthState = {
isAuthenticated: {{ not session.is_guest | lower }},
isGuest: {{ session.is_guest | lower }}
};<button class="btn btn-filled btn-lg">Primary</button>
<button class="btn btn-outlined btn-md">Secondary</button>
<button class="btn btn-text btn-sm">Text</button>
<button class="btn btn-destructive btn-md">Delete</button>
<button class="btn btn-icon btn-icon-md"><svg>...</svg></button><label class="form-label">Label <span class="form-label-required">*</span></label>
<input class="form-input" placeholder="Enter text" />
<p class="form-caption">Helper text</p><label class="form-control">
<input type="checkbox" class="form-checkbox" />
Label
</label><span class="badge badge-filled">Tag</span>
<span class="badge badge-outlined">Tag</span>A reusable component with +, -, and delete buttons:
<div data-qty-input="product-123">
<button data-qty-action="remove">Delete</button>
<button data-qty-action="decrease">-</button>
<input data-qty-value min="1" max="10" value="1" />
<button data-qty-action="increase">+</button>
</div>Supports syncing between multiple inputs via data-qty-sync="other-id".
Full phone input component with country selector, search, and validation:
<div data-phone-input data-all-countries="true">
<input type="hidden" data-phone-full-value />
<input type="hidden" data-phone-country-code value="+966" />
<button data-slot="country-trigger" popovertarget="phone-popover">
<span data-phone-country-display>+966</span>
</button>
<input data-phone-number-input placeholder="5XX XXX XXX" />
</div>The component exposes a programmatic API via wrapper._phoneInput:
getValue(),getDialCode(),getPhoneNumber()setDialCode(code),setPhoneNumber(number)validate(),isValid(),reset()
Unstyled, BEM-named product components in components/products/headless/. These provide structure without default styles -- developers control all styling via CSS classes in css/product-options.css, css/custom-fields.css, and css/product-filters.css.
See docs/headless/ for full documentation.
{{ _("Add to cart") }}
{{ _("Remaining %s only") }}Translation strings live in locale/ar/LC_MESSAGES/messages.po. Each msgid must be unique -- duplicates break compilation.
The theme is designed RTL-first (Arabic). Use logical properties and Tailwind's rtl: prefix:
<div class="ms-4 me-2">Logical margins</div>
<svg class="rtl:rotate-180">Arrow icon</svg>For CSS overrides:
[dir="rtl"] .element {
/* RTL-specific styles */
}RTL handling in JavaScript:
- Carousels: Embla direction is set based on
document.documentElement.dir - Price slider: noUiSlider direction is set to
"rtl"whendocument.documentElement.dir === "rtl" - Marquee: Uses separate
marquee-scroll-rtlkeyframes (translates +50% instead of -50%) - Loyalty button: Positioned based on
loyalty_button_directionconfig
The layout module handles language/region switching via:
navigateToLocale(countryCode, languageCode)-- constructs locale URL and redirectswindow.selectMobileCountry(code)andwindow.selectMobileLanguage(code)-- mobile drawer shortcuts[data-locale-form]forms for desktop locale selection
| Package | Purpose | Used In |
|---|---|---|
embla-carousel |
Product carousels and sliders | lib/carousel.js, product/gallery.js |
embla-carousel-auto-height |
Auto-height plugin for carousels | lib/carousel.js |
embla-carousel-auto-scroll |
Auto-scroll plugin for carousels | lib/carousel.js |
nouislider |
Price range filter slider | features/price-slider.js |
photoswipe |
Product image lightbox | product/lightbox.js |
| Package | Purpose |
|---|---|
photoswipe |
Base lightbox styles (overridden by css/lightbox.css) |
nouislider |
Base slider styles (overridden by css/price-slider.css) |
@tailwindcss/typography |
Prose content styling for static pages |
| Library | Version | Purpose |
|---|---|---|
| TailwindPlus Elements | @tailwindplus/elements@1.0.22 |
Interactive UI: el-dialog, el-disclosure, el-dropdown, el-select, el-popover |
| Google Fonts | (varies per merchant) | Font family set by merchant in theme settings |
| Flag CDN | flagcdn.com |
Country flag images for phone input component |
There are no secrets in this repository. No .env files, no API keys, no credentials.
- Authentication is handled by the Vitrin CLI's global login session (
vitrin login) - All API calls go through
window.zid(SDK injected by the platform) - The one API call made directly (
/api/v1/products/bundle-offersand/api/v1/products/{id}/stock-alerts) uses the store's own domain origin and requires no auth tokens
node_modules/.envfiles (precautionary, none exist)*.zipfiles.vitrin/dist/(CLI build artifacts)assets/styles.css(compiled CSS)assets/dist/(compiled JS)
Elements with data-auth-guest or data-auth-user attributes are shown/hidden based on login state. This allows page HTML to be cached by CDN while still showing correct content after client-side hydration:
<a data-auth-guest href="/login">Login</a>
<a data-auth-user href="/profile" class="hidden">My Account</a>The initAuthVisibility() function in layout.js toggles these on load and after vitrin:auth:success.
Cart product IDs from the API sometimes include hyphens and mixed case. The findCartItem() function normalizes both IDs before comparison:
const normalize = (id) => String(id).replace(/-/g, "").toLowerCase();This prevents mismatches where the same product would show "Add to cart" instead of a quantity input.
There are two loyalty modules:
-
layout-loyalty.js-- A standalone script loaded via<script>tag after{% vitrin_body %}. It uses global variables (store_currency_code,text_loyalty_rewards, etc.) set by inline script inlayout.jinja. This runs on all pages and creates a floating rewards button. -
features/loyalty-rewards.js-- A module bundled intheme.js. It initializes only whenwindow.loyaltyConfigis set. It provides the same floating button but through the module system.
The store configuration determines which one is active. Both expose the same global functions (window.popupLoyaltyReward, window.closeLoyaltyRewardsWindow).
When a user hovers over a product card's quick-view trigger, the theme starts fetching the product data after a 3-second delay. This means the modal opens instantly when clicked. The LRU cache holds up to 20 product responses.
On every page load, syncCartState() fetches the current cart and scans the page for all "Add to cart" buttons. If a product is already in the cart, the button is replaced with a quantity input showing the current quantity. This ensures consistency between the cart and product listings.
The loyalty rewards popup HTML is injected by {% vitrin_body %} with the class .loyalty-rewards-popup-init. On first open, the theme moves this element to document.body (reparenting from its injected position) and removes the init class. This is necessary because the platform injects it in a position that may have CSS overflow issues.
The cart page stores the cart data on window.cartObj for payment widget updates. The totals module reads from this global when updating Tamara/Tabby widgets and loyalty calculations.
The time-ago.js utility updates relative time strings every 60 seconds via setInterval. Elements with data-time-ago-date are automatically refreshed.