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
This commit is contained in:
2026-09-06 15:22:50 +05:30
parent 06f6e75b57
commit 296c05cf88
7 changed files with 256 additions and 21 deletions
+108
View File
@@ -205,6 +205,15 @@ const COLLECTION_ROWS = [
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 = [
@@ -214,6 +223,14 @@ const GIFTING_PAGE = [
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,
},
];
@@ -296,6 +313,14 @@ const SCHEMAS = {
{ 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" },
],
},
@@ -308,6 +333,41 @@ const SITE_SETTINGS = [
"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 = [
@@ -475,6 +535,54 @@ async function main() {
}
}
}
// 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]) {
+15 -8
View File
@@ -14,7 +14,8 @@ export const Route = createFileRoute("/gifting")({
});
function GiftingPage() {
const { hero, tabs, hampers, seasonal } = Route.useLoaderData();
const { hero, sections, tabs, hampers, seasonal } =
Route.useLoaderData();
return (
<div className="min-h-screen bg-ivory-50">
@@ -51,7 +52,7 @@ function GiftingPage() {
>
<div className="wrap section-pad pb-0!">
<Reveal>
<p className="eyebrow text-orange-500">Who is it for?</p>
<p className="eyebrow text-orange-500">{sections.tabs.eyebrow}</p>
</Reveal>
<Reveal delay={0.05}>
<h2 className="display mt-5 max-w-2xl text-[clamp(1.9rem,3.8vw,2.9rem)] text-ink-900">
@@ -71,13 +72,15 @@ function GiftingPage() {
<div className="flex flex-wrap items-end justify-between gap-6">
<Reveal>
<h2 className="display text-[clamp(2rem,4vw,3.4rem)]">
Hampers &amp;{" "}
<em className="italic text-orange-500">boxes</em>
{sections.hampers.title}{" "}
<em className="italic text-orange-500">
{sections.hampers.script}
</em>
</h2>
</Reveal>
<Reveal delay={0.1}>
<a
href="/collections/gift-collection"
href="/collections/hampers-boxes"
className="link-quiet eyebrow text-ink-700 hover:text-orange-500"
>
Full gifting collection
@@ -118,12 +121,16 @@ function GiftingPage() {
<section id="seasonal" className="relative bg-orange-500">
<div className="wrap section-pad">
<Reveal>
<p className="eyebrow text-ivory-100">The Seasonal Edit</p>
<p className="eyebrow text-ivory-100">
{sections.seasonal.eyebrow}
</p>
</Reveal>
<Reveal delay={0.1}>
<h2 className="display mt-5 max-w-3xl text-[clamp(2.1rem,4.5vw,3.9rem)] text-ivory-50">
Chocolate for the{" "}
<em className="italic text-orange-300">calendar</em>
{sections.seasonal.title}{" "}
<em className="italic text-orange-300">
{sections.seasonal.script}
</em>
</h2>
</Reveal>
+8 -3
View File
@@ -15,8 +15,12 @@ import SeasonalTeaser from "@/components/sections/SeasonalTeaser";
import ExperiencesTeaser from "@/components/sections/ExperiencesTeaser";
import JournalTeaser from "@/components/sections/JournalTeaser";
import { PressStrip, InstagramSection } from "@/components/sections/Social";
import { getHomeCopy, type HomeCopy } from "@/lib/content";
export const Route = createFileRoute("/")({ component: Home });
export const Route = createFileRoute("/")({
component: Home,
loader: (): Promise<HomeCopy> => getHomeCopy(),
});
const TICKER = [
"Single Origin",
@@ -28,6 +32,7 @@ const TICKER = [
];
function Home() {
const copy = Route.useLoaderData();
return (
<div className="min-h-screen bg-ivory-50">
<Header />
@@ -37,12 +42,12 @@ function Home() {
<BrandStatement />
<ExperienceTiles />
<MozimoEdit />
<UniverseSection />
<UniverseSection copy={copy.universe} />
<Signatures />
<CraftCinematic />
<IngredientStory />
<GiftingSection />
<SeasonalTeaser />
<SeasonalTeaser copy={copy.seasonal} />
<ExperiencesTeaser />
<JournalTeaser />
<PressStrip />
+12 -4
View File
@@ -1,19 +1,27 @@
import Reveal from "@/components/Reveal";
import { seasonal } from "@/data/catalog";
import type { HomeCopy } from "@/lib/content";
export default function SeasonalTeaser() {
export default function SeasonalTeaser({
copy,
}: {
copy?: HomeCopy["seasonal"];
}) {
const eyebrow = copy?.eyebrow ?? "The Seasonal Edit";
const title = copy?.title ?? "Chocolate for the";
const script = copy?.script ?? "calendar";
return (
<section id="seasonal" className="relative bg-orange-500">
<div className="wrap section-pad">
<div className="flex flex-wrap items-end justify-between gap-6">
<div>
<Reveal>
<p className="eyebrow text-ivory-100">The Seasonal Edit</p>
<p className="eyebrow text-ivory-100">{eyebrow}</p>
</Reveal>
<Reveal delay={0.1}>
<h2 className="display mt-5 max-w-3xl text-[clamp(2.1rem,5vw,4.2rem)] text-ivory-50">
Chocolate for the{" "}
<em className="italic text-ivory-100">calendar</em>
{title}{" "}
<em className="italic text-ivory-100">{script}</em>
</h2>
</Reveal>
</div>
+13 -4
View File
@@ -1,4 +1,5 @@
import Reveal from "@/components/Reveal";
import type { HomeCopy } from "@/lib/content";
/**
* "Discover the universe of Mozimo" Hermes-inspired universe gateway.
@@ -6,7 +7,15 @@ import Reveal from "@/components/Reveal";
* link into /our-world. Imagery is swappable in one line once the new
* cacao/pastel photography lands.
*/
export default function UniverseSection() {
export default function UniverseSection({
copy,
}: {
copy?: HomeCopy["universe"];
}) {
const eyebrow = copy?.eyebrow ?? "Our World";
const title = copy?.title ?? "Discover the universe of";
const script = copy?.script ?? "Mozimo";
const cta = copy?.cta ?? "Explore our world";
return (
<section className="relative" aria-label="The universe of Mozimo">
<Reveal>
@@ -21,11 +30,11 @@ export default function UniverseSection() {
/>
<span className="absolute inset-0 bg-gradient-to-t from-bean-950/80 via-bean-950/20 to-transparent" />
<span className="absolute inset-x-0 bottom-0 p-8 sm:p-14">
<span className="eyebrow block text-ivory-100/70">Our World</span>
<span className="eyebrow block text-ivory-100/70">{eyebrow}</span>
<span className="display mt-4 block max-w-3xl text-[clamp(2rem,4.5vw,3.8rem)] text-ivory-50">
Discover the universe of{" "}
{title}{" "}
<em className="font-script text-[1.12em] font-normal italic text-orange-300">
Mozimo
{script}
</em>
</span>
<span className="link-quiet eyebrow mt-6 inline-block text-ivory-100/80 transition-colors group-hover:text-orange-300">
+16
View File
@@ -161,6 +161,14 @@ export type CmsGiftingPage = {
heroTitle?: string;
heroScript?: string;
heroLead?: string;
tabsEyebrow?: string;
tabsTitle?: string;
tabsScript?: string;
hampersTitle?: string;
hampersScript?: string;
seasonalEyebrow?: string;
seasonalTitle?: string;
seasonalScript?: string;
};
export async function getCmsGiftingPage(): Promise<CmsGiftingPage | null> {
@@ -172,6 +180,14 @@ export async function getCmsGiftingPage(): Promise<CmsGiftingPage | null> {
heroTitle: str(r.hero_title) || undefined,
heroScript: str(r.hero_script) || undefined,
heroLead: str(r.hero_lead) || undefined,
tabsEyebrow: str(r.tabs_eyebrow) || undefined,
tabsTitle: str(r.tabs_title) || undefined,
tabsScript: str(r.tabs_script) || undefined,
hampersTitle: str(r.hampers_title) || undefined,
hampersScript: str(r.hampers_script) || undefined,
seasonalEyebrow: str(r.seasonal_eyebrow) || undefined,
seasonalTitle: str(r.seasonal_title) || undefined,
seasonalScript: str(r.seasonal_script) || undefined,
};
}
+84 -2
View File
@@ -113,11 +113,29 @@ export type ResolvedGiftingTab = Omit<GiftingTab, "cta"> & {
export type GiftingPageData = {
hero: GiftingHero;
sections: GiftingSections;
tabs: ResolvedGiftingTab[];
hampers: Product[];
seasonal: typeof defaultSeasonal;
};
/** The /gifting section headings — PB-editable, bundled fallbacks below. */
export type GiftingSections = {
tabs: { eyebrow: string; title: string; script: string };
hampers: { title: string; script: string };
seasonal: { eyebrow: string; title: string; script: string };
};
const fallbackSections: GiftingSections = {
tabs: { eyebrow: "Who is it for?", title: "Gifts,", script: "price by price" },
hampers: { title: "Hampers &", script: "boxes" },
seasonal: {
eyebrow: "The Seasonal Edit",
title: "Chocolate for the",
script: "calendar",
},
};
const fallbackTabs = (): ResolvedGiftingTab[] =>
defaultGiftingTabs.map((t) => ({
...t,
@@ -242,6 +260,24 @@ export const getGiftingPageData = createServerFn({ method: "GET" }).handler(
lead: cmsHero.heroLead ?? fallbackHero.lead,
}
: fallbackHero,
sections: cmsHero
? {
tabs: {
eyebrow: cmsHero.tabsEyebrow ?? fallbackSections.tabs.eyebrow,
title: cmsHero.tabsTitle ?? fallbackSections.tabs.title,
script: cmsHero.tabsScript ?? fallbackSections.tabs.script,
},
hampers: {
title: cmsHero.hampersTitle ?? fallbackSections.hampers.title,
script: cmsHero.hampersScript ?? fallbackSections.hampers.script,
},
seasonal: {
eyebrow: cmsHero.seasonalEyebrow ?? fallbackSections.seasonal.eyebrow,
title: cmsHero.seasonalTitle ?? fallbackSections.seasonal.title,
script: cmsHero.seasonalScript ?? fallbackSections.seasonal.script,
},
}
: fallbackSections,
tabs: cmsTabs ? await resolveTabs(cmsTabs) : fallbackTabs(),
hampers,
seasonal: defaultSeasonal,
@@ -263,7 +299,11 @@ export type CollectionPageData = {
export const getCollectionPage = createServerFn({ method: "GET" })
.validator((handle: string) => handle)
.handler(async ({ data: handle }): Promise<CollectionPageData | null> => {
const shop = await getShopifyCollection(handle, 50);
const [shop, rows] = await Promise.all([
getShopifyCollection(handle, 50),
getCmsCollectionRows(),
]);
const row = rows?.find((r) => r.shopifyHandle === handle);
if (!shop) {
// graceful fallback for the handful of curated handles
const def = defaultCollections.find((c) =>
@@ -282,7 +322,7 @@ export const getCollectionPage = createServerFn({ method: "GET" })
return {
handle,
title: shop.title,
description: shop.description,
description: shop.description || row?.blurb || "",
image: shop.image,
products: shop.products.map(toProduct),
};
@@ -307,6 +347,48 @@ export const getAnnouncement = createServerFn({ method: "GET" }).handler(
},
);
/* ── homepage editorial copy (PB `site_settings`, bundled fallbacks) ── */
export type HomeCopy = {
seasonal: { eyebrow: string; title: string; script: string };
universe: { eyebrow: string; title: string; script: string; cta: string };
};
const fallbackHomeCopy: HomeCopy = {
seasonal: {
eyebrow: "The Seasonal Edit",
title: "Chocolate for the",
script: "calendar",
},
universe: {
eyebrow: "Our World",
title: "Discover the universe of",
script: "Mozimo",
cta: "Explore our world",
},
};
export const getHomeCopy = createServerFn({ method: "GET" }).handler(
async (): Promise<HomeCopy> => {
const settings = await getCmsSiteSettings();
if (!settings) return fallbackHomeCopy;
const pick = (key: string, fb: string) => settings[key]?.trim() || fb;
return {
seasonal: {
eyebrow: pick("home_seasonal_eyebrow", fallbackHomeCopy.seasonal.eyebrow),
title: pick("home_seasonal_title", fallbackHomeCopy.seasonal.title),
script: pick("home_seasonal_script", fallbackHomeCopy.seasonal.script),
},
universe: {
eyebrow: pick("home_universe_eyebrow", fallbackHomeCopy.universe.eyebrow),
title: pick("home_universe_title", fallbackHomeCopy.universe.title),
script: pick("home_universe_script", fallbackHomeCopy.universe.script),
cta: pick("home_universe_cta", fallbackHomeCopy.universe.cta),
},
};
},
);
/* ── predictive search ─────────────────────────────────────── */
export type SearchResults = {