#!/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: "https://shop.mozimo.in/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: "https://shop.mozimo.in/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: "https://shop.mozimo.in/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: "https://shop.mozimo.in/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: "https://shop.mozimo.in/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, }, ]; 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.", 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: "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, }, ]; 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`, ); 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); });