Files
mozimo.in/scripts/pb-seed.mjs
T
tanshu 296c05cf88 PB-driven editorial copy + Hampers & Boxes category (Q1a, Q2, Q5)
- collection_rows: hampers-boxes row (seed default + ensure-row migration);
  /chocolate shows the button, /collections/hampers-boxes gets the two-tone
  hero (last-word script rule) + live Shopify hampers + row blurb as lead
- gifting_page: tabs/hampers/seasonal heading fields (seed + fill-missing
  migration, never overwrites admin edits); gifting.tsx consumes them
- site_settings: home seasonal + universe copy keys (seed + ensure-keys
  migration); index loader passes them to SeasonalTeaser/UniverseSection
- hero lead on collection pages falls back to the PB row blurb
2026-09-06 15:22:50 +05:30

611 lines
18 KiB
JavaScript

#!/usr/bin/env node
/**
* PocketBase bootstrap — idempotent.
*
* 1. ensures the editorial collections exist (gifting_tabs, collection_rows,
* gifting_page) with public read rules;
* 2. seeds each empty collection with the content currently shipped in the
* site bundle, so a fresh PocketBase looks exactly like the static site.
*
* Runs at container start (entrypoint) and can be re-run any time:
* node --env-file=.env scripts/pb-seed.mjs
*
* Env:
* PB_URL default http://127.0.0.1:8090
* PB_SUPERUSER_EMAIL required to seed
* PB_SUPERUSER_PASSWORD required to seed
*
* Never fatal: any failure logs a warning and exits 0 — the site falls back
* to its bundled defaults until PocketBase is ready.
*/
const PB_URL = (process.env.PB_URL ?? "http://127.0.0.1:8090").replace(
/\/$/,
"",
);
const EMAIL = process.env.PB_SUPERUSER_EMAIL;
const PASSWORD = process.env.PB_SUPERUSER_PASSWORD;
const IMAGE_MIME = [
"image/jpeg",
"image/png",
"image/webp",
"image/avif",
"image/gif",
];
/* ── defaults: mirror of the shipped site content ──────────── */
const GIFTING_TABS = [
{
name: "Signature",
note: "From ₹265",
title: "Small gestures, remembered.",
blurb:
"Thoughtful everyday gifting — a bar, a little box, a note of chocolate that says enough.",
panel: "products",
tile_url: "/products/mozi14.jpg",
product_handles: "mozimo-dark-chocolate-bar-85\n9-pralines-box\nbars-box",
collection_handle: "gift-collection",
cta_label: "View all Signature gifts",
cta_url: "/collections/gift-collection",
sort: 1,
visible: true,
},
{
name: "Best Sellers",
note: "From ₹825",
title: "The boxes they ask for by name.",
blurb:
"The spreads, dragees and bars our regulars return for — gifting that never misses.",
panel: "products",
tile_url: "/products/mozi16.jpg",
product_handles:
"pistachio-spread-60\nmozimo-almond-dragees-60\nkunafa-chocolate-bar\nspreads-box",
collection_handle: "best-sellers",
cta_label: "Shop Best Sellers",
cta_url: "/collections/best-sellers",
sort: 2,
visible: true,
},
{
name: "Exquisite",
note: "From ₹2,400",
title: "Celebrations, elevated.",
blurb:
"The Carnival boxes — layered, abundant and composed for the moments in between.",
panel: "products",
tile_url: "/products/mozi20.jpg",
product_handles:
"mozimo-carnival-celebration-s\nmozimo-carnival-celebration-m",
collection_handle: "gift-collection",
cta_label: "View the full collection",
cta_url: "/collections/gift-collection",
sort: 3,
visible: true,
},
{
name: "Premium",
note: "From ₹3,900",
title: "Generosity, composed.",
blurb:
"Our grandest hampers — the ones that arrive before you speak and say everything after.",
panel: "products",
tile_url: "/products/mozi-33.jpg",
product_handles: "mozimo-carnival-celebration-l\nmozimo-celestial-s",
collection_handle: "gift-collection",
cta_label: "View the full collection",
cta_url: "/collections/gift-collection",
sort: 4,
visible: true,
},
{
name: "Seasonal",
note: "Limited editions",
title: "Chocolate for the calendar.",
blurb:
"Diwali to Valentine's — editions composed for the season's light, here only while it lasts.",
panel: "seasonal",
tile_url: "/products/mozi-30.jpg",
collection_handle: "gift-collection",
cta_label: "Shop the Seasonal Edit",
cta_url: "/collections/gift-collection",
sort: 5,
visible: true,
},
{
name: "Corporate & Bulk",
note: "At scale",
title: "Gratitude at scale.",
blurb:
"Branded hampers, volume pricing and door-step delivery for teams, clients and occasions of every size.",
panel: "enquiry",
tile_url: "/products/mozi21.jpg",
cta_label: "Start a corporate enquiry",
cta_url: "/bespoke#enquiry",
sort: 6,
visible: true,
},
{
name: "Bespoke Gifting",
note: "Made to order",
title: "Composed around your moment.",
blurb:
"Custom selections, packaging and personalisation — designed with our chocolatiers from the first conversation.",
panel: "enquiry",
tile_url: "/products/mozi-17.jpg",
cta_label: "Begin a bespoke commission",
cta_url: "/bespoke#enquiry",
sort: 7,
visible: true,
},
];
const COLLECTION_ROWS = [
{
shopify_handle: "bars",
name_override: "Bars",
blurb: "Single-origin tablets, 45% to 85%",
carousel_urls:
"/products/01-dark85-60gm.jpg\n/products/02-dark60-60gm.jpg\n/products/img-5469.jpg",
sort: 1,
visible: true,
},
{
shopify_handle: "bark",
name_override: "Barks",
blurb: "Crackled slabs of nuts, fruit & crunch",
carousel_urls:
"/products/01-almondbark60.jpg\n/products/02-almondbark45.jpg\n/products/20-almondpralinebark.jpg",
sort: 2,
visible: true,
},
{
shopify_handle: "pralines",
name_override: "Pralines",
blurb: "Hand-piped ganache, boxed like jewels",
carousel_urls:
"/products/mozi-27.jpg\n/products/irw05205.jpg\n/products/14-mozimopralinepanetela-9c9a80d8-e4fc-4c4b-9c56-ca7f42b57847.jpg",
sort: 3,
visible: true,
},
{
shopify_handle: "spreads",
name_override: "Spreads",
blurb: "Stone-ground, silky, single origin",
carousel_urls:
"/products/02-pistachiospread60.jpg\n/products/03-hazelnutspread45.jpg\n/products/mozi-23.jpg",
sort: 4,
visible: true,
},
{
shopify_handle: "dragees",
name_override: "Dragees",
blurb: "Roasted nuts under a thin chocolate shell",
carousel_urls:
"/products/04-almonddragees60.jpg\n/products/02-hazelnutdragees45.jpg\n/products/03-pistachiodragees.jpg",
sort: 5,
visible: true,
},
{
shopify_handle: "tuilles",
name_override: "Tuilles & Rocks",
blurb: "Lace-thin wafers, orangettes & slivers",
carousel_urls:
"/products/01-milk45-tuilles-100gm.jpg\n/products/02-whitechocolatetuilles-100gm.jpg\n/products/01-almondsliversrocks-100gm.jpg",
sort: 6,
visible: true,
},
{
shopify_handle: "gift-collection",
name_override: "Gifting",
blurb: "Hampers composed for every celebration",
carousel_urls:
"/products/mozi20.jpg\n/products/mozi-33.jpg\n/products/mozi4.jpg",
sort: 7,
visible: true,
},
{
shopify_handle: "hampers-boxes",
name_override: "Hampers & Boxes",
blurb: "Celebration hampers and keepsake boxes, composed to order",
carousel_urls:
"/products/mozi20.jpg\n/products/mozi-33.jpg\n/products/mozi21.jpg",
sort: 8,
visible: true,
},
];
const GIFTING_PAGE = [
{
hero_eyebrow: "Gifts",
hero_title: "The art of",
hero_script: "giving",
hero_lead:
"Some gifts are opened. Others are remembered. Every Mozimo hamper is composed by hand — chocolate first, packaging worthy of it second.",
tabs_eyebrow: "Who is it for?",
tabs_title: "Gifts,",
tabs_script: "price by price",
hampers_title: "Hampers &",
hampers_script: "boxes",
seasonal_eyebrow: "The Seasonal Edit",
seasonal_title: "Chocolate for the",
seasonal_script: "calendar",
sort: 0,
},
];
/* ── collection schemas ────────────────────────────────────── */
const SCHEMAS = {
gifting_tabs: {
name: "gifting_tabs",
type: "base",
listRule: "",
viewRule: "",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{ name: "name", type: "text", required: true },
{ name: "note", type: "text" },
{ name: "title", type: "text" },
{ name: "blurb", type: "text" },
{
name: "panel",
type: "select",
values: ["products", "seasonal", "enquiry"],
maxSelect: 1,
},
{ name: "tile", type: "file", maxSelect: 1, mimeTypes: IMAGE_MIME },
{ name: "tile_url", type: "text" },
{ name: "product_handles", type: "text" },
{ name: "collection_handle", type: "text" },
{ name: "parent_slug", type: "text" },
{ name: "cta_label", type: "text" },
{ name: "cta_url", type: "text" },
{ name: "sort", type: "number" },
{ name: "visible", type: "bool" },
],
},
collection_rows: {
name: "collection_rows",
type: "base",
listRule: "",
viewRule: "",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{ name: "shopify_handle", type: "text", required: true },
{ name: "name_override", type: "text" },
{ name: "blurb", type: "text" },
{ name: "carousel", type: "file", maxSelect: 3, mimeTypes: IMAGE_MIME },
{ name: "carousel_urls", type: "text" },
{ name: "sort", type: "number" },
{ name: "visible", type: "bool" },
],
},
site_settings: {
name: "site_settings",
type: "base",
listRule: "",
viewRule: "",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{ name: "key", type: "text", required: true },
{ name: "value", type: "text" },
{ name: "sort", type: "number" },
],
},
gifting_page: {
name: "gifting_page",
type: "base",
listRule: "",
viewRule: "",
createRule: null,
updateRule: null,
deleteRule: null,
fields: [
{ name: "hero_eyebrow", type: "text" },
{ name: "hero_title", type: "text" },
{ name: "hero_script", type: "text" },
{ name: "hero_lead", type: "text" },
{ name: "tabs_eyebrow", type: "text" },
{ name: "tabs_title", type: "text" },
{ name: "tabs_script", type: "text" },
{ name: "hampers_title", type: "text" },
{ name: "hampers_script", type: "text" },
{ name: "seasonal_eyebrow", type: "text" },
{ name: "seasonal_title", type: "text" },
{ name: "seasonal_script", type: "text" },
{ name: "sort", type: "number" },
],
},
};
const SITE_SETTINGS = [
{
key: "announcement",
value:
"Complimentary delivery in Chandigarh Tricity on orders above ₹1,500 · Intercity shipped at actuals",
sort: 0,
},
{
key: "home_seasonal_eyebrow",
value: "The Seasonal Edit",
sort: 1,
},
{
key: "home_seasonal_title",
value: "Chocolate for the",
sort: 2,
},
{
key: "home_seasonal_script",
value: "calendar",
sort: 3,
},
{
key: "home_universe_eyebrow",
value: "Our World",
sort: 4,
},
{
key: "home_universe_title",
value: "Discover the universe of",
sort: 5,
},
{
key: "home_universe_script",
value: "Mozimo",
sort: 6,
},
{
key: "home_universe_cta",
value: "Explore our world",
sort: 7,
},
];
const CORPORATE_CHILDREN = [
[
"Welcome Gifts",
"corporate-welcome-gifts",
"Day-one boxes that say the onboarding was personal.",
0,
],
[
"Joining Anniversaries",
"corporate-joining-anniversaries",
"Milestone chocolate for the day they said yes to you.",
1,
],
[
"Farewell & Leaving Gifts",
"corporate-farewell-gifts",
"A graceful send-off, wrapped properly.",
2,
],
[
"5-Year Milestones",
"corporate-5-year-milestones",
"Half a decade deserves more than a certificate.",
3,
],
[
"10-Year Milestones",
"corporate-10-year-milestones",
"A decade of craft, honoured in kind.",
4,
],
[
"CXO & VIP Gifting",
"corporate-cxo-vip-gifting",
"Executive gifting composed to the person, not the policy.",
5,
],
].map(([name, handle, blurb, sort]) => ({
name,
note: "",
title: name,
blurb,
panel: "products",
tile_url: "",
product_handles: "",
collection_handle: handle,
parent_slug: "corporate-bulk",
cta_label: "Enquire",
cta_url: "/bespoke#enquiry",
sort,
visible: true,
}));
const SEEDS = {
gifting_tabs: [...GIFTING_TABS, ...CORPORATE_CHILDREN],
collection_rows: COLLECTION_ROWS,
gifting_page: GIFTING_PAGE,
site_settings: SITE_SETTINGS,
};
/* ── helpers ───────────────────────────────────────────────── */
const api = async (path, options = {}) => {
const res = await fetch(`${PB_URL}${path}`, {
...options,
headers: { "Content-Type": "application/json", ...(options.headers ?? {}) },
signal: AbortSignal.timeout(10_000),
});
const body = await res.json().catch(() => ({}));
return { status: res.status, ok: res.ok, body };
};
async function main() {
if (!EMAIL || !PASSWORD) {
console.log(
"[pb-seed] PB_SUPERUSER_EMAIL/PASSWORD not set — skipping seed",
);
return;
}
const auth = await api("/api/collections/_superusers/auth-with-password", {
method: "POST",
body: JSON.stringify({ identity: EMAIL, password: PASSWORD }),
});
if (!auth.ok) {
console.warn(
`[pb-seed] superuser auth failed (${auth.status}) — skipping seed`,
);
return;
}
const token = auth.body.token;
const authed = (path, options = {}) =>
api(path, {
...options,
headers: { Authorization: token, ...(options.headers ?? {}) },
});
for (const [name, schema] of Object.entries(SCHEMAS)) {
const created = await authed("/api/collections", {
method: "POST",
body: JSON.stringify(schema),
});
if (created.ok) {
console.log(`[pb-seed] created collection "${name}"`);
} else if (created.status === 400) {
// exists — add any fields the schema gained, keep public read rules
const cur = await authed(`/api/collections/${name}`);
if (cur.ok) {
const curFields = cur.body.fields ?? [];
const have = new Set(curFields.map((f) => f.name));
const missing = (schema.fields ?? []).filter((f) => !have.has(f.name));
const body = missing.length
? { fields: [...curFields, ...missing], listRule: "", viewRule: "" }
: { listRule: "", viewRule: "" };
const patched = await authed(`/api/collections/${name}`, {
method: "PATCH",
body: JSON.stringify(body),
});
if (!patched.ok && missing.length) {
console.warn(
`[pb-seed] field merge for "${name}" failed:`,
patched.status,
JSON.stringify(patched.body).slice(0, 160),
);
}
}
} else {
console.warn(
`[pb-seed] collection "${name}":`,
created.status,
JSON.stringify(created.body).slice(0, 200),
);
continue;
}
// seed only when empty
const existing = await authed(`/api/collections/${name}/records?perPage=1`);
if (!existing.ok) {
console.warn(`[pb-seed] cannot read "${name}" (${existing.status})`);
continue;
}
if (existing.body.totalItems > 0) {
console.log(
`[pb-seed] "${name}" already has ${existing.body.totalItems} record(s) — leaving as is`,
);
// one-off migration: gifting tab CTAs must keep users on mozimo.in,
// never bounce them out to the shopify domain
if (name === "gifting_tabs") {
const all = await authed(`/api/collections/${name}/records?perPage=100`);
for (const rec of all.body.items ?? []) {
const url = rec.cta_url ?? "";
if (url.startsWith("https://shop.mozimo.in/")) {
const internal = url.replace("https://shop.mozimo.in", "");
const patched = await authed(`/api/collections/${name}/records/${rec.id}`, {
method: "PATCH",
body: JSON.stringify({ cta_url: internal }),
});
if (patched.ok) {
console.log(`[pb-seed] migrated cta_url for "${rec.name}" -> ${internal}`);
} else {
console.warn(`[pb-seed] cta migration failed for "${rec.name}":`, patched.status);
}
}
}
}
// migration: fill section copy that arrived after the record was seeded
// (only empty fields — anything edited in the admin is never overwritten)
if (name === "gifting_page") {
const rec = (await authed(`/api/collections/${name}/records?perPage=1`)).body.items?.[0];
if (rec) {
const patch = {};
for (const [k, v] of Object.entries(SEEDS[name][0] ?? {})) {
if (k !== "sort" && !rec[k]) patch[k] = v;
}
if (Object.keys(patch).length) {
const patched = await authed(`/api/collections/${name}/records/${rec.id}`, {
method: "PATCH",
body: JSON.stringify(patch),
});
if (patched.ok)
console.log(`[pb-seed] gifting_page: filled ${Object.keys(patch).length} empty field(s)`);
}
}
}
// migration: make sure editorial rows/settings that shipped later exist
if (name === "collection_rows") {
const all = (await authed(`/api/collections/${name}/records?perPage=100`)).body.items ?? [];
for (const row of COLLECTION_ROWS) {
if (!all.some((r) => r.shopify_handle === row.shopify_handle)) {
const inserted = await authed(`/api/collections/${name}/records`, {
method: "POST",
body: JSON.stringify(row),
});
if (inserted.ok)
console.log(`[pb-seed] collection_rows: added missing row "${row.name_override}"`);
}
}
}
if (name === "site_settings") {
const all = (await authed(`/api/collections/${name}/records?perPage=100`)).body.items ?? [];
for (const setting of SITE_SETTINGS) {
if (!all.some((r) => r.key === setting.key)) {
const inserted = await authed(`/api/collections/${name}/records`, {
method: "POST",
body: JSON.stringify(setting),
});
if (inserted.ok)
console.log(`[pb-seed] site_settings: added "${setting.key}"`);
}
}
}
continue;
}
for (const record of SEEDS[name]) {
const inserted = await authed(`/api/collections/${name}/records`, {
method: "POST",
body: JSON.stringify(record),
});
if (!inserted.ok) {
console.warn(
`[pb-seed] seed record in "${name}":`,
inserted.status,
JSON.stringify(inserted.body).slice(0, 200),
);
}
}
console.log(
`[pb-seed] seeded ${SEEDS[name].length} record(s) into "${name}"`,
);
}
console.log("[pb-seed] done");
}
main().catch((error) => {
console.warn("[pb-seed] failed:", error?.message ?? error);
});