Philocaly Documentation
Storefront Documentation
How the Philocaly Shopify storefront is configured — product pages and cards, the metafields and metaobjects that power them, the B2B loyalty program, course video management, preorders & split shipments, the Chatters portal, pro vs. regular pricing, and the developer apps (checkout validation, checkout UI extension, Chatters API). Use the sidebar to jump to a topic.
Guides
Pick a topic to get started. Each guide maps directly to how things are configured in the Shopify admin.
Product Setup Guide
How products, the PDP and product cards are configured — images, info blocks, and the metafields & metaobjects behind them.
Read guide → 02Metaobject Entry Guide
Step-by-step instructions for creating each metaobject entry and linking it to a product or variant.
Read guide → 03B2B Loyalty Program
Tiers, benefits and how points, discounts, shipping and birthday rewards are handled across Loyalty Lion and Shopify.
Read guide → 04Course Video Management
The Shopify + Vimeo workflow — upload videos, register them, and organise them into ordered course groupings.
Read guide → 05Preorders & Split Shipments
How native preorders work — what customers see, split fulfillment behind the scenes, and the shipping setup.
Read guide → 06Chatters
The Chatters x Philocaly portal — custom pricing, payment-free checkout, monthly order consolidation and HQ invoicing.
Read guide → 07Pro Pricing & Hidden Prices
Why and how regular (D2C) prices are hidden for professional-only products, and how the B2B pro discount is applied.
Read guide → 08Checkout Validation Dev
A Shopify Function that blocks checkout when a non-professional customer tries to buy professional-only (B2B) products.
Read guide → 09Checkout UI Extension Dev
A checkout extension that clears the cart on logout, so a shared device never inherits a previous (B2B) shopper's cart.
Read guide → 10Chatters API Dev
A Node/Express service that creates Shopify orders for ambassadors with discounts, shipping and net payment terms.
Read guide →Product Setup Guide
01 Key concepts: metafields vs. metaobjects
Most of what appears on a Philocaly product page is not typed into the standard Shopify product fields. It comes from custom data structures. Understanding these two building blocks makes the rest of this document straightforward.
A single custom field on a product or variant
A metafield stores one piece of extra information attached directly to a product (or to a single variant). Examples: a badge, a b2b_discount percentage, a "professional only" toggle. You edit these in the Shopify admin under the Metafields section at the bottom of a product page.
A reusable, structured content record
A metaobject is a small, repeatable "mini record" with several fields of its own (e.g. a Badge with a label + colour, or a Variant Images set holding a gallery). You build them once under Settings → Custom data → Metaobjects, then link them to products through a metafield.
custom.badge) that points to a metaobject entry (the actual "New" badge with its colour). This keeps content consistent and editable in one place, then reused across many products.02 The Product Page (PDP) anatomy
The product page is a two-column layout. The left column is the image gallery; the right column is a stack of content blocks that merchandisers can arrange in the theme editor. Source: sections/main-product.liquid
Each block on the right is optional and added per-template in the theme editor. The blocks read their content from the metafields and metaobjects described below.
03 PDP — Images & media
Philocaly products use a dual image system. Simpler products fall back to the standard Shopify media gallery, while colour/length products use a dedicated Variant Images metaobject so each colour shows its own curated gallery.
How the gallery decides what to show
Does the product have BOTH a "Color" option AND a "Length" option?
│
┌─────┴───────────────────────────┐
YES NO
│ │
▼ ▼
Use each variant's Use the standard product
custom.product_variant_images media gallery
metaobject → its .files (product.media — images & video
gallery (images + video), uploaded directly on the product)
shown/hidden based on the
selected colour (+ texture)
1 · Variant Images metaobject (primary system)
For extension products (Colour + Length, optionally Texture), each variant links to a Variant Images metaobject via the variant metafield custom.product_variant_images. The theme groups variants by colour (and texture) and renders one gallery per unique combination — thumbnails on the left, large images/video in the main viewer. Switching colour swaps the gallery instantly.
2 · Standard media gallery (fallback)
Products without the Colour+Length combination simply use the images and videos uploaded directly to the product in Shopify (product.media). Images render at up to 1000px; videos autoplay, muted and looping.
3 · Product badge (overlay on the image)
A small uppercase label (e.g. "New", "Best Seller") can overlay the top-left of the main image. It is driven by the Badge metaobject linked through custom.badge, which carries both a label and a background colour. The same badge also appears on the product card.
| What | Where it comes from | Notes |
|---|---|---|
| Variant galleries (per colour) | variant.metafields.custom.product_variant_images → Variant Images | Colour + Length products. Images + video. |
| Standard gallery | Product media (uploaded on the product) | Fallback for all other products. |
| Badge overlay | product.metafields.custom.badge → Badge metaobject (label, color) | Optional. Shown on both PDP & card. |
| Colour swatch image | Shopify color-pattern metaobject (label, color, image) | Used for the colour picker thumbnails. |
04 PDP — Product information
The right-hand column is assembled from content blocks. Below is what each block displays and which field feeds it.
Product header — title, price, payment
- Title — the standard Shopify product title.
- Product type label — a small label above the title, set on the section ("Professional Extension" by default).
- Price — standard variant price, with automatic handling of compare-at (sale) price, collection-based automatic discounts, and B2B discounts (see §09).
- Sezzle / pay monthly — optional "or pay monthly" line.
Source: snippets/product-header.liquid
Description & specifications
The description block is variant-aware. It can show a different description per variant and falls back to a product-level one, then to the standard Shopify description.
| Element | Field | Behaviour |
|---|---|---|
| Per-variant description | variant.metafields.custom.product_description → metaobject (title, description) | Swaps when a variant is selected. |
| Default description | product.metafields.custom.product_description → metaobject (title, description) | Shown when no variant-specific one exists. |
| Plain fallback | Standard product description | Used only if no metaobject is set. |
| Specifications | product.metafields.custom.product_specifications → metaobject (title, specifications) | Rendered as a collapsible accordion. |
Source: snippets/product-description.liquid · product-specifications.liquid
Variant pickers — Colour, Texture, Size
- Colour select — swatches built from the Shopify color-pattern metaobject (each colour's swatch image + name). Colours can be filtered by colour category tabs sourced from the Color Categories metaobject. An optional per-colour blurb is read from
variant.metafields.custom.color_description. - Texture select — shown only when the product has a Texture option.
- Size / Length select — uses the product's Size or Length option.
- Help block — an optional editable note under the colour picker (e.g. "order a Color Ring for matching").
Source: snippets/color-select.liquid · texture-select.liquid · size-select.liquid
Product USPs
A row of small uppercase chips (e.g. "100% Remy Hair", "Reusable"). Sourced from a list of Product USP metaobjects via custom.product_usps, each with a title.
Pair It With
A curated mini list of recommended variants to add alongside the product. Sourced from variant.metafields.custom.pair_it_with (a list of variant references). Each entry shows its image, option name and price.
Source: snippets/pair-it-with.liquid
Complementary products ("Buy it with")
Uses Shopify's Search & Discovery app recommendations — metafields['shopify--discovery--product_recommendation'].complementary_products — limited to 5 available products. This is managed in the Search & Discovery app, not as a custom metafield.
Source: snippets/complementary-products.liquid
Add to Cart
The add-to-cart area renders the quantity selector and the price-aware button. It also handles pre-order messaging, "notify when in stock", and login-gated purchasing for professional-only products.
SEO / structured data
Every product outputs JSON-LD (Product + AggregateRating) for Google. Ratings come from the Stamped review app metafields (stamped.reviews_average, stamped.reviews_count).
Source: snippets/structured-data-product.liquid
05 The Product Card anatomy
The product card is the compact tile used in collections, carousels and "you may also like" rows. Source: snippets/product-card.liquid
- Badge
custom.badgemetaobject (label + colour), same as PDP. - Main + hover imageMain image is the product/variant image; an optional second image on hover comes from
custom.hover_image. - Colour swatchesUp to 4 colours from the color-pattern metaobject; "+N" link for the rest. Hovering a swatch swaps the card image.
- Title & "Available in"Title is standard; for extensions, a line lists available textures (or sizes).
- PriceStandard price with sale, automatic-discount and B2B logic mirrored from the PDP.
- Button"Quick shop", "Add to bag" or "See details" depending on context.
| Card element | Field / source |
|---|---|
| Hover image | product.metafields.custom.hover_image image |
| Badge | product.metafields.custom.badge → Badge |
| Colour swatches | Shopify metafields.shopify['color-pattern'] standard |
| B2B price | product.metafields.custom.b2b_discount number |
| Title / type / options | Standard Shopify product fields |
06 Metaobjects reference
These are the reusable structured records used across product pages. Build and edit them under Settings → Custom data → Metaobjects.
| Metaobject | Fields | Used for | Linked via |
|---|---|---|---|
| Badge | label (text), color (colour) |
Overlay label on PDP image & product card | custom.badge |
| Variant Images | files (list of images/videos) |
Per-colour image gallery on the PDP | variant.custom.product_variant_images |
| Product Description | title (text), description (rich text) |
Product / per-variant description block | custom.product_description (product & variant) |
| Product Specifications | title (text), specifications (rich text) |
Spec accordion under the description | custom.product_specifications |
| Product USP | title (text) |
USP chips on the PDP | custom.product_usps (list) |
| Color Description | content (rich text) |
Per-colour blurb under the colour picker | variant.custom.color_description |
| Color Categories | category_name (text), colors (list of color-pattern) |
Filter tabs in the colour picker | Shop-level metaobject |
| Course Information | title (text), content (rich text) |
FAQ-style accordions on course products | custom.course_information (list) |
| Color Pattern Shopify standard | label, color, image |
Colour swatches (PDP picker & card) | metafields.shopify['color-pattern'] |
| Digital Content Video / Document | Content references | Gated digital content for course/digital products | custom.digital_content_videos / ...documents |
07 Metafields reference
Custom fields attached to products and variants. Edit them in the Metafields panel at the bottom of a product (or variant) in the Shopify admin.
Product-level — custom namespace
| Metafield | Type | Purpose |
|---|---|---|
badge | Metaobject ref | Badge overlay on image & card. |
hover_image | Image | Second image shown on card hover. |
product_description | Metaobject ref | Default description block. |
product_specifications | Metaobject ref | Specs accordion. |
product_usps | List of metaobject refs | USP chips. |
b2b_discount | Number (percent) | Discount % applied for professional/B2B customers. |
is_a_professional_only_product | Boolean | Restricts purchase to professional accounts. |
subtitle | Text | Supplementary title text. |
course_information | List of metaobject refs | Course accordions. |
course_bullet_list | List (text) | "Why you'll love it" bullets (courses). |
related_courses | Product refs | Related course products. |
spots_left | Text | "X spots left" label (courses). |
faqs / courses_faqs | Metaobject / list | FAQ content. |
digital_content_videos / digital_content_documents | Metaobject refs | Gated digital downloads. |
it_s_private | Boolean | Privacy / visibility flag. |
Variant-level — custom namespace
| Metafield | Type | Purpose |
|---|---|---|
product_variant_images | Metaobject ref | Per-colour image gallery (Colour + Length products). |
product_description | Metaobject ref | Variant-specific description. |
color_description | Metaobject ref | Per-colour blurb under the picker. |
pair_it_with | List of variant refs | "Pair It With" recommendations. |
Course-specific — course_details namespace
| Metafield | Purpose |
|---|---|
date · time · location | Shown in the course details panel. |
methods · avatar | Course methods & instructor avatar. |
App / standard metafields (managed by their apps)
| Source | Field(s) | Used for |
|---|---|---|
| Stamped | stamped.reviews_average, stamped.reviews_count | Star rating + JSON-LD structured data. |
| Search & Discovery | shopify--discovery--product_recommendation.complementary_products | "Buy it with" products. |
| Shopify | shopify['color-pattern'] | Colour swatches. |
08 Course products (special case)
Academy / course products reuse the same product page but surface a different set of blocks.
- Course Details panel — date, time and location from the
course_detailsmetafields. - "Why you'll love it" — bullet list from
custom.course_bullet_list. - Course Information — accordions from the Course Information metaobject list.
- Spots left — a highlighted availability label from
custom.spots_left. - Participants quantity — the add-to-cart quantity is labelled "participants".
Source: snippets/product-page-course-details.liquid · course-information-accordions.liquid
09 B2B pricing & access rules
Philocaly sells to both consumers and professionals, so pricing and access adapt to the logged-in customer.
| Rule | Driven by | Effect |
|---|---|---|
| Professional-only product | custom.is_a_professional_only_product + customer tags | Non-professional customers can't purchase; sees a login/help prompt. |
| B2B discount | custom.b2b_discount (%) + professional tags | Discounted price shown to professionals; suggested retail price shown alongside. |
| Automatic discount collections | Theme setting (collection list + %) | Applies a storewide collection discount to price display. |
| Chatters customers | Customer tags + price markup | Redirected to account; markup logic applied to displayed prices. |
b2b_discount metafield.10 Setup checklist per product
A practical order of operations when configuring a new extension product.
- Basics — title, standard description (fallback), product type, options (Colour / Texture / Length), variants & prices.
- Colours — ensure every colour value maps to a Shopify color-pattern metaobject (swatch image + colour) so swatches render.
- Variant images — create/link a Variant Images metaobject per colour and connect it on each variant via
product_variant_images. - Description & specs — fill the Product Description and Product Specifications metaobjects; link via the product metafields.
- USPs — attach the relevant Product USP entries to
product_usps. - Badge (optional) — link a Badge metaobject for "New", "Best Seller", etc.
- Hover image (optional) — set
hover_imagefor the card. - Pair It With (optional) — add variant references on
pair_it_with. - Complementary products — set "Buy it with" in the Search & Discovery app.
- B2B (if applicable) — set
b2b_discountand/oris_a_professional_only_product. - Reviews — managed automatically by Stamped once the product has reviews.
Metaobject Entry Guide
00 The basics: where & how
A step-by-step companion to the Product Setup Guide — how to create a new entry for each metaobject and connect it to a product. Every metaobject entry is created in the same place; once you know the flow, each entry below is just a matter of filling in different fields.
1 · Badge
Metaobjectproduct.metafields.custom.badge| Field | Type | Example / guidance |
|---|---|---|
label | Single line text | e.g. New, Best Seller — kept short, shown in uppercase. |
color | Color | Background colour of the badge (e.g. a brand accent). Text is rendered over it. |
- Go to Settings → Custom data → Metaobjects → Badge.
- Click Add entry.
- Type the
label(e.g. "New"). - Pick the
colorfor the background. - Click Save.
- Open the product → Metafields → Badge → select this entry.
2 · Variant Images
MetaobjectPer variantvariant.metafields.custom.product_variant_images| Field | Type | Example / guidance |
|---|---|---|
files | List of files (images & videos) | The ordered gallery for one colour — the first file is the lead image. Both images and video are supported. |
- Go to Settings → Custom data → Metaobjects → Variant Images.
- Click Add entry.
- Give it a clear name/handle so you can find it later (e.g. the colour name, like
caramel-blonde). - Add the images/videos to the
filesfield in the order you want them shown. - Click Save.
- Open the product → click the variant → Metafields → Variant Images → select this entry.
- Repeat for each colour, then link the same entry to every length/size that shares that colour.
3 · Product Description
Metaobjectcustom.product_description (on the product, or per variant)| Field | Type | Example / guidance |
|---|---|---|
title | Single line text | Optional heading shown in italics above the text. |
description | Rich text | The body copy — supports bold, links, lists, etc. |
- Go to Settings → Custom data → Metaobjects → Product Description.
- Click Add entry.
- Optionally add a
title. - Write the
descriptionin the rich-text editor. - Click Save.
- Link it on the product (Metafields → Product Description) for the default, or on a specific variant for a colour-specific description.
4 · Product Specifications
Metaobjectcustom.product_specifications| Field | Type | Example / guidance |
|---|---|---|
title | Single line text | The accordion heading, e.g. "Specifications". |
specifications | Rich text | The spec content — typically a list (weight, length, hair type, etc.). |
- Go to Settings → Custom data → Metaobjects → Product Specifications.
- Click Add entry.
- Set the
title(the accordion label). - Fill the
specificationsrich text (a bulleted list works well). - Click Save.
- Open the product → Metafields → Product Specifications → select this entry.
5 · Product USP
MetaobjectListcustom.product_usps (a list)| Field | Type | Example / guidance |
|---|---|---|
title | Single line text | One selling point, e.g. 100% Remy Hair, Reusable. |
- Go to Settings → Custom data → Metaobjects → Product USP.
- Click Add entry, set the
title, and Save. Create one entry per USP you want available. - Open the product → Metafields → Product USPs.
- Because this is a list, click Add and select each USP entry you want shown.
- Re-order them if needed, then save the product.
6 · Color Description
MetaobjectPer variantvariant.metafields.custom.color_description| Field | Type | Example / guidance |
|---|---|---|
content | Rich text | A sentence or two describing the colour/shade. |
- Go to Settings → Custom data → Metaobjects → Color Description.
- Click Add entry, write the
content, and Save. - Open the product → click the variant for that colour → Metafields → Color Description → select this entry.
- Link the same entry to every variant of that colour (e.g. all lengths).
7 · Color Categories
MetaobjectList of colours| Field | Type | Example / guidance |
|---|---|---|
category_name | Single line text | The tab label, e.g. Blondes. |
colors | List of Color Pattern | The colours that belong to this category (referenced from the Color Pattern metaobject). |
- Go to Settings → Custom data → Metaobjects → Color Categories.
- Click Add entry and set the
category_name. - In the
colorslist, add each Color Pattern that belongs to this category. - Click Save. The category tab appears automatically on any product that has at least one matching colour.
8 · Course Information
MetaobjectListcustom.course_information (a list)| Field | Type | Example / guidance |
|---|---|---|
title | Single line text | The accordion heading / question. |
content | Rich text | The answer / details revealed when expanded. |
- Go to Settings → Custom data → Metaobjects → Course Information.
- Click Add entry, fill the
titleandcontent, and Save. Create one entry per accordion. - Open the course product → Metafields → Course Information.
- Since it's a list, add each entry in the order you want the accordions to appear.
9 · Color Pattern
Shopify standardmetafields.shopify['color-pattern']| Field | Type | Example / guidance |
|---|---|---|
label | Text | The colour name — must match the product's Color option value exactly. |
color | Color | A solid fallback colour for the swatch. |
image | Image | A small swatch photo of the actual shade (preferred over the solid colour). |
- This is a built-in Shopify metaobject. Go to Settings → Custom data → Metaobjects and open Color pattern (it may appear under "Shopify" standard definitions).
- Click Add entry.
- Set the
labelto exactly match the option value used on products (e.g.Caramel Blonde). - Add a swatch
imageand/or pick a fallbackcolor. - Click Save.
- Connect it to a product's Color option (Shopify links swatches to option values via this metaobject).
label doesn't match the product's Color option value letter-for-letter, the swatch won't render for that colour.10 · Digital Content (Video / Document)
Metaobjectcustom.digital_content_videos / custom.digital_content_documents| Field | Type | Example / guidance |
|---|---|---|
title | Text | Name of the lesson / document. |
| file / video | File or URL | The downloadable document or the video source. |
- Go to Settings → Custom data → Metaobjects and open Digital Content Video or Digital Content Document.
- Click Add entry and fill in the title and the file/video.
- Click Save.
- Open the product → Metafields → Digital Content Videos / Documents → add the entry (these are reference fields).
★ Quick reference
| Metaobject | Main fields | Linked through |
|---|---|---|
| Badge | label, color | custom.badge |
| Variant Images | files | variant.custom.product_variant_images |
| Product Description | title, description | custom.product_description (product/variant) |
| Product Specifications | title, specifications | custom.product_specifications |
| Product USP | title | custom.product_usps (list) |
| Color Description | content | variant.custom.color_description |
| Color Categories | category_name, colors | Shop-level (global) |
| Course Information | title, content | custom.course_information (list) |
| Color Pattern | label, color, image | Product Color option (Shopify standard) |
| Digital Content | title, file/video | custom.digital_content_videos / ...documents |
B2B Loyalty Program
01 Overview
How the rewards program works, what's live, and where the moving parts live.
Once a customer is approved for a B2B account, they're automatically enrolled in the rewards program. All new members start in the Bronze tier and earn points on every dollar spent. Tier progression is based on annual spend, and points can be redeemed at checkout through the Loyalty Lion checkout extension, which handles the automatic conversion.
02 Tiers and benefits
There are four tiers. Member counts below reflect the current state of the program.
| Tier | Members | Points | Discount | Shipping | Birthday | Additional benefits |
|---|---|---|---|---|---|---|
| Bronze Starting tier |
24,107 | 1 pt / $ | — | — | 10% off Tools & Accessories (one use/month) | — |
| Silver $2,500 annual spend |
285 | 2 pts / $ | 10% off all courses, year-round | — | 10% off Tools & Accessories (one use/month) | — |
| Gold $8,000 annual spend |
74 | 2 pts / $ | 10% off all courses, year-round | 50% off standard shipping | 20% off Tools & Supplies (one use/month) | — |
| Platinum $20,000 annual spend |
8 | 5 pts / $ | 15% off all education | Free express shipping | 10% off Pro Collection (one use/month) | Dedicated concierge account manager + first access to new launches, exclusives & masterclasses |
03 Implementation notes
How each benefit type is handled technically.
Points multiplier
Handled entirely by Loyalty Lion. No Shopify-side config is needed for points accumulation or multipliers — Loyalty Lion owns this logic.
Discount benefits (Education)
Managed via Shopify discounts connected to dynamic customer segments. Those segments are created and maintained by Loyalty Lion automatically. Discounts are applied automatically at checkout — no coupon code required from the customer.
| Discount name | Status | Method | Applies to |
|---|---|---|---|
| Loyalty Lion: 15% Off Education – Year Round | Active | Automatic | Members + Platinum customers (2 segments) |
| Loyalty Lion: 10% Off Education – Year Round | Active | Automatic | Silver + Gold customers (2 segments) |
Shipping benefits
Same mechanic as discount benefits — Shopify discounts tied to Loyalty Lion-managed segments, applied automatically at checkout.
| Discount name | Status | Method | Usage |
|---|---|---|---|
| Platinum Membership: Free Shipping | Active | Automatic | Used 8 times |
| Gold Member: 50% off Shipping | Active | Automatic | Used 89 times |
Birthday benefits
Birthday rewards are handled via Loyalty Lion. The flow works like this:
- On the member's birthday, Loyalty Lion sends them an email letting them know a reward is waiting.
- Clicking the link takes them to the store, where they're prompted to sign in.
- After signing in, they land on the Reward Collection page and see their tier-specific discount code.
- The code is valid for one month and applies to any order that meets the reward requirements.
★ Quick reference
Where does each piece live?
| Piece | Where it lives |
|---|---|
| Points & tier logic | Loyalty Lion dashboard |
| Discount codes (education + shipping) | Shopify Admin → Discounts (automatically applied) |
| Customer segments | Shopify Admin → Customers → Segments (created dynamically by Loyalty Lion) |
| Birthday rewards | Loyalty Lion (email trigger + reward collection page) |
| Checkout redemption | Loyalty Lion checkout extension (automatic points conversion) |
Course Video Management
01 Overview
Shopify + Vimeo workflow for managing course videos. Last updated April 2026
This guide explains how to manage course videos for the Philocaly Shopify store. The workflow is split into two main parts: uploading videos to Vimeo, then organising them inside Shopify using two metaobjects.
The reason for using Vimeo is straightforward: video files are large, and hosting them directly inside Shopify wastes storage. Vimeo handles delivery, while Shopify holds the metadata and structure.
02 Step 1 — Upload videos to Vimeo
Before anything touches Shopify, every course video must be uploaded to Vimeo.
Once uploaded, Vimeo provides a unique Video ID for each video. You'll need this ID in the next step.
vimeo.com/123456789 — the number at the end is your Vimeo ID.03 Step 2 — Add videos to "Course Videos"
Once a video is on Vimeo, register it inside Shopify's Course Videos metaobject — this is where all individual video entries live.
- In your Shopify admin, navigate to Content → Metaobjects and open Course Videos.
- Click Add entry to create a new record.
- Enter the title of the video in the
Titlefield. - Add a short description in the
Descriptionfield. Keep it concise — a sentence or two is ideal. - Paste the Vimeo ID (the number from the Vimeo URL) into the
Vimeo IDfield. - Save the entry.
04 Step 3 — Organize videos in "Course Video Groupings"
With all individual videos created, the next step is to group them. The groupings in Course Video Groupings have already been set up — the team simply needs to select which videos belong to each one.
- Navigate to Content → Metaobjects and open Course Video Groupings.
- Click on the grouping you want to configure.
- In the
Videosfield, click Add to start selecting videos from the Course Videos metaobject. - Select all videos that belong to this grouping.
- Arrange the videos into the correct order. Each grouping is a list, so you can drag and reorder freely.
- Save the grouping.
- Repeat for each grouping.
05 A note on tagging
The existing tagging rules remain in place and have not changed. The Course Video Groupings step is an addition to the workflow, not a replacement for tagging.
★ Quick reference
| Step | Action |
|---|---|
| 1. Vimeo upload | Upload the video to Vimeo and note the Vimeo ID from the URL. |
| 2. "Course Videos" entry | Add a new entry with title, description, and Vimeo ID. |
| 3. "Course Video Groupings" | Open each grouping, select the relevant videos, arrange them in order, and save. |
Preorders & Split Shipments
01 Overview
How preorders and split shipments work on Shopify. Created October 7, 2025
Shopify supports native preorders, which let you sell products before they're in stock. Customers can purchase preorder items in the same cart as regular items, and Shopify automatically separates those into different shipments.
If you set up a dedicated shipping profile for preorder items and make it free shipping, customers will see that at checkout, and your team won't need to manage those rates manually.
02 What customers experience
On the product page
- Customers see a "Preorder" or "Ships later" message on any item that's not yet in stock.
- They can add preorder items to their cart like any other product.
In the cart
- If a customer has both in-stock and preorder items, Shopify keeps them in one cart but labels preorder items as shipping separately.
- This ensures the customer knows they'll get multiple deliveries.
At checkout
- Shopify automatically groups shipping options by availability.
- Regular items show normal shipping choices.
- Preorder items show "Free Shipping" (based on your preorder shipping profile).
- The customer pays for the full order upfront unless you've configured preorders to charge later (a merchant choice).
After checkout
- The customer receives one order confirmation but will later get separate shipping notifications when each part ships.
- The experience is smooth — they never need to place separate orders.
03 What happens behind the scenes
One order, multiple fulfillments
Shopify creates a single order in your admin, but automatically separates the items into fulfillment groups.
- Group 1 — in-stock products (ready to ship now).
- Group 2 — preorder products (waiting for restock).
Automatic split shipments
When the preorder items arrive and are marked as in stock, your team can fulfill that part of the order without touching the rest.
- You can do this manually or connect your fulfillment app or 3PL to fulfill automatically once inventory updates.
- Shopify sends a new shipping confirmation to the customer automatically.
Inventory updates trigger fulfillment readiness
When your preorder stock lands and the inventory quantity increases, Shopify flags those items as ready to fulfill.
- You can filter orders in the Admin by "Unfulfilled (preorder)" to process them in batches.
04 Shipping setup for preorders
You'll create a separate shipping profile for preorder items.
- Assign only your preorder products to that profile.
- Add one shipping rate: "Free Shipping – $0."
- Shopify will automatically show that option for preorder items at checkout.
05 Internal benefits
- No manual workarounds — the system automatically manages which items ship when.
- Clear visibility — orders are clearly labeled in the admin so your fulfillment team can see what's preorder vs. ready-to-ship.
- Reduced customer confusion — customers receive separate shipping updates automatically, improving communication and reducing "Where's my order?" messages.
- Marketing alignment — you can promote upcoming launches while letting customers lock in their purchase early.
06 Operational considerations
- Charging timing — you can choose to collect payment at the time of order or only when the item ships (depending on your preorder setup).
- Fulfillment workflow — your team fulfills the in-stock shipment immediately. When preorder stock arrives, you simply open the same order and fulfill the remaining items.
- Customer service — be transparent about preorder timelines in product descriptions and order confirmations.
- Returns — returns and refunds process normally, regardless of when the product shipped.
07 Example
A customer orders:
- 1 in-stock CW Genius Weft ($300, ships now)
- 1 preorder Curly CW Genius Weft ($400, ships next month)
| Stage | What happens |
|---|---|
| At checkout | CW Genius Weft → standard shipping ($8); Curly CW Genius Weft → free shipping (preorder profile). Customer pays $708 total. |
| In Shopify Admin | One order appears with two fulfillment groups. You fulfill the CW Genius Weft immediately; the Curly CW Genius Weft stays "unfulfilled" until it's back in stock. |
| When stock arrives | You click "Fulfill." Shopify emails the customer automatically with tracking. |
08 Summary
Customers can buy future-release products and current items in a single, simple checkout. Shopify handles split shipments, free preorder shipping, and separate fulfillment automatically. Internally, your team doesn't need to create separate orders or worry about shipping overcharges — it's all handled by the native preorder + multi-shipping-profile system.
Chatters
01 Overview
The Philocaly x Chatters portal — global overview of how it works.
The Chatters x Philocaly portal enables individual Chatters stores to access a curated selection of products at a custom price, with streamlined ordering and a monthly payment arrangement through Chatters HQ.
The system is designed to:
- Give each store its own login and account tagging for easy identification.
- Provide a custom checkout flow that does not require payment at the time of order.
- Apply Chatters-specific pricing and markup rules.
- Automate order fulfillment and monthly invoicing.
02 Portal functionality
Store access & login
- Each Chatters store has its own Shopify account (created by Philocaly), under the segment "Chatters Corporate".
- Accounts are tagged with the
chatterstag in Shopify (by Philocaly). - When logged in, stores are restricted to an account page that includes only the curated set of approved products — they cannot leave this section without logging out.
Pricing rules
- Chatters stores see a 30% markup on standard B2B prices in the portal (this is a displayed markup, not the actual HQ invoice rate). Controlled by the customizer theme settings (section name: Chatters).
- Chatters HQ is invoiced at a 10% discount on standard B2B prices for all orders. This is handled with a discount code applied automatically on the back end via custom code.
| Setting | Value |
|---|---|
| Portal markup (displayed to stores) | +30% on standard B2B price |
| HQ invoice rate | −10% on standard B2B price |
| HQ discount code | XSWA2GT4FRP78-CHATTERS-HQ |
| Theme settings section | Chatters (customizer) |
Checkout process
- Orders placed through the portal do not require payment at checkout.
- Shipping is or is not included per the store's shipping rules, and the order is sent to the address associated with the store's account.
- Taxes are not visible to stores. Taxes are applied only on the HQ invoice and calculated based on the individual store's shipping location.
03 Order processing workflow
Once a draft order is placed by a Chatters store, the following automated flows are triggered in Shopify.
1 · Order tagging by month
- Any order from an account tagged
chattersorchatters HQreceives an order tag in the formatYYYY-MMbased on the order date. Handled via a custom back-end API. This tag is used for monthly aggregation and invoicing. - There are 2 Mechanic tasks:
- For regular Chatters users — consolidate all orders placed by Chatters customers.
- For Chatters HQ users — consolidate all orders placed by Chatters HQ customers.
2 · Auto fulfillment + invoicing
- Draft orders from tagged accounts are automatically marked as paid, even if payment hasn't been collected. Handled via custom back-end API.
Mechanic Flow: "Convert Draft Consolidation Orders to Real Orders - DO NOT DELETE" · runs every 30 minutes - Individual order confirmation emails are sent to the stores as orders are placed. Prices are not shown in confirmation emails to Chatters-tagged stores (custom code over the native Shopify confirmation emails).
- On the first day of every month, a flow searches for all orders with the previous month's tag and consolidates them into a single draft order / invoice.
Mechanic Flow: "ORDER CONSOLIDATION - DO NOT DELETE"
Mechanic Flow — ORDER CONSOLIDATION (regular Chatters) Liquid
{% if options.hq_customer_id != blank %}
{%- assign format = options.month_tag_format | default: "%Y-%m" -%}
{%- if options.target_month_tag != blank -%}
{%- assign month_tag = options.target_month_tag -%}
{%- else -%}
{%- assign month_tag = "now" | date: format -%}
{%- endif -%}
{%- assign current_date = "now" | date -%}
{%- assign month_tag_array = month_tag | split: '-' -%}
{%- assign month_tag_array_month = month_tag_array | last | times: 1 -%}
{%- assign month_tag_array_month = month_tag_array_month | minus: 1 -%}
{% if month_tag_array_month == 0 %}
{%- assign month_tag_array_month = 12 -%}
{% endif %}
{% if month_tag_array_month < 10 %}
{%- assign month_tag_array_month = '0' | append: month_tag_array_month -%}
{% endif %}
{% assign month_tag_year = month_tag_array | first %}
{% assign month_tag = month_tag_year | append:'-' | append: month_tag_array_month %}
{%- capture order_search -%}tag:'{{ month_tag }}' AND tag:'chatters' AND cancelReason:null AND -financial_status:refunded AND -financial_status:partially_refunded AND -status:cancelled{%- endcapture -%}
{% comment %} we need to know the payment terms id for NET 45 {% endcomment %}
{%- capture query -%}
query paymentTerms {
paymentTermsTemplates (paymentTermsType:NET) {
id
description
name
paymentTermsType
}
}
{%- endcapture -%}
{%- assign result = query | shopify -%}
{%- if result.errors -%}
{%- log level: "error", message: "Payment Terms not found", errors: result.errors -%}
{%- break -%}
{%- endif -%}
{%- assign result_info = result.data.paymentTermsTemplates -%}
{%- assign result_info = result_info | where: 'name','Net 45' | first -%}
{%- if result_info == nil -%}
{%- break -%}
{%- endif -%}
{%- assign payment_terms_id = result_info.id -%}
{%- assign orders = array -%}
{%- assign cursor = nil -%}
{% comment %} loop orders to get the one we want to consolidate {% endcomment %}
{%- for n in (1..250) -%}
{%- capture query -%}
query($query: String!, $cursor: String) {
orders(first: 250, after: $cursor, query: $query, sortKey: CREATED_AT, reverse: true) {
pageInfo { hasNextPage }
edges {
cursor
node {
id
name
createdAt
totalPriceSet { shopMoney { amount currencyCode } }
customer {
id
displayName
defaultEmailAddress { emailAddress }
}
}
}
}
}
{%- endcapture -%}
{%- capture vars_json -%}{
"query": {{ order_search | json }},
"cursor": {{ cursor | json }}
}{%- endcapture -%}
{%- assign vars = vars_json | parse_json -%}
{%- assign result = query | shopify: variables: vars -%}
{%- if result.errors -%}
{%- log level: "error", message: "GraphQL error while querying orders", errors: result.errors, query: order_search -%}
{%- break -%}
{%- endif -%}
{%- assign edges = result.data.orders.edges -%}
{%- if edges == blank -%}
{%- break -%}
{%- endif -%}
{%- for edge in edges -%}
{%- assign orders = orders | push: edge.node -%}
{%- endfor -%}
{% comment %} get all pages {% endcomment %}
{%- if result.data.orders.pageInfo.hasNextPage -%}
{%- assign cursor = edges.last.cursor -%}
{%- else -%}
{%- assign cursor = nil -%}
{%- break -%}
{%- endif -%}
{%- endfor -%}
{%- if orders == blank -%}
{%- log message: "No Chatters orders found for month", month: month_tag, query: order_search -%}
{%- break -%}
{%- endif -%}
{% comment %} group the orders by customer id / salon {% endcomment %}
{%- assign customer_ids = orders | map: "customer" | map: "id" -%}
{%- assign unique_customer_ids = customer_ids | uniq -%}
{%- assign line_items_list = array -%}
{%- for one_customer in unique_customer_ids -%}
{%- assign customer_name = nil -%}
{%- for o in orders -%}
{%- if o.customer == nil or o.customer.id != one_customer -%}
{%- continue -%}
{%- endif -%}
{%- assign company_name = blank -%}
{%- if o.customer.current_company != blank -%}
{%- assign company_name = o.customer.current_company.name -%}
{%- endif -%}
{%- assign customer_name = o.customer.displayName | append: ' ' | append: company_name -%}
{%- assign title = "Order " | append: o.name | append: " (" | append: o.createdAt | append: ") " | append: customer_name -%}
{%- assign amount = o.totalPriceSet.shopMoney.amount -%}
{%- capture item_json -%}{
title: {{ title | json }},
quantity: 1,
originalUnitPriceWithCurrency: {
amount: {{ amount | json }},
currencyCode: {{ o.totalPriceSet.shopMoney.currencyCode }}
},
requiresShipping: false,
taxable: false,
customAttributes: [
{ key: "order-id", value: {{ o.id | json }} },
{ key: "order-number", value: "{{ o.name}}" }
]
}{%- endcapture -%}
{%- assign item = item_json -%}
{%- assign line_items_list = line_items_list | push: item -%}
{%- endfor -%}
{% endfor %}
{%- assign note_prefix = "Consolidated invoice for: " -%}
{%- assign hq_customer = "gid://shopify/Customer/" | append: options.hq_customer_id -%}
{%- capture input_json -%}{
purchasingEntity: { customerId: "{{ hq_customer }}" },
useCustomerDefaultAddress: true,
tags: {{ month_tag | append: ',portal-orders,ready-to-complete,consolidated-order' | json }},
note: {{ note_prefix | append: month_tag | json }},
lineItems: [{{ line_items_list }}],
paymentTerms: {
paymentTermsTemplateId: "{{payment_terms_id}}",
paymentSchedules: { issuedAt: "{{ current_date }}" }
}
}{%- endcapture -%}
{%- assign input = input_json -%}
{%- capture vars_json -%}{ "input": {{ input }} }{%- endcapture -%}
{%- assign vars = vars_json -%}
{%- action "shopify" -%}
mutation draftOrderCreate {
draftOrderCreate(input: {{input }}) {
draftOrder { id name invoiceUrl }
userErrors { field message }
}
}
{%- endaction -%}
{% action "mechanic/run" %}
{ "task_id": "c13f3dbc-610f-40d7-b377-9af6da7fe7a3" }
{% endaction %}
{% else %}
{% log "You must enter the HQ Customer ID" %}
{% endif %}
Mechanic Flow — ORDER CONSOLIDATION (for Chatters HQ) Liquid
tag:'exclude' instead of tag:'chatters', and the consolidated draft tags include an extra empty segment. The differing lines are shown below.// order search — uses tag:'exclude' instead of tag:'chatters' {%- capture order_search -%}tag:'{{ month_tag }}' AND tag:'exclude' AND cancelReason:null AND -financial_status:refunded AND -financial_status:partially_refunded AND -status:cancelled{%- endcapture -%} // consolidated draft tags — note the extra ',' segment tags: {{ month_tag | append: ',' | append: ',portal-orders,ready-to-complete,consolidated-order' | json }},
3 · Post payment
- Once the consolidated order is marked as paid, a script automatically updates all included orders as "Paid" in Shopify. Runs daily.
Mechanic Flow: "MARK ORDER INSIDE CONSOLIDATE AS PAID - DO NOT DELETE"
Mechanic Flow — MARK ORDER INSIDE CONSOLIDATE AS PAID Liquid
{%- comment -%} STEP 1 — Gather all matching open drafts with both tags {%- endcomment -%}
{%- assign orders = array -%}
{%- assign cursor = nil -%}
{%- for n in (1..20) -%}
{%- capture query -%}
query($cursor: String) {
orders(
first: 250
query: "financial_status:PENDING AND tag:'chatters' AND tag:'ready-to-complete'"
reverse: true
after: $cursor
sortKey: CREATED_AT
) {
pageInfo { hasNextPage }
edges {
cursor
node {
id
name
createdAt
lineItems(first: 250) {
edges { node { id customAttributes { value key } } }
}
}
}
}
}
{%- endcapture -%}
{%- capture vars_json -%}{ "cursor": {{ cursor | json }} }{%- endcapture -%}
{%- assign vars = vars_json | parse_json -%}
{%- assign result = query | shopify: variables: vars -%}
{%- if result.errors -%}
{%- log level:"error", message:"GraphQL error while querying draftOrders", errors: result.errors -%}
{%- break -%}
{%- endif -%}
{%- assign edges = result.data.orders.edges -%}
{%- if edges == blank -%}{% break %}{%- endif -%}
{%- for edge in edges -%}
{%- assign orders = orders | push: edge.node -%}
{%- endfor -%}
{%- if result.data.orders.pageInfo.hasNextPage -%}
{%- assign cursor = edges.last.cursor -%}
{%- else -%}
{%- assign cursor = nil -%}
{%- break -%}
{%- endif -%}
{%- endfor -%}
{%- assign orders_to_mark = array -%}
{%- for one_order in orders -%}
{%- for line_item in one_order.lineItems.edges -%}
{% assign line_item_info = line_item.node.customAttributes %}
{%- for one_item in line_item_info -%}
{% if one_item.key == 'order-id' %}
{%- assign orders_to_mark = orders_to_mark | push: one_item.value -%}
{% endif %}
{%- endfor -%}
{%- endfor -%}
{%- endfor -%}
{% for order_pay in orders_to_mark %}
{%- capture input -%}{ id: "{{order_pay}}" }{%- endcapture -%}
{%- capture vars_json -%}{ "input": {{input}} }{%- endcapture -%}
{%- action "shopify" -%}
mutation MarkPaid {
orderMarkAsPaid(input: {{input}}) {
order { id }
userErrors { field message }
}
}
{% endaction %}
{% endfor %}
04 Payment
- Terms — Chatters HQ pays for all orders in a single transaction at the end of each month (Net 45).
- Consolidated invoice contents:
- Consolidated list of orders by store.
- Taxes applied by province (based on shipping location). These taxes are included in the "Unit Price" of the consolidated invoice.
- Total monthly payment due.
05 Key notes & considerations
- Shipping is dictated by the store's standard shipping rules.
- Taxes are paid by Chatters HQ and calculated dynamically based on shipping location.
- Portal access is restricted to tagged
chattersaccounts. - Monthly tagging ensures accurate reconciliation and reporting.
- All flows operate automatically once configured.
06 Appendix — invoice example
A sample consolidated invoice sent to Chatters HQ.
| Field | Value |
|---|---|
| Invoice # | 81841 |
| Issue date | May 1, 2026 |
| From | Philocaly Hair Extensions · 246 Stewart Green SW, Suite 2047, Calgary AB T3H 3C8, Canada · GST/HST 797462710RT0001 |
| Customer / shipping | Chatters Distribution Center · 271 Burnt Park Drive, Red Deer AB T4S 0K7, Canada · education@chatters.ca |
| Item | Qty | Unit price | Tax | Total |
|---|---|---|---|---|
| Order #81725 (2026-04-30) Chatters Support Centre | 1 | 1,192.80 | $0.00 | $1,192.80 |
| Order #81576 (2026-04-28) CHATTERS Crossroads | 1 | 544.99 | $0.00 | $544.99 |
| Order #81560 (2026-04-28) CHATTERS Village Green Mall (Vernon) | 1 | 280.90 | $0.00 | $280.90 |
| Order #81547 (2026-04-28) CHATTERS Grandview Corners | 1 | 137.76 | $0.00 | $137.76 |
| Order #81423 (2026-04-27) CHATTERS Grandview Corners | 1 | 280.90 | $0.00 | $280.90 |
| Order #81416 (2026-04-27) CHATTERS Camrose Commons | 1 | 510.93 | $0.00 | $510.93 |
| Order #81358 (2026-04-26) CHATTERS Willoughby Centre | 1 | 237.75 | $0.00 | $237.75 |
| Order #79542 (2026-04-05) CHATTERS Willoughby Centre | 1 | 1,056.38 | $0.00 | $1,056.38 |
| Order #81288 (2026-04-25) CHATTERS Sudbury Centre | 1 | 150.18 | $0.00 | $150.18 |
| Order #81255 (2026-04-24) CHATTERS Polo Park | 1 | 500.64 | $0.00 | $500.64 |
| Order #81239 (2026-04-24) CHATTERS Bower Place | 1 | 129.15 | $0.00 | $129.15 |
$0.00 per line because provincial taxes are rolled into the consolidated unit prices on the HQ invoice rather than charged to the individual stores.Pro Pricing & Hidden Prices
01 Why prices are hidden
Why prices and purchase options are hidden from non-professional ("non-pro") customers in this theme.
This is a hybrid B2C / B2B (wholesale / "stylist") store. Some products are professional-only — they should only be visible and purchasable by qualified pros (stylists, salons, B2B accounts). For regular direct-to-consumer ("D2C") shoppers, these products must:
- Not show a price — wholesale pricing is confidential / not relevant to D2C.
- Not be purchasable — instead of an "Add to cart" button, the shopper sees a prompt to log in or register for a pro account.
- Optionally show a help callout steering D2C shoppers toward the right alternative ("Not a professional? Check out our Clip-In Extensions…").
Eligible pros, once logged in and tagged, see the price and can buy — and may additionally receive a B2B discount off the retail price.
02 How it works
The system has two independent layers: an eligibility gate (controls whether price + purchase are shown at all), and pro pricing (for eligible pros, applies a B2B discount).
The key inputs
| Input | Where it lives | Purpose |
|---|---|---|
Customer tags (e.g. professional, b2b) | Customer record in Shopify Admin | Identifies who is a pro |
professional_tags theme setting | Theme settings → Professional / Students Settings | Defines which tags count as "pro" |
is_a_professional_only_product metafield (boolean) | Product → custom.is_a_professional_only_product | Flags a product as pro-only |
b2b_discount metafield (number, %) | Product → custom.b2b_discount | Discount % applied for pros |
Layer 1 — Eligibility gate
The gate is computed at the top of the product section in main-product.liquid:
{% assign is_eligible_to_purchase = true %}
{% if product.metafields.custom.is_a_professional_only_product %}
{% assign is_eligible_to_purchase = false %}
{% assign professional_tags = settings.professional_tags | downcase %}
{% for tag in customer.tags %}
{% assign downcased_tag = tag | downcase %}
{% if professional_tags contains downcased_tag %}
{% assign is_eligible_to_purchase = true %}
{% break %}
{% endif %}
{% endfor %}
{% endif %}
Logic:
- If the product is not flagged pro-only →
is_eligible_to_purchase = truefor everyone (normal product, price shown to all). - If the product is pro-only → start with
false, then flip totrueonly if one of the customer's tags matches a tag in theprofessional_tagssetting (case-insensitive).
This is_eligible_to_purchase flag is then passed down to every snippet that renders price or purchase UI.
- Price is hidden — the entire price block in product-header.liquid is wrapped in
{% if is_eligible_to_purchase %} … {% endif %}. If the shopper isn't eligible, no price markup is rendered at all. - Purchase is blocked — in main-product.liquid, when the flag is false the "Add to cart" button is replaced with a login/register button (label from
settings.not_logged_in_add_to_cart_label, linking to the account login URL), plus the disclaimer (settings.not_logged_in_disclaimer) and the D2C help callout (settings.d2c_help_callout_title/d2c_help_callout_content).
The same gate is enforced everywhere a price/buy action could leak through:
| Surface | Behaviour |
|---|---|
| add-to-cart-popup.liquid | Entire popup wrapped in the eligibility check. |
| product-card.liquid | Collection cards only show price when allowed. |
| head-assets.liquid | Price is omitted from JSON-LD structured data for pro-only products (so it doesn't leak into SEO / search results). |
Layer 2 — Pro (B2B) pricing
For eligible pros, an optional B2B discount is applied. First the snippet determines if the logged-in customer is a B2B customer (product-header.liquid):
{% assign is_b2b_customer = false %}
{% assign professional_tags = settings.professional_tags | downcase %}
{% for tag in customer.tags %}
{% assign downcased_tag = tag | downcase %}
{% if professional_tags contains downcased_tag %}
{% assign is_b2b_customer = true %}
{% break %}
{% endif %}
{% endfor %}
Then, if the product has a b2b_discount metafield and the customer is a pro, the displayed price is discounted:
{% if product.metafields.custom.b2b_discount != blank and is_b2b_customer %}
{% assign b2b_discount = 100 | minus: product.metafields.custom.b2b_discount %}
{% assign price = b2b_discount | times: product.selected_or_first_available_variant.price | divided_by: 100 %}
...
{% endif %}
So a b2b_discount of 15 shows the price at 85% of retail. The original retail price is still shown to pros as a "Suggested Retail Price" line.
Related: special-tag price markup (Chatters)
Separately from the pro gate, customers tagged as Chatters / Chatters HQ have a markup (or discount) applied to all displayed prices via price-markup.liquid, controlled by the chatters_markup_percentage / chatters_hq_markup_percentage theme settings. This is a price adjustment, not a hiding mechanism, but it shares the same customer-tag pattern. See the Chatters guide.
Decision flow
Shopper opens a product page
│
├─ Is product flagged custom.is_a_professional_only_product?
│ ├─ NO → show price + Add to cart to everyone
│ └─ YES → does customer.tags match a professional_tags entry?
│ ├─ NO → HIDE price, replace buy button with
│ │ "Login / Register to shop" + disclaimer + help callout
│ └─ YES → show price + Add to cart
│
└─ For eligible pros: if product has custom.b2b_discount,
displayed price = retail × (100 − b2b_discount) / 100
(retail still shown as "Suggested Retail Price")
03 Merchant setup
1 · Configure the theme settings
In the Shopify theme editor → Theme settings → Professional / Students Settings (settings_schema.json):
| Setting | Default | Notes |
|---|---|---|
| Professional Tags | professional, b2b | Comma-separated tags that count as a pro |
| Student Tags | Student | Tags that count as a student |
| Label on add to cart button for not logged in users | Login / Register to shop | Button text shown to non-eligible shoppers |
| Disclaimer for not logged in users | Stylist exclusive. Login or register for a pro account today! | Shown under the button |
| Title for D2C Help Callout | Not a professional? | Help section heading |
| Content for D2C Help Callout | Check out our Clip-In Extensions instead or Find a Stylist | Help section body |
2 · Tag your pro customers
In Shopify Admin → Customers, open each professional/stylist/B2B account and add a tag that matches one of the values in Professional Tags (e.g. professional or b2b). Tag matching is case-insensitive.
3 · Flag the pro-only products
For each product that should be hidden from D2C shoppers, set the metafield:
- Namespace / key:
custom.is_a_professional_only_product - Type: Boolean (true/false)
- Value:
true
Products without this metafield (or set to false) remain visible and purchasable by everyone.
4 · (Optional) Set a B2B discount per product
To give pros a discount off retail, set the metafield:
- Namespace / key:
custom.b2b_discount - Type: Number (integer percentage, e.g.
15for 15% off)
Pros will see the discounted price plus the original as "Suggested Retail Price".
Quick verification
- Open a pro-only product in an incognito window (logged out) → you should see no price and a Login / Register to shop button + disclaimer.
- Log in as a customer without a pro tag → same hidden behaviour.
- Log in as a customer with a pro tag → price and Add to cart appear; if a
b2b_discountis set, the discounted price shows with the retail price as "Suggested Retail Price".
04 File reference
| Concern | File |
|---|---|
| Eligibility gate + login/buy block | src/sections/main-product.liquid |
| Price display (gated) + B2B discount | src/snippets/product-header.liquid |
| Add-to-cart popup (gated) | src/snippets/add-to-cart-popup.liquid |
| Collection card price (gated) | src/snippets/product-card.liquid |
| SEO / structured data price suppression | src/snippets/head-assets.liquid |
| Special-tag price markup (Chatters) | src/snippets/price-markup.liquid |
| Theme settings | src/config/settings_schema.json |
Checkout Validation Developer app
01 Overview
A Shopify Cart & Checkout Validation function that blocks checkout when a non-professional customer tries to buy products reserved for professional (B2B) accounts.
This is an extension-only Shopify app — it has no admin UI and no backend server. It ships a single Shopify Function that runs at the cart.validations.generate.run target. Shopify executes the function automatically as the customer moves toward checkout.
The rule it enforces: if the cart contains at least one product from a designated "Professional" collection, and the customer is not tagged as a professional, checkout is blocked with a clear error message. Everyone else checks out normally.
Typical use case: a store sells some items only to verified B2B / trade customers. Those items live in a "Professional" collection. Retail shoppers can browse and add them to the cart, but cannot complete the purchase unless their account is approved.
02 How it works
On each cart/checkout evaluation, the function receives the cart contents and buyer identity, then applies this logic.
- Read the cart lines and check whether any product is a member of one of the configured collection IDs (
inCollections). - Read the buyer identity and check whether the customer has the
b2borprofessionaltag. - Decision: is there a professional product in the cart and the customer is not a professional?
- Yes → block. A validation error is added targeting the cart. Shopify disables the "Pay now" / "Place order" button and shows the message.
- No → allow. The function returns no operations and checkout proceeds normally.
The error shown to the customer is:
You need to have a professional account to place this order. Make sure the order email matches your professional account email.
Core logic: extensions/philocaly-cart-checkout-validation/src/cart_validations_generate_run.js — and the data it reads is defined in the sibling .graphql input query.
03 Requirements & setup
Requirements
- Node.js installed.
- A Shopify Partner account.
- A test store — a development store or a Plus sandbox store.
- The Shopify CLI.
Install & run locally
From the root of the app, start a development session. The CLI connects to your app in the Partners dashboard, provides environment variables, and runs the function in dev mode:
yarn install yarn dev
Whenever you change the main function file cart_validations_generate_run.js, regenerate the TypeScript types used for JSDoc type-safety:
shopify app function typegen
Deploy
Push the function to Shopify:
shopify app deploy
After deploying, the validation function appears in your store under Settings → Checkout → Checkout rules, where it can be enabled.
04 Configuration
The function reads its list of "professional" collections from a metafield, so you can change which collections are gated without redeploying code. There are two metafields to be aware of.
1 · The validation config metafield
The extension is bound (in shopify.extension.toml) to read a metafield with:
| Property | Value |
|---|---|
| Namespace | philocaly |
| Key | checkout_validation_config |
| Type | json |
| Value | {"collectionIds": ["gid://shopify/Collection/280538153094"]} |
Replace the collection GID with the ID of the collection you want to gate. You can list multiple collection IDs in the array.
2 · Attach the config to the validation
The metafield must be owned by the Validation object that Shopify creates for your function. First, find the validation ID by running this query (e.g. in the GraphiQL app or via the Admin API):
{
validations(first: 10) {
nodes {
id
title
shopifyFunction { title }
}
}
}
Then set the config metafield on that validation. Replace the ownerId with your validation's GID and the collection ID with your collection's GID:
mutation {
metafieldsSet(metafields: [
{
ownerId: "gid://shopify/Validation/34373766",
namespace: "philocaly",
key: "checkout_validation_config",
type: "json",
value: "{\"collectionIds\": [\"gid://shopify/Collection/280538153094\"]}"
}
]) {
metafields { id }
}
}
Validation object (not the shop or a product), so the function can read it via its bound input variables. Get the collection GID from the collection page URL in the admin, or from a collections GraphQL query.3 · Tag your professional customers
The function treats a customer as a professional if their account carries either of these tags: b2b or professional.
Add the tag to approved customers in Admin → Customers → (customer) → Tags. A tagged customer whose order email matches their account can check out with professional products; everyone else is blocked.
05 Testing the behavior
- Make sure a product belongs to the configured "Professional" collection.
- As a guest / untagged customer: add that product to the cart and proceed to checkout — the "Place order" button should be blocked with the professional-account message.
- As a customer tagged
b2borprofessional: log in and repeat — checkout should now succeed. - A cart with no professional-collection products always checks out normally, regardless of tags.
06 Reference
Key files
| Path | Purpose |
|---|---|
extensions/philocaly-cart-checkout-validation/src/cart_validations_generate_run.js | The validation logic. |
…/src/cart_validations_generate_run.graphql | Input query — the cart & buyer data the function receives. |
…/shopify.extension.toml | Extension config: target, input metafield namespace/key. |
shopify.app.toml | App-level configuration. |
What the function reads (input query)
Per cart, the function receives: the customer's numberOfOrders and whether they have the b2b / professional tags; for each line, the product and which of the configured collections it belongs to (inCollections); and the cart's total cost.
07 Troubleshooting
| Symptom | Check |
|---|---|
| Checkout is never blocked | Confirm the validation metafield is set on the correct Validation owner, that the collection GID is correct, and that a cart product actually belongs to that collection. |
| Checkout is always blocked, even for B2B customers | Verify the customer is logged in and carries the exact tag b2b or professional (tags are evaluated against the logged-in customer). |
| Changes to the JS aren't reflected | Re-run shopify app function typegen if you changed inputs, then shopify app deploy. |
| Function not appearing in checkout rules | Make sure the app is installed on the store and the function was deployed successfully. |
Checkout UI Extension Developer app
01 Purpose
A checkout extension that automatically clears the shopping cart when a shopper logs out during checkout — preventing the next person on the same device from inheriting (and purchasing) a previous user's cart.
This extension enhances security and privacy at checkout. Its primary business goal is to stop B2B customers from buying products at retail prices: when a B2B (wholesale) user logs out, their negotiated cart must not remain available to a subsequent, unauthenticated retail shopper using the same browser or device.
It does this silently in the background — it renders no visible UI. It watches the customer's authentication state and, the moment a logged-in session becomes logged out, it empties the cart.
Privacy
No leftover cart contents are exposed to the next user of a shared device.
Price integrity
Prevents wholesale carts from being checked out at retail pricing after logout.
02 How it works
The extension is rendered at the purchase.checkout.block.render target. On each render it compares the shopper's current login state against the state it stored last time, using Shopify's per-extension shopify.storage API.
Read previous state → Read current customer → Write current state
→ logged in → logged out? → Remove every cart line
The core logic lives in extensions/philocaly-ui/src/Checkout.jsx:
const customer = useCustomer();
const cartLines = useCartLines();
const applyCartLinesChange = useApplyCartLinesChange();
const { storage } = shopify;
const isLoggedIn = !!customer;
// Compare the previous state with the current one
const wasLoggedIn = await storage.read('isLoggedIn');
await storage.write('isLoggedIn', isLoggedIn);
// On a logged-in → logged-out transition, empty the cart
if (wasLoggedIn === true && isLoggedIn === false) {
for (const line of cartLines) {
await applyCartLinesChange({
type: 'removeCartLine',
id: line.id,
quantity: line.quantity,
});
}
}
shopify.storage? It persists a small flag (isLoggedIn) scoped to this extension, so the extension can detect a transition between renders rather than just reading a single point-in-time value.03 Project structure
| Path | What it is |
|---|---|
shopify.app.toml | App-level config: client ID, name, access scopes (read_customers, unauthenticated_read_customers). |
package.json | Root workspace and Shopify CLI scripts (dev, build, deploy). |
extensions/philocaly-ui/ | The checkout UI extension itself. |
…/shopify.extension.toml | Extension config: target, API access capability, API version. |
…/src/Checkout.jsx | Extension source — the cart-clearing logic (Preact). |
…/locales/ | Translation strings (en.default.json, fr.json). |
04 Setup & development
Prerequisites
- Node.js and npm installed.
- The Shopify CLI (invoked via
npm runscripts /npx shopify). - A Shopify Partner account and a development store with checkout extensibility enabled.
- Access to the Partner organization that owns the app client ID.
Setup
git clone <repo-url> cd philocaly-ui-extension npm install
Local development
Start the Shopify development server. The CLI prompts you to choose a Partner organization and a development store, then gives you a preview URL.
npm run dev
# equivalent to: shopify app dev
- Select your Partner organization when prompted.
- Select (or create) a development store.
- Open the preview URL the CLI prints, then go through checkout.
- Log in as a customer, then log out — confirm the cart empties. Check the browser console for the
Philocaly UI Extensionlog line.
Checkout.jsx.Deployment
npm run deploy
# equivalent to: shopify app deploy
- Release the version in the Partner Dashboard so it becomes live for merchants/stores.
- In the store's checkout editor, place the extension block where it should run (the default target lets the merchant choose placement).
05 Configuration reference
| Setting | Value | File |
|---|---|---|
| Extension target | purchase.checkout.block.render | shopify.extension.toml |
| Checkout API version | 2025-10 | shopify.extension.toml |
| Webhooks API version | 2026-01 | shopify.app.toml |
| API access | enabled (api_access = true) | shopify.extension.toml |
| Access scopes | read_customers, unauthenticated_read_customers | shopify.app.toml |
Available helper scripts: npm run dev · build · deploy · generate · info
06 Troubleshooting
| Symptom | Check |
|---|---|
| The cart doesn't clear on logout | Confirm the extension block is actually placed in the checkout editor, and that you transitioned from a logged-in to a logged-out state (the trigger only fires on that specific transition, not on a plain anonymous session). |
| Extension not appearing in checkout | Make sure the dev server is running (npm run dev), you opened the CLI's preview URL, and the store has checkout extensibility enabled. |
| "Failed to remove line" in the console | A removeCartLine change returned an error. Verify the checkout supports cart line edits at the current step and that API access is enabled in shopify.extension.toml. |
Chatters API Developer app
01 Overview
A small Node/Express service that creates Shopify orders for the Philocaly "Chatters" ambassador program — with deferred (net-terms) payment.
Chatters are Philocaly's brand ambassadors / sales reps. This API lets the storefront place orders on a chatter's behalf programmatically. Instead of charging the customer immediately, each order is created in Shopify as a draft, has shipping and an ambassador discount applied, is assigned net payment terms (the invoice is due on the first day of the month three months out), and is then completed into a real, unpaid order. This lets ambassadors order stock now and settle the invoice later.
What it does
Turns a cart payload into a completed Shopify order with discounts, calculated shipping and net payment terms.
Who calls it
The Philocaly storefront (CORS-locked to the philocalyhairextensions.com domains).
Where it runs
Deployed as a serverless function on Vercel; runs locally on port 4000 for dev.
02 How it works
When the storefront posts a cart to /create-order, the service performs a multi-step Shopify Admin GraphQL flow.
- Create draft order — builds a
DraftOrderInputfrom the payload (customer email, line items, shipping/billing address) and applies the correct discount code. Shopify calculates taxes. - Calculate shipping — runs
draftOrderCalculateagainst the shipping address and selects the first available shipping rate. - Fetch payment terms — queries the store's fixed/net payment-term templates and picks one.
- Update the draft — attaches the payment terms with a due date (1st of the month, +3 months), sets the chosen shipping line, and hides the draft from the customer.
- Complete the draft — converts it into a real order via
draftOrderCompleteand returns the order to the caller.
Every order is automatically tagged so downstream automation can find it:
The chatters / exclude tag depends on whether the request is flagged as an HQ customer (isChattersHQCustomer), which also decides which discount code is applied.
03 Tech stack
| Concern | Choice |
|---|---|
| Runtime | Node.js (ES Modules — "type": "module") |
| Framework | Express 4 |
| Shopify | @shopify/admin-api-client (Admin GraphQL) |
| Config | dotenv |
| CORS | cors — locked to Philocaly domains |
| Hosting | Vercel serverless (vercel.json) |
| Automation | Shopify Mechanic tasks (the .liquid files) |
04 Setup & environment
Prerequisites
- Node.js 18+ and
npm(oryarn— ayarn.lockis committed). - A Shopify store with a custom/private app that has Admin API access (draft orders, orders, customers, metafields).
- The Shopify Admin access token and the relevant discount codes.
Install & run
# 1. Clone and enter the project git clone <repo-url> cd philocaly-chatters-api-node # 2. Install dependencies npm install # or: yarn # 3. Create your .env file (see below) # 4. Start the server npm run start # plain node npm run watch # auto-reload with nodemon (dev)
Locally the server listens on http://localhost:4000. In production (NODE_ENV=production) it does not call app.listen — it exports the Express app for Vercel to invoke as a serverless function.
Environment variables
Create a .env file in the project root:
SHOPIFY_STORE_DOMAIN=your-store.myshopify.com SHOPIFY_API_VERSION=2025-01 SHOPIFY_ADMIN_ACCESS_TOKEN=shpat_xxxxxxxxxxxxxxxx SHOPIFY_DISCOUNT_CODE=chatters-discount SHOPIFY_DISCOUNT_CODE_HQ=chatters-hq-discount SHOPIFY_DISCOUNT_PERCENTAGE=20
| Variable | Purpose |
|---|---|
SHOPIFY_STORE_DOMAIN | The *.myshopify.com domain of the store. |
SHOPIFY_API_VERSION | Admin API version, e.g. 2025-01. |
SHOPIFY_ADMIN_ACCESS_TOKEN | Admin API access token for the private app. |
SHOPIFY_DISCOUNT_CODE | Discount applied to standard chatter orders. |
SHOPIFY_DISCOUNT_CODE_HQ | Discount applied when isChattersHQCustomer is true. |
SHOPIFY_DISCOUNT_PERCENTAGE | Discount percentage (reference value). |
.env or access token. On Vercel, set these under Project → Settings → Environment Variables.Deployment
Deployment targets Vercel. The vercel.json builds index.js with @vercel/node and rewrites every route to that function:
{
"version": 2,
"builds": [{ "src": "index.js", "use": "@vercel/node" }],
"rewrites": [{ "source": "/(.*)", "destination": "/index.js" }]
}
Push to the connected Git branch (or run vercel from the CLI) and add the environment variables in the Vercel dashboard.
05 API endpoints
All requests must come from an allowed origin (CORS): philocalyhairextensions.com, www.philocalyhairextensions.com, or philocaly-hair.myshopify.com. Request bodies are JSON.
POST /create-order
Creates and completes a Shopify order with shipping, discount and net payment terms.
Request body
{
"customer": { "toAssociate": { "email": "rep@example.com" } },
"isChattersHQCustomer": "false",
"shippingAddress": {
"address1": "123 Main St",
"city": "Austin",
"province": "TX",
"country": "United States",
"zip": "78701"
},
"lineItems": [
{ "variantId": "gid://shopify/ProductVariant/123456789", "quantity": 2 }
]
}
| Field | Notes |
|---|---|
customer.toAssociate.email | Email attached to the draft order. |
isChattersHQCustomer | String "true"/"false". Selects HQ vs standard discount and the exclude/chatters tag. |
shippingAddress | Used for both shipping and billing, and to calculate shipping rates. |
lineItems[] | Each item needs a variantId (GID) and quantity. Catalog price is used. |
Success — 200
{
"message": "Order created",
"order": {
"id": "gid://shopify/Order/...",
"totalTaxSet": { "shopMoney": { "amount": "12.34", "currencyCode": "USD" } }
}
}
Errors — 400 with { error, details } for GraphQL/user errors or when no shipping rates are available; 500 on unexpected failures.
POST /add-birthdate
Stores a customer's birthday as a metafield (loyaltylion.birthday, type date) — used for loyalty / LoyaltyLion.
Request body
{
"customerId": "1234567890",
"birthdate": "1990-05-21" // YYYY-MM-DD
}
Returns 200 with the updated metafields, or 400 for an invalid date format / metafield errors.
06 Companion Mechanic tasks
The repo also ships Shopify Mechanic task templates (.liquid). These run inside Shopify (not in this API) and handle the back-office side of the program — consolidating, invoicing and notifying.
| File | Role |
|---|---|
mechanic-consolidate.liquid | Finds open chatters orders for a month tag and consolidates them (looks up the NET payment-terms template). |
mechanic-convert-to-order.liquid | Gathers open drafts tagged chatters + ready-to-complete and converts them to orders. |
mechanic-mark-inner-consolidate-paid.liquid | Marks the inner/consolidated orders as paid. |
mechanic-send-email.liquid | Sends notification email(s), optionally filtered by a product tag on the order. |
07 Troubleshooting
| Symptom | Check |
|---|---|
| CORS errors | The calling origin must be in the allow-list in index.js. Add new domains there. |
| "No shipping rates available" | The store has no shipping rate matching the address; check Shopify shipping zones. |
| GraphQL / draft errors | Inspect the details array in the 400 response; usually a bad variantId, missing scope, or invalid discount code. |
| Auth failures | Verify SHOPIFY_ADMIN_ACCESS_TOKEN, SHOPIFY_STORE_DOMAIN and SHOPIFY_API_VERSION match the store/app. |