From 7de5b6392d71ccb73d267bc4bd0020dba7f2b6bb Mon Sep 17 00:00:00 2001 From: Steve Simonson <144349219+SteveSimonson@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:03:26 -0500 Subject: [PATCH] feat: avatar-locked bamboo gift guides (SEO wave 1) Add /gifts hub + five catalog-backed listicles (housewarming, host, kitchen, eco, Christmas home). Wire SEO (ItemList + FAQPage), sitemap, routeMeta, nav/footer, home rail, and PDP gift chips. Admin route untouched. --- AGENTS.md | 14 + public/sitemap.xml | 36 ++ scripts/generate-sitemap.mjs | 28 +- scripts/route-meta.ts | 8 + src/App.tsx | 4 + src/components/Layout.tsx | 7 + src/data/giftGuides.ts | 570 ++++++++++++++++++++++++++++++++ src/data/types.ts | 63 ++++ src/lib/seo.ts | 21 ++ src/lib/seoData.ts | 71 +++- src/pages/GiftGuide.tsx | 321 ++++++++++++++++++ src/pages/Gifts.tsx | 114 +++++++ src/pages/Home.tsx | 59 ++++ src/pages/Product.tsx | 21 ++ worker/generated/routeMeta.json | 2 +- 15 files changed, 1336 insertions(+), 3 deletions(-) create mode 100644 src/data/giftGuides.ts create mode 100644 src/pages/GiftGuide.tsx create mode 100644 src/pages/Gifts.tsx diff --git a/AGENTS.md b/AGENTS.md index 294c1e4..8b9e79c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,6 +107,20 @@ with the React pages); edit SEO copy there, never in the Worker. - `npm run deploy` = build + `wrangler deploy`. - Custom domain is configured in `wrangler.jsonc`; do not casually change worker/domain names. +## Gift guides (SEO listicles) + +Avatar-locked bamboo gift guides at `/gifts` and `/gifts/:slug`. + +- **Skill:** `~/.grok/skills/affiliate-gifting-seo` (`/affiliate-gifting-seo`) +- **Data:** `src/data/giftGuides.ts` — catalog-backed `productEntries` only +- **Pages:** `Gifts.tsx` hub, `GiftGuide.tsx` listicle +- **SEO:** ItemList + FAQPage via `giftsHubSeo` / `giftGuideSeo`; sitemap + routeMeta +- **Heroes:** reuse brand category / vibe / flatlay art (`heroImage` paths) until dedicated `/brand/gifts/` assets exist +- **Voice:** calm natural living; prefer role (host, couple, new home) over forced gender +- **Rules:** original prose; `giftWhy` ≠ PDP paste; footer disclosure only; no Conbal unless integrated + +Wave-1 slugs: `housewarming-gifts`, `gifts-for-the-host`, `kitchen-gifts`, `eco-friendly-gifts`, `christmas-home-gifts`. + ## Product page enrichment PDPs should be **destinations** (judgment + depth), not thin Amazon hops. diff --git a/public/sitemap.xml b/public/sitemap.xml index ddd34b9..0ad8a47 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -18,6 +18,12 @@ daily 0.9 + + https://ibamboo.com/gifts + 2026-08-02 + weekly + 0.9 + https://ibamboo.com/quiz 2026-08-02 @@ -1290,4 +1296,34 @@ weekly 0.75 + + https://ibamboo.com/gifts/housewarming-gifts + 2026-08-02 + monthly + 0.85 + + + https://ibamboo.com/gifts/gifts-for-the-host + 2026-08-02 + monthly + 0.85 + + + https://ibamboo.com/gifts/kitchen-gifts + 2026-08-02 + monthly + 0.85 + + + https://ibamboo.com/gifts/eco-friendly-gifts + 2026-08-02 + monthly + 0.85 + + + https://ibamboo.com/gifts/christmas-home-gifts + 2026-08-02 + monthly + 0.85 + diff --git a/scripts/generate-sitemap.mjs b/scripts/generate-sitemap.mjs index 6f12954..ac8b722 100644 --- a/scripts/generate-sitemap.mjs +++ b/scripts/generate-sitemap.mjs @@ -52,11 +52,29 @@ const products = [...new Set(productSlugs)].filter( (s) => !s.startsWith('fill-'), ) +/** Only top-level guide slugs (avoid product slugs inside productEntries). */ +function extractGiftGuideSlugs(filePath) { + try { + const src = readFileSync(filePath, 'utf8') + const slugs = [] + for (const m of src.matchAll( + /\{\s*slug:\s*['"]([a-z0-9][a-z0-9-]*)['"]\s*,\s*title:/g, + )) { + slugs.push(m[1]) + } + return [...new Set(slugs)] + } catch { + return [] + } +} +const giftSlugs = extractGiftGuideSlugs(join(ROOT, 'src/data/giftGuides.ts')) + /** @type {{ loc: string, changefreq: string, priority: string }[]} */ const urls = [ { loc: '/', changefreq: 'daily', priority: '1.0' }, { loc: '/shop', changefreq: 'daily', priority: '0.95' }, { loc: '/shop?limited=1', changefreq: 'daily', priority: '0.9' }, + { loc: '/gifts', changefreq: 'weekly', priority: '0.9' }, { loc: '/quiz', changefreq: 'weekly', priority: '0.85' }, { loc: '/why', changefreq: 'monthly', priority: '0.7' }, ] @@ -85,6 +103,14 @@ for (const slug of products) { }) } +for (const slug of giftSlugs) { + urls.push({ + loc: `/gifts/${slug}`, + changefreq: 'monthly', + priority: '0.85', + }) +} + function escapeXml(s) { return s .replace(/&/g, '&') @@ -113,7 +139,7 @@ ${body} const out = join(ROOT, 'public/sitemap.xml') writeFileSync(out, xml) console.log( - `Wrote ${out} (${urls.length} URLs: ${products.length} products, ${vibes.length} vibes, ${categories.length} categories)`, + `Wrote ${out} (${urls.length} URLs: ${products.length} products, ${giftSlugs.length} gifts, ${vibes.length} vibes, ${categories.length} categories)`, ) /** diff --git a/scripts/route-meta.ts b/scripts/route-meta.ts index 1200925..7ac6a33 100644 --- a/scripts/route-meta.ts +++ b/scripts/route-meta.ts @@ -7,6 +7,7 @@ * the React app sets after hydration. og:image stays sitewide per scope. */ import { CATEGORY_OPTIONS, products } from '../src/data/catalog' +import { giftGuides } from '../src/data/giftGuides' import { getProductEnrichment } from '../src/data/productEnrichments' import { VIBE_LIST } from '../src/data/vibes' import { @@ -16,6 +17,8 @@ import { } from '../src/lib/seo' import { finalizeRouteMeta, + giftGuideSeo, + giftsHubSeo, homeSeo, productSeo, quizSeo, @@ -46,6 +49,11 @@ export function buildRouteMeta(): RouteMetaFile { } routes['/quiz'] = finalizeRouteMeta(quizSeo()) routes['/why'] = finalizeRouteMeta(whySeo()) + routes['/gifts'] = finalizeRouteMeta(giftsHubSeo()) + + for (const g of giftGuides) { + routes[`/gifts/${g.slug}`] = finalizeRouteMeta(giftGuideSeo(g)) + } for (const vibe of VIBE_LIST) { routes[`/vibe/${vibe.id}`] = finalizeRouteMeta(vibeSeo(vibe)) diff --git a/src/App.tsx b/src/App.tsx index 899db55..f050872 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,8 @@ import { ProductPage } from './pages/Product' import { Why } from './pages/Why' import { Quiz } from './pages/Quiz' import { VibePage } from './pages/Vibe' +import { GiftsHubPage } from './pages/Gifts' +import { GiftGuidePage } from './pages/GiftGuide' const Admin = lazy(() => import('./pages/Admin').then((m) => ({ default: m.Admin })), @@ -39,6 +41,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index ac1272d..8bed53c 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -16,6 +16,7 @@ type NavItem = */ const nav: NavItem[] = [ { kind: 'link', to: '/quiz', label: 'Vibe check' }, + { kind: 'link', to: '/gifts', label: 'Gifts' }, { kind: 'shop', mode: 'cat', cat: 'kitchen', label: 'Kitchen' }, { kind: 'shop', mode: 'cat', cat: 'cutting-boards', label: 'Boards' }, { kind: 'shop', mode: 'cat', cat: 'dining', label: 'Table' }, @@ -36,6 +37,7 @@ function useShopNavActive() { return (item: NavItem): boolean => { if (item.kind === 'link') { if (item.to === '/quiz') return pathname.startsWith('/quiz') + if (item.to === '/gifts') return pathname.startsWith('/gifts') return pathname === item.to } if (!onShop) return false @@ -238,6 +240,11 @@ export function Layout() { Our story +
  • + + Gift guides + +
  • Vibe check diff --git a/src/data/giftGuides.ts b/src/data/giftGuides.ts new file mode 100644 index 0000000..ede086d --- /dev/null +++ b/src/data/giftGuides.ts @@ -0,0 +1,570 @@ +/** + * iBamboo gift guides — avatar-locked bamboo home listicles. + * Catalog-backed only. Original prose. Calm natural living voice. + * Prefer role (host / couple / new home) over forced gender. + */ +import type { GiftGuide, GiftOccasionId, GiftRecipientId } from './types' + +const U = '2026-08-02' +const P = '2026-08-02' + +const AMAZON_FAQ = { + q: 'Will it arrive in time, and can they return it?', + a: 'Shipping speed and Prime eligibility depend on the live Amazon listing and seller. Returns follow Amazon’s policies for that order. iBamboo shows typical street prices; always confirm delivery dates and return windows on Amazon before you buy.', +} + +export const giftGuides: GiftGuide[] = [ + { + slug: 'housewarming-gifts', + title: 'Housewarming Gifts in Bamboo', + dek: 'Boards, trays, plates, and kitchen tools that make a new place feel inhabited — not staged.', + primaryQuery: 'housewarming gifts bamboo', + recipientIds: ['new-home', 'couple', 'friend'], + occasionIds: ['housewarming', 'wedding', 'just-because'], + budgetBands: ['under-50', '50-150'], + heroImage: '/brand/categories/dining.webp', + publishedAt: P, + updatedAt: U, + readMinutes: 6, + intro: + 'A housewarming gift should survive the first dinner, not decorate the entry for a week. Bamboo earns its keep: warm under the hand, honest grain, light enough to use every day. This list stays on the iBamboo shelf — prep boards, serving pieces, plates, and utensils that say the kitchen is open.', + productEntries: [ + { + productSlug: 'grand-prep-board', + rank: 1, + priceBand: 'under-50', + badge: 'Anchor gift', + giftWhy: + 'The board that makes a bare counter feel like a kitchen. Generous surface for chopping and plating; the kind of object new homeowners actually reach for on week one. Safe when you know they cook even a little.', + }, + { + productSlug: 'nesting-board-set', + rank: 2, + priceBand: 'under-50', + giftWhy: + 'Three sizes that stack cleanly — ideal when cabinet space is still a question. Feels considered without requiring a huge footprint. Pair with a note about oiling once a month.', + }, + { + productSlug: 'dinner-plate-set-of-four', + rank: 3, + priceBand: 'under-50', + badge: 'Table-ready', + giftWhy: + 'Four plates that set a first real meal without mismatched thrift finds. Bamboo fiber reads calm and modern; confirm care notes on the listing if their dishwasher habits are aggressive.', + }, + { + productSlug: 'artisan-cooking-utensil-set', + rank: 4, + priceBand: 'under-50', + badge: 'Everyday yes', + giftWhy: + 'The practical core of a house gift: spoons and spatulas that live in a crock, not a drawer of regret. Low risk, high use. Perfect when the budget is tight but the intent is warm.', + }, + { + productSlug: 'handled-serving-tray', + rank: 5, + priceBand: 'under-50', + giftWhy: + 'Coffee to the couch, cheese to the patio, mail off the counter. A tray is hospitality hardware. Choose this when they already own a board and need carry energy.', + }, + { + productSlug: 'woven-placemat-set', + rank: 6, + priceBand: 'under-50', + giftWhy: + 'Soft structure for a table that is still learning its rhythm. Texture without fuss. Works as a standalone under-$25 gift or as a pair with plates.', + }, + { + productSlug: 'entry-key-tray', + rank: 7, + priceBand: 'under-50', + giftWhy: + 'The “home has a place for things” gift. Small, useful, and hard to get wrong. Ideal stocking-stuffer tier next to a bigger board.', + }, + { + productSlug: 'coaster-set-with-caddy', + rank: 8, + priceBand: 'under-50', + giftWhy: + 'Protects the first real table without plastic coasters. Complete with a caddy so it does not scatter. Quiet gift that still feels finished.', + }, + ], + sections: [ + { + heading: 'Who this list is for', + body: 'People walking into a new address — renters and owners — who will cook, host once, or simply want fewer plastic objects on the counter. Skip if they asked for pure art or already stocked a full kitchen from a registry.', + }, + { + heading: 'How to choose in sixty seconds', + body: 'They cook → board + utensils. They host → tray or placemats. Bare bones everything → plate set or the utensil set first. Budget under $25? Key tray, coasters, or the utensil set alone still lands as intentional.', + }, + ], + faq: [ + AMAZON_FAQ, + { + q: 'Is bamboo a good housewarming material?', + a: 'Yes when the gift is something they will touch daily — boards, utensils, trays. Bamboo reads natural without looking temporary. Avoid novelty bamboo gadgets; stick to kitchen and table workhorses.', + }, + { + q: 'What if they already have a cutting board?', + a: 'Shift to serving (tray, lazy susan on the host guide), a plate set, or bath/eco picks. Nested boards still help when they only own one undersized board.', + }, + { + q: 'Why buy through iBamboo instead of searching Amazon yourself?', + a: 'We curate a short list with house judgment and product pages that explain fit. You still check out on Amazon with normal shipping and returns. We may earn a referral commission on qualifying purchases.', + }, + ], + }, + { + slug: 'gifts-for-the-host', + title: 'Gifts for the Host', + dek: 'Charcuterie, serving, lazy Susan energy — tools for people who set the table for others.', + primaryQuery: 'gifts for the host', + recipientIds: ['host', 'couple', 'friend'], + occasionIds: ['hosting', 'christmas', 'birthday', 'just-because'], + budgetBands: ['under-50', '50-150'], + heroImage: '/brand/vibes/host-scene.webp', + publishedAt: P, + updatedAt: U, + readMinutes: 6, + intro: + 'Hosts do not need another scented candle. They need surfaces and tools that make feeding people easier: a board that carries a spread, a turntable that keeps sides in reach, cheese tools that do not bend. This list is pure hosting hardware from the iBamboo catalog — calm grain, real use, no party gimmicks.', + productEntries: [ + { + productSlug: 'charcuterie-host-set', + rank: 1, + priceBand: 'under-50', + badge: 'Thank-you gift', + giftWhy: + 'Board plus accessories in one gesture — the gift that says you noticed they always feed the room. Self-contained for a dinner party thank-you. Confirm included tools on the live listing.', + }, + { + productSlug: 'charcuterie-board', + rank: 2, + priceBand: 'under-50', + giftWhy: + 'When they already own knives and bowls, give them a better stage. Dual-sided utility for prep and serve. The classic host upgrade without clutter.', + }, + { + productSlug: 'lazy-susan-turntable', + rank: 3, + priceBand: 'under-50', + badge: 'Table hero', + giftWhy: + 'Passes sauces and sides without standing up. Feels generous on a crowded table. Best for hosts who run family-style meals or game nights with shared plates.', + }, + { + productSlug: 'handled-serving-tray', + rank: 4, + priceBand: 'under-50', + giftWhy: + 'From kitchen island to living room in one trip. Handles matter when the tray is loaded. Pair with a bottle if you want the gift to feel complete the same night.', + }, + { + productSlug: 'soft-cheese-spreader-set', + rank: 5, + priceBand: 'under-50', + giftWhy: + 'Small spend, high host polish. Soft cheeses stop fighting plastic knives. Excellent add-on when the main gift is a board or host set.', + }, + { + productSlug: 'serving-spoon-and-fork', + rank: 6, + priceBand: 'under-50', + giftWhy: + 'Salad bowls and family dishes deserve dedicated servers. Quiet, useful, and hard to duplicate poorly. Safe coworker or “thanks for dinner” tier.', + }, + { + productSlug: 'family-salad-bowl', + rank: 7, + priceBand: '50-150', + badge: 'Statement piece', + giftWhy: + 'The center of the table when greens are the side. Larger presence; confirm they have shelf height. Splurge energy without leaving bamboo territory.', + }, + { + productSlug: 'condiment-dish-set', + rank: 8, + priceBand: 'under-50', + giftWhy: + 'Dips, olives, finishing salts — separated instead of piled on one plate. Looks intentional for cocktail hour. Under-$20 finish for a bigger host gift.', + }, + ], + sections: [ + { + heading: 'Dinner party vs everyday host', + body: 'Dinner-party hosts light up for the charcuterie set, lazy Susan, and salad bowl. Everyday hosts — weeknight open-door energy — prefer the tray and serving utensils. When unsure, the host set or a solid board is the broadest yes.', + }, + { + heading: 'Skip if', + body: 'They already run a full entertaining kit and asked for wine or experiences. They never serve food at home. They want pure décor with zero kitchen function — this list is workhorse hosting, not shelf sculpture.', + }, + ], + faq: [ + AMAZON_FAQ, + { + q: 'What is the safest thank-you gift for a host?', + a: 'A charcuterie host set or handled tray ranks highest: useful the next time they open the door. Cheese spreaders win when budget is tight.', + }, + { + q: 'Should I bring food too?', + a: 'Hardware plus something edible is classic. If you only bring one thing, choose the tool that outlasts the evening — boards and trays stay after the cheese is gone.', + }, + { + q: 'Is a lazy Susan too personal?', + a: 'Only if their table is tiny or they never share plates. When they host family-style, it reads as practical care, not presumption.', + }, + ], + }, + { + slug: 'kitchen-gifts', + title: 'Kitchen Gifts in Bamboo', + dek: 'Utensils, boards, and the knife-block insert — tools for people who actually cook.', + primaryQuery: 'bamboo kitchen gifts', + recipientIds: ['cook', 'couple', 'self', 'friend'], + occasionIds: ['birthday', 'christmas', 'wedding', 'just-because'], + budgetBands: ['under-50', '50-150'], + heroImage: '/brand/categories/kitchen.webp', + publishedAt: P, + updatedAt: U, + readMinutes: 6, + intro: + 'Kitchen gifts fail when they are cute. They work when they replace a warping plastic spatula or a scarred scrap of plastic board. Bamboo is kind to cookware, light in the hand, and honest about what it is. Every pick here is a real tool from the iBamboo kitchen and board shelves.', + productEntries: [ + { + productSlug: 'artisan-cooking-utensil-set', + rank: 1, + priceBand: 'under-50', + badge: 'Starter kit', + giftWhy: + 'The gift that retires a drawer of melted plastic. Six tools, one grain language. Highest odds of daily use for anyone with a stovetop.', + }, + { + productSlug: 'grand-prep-board', + rank: 2, + priceBand: 'under-50', + badge: 'Prep anchor', + giftWhy: + 'Cooks run out of board space before they run out of recipes. A generous prep board is permission to cook more. Confirm dimensions against their sink and cabinet depth.', + }, + { + productSlug: 'universal-knife-block-insert', + rank: 3, + priceBand: 'under-50', + giftWhy: + 'When the knives are good and the storage is chaos. Bamboo insert calms a drawer or block setup without forcing a full block purchase. Only if you know their knife count roughly fits.', + }, + { + productSlug: 'slim-spatula-trio', + rank: 4, + priceBand: 'under-50', + giftWhy: + 'Eggs, fish, nonstick pans — thin edges matter. A focused trio for cooks who already own bulkier spoons. Excellent under-$20 kitchen gift.', + }, + { + productSlug: 'end-grain-butcher-block', + rank: 5, + priceBand: 'under-50', + giftWhy: + 'For the cook who cares about edge life. End grain is a statement of seriousness. Heavier than a thin board — skip for tiny apartments with no counter real estate.', + }, + { + productSlug: 'bakers-rolling-pin', + rank: 6, + priceBand: 'under-50', + giftWhy: + 'Pie, pasta, cookies — one tool that outlives a holiday. Best when you have seen them bake once. Otherwise stick to utensils and boards.', + }, + { + productSlug: 'locking-kitchen-tongs', + rank: 7, + priceBand: 'under-50', + giftWhy: + 'Grilling, pasta, salad, toast retrieval. Locking jaws travel and store cleanly. Unsexy, used weekly — the gift cooks quietly thank you for.', + }, + { + productSlug: 'soup-ladle', + rank: 8, + priceBand: 'under-50', + giftWhy: + 'Broth, chili, punch — a proper ladle ends the era of coffee mugs as servers. Simple, complete, easy to wrap with a soup mix if you want.', + }, + ], + sections: [ + { + heading: 'New cook vs seasoned cook', + body: 'New cooks need the utensil set and a solid prep board. Seasoned cooks already own basics — lean knife insert, end-grain block, slim spatulas, or a rolling pin if they bake. Never gift a full set that duplicates what lives in their crock without checking.', + }, + { + heading: 'Care honesty', + body: 'Bamboo kitchen tools want hand wash and dry time. If the recipient only runs the dishwasher, say so in the note and pick pieces they will still hand-rinse, or choose fiber dinnerware with clearer care labels on Amazon.', + }, + ], + faq: [ + AMAZON_FAQ, + { + q: 'What is the single best bamboo kitchen gift?', + a: 'A full utensil set or a generous prep board. Both hit daily use. Add tongs or a ladle when they already own those two.', + }, + { + q: 'Will bamboo utensils scratch my pans?', + a: 'Quality bamboo is gentler than metal on nonstick and enamel. Still avoid using any hard tool as a scraper on damaged coatings — technique matters more than material marketing.', + }, + { + q: 'Can I gift kitchen tools for a wedding?', + a: 'Yes when the couple cooks. Frame it as “for the first real kitchen,” not as a joke. Pair board + utensils for a complete starter without registry clutter.', + }, + ], + }, + { + slug: 'eco-friendly-gifts', + title: 'Eco-Friendly Bamboo Gifts', + dek: 'Bath and kitchen everyday swaps — real catalog pieces that replace plastic without the lecture.', + primaryQuery: 'eco friendly bamboo gifts', + recipientIds: ['eco-minded', 'friend', 'self', 'coworker'], + occasionIds: ['birthday', 'christmas', 'just-because', 'housewarming'], + budgetBands: ['under-50'], + heroImage: '/brand/categories/bath.webp', + publishedAt: P, + updatedAt: U, + readMinutes: 5, + intro: + 'Eco gifts fail when they are symbolic junk. They work when they replace something already in the bathroom or kitchen drawer. This list is only what iBamboo actually sells: bamboo toothbrushes, cotton swabs, soap dishes, everyday utensils, and small travel pieces. Material truth over greenwashing slogans.', + productEntries: [ + { + productSlug: 'soft-bristle-toothbrush-set', + rank: 1, + priceBand: 'under-50', + badge: 'Daily swap', + giftWhy: + 'The most honest eco gift: they will use it twice a day. Set format means the gift lasts past week one. Soft bristles suit most adults; check listing for firmness if they are particular.', + }, + { + productSlug: 'bamboo-cotton-swabs', + rank: 2, + priceBand: 'under-50', + giftWhy: + 'A drawer upgrade with zero learning curve. Low cost, high “they thought about plastic” signal without a sermon. Bundle with the toothbrush set for a bath kit under $20.', + }, + { + productSlug: 'artisan-cooking-utensil-set', + rank: 3, + priceBand: 'under-50', + badge: 'Kitchen swap', + giftWhy: + 'Replaces a nest of plastic utensils with one coherent bamboo set. Eco story meets real cooking. The gift that lives by the stove, not in a “green” cabinet of unused gadgets.', + }, + { + productSlug: 'slatted-soap-dish', + rank: 4, + priceBand: 'under-50', + giftWhy: + 'Lets bar soap dry instead of melting into sludge. Small bathroom object with daily presence. Pairs cleanly with the toothbrush stand for a sink vignette.', + }, + { + productSlug: 'toothbrush-stand', + rank: 5, + priceBand: 'under-50', + giftWhy: + 'Keeps bamboo brushes upright and drying. Completes the oral-care swap so the gift feels designed, not incomplete. Skip if they already have a closed holder they love.', + }, + { + productSlug: 'reusable-chopstick-set', + rank: 6, + priceBand: 'under-50', + giftWhy: + 'For takeout nights and noodle bowls at home. Reusable without being preachy. Good coworker or Secret Santa tier with a clear use case.', + }, + { + productSlug: 'safety-razor-handle', + rank: 7, + priceBand: 'under-50', + giftWhy: + 'A longer game than disposable plastic razors — blades are consumable, the handle is not. Only if they are open to a slight learning curve; include a note to buy blades separately.', + }, + { + productSlug: 'cutlery-travel-roll', + rank: 8, + priceBand: 'under-50', + giftWhy: + 'Lunch bags, picnics, desk drawers. Portable utensils that cut single-use plastic on the go. Best for commuters and outdoor days, not for homebody cooks who never leave.', + }, + ], + sections: [ + { + heading: 'What “eco” means on this list', + body: 'We mean objects made of bamboo that replace a plastic habit — not carbon-offset certificates or mystery “biodegradable” packs. Bristles, packaging, and blade systems still have end-of-life steps; read the Amazon listing for disposal notes. Honesty is part of the gift.', + }, + { + heading: 'How to gift without preaching', + body: 'Lead with use (“for your sink,” “for the stove”), not virtue. Bundle bath pieces as a “quiet morning kit.” Kitchen swaps stand alone. Avoid lecture cards; the material is the message.', + }, + ], + faq: [ + AMAZON_FAQ, + { + q: 'Are bamboo toothbrushes fully compostable?', + a: 'Handles are typically compostable or easier to recycle than full plastic brushes; nylon bristles often need removal first. Check the specific listing. Frame the gift as a better daily tool, not perfect zero-waste.', + }, + { + q: 'What is the best under-$15 eco gift?', + a: 'Toothbrush set, cotton swabs, soap dish, or chopsticks. All clear, usable, and hard to misread as clutter.', + }, + { + q: 'Can I gift this to someone who is not “into eco”?', + a: 'Yes if the object is simply better — a good utensil set or soap dish. Skip the safety razor and travel roll for people who dislike change; start with kitchen or soft oral care.', + }, + ], + }, + { + slug: 'christmas-home-gifts', + title: 'Christmas Home Gifts in Bamboo', + dek: 'A seasonal mix of host and kitchen picks — gifts that earn a place after the wrapping paper is gone.', + primaryQuery: 'christmas bamboo home gifts', + recipientIds: ['host', 'couple', 'friend', 'cook', 'new-home'], + occasionIds: ['christmas', 'black-friday', 'just-because'], + budgetBands: ['under-50', '50-150'], + heroImage: '/brand/products-flatlay.webp', + publishedAt: P, + updatedAt: U, + readMinutes: 6, + seasonal: { peakMonths: [11, 12], yearHint: 2026 }, + intro: + 'Christmas home gifts should still make sense in January. Bamboo boards, host sets, plates, and a few everyday swaps do that work: warm under winter light, useful when guests leave. This list mixes the best host and kitchen energy from the iBamboo catalog — no novelty snowflake gadgets.', + productEntries: [ + { + productSlug: 'charcuterie-host-set', + rank: 1, + priceBand: 'under-50', + badge: 'Holiday table', + giftWhy: + 'December entertaining is the brief. A full host set arrives ready for cheese night and leftovers day. The gift they open and use before New Year’s.', + }, + { + productSlug: 'grand-prep-board', + rank: 2, + priceBand: 'under-50', + badge: 'Cook’s Christmas', + giftWhy: + 'Holiday prep multiplies board demand. A large prep surface is empathy for the person cooking. Wrap with a good olive oil for finishing if you want ceremony.', + }, + { + productSlug: 'dinner-plate-set-of-four', + rank: 3, + priceBand: 'under-50', + giftWhy: + 'Sets the Christmas table without china anxiety. Four places for a small feast; stack for guest overflow. Calm material in a season of sparkle.', + }, + { + productSlug: 'artisan-cooking-utensil-set', + rank: 4, + priceBand: 'under-50', + giftWhy: + 'The reliable stocking-to-main-gift bridge. Everyone who cooks can use better utensils. Easy to ship, easy to love, hard to return out of politeness.', + }, + { + productSlug: 'lazy-susan-turntable', + rank: 5, + priceBand: 'under-50', + giftWhy: + 'Family-style Christmas dinner and leftover grazing both improve when the table spins. Fun without being a toy. Best for multi-generation tables.', + }, + { + productSlug: 'nesting-board-set', + rank: 6, + priceBand: 'under-50', + giftWhy: + 'Three boards for holiday multi-tasking — bread, veg, serving. Nested storage matters when the kitchen is already full of seasonal gear.', + }, + { + productSlug: 'soft-bristle-toothbrush-set', + rank: 7, + priceBand: 'under-50', + badge: 'Stocking', + giftWhy: + 'Small, useful, and on-brand for the eco-minded branch of the tree. Bundle with cotton swabs for a complete stocking. Not the headline gift — the smart filler.', + }, + { + productSlug: 'handled-serving-tray', + rank: 8, + priceBand: 'under-50', + giftWhy: + 'Cookies to the living room, drinks to the porch lights. Trays earn their keep all December. Pair with the host set when you want a full entertaining story.', + }, + ], + sections: [ + { + heading: 'Christmas 2026 timing', + body: 'Order early if you need Prime delivery certainty — December traffic is real. This guide’s product set is evergreen; refresh the intro and seasonal badge each November rather than inventing new novelty SKUs.', + }, + { + heading: 'How to match the recipient', + body: 'They host → charcuterie set, lazy Susan, tray. They cook → grand board, utensils, nesting boards. New home or couple → plates + utensils. Stocking only → toothbrush set. When the list is long, one excellent board beats three forgettable gadgets.', + }, + ], + faq: [ + AMAZON_FAQ, + { + q: 'What is the best last-minute Christmas bamboo gift?', + a: 'Utensil set, toothbrush set, or cheese spreaders if still in stock with two-day shipping. Confirm the live delivery promise on Amazon before you promise the recipient.', + }, + { + q: 'Is bamboo “too everyday” for Christmas?', + a: 'Everyday is the point. Holiday gifts that become January tools feel more generous than décor that bags for storage. Add a handwritten note; material does the rest.', + }, + { + q: 'Can I mix host and kitchen gifts?', + a: 'Yes — a board plus a tray, or utensils plus a host set, tells a complete house story. Keep total pieces few so wrapping stays calm.', + }, + ], + }, +] + +const bySlug = new Map(giftGuides.map((g) => [g.slug, g])) + +export function getGiftGuide(slug: string): GiftGuide | undefined { + return bySlug.get(slug) +} + +export function guidesForProduct(productSlug: string): GiftGuide[] { + return giftGuides.filter((g) => + g.productEntries.some((e) => e.productSlug === productSlug), + ) +} + +export function guidesForOccasion(occasion: GiftOccasionId): GiftGuide[] { + return giftGuides.filter((g) => g.occasionIds.includes(occasion)) +} + +export function guidesForRecipient(recipient: GiftRecipientId): GiftGuide[] { + return giftGuides.filter((g) => g.recipientIds.includes(recipient)) +} + +export function featuredGiftGuides(limit = 5): GiftGuide[] { + const month = new Date().getMonth() + 1 + const scored = giftGuides.map((g) => { + const seasonalBoost = g.seasonal?.peakMonths.includes(month) ? 100 : 0 + const updated = g.updatedAt || g.publishedAt + return { g, score: seasonalBoost + (updated >= U ? 10 : 0) } + }) + scored.sort((a, b) => b.score - a.score) + return scored.slice(0, limit).map((x) => x.g) +} + +export const BUDGET_LABELS: Record = { + 'under-50': 'Under $50', + '50-150': '$50–150', + '150-400': '$150–400', + splurge: 'Splurge', +} + +export const RECIPIENT_LABELS: Record = { + host: 'For the host', + couple: 'For couples', + friend: 'For a friend', + self: 'For yourself', + coworker: 'Coworker', + 'new-home': 'New home', + cook: 'For cooks', + 'eco-minded': 'Eco-minded', +} diff --git a/src/data/types.ts b/src/data/types.ts index cd62f44..e5be214 100644 --- a/src/data/types.ts +++ b/src/data/types.ts @@ -112,3 +112,66 @@ export interface ProductEnrichment { /** Site names only — not full articles */ researchNotes?: string[] } + +/** Gift guide listicles — avatar-locked, catalog-backed. See AGENTS.md. */ +export type GiftRecipientId = + | 'host' + | 'couple' + | 'friend' + | 'self' + | 'coworker' + | 'new-home' + | 'cook' + | 'eco-minded' + +export type GiftOccasionId = + | 'christmas' + | 'housewarming' + | 'birthday' + | 'wedding' + | 'hosting' + | 'just-because' + | 'black-friday' + +export type GiftBudgetBand = 'under-50' | '50-150' | '150-400' | 'splurge' + +export interface GiftGuideProductEntry { + productSlug: string + rank?: number + /** Why this works as a gift — not a PDP paste */ + giftWhy: string + priceBand?: GiftBudgetBand + badge?: string +} + +export interface GiftGuideSection { + heading: string + body: string +} + +export interface GiftGuideFaq { + q: string + a: string +} + +export interface GiftGuide { + slug: string + title: string + dek: string + primaryQuery: string + recipientIds: GiftRecipientId[] + occasionIds: GiftOccasionId[] + budgetBands: GiftBudgetBand[] + productEntries: GiftGuideProductEntry[] + intro: string + sections: GiftGuideSection[] + faq: GiftGuideFaq[] + heroImage?: string + publishedAt: string + updatedAt: string + seasonal?: { + peakMonths: number[] + yearHint?: number + } + readMinutes?: number +} diff --git a/src/lib/seo.ts b/src/lib/seo.ts index a229e43..93ec6a0 100644 --- a/src/lib/seo.ts +++ b/src/lib/seo.ts @@ -112,6 +112,27 @@ export function breadcrumbJsonLd( } } +export function itemListJsonLd(opts: { + name: string + path: string + items: { name: string; path: string; position: number }[] +}) { + return { + '@context': 'https://schema.org', + '@type': 'ItemList', + name: opts.name, + url: absoluteUrl(opts.path), + itemListOrder: 'https://schema.org/ItemListOrderAscending', + numberOfItems: opts.items.length, + itemListElement: opts.items.map((item) => ({ + '@type': 'ListItem', + position: item.position, + url: absoluteUrl(item.path), + name: item.name, + })), + } +} + export function productJsonLd(opts: { name: string description: string diff --git a/src/lib/seoData.ts b/src/lib/seoData.ts index abdbe1f..34b7fe0 100644 --- a/src/lib/seoData.ts +++ b/src/lib/seoData.ts @@ -9,11 +9,13 @@ import { CATEGORY_LABELS, categoryLabel, filterProducts, + getProduct, productGalleryThumbs, productImageChain, } from '../data/catalog' import { getCategoryHero } from '../data/categoryHeroes' -import type { Category, Product } from '../data/types' +import { giftGuides } from '../data/giftGuides' +import type { Category, GiftGuide, Product } from '../data/types' import type { VibeProfile } from '../data/vibes' import { isQuietPlaceholder } from './productImages' import { @@ -23,6 +25,7 @@ import { breadcrumbJsonLd, clipMeta, faqPageJsonLd, + itemListJsonLd, pageTitle, productJsonLd, type PageSeo, @@ -204,6 +207,72 @@ export function quizSeo(): PageSeo { } } +export function giftsHubSeo(): PageSeo { + return { + title: 'Bamboo gift guides', + description: clipMeta( + `Housewarming, host, kitchen, eco, and Christmas home gifts — ${giftGuides.length} curated iBamboo guides. Chosen here; buy on Amazon.`, + ), + path: '/gifts', + image: '/brand/soho-collection.webp', + jsonLd: [ + breadcrumbJsonLd([ + { name: 'Home', path: '/' }, + { name: 'Gifts', path: '/gifts' }, + ]), + itemListJsonLd({ + name: 'iBamboo bamboo gift guides', + path: '/gifts', + items: giftGuides.map((g, i) => ({ + name: g.title, + path: `/gifts/${g.slug}`, + position: i + 1, + })), + }), + ], + } +} + +export function giftGuideSeo(g: GiftGuide): PageSeo { + const path = `/gifts/${g.slug}` + const products = g.productEntries + .map((e) => getProduct(e.productSlug)) + .filter(Boolean) as Product[] + + const jsonLd: Record[] = [ + breadcrumbJsonLd([ + { name: 'Home', path: '/' }, + { name: 'Gifts', path: '/gifts' }, + { name: g.title, path }, + ]), + itemListJsonLd({ + name: g.title, + path, + items: products.map((p, i) => ({ + name: p.name, + path: `/product/${p.slug}`, + position: g.productEntries[i]?.rank ?? i + 1, + })), + }), + ] + + if (g.faq.length) { + const faqLd = faqPageJsonLd(g.faq, path) + if (faqLd) jsonLd.push(faqLd) + } + + return { + title: g.title, + description: clipMeta( + `${g.dek} ${g.productEntries.length} bamboo picks on iBamboo. Updated ${g.updatedAt.slice(0, 4)}.`, + ), + path, + type: 'article', + image: g.heroImage || products[0]?.images?.[0] || '/brand/social.png', + jsonLd, + } +} + /** Rendered head values for one route (pageTitle/clipMeta applied, absolute URLs). */ export type RouteMeta = { title: string diff --git a/src/pages/GiftGuide.tsx b/src/pages/GiftGuide.tsx new file mode 100644 index 0000000..b38bba6 --- /dev/null +++ b/src/pages/GiftGuide.tsx @@ -0,0 +1,321 @@ +import { useState } from 'react' +import { Link, useParams } from 'react-router-dom' +import { + ArrowLeft, + ChevronDown, + Clock3, + ExternalLink, + Gift, +} from 'lucide-react' +import { formatMoney, getProduct } from '../data/catalog' +import { + BUDGET_LABELS, + getGiftGuide, + giftGuides, +} from '../data/giftGuides' +import { ProductCard } from '../components/ProductCard' +import { Seo } from '../components/Seo' +import { affiliateUrl } from '../lib/amazon' +import { trackAmazonClick } from '../lib/analytics' +import { giftGuideSeo } from '../lib/seoData' + +function Paragraphs({ text }: { text: string }) { + return ( + <> + {text.split(/\n\n+/).map((para, i) => ( +

    + {para} +

    + ))} + + ) +} + +function FaqItem({ q, a }: { q: string; a: string }) { + const [open, setOpen] = useState(false) + return ( +
    + + {open ? ( +

    + {a} +

    + ) : null} +
    + ) +} + +export function GiftGuidePage() { + const { slug } = useParams() + const guide = slug ? getGiftGuide(slug) : undefined + + if (!guide) { + return ( +
    + +

    Not found

    + + Back to gifts + +
    + ) + } + + const entries = guide.productEntries + .map((e) => { + const product = getProduct(e.productSlug) + return product ? { entry: e, product } : null + }) + .filter(Boolean) as { + entry: (typeof guide.productEntries)[0] + product: NonNullable> + }[] + + const related = giftGuides.filter((g) => g.slug !== guide.slug).slice(0, 3) + + const updated = new Date(guide.updatedAt + 'T12:00:00').toLocaleDateString( + 'en-US', + { month: 'long', year: 'numeric' }, + ) + + return ( +
    + + +
    +
    + + Home + + / + + Gifts + + / + + {guide.title} + +
    +
    + + {guide.heroImage ? ( +
    + +
    +
    + ) : null} + +
    + + All gift guides + + +

    + Gift guide +

    +

    + {guide.title} +

    +

    + {guide.dek} +

    +

    + + + {guide.readMinutes ?? 6} min read + + · + Updated {updated} + · + {entries.length} picks +

    + +
    + {guide.budgetBands.map((b) => ( + + {BUDGET_LABELS[b] || b} + + ))} +
    + +
    + +
    + +
    +

    + The list +

    +
      + {entries.map(({ entry, product }, i) => { + const shopUrl = affiliateUrl({ + asin: product.asin, + searchKeywords: product.searchKeywords, + name: product.name, + }) + return ( +
    1. +
      +
      + +
      +
      +
      +
      +

      + #{entry.rank ?? i + 1} + {entry.badge ? ` · ${entry.badge}` : ''} + {entry.priceBand + ? ` · ${BUDGET_LABELS[entry.priceBand] || entry.priceBand}` + : ''} +

      +

      + + {product.name} + +

      +

      + ~{formatMoney(product.priceHint)} on Amazon · varies +

      +
      +
      +

      + {entry.giftWhy} +

      + +
      +
      +
    2. + ) + })} +
    +
    + + {guide.sections.map((sec) => ( +
    +

    + {sec.heading} +

    + +
    + ))} + + {guide.faq.length > 0 && ( +
    +

    + Questions before you buy +

    +
    + {guide.faq.map((f) => ( + + ))} +
    +
    + )} +
    + + {related.length > 0 && ( +
    +

    More gift edits

    +

    + Related guides +

    +
    + {related.map((g) => ( + +

    + {g.primaryQuery} +

    +

    + {g.title} +

    +

    + {g.dek} +

    + + ))} +
    +
    + )} +
    + ) +} diff --git a/src/pages/Gifts.tsx b/src/pages/Gifts.tsx new file mode 100644 index 0000000..127f6a1 --- /dev/null +++ b/src/pages/Gifts.tsx @@ -0,0 +1,114 @@ +import { Link } from 'react-router-dom' +import { ArrowRight, Gift } from 'lucide-react' +import { + BUDGET_LABELS, + RECIPIENT_LABELS, + giftGuides, +} from '../data/giftGuides' +import { Seo } from '../components/Seo' +import { giftsHubSeo } from '../lib/seoData' + +export function GiftsHubPage() { + return ( +
    + +
    +
    + +
    +
    +
    +

    + Gift edit +

    +

    + Gifts with a place in the house +

    +

    + Bamboo listicles locked to the iBamboo shelf — housewarming, hosts, + kitchen, everyday eco swaps, and Christmas home picks. Chosen here. + Bought on Amazon. +

    +
    + {giftGuides.map((g) => ( + + {g.primaryQuery} + + ))} +
    +
    +
    + +
    +
    +
    +

    Wave one

    +

    + Start with the occasion +

    +
    +
    +
    + {giftGuides.map((g) => { + const recipients = g.recipientIds + .map((id) => RECIPIENT_LABELS[id] || id) + .filter(Boolean) + .slice(0, 2) + const budgets = g.budgetBands + .map((b) => BUDGET_LABELS[b]) + .filter(Boolean) + .slice(0, 2) + return ( + +
    + {g.heroImage ? ( + + ) : null} +
    +
    +
    +

    + {recipients.join(' · ') || 'Gift guide'} + {g.readMinutes ? ` · ${g.readMinutes} min` : ''} +

    +

    + {g.title} +

    +

    + {g.dek} +

    + {budgets.length > 0 && ( +

    + {budgets.join(' · ')} · {g.productEntries.length} picks +

    + )} + + Open guide + +
    + + ) + })} +
    +
    +
    + ) +} diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 2e01a3f..b7d49fa 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -9,6 +9,7 @@ import { limitedTimeCopy, } from '../data/catalog' import { HEROES } from '../data/categoryHeroes' +import { featuredGiftGuides } from '../data/giftGuides' import { VIBE_LIST, vibePath } from '../data/vibes' import { ProductCard } from '../components/ProductCard' import { AdaptiveContentBalloon } from '../components/AdaptiveContentBalloons' @@ -223,6 +224,64 @@ export function Home() {
    + {/* Gift guides rail */} +
    +
    +
    +
    +

    Gift edit

    +

    + Gifts with a place in the house +

    +

    + Housewarming, hosts, kitchen, everyday eco swaps, and Christmas + home picks — listicles from the iBamboo shelf only. +

    +
    + + All gift guides + +
    +
    + {featuredGiftGuides(5).map((g) => ( + + {g.heroImage ? ( +
    + +
    + ) : null} +
    +

    + {g.primaryQuery} +

    +

    + {g.title} +

    +

    + {g.dek} +

    + + Open guide + +
    + + ))} +
    +
    +
    +
    diff --git a/src/pages/Product.tsx b/src/pages/Product.tsx index 0f59c4c..49be1d7 100644 --- a/src/pages/Product.tsx +++ b/src/pages/Product.tsx @@ -40,6 +40,7 @@ import { isQuietPlaceholder } from '../lib/productImages' import { productSeo } from '../lib/seoData' import { useFlashCatalog } from '../hooks/useFlashCatalog' import { getProductEnrichment } from '../data/productEnrichments' +import { guidesForProduct } from '../data/giftGuides' import { ProductEnrichmentSections } from '../components/ProductEnrichment' export function ProductPage() { @@ -157,6 +158,7 @@ export function ProductPage() { // Local alias so nested handlers keep the narrowed product type const p = product + const onGiftGuides = guidesForProduct(p.slug) const shopUrl = affiliateUrl({ asin: p.asin, searchKeywords: p.searchKeywords, @@ -328,6 +330,25 @@ export function ProductPage() {

    {product.tagline}

    + {onGiftGuides.length > 0 && ( +
    +

    + Also on gift guides +

    +
    + {onGiftGuides.map((g) => ( + + {g.title} + + ))} +
    +
    + )} +