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:
@@ -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
@@ -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 &{" "}
|
||||
<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
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user