create-cartbase 0.1.16 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +25 -25
  3. package/dist/index.js +20 -20
  4. package/package.json +1 -1
  5. package/template/app/docs/auth.md +105 -105
  6. package/template/app/docs/carts.md +376 -376
  7. package/template/app/docs/categories.md +194 -194
  8. package/template/app/docs/checkout.md +714 -714
  9. package/template/app/docs/components.md +55 -11
  10. package/template/app/docs/consent.md +91 -91
  11. package/template/app/docs/deploy.md +197 -197
  12. package/template/app/docs/gift-cards.md +153 -153
  13. package/template/app/docs/metaobjects.md +126 -126
  14. package/template/app/docs/orders.md +221 -221
  15. package/template/app/docs/products.md +51 -2
  16. package/template/app/docs/regions.md +269 -269
  17. package/template/app/docs/reviews.md +223 -223
  18. package/template/app/docs/search.md +227 -227
  19. package/template/app/docs/store.md +47 -47
  20. package/template/app/docs/subscriptions.md +148 -148
  21. package/template/app/docs/variables.md +315 -315
  22. package/template/app/package.json +1 -1
  23. package/template/app/postcss.config.cjs +11 -11
  24. package/template/app/src/app/checkout/checkout-page-client.tsx +73 -73
  25. package/template/app/src/app/checkout/page.tsx +48 -48
  26. package/template/app/src/app/globals.css +26 -26
  27. package/template/app/src/app/page.tsx +28 -28
  28. package/template/app/src/app/products/[handle]/page.tsx +87 -87
  29. package/template/app/src/app/providers.tsx +68 -64
  30. package/template/app/src/app/search/page.tsx +23 -23
  31. package/template/app/src/lib/browser-client.ts +35 -35
  32. package/template/app/src/lib/config.ts +41 -41
  33. package/template/app/src/lib/server-client.ts +25 -25
  34. package/template/app/src/lib/cart-actions.ts +0 -47
@@ -1,11 +1,11 @@
1
- /*
2
- * Tailwind 4 ships its own PostCSS plugin and handles vendor prefixing
3
- * internally, so the v3 pairing of `tailwindcss` + `autoprefixer` is gone.
4
- * There is no tailwind.config file either: the theme is defined in CSS
5
- * (see src/app/globals.css and the theme shipped by @cartbase/storefront).
6
- */
7
- module.exports = {
8
- plugins: {
9
- "@tailwindcss/postcss": {},
10
- },
11
- }
1
+ /*
2
+ * Tailwind 4 ships its own PostCSS plugin and handles vendor prefixing
3
+ * internally, so the v3 pairing of `tailwindcss` + `autoprefixer` is gone.
4
+ * There is no tailwind.config file either: the theme is defined in CSS
5
+ * (see src/app/globals.css and the theme shipped by @cartbase/storefront).
6
+ */
7
+ module.exports = {
8
+ plugins: {
9
+ "@tailwindcss/postcss": {},
10
+ },
11
+ }
@@ -1,73 +1,73 @@
1
- "use client"
2
-
3
- import { useRouter } from "next/navigation"
4
- import type { Cart } from "@cartbase/storefront/api/carts"
5
- import type { StoreShippingOption } from "@cartbase/storefront/api/checkout"
6
- import type { StorePaymentEntry } from "@cartbase/storefront/api/checkout"
7
- import { CheckoutProvider } from "@cartbase/storefront/checkout/context"
8
- import { CheckoutClient } from "@cartbase/storefront/checkout/checkout-client"
9
- import { browserClient } from "@/lib/browser-client"
10
-
11
- /** sessionStorage key the confirmation page reads (guests have no order
12
- * read endpoint — the completeCart() response is the only order handle,
13
- * orders.md). */
14
- export const LAST_ORDER_STORAGE_KEY = "barter:last-order"
15
-
16
- export function CheckoutPageClient({
17
- cart,
18
- shippingOptions,
19
- paymentProviders,
20
- }: {
21
- cart: Cart
22
- shippingOptions: StoreShippingOption[]
23
- paymentProviders: StorePaymentEntry[]
24
- }) {
25
- const router = useRouter()
26
-
27
- return (
28
- <CheckoutProvider orderConfirmedPath="/order/{id}/confirmed">
29
- <CheckoutClient
30
- client={browserClient}
31
- cart={cart}
32
- customer={null}
33
- availableShippingMethods={shippingOptions}
34
- availablePaymentMethods={paymentProviders}
35
- // NO COUNTRY LIST HERE, on purpose (2026-09-13). This file is the
36
- // seed for every scaffolded store, and it used to pass
37
- // `countries={[{iso_2:"bg", display_name:"Bulgaria"}]}` with
38
- // `countryCode="bg"`, so every store on earth shipped a checkout
39
- // offering one country, Bulgaria, in a box the shopper could not
40
- // change. The list belongs to the store, not to this file: omit the
41
- // prop and the hook reads GET /api/store/countries, which answers
42
- // with the countries the store's Markets declare, or with the whole
43
- // world when it has declared none.
44
- //
45
- // A single-market store adds `countryCode="xx"` here to preselect
46
- // its country, and a store that sells to exactly one country gets
47
- // the read-only field automatically, because its Markets say so.
48
- // Per-store rule (the documented paymentMethodFilter seam): this
49
- // reference store checks out offline via its merchant methods only
50
- // (pp_* kill: method entries carry payment_method_id, processors
51
- // carry id) — the filter drops any connected processor.
52
- paymentMethodFilter={(methods) =>
53
- methods?.filter((m) => "payment_method_id" in m) ?? null
54
- }
55
- onOrderPlaced={(order) => {
56
- // Guest order handle = the completeCart() response (orders.md).
57
- // Stash it (+ the decorated cart lines for the items list) for
58
- // the confirmation page, then navigate.
59
- try {
60
- sessionStorage.setItem(
61
- LAST_ORDER_STORAGE_KEY,
62
- JSON.stringify({ order, cartItems: cart.items ?? [] })
63
- )
64
- } catch {
65
- // best-effort — the confirmation page has a fallback state
66
- }
67
- router.push(`/order/${order.id}/confirmed`)
68
- }}
69
- onCartChange={() => router.refresh()}
70
- />
71
- </CheckoutProvider>
72
- )
73
- }
1
+ "use client"
2
+
3
+ import { useRouter } from "next/navigation"
4
+ import type { Cart } from "@cartbase/storefront/api/carts"
5
+ import type { StoreShippingOption } from "@cartbase/storefront/api/checkout"
6
+ import type { StorePaymentEntry } from "@cartbase/storefront/api/checkout"
7
+ import { CheckoutProvider } from "@cartbase/storefront/checkout/context"
8
+ import { CheckoutClient } from "@cartbase/storefront/checkout/checkout-client"
9
+ import { browserClient } from "@/lib/browser-client"
10
+
11
+ /** sessionStorage key the confirmation page reads (guests have no order
12
+ * read endpoint — the completeCart() response is the only order handle,
13
+ * orders.md). */
14
+ export const LAST_ORDER_STORAGE_KEY = "barter:last-order"
15
+
16
+ export function CheckoutPageClient({
17
+ cart,
18
+ shippingOptions,
19
+ paymentProviders,
20
+ }: {
21
+ cart: Cart
22
+ shippingOptions: StoreShippingOption[]
23
+ paymentProviders: StorePaymentEntry[]
24
+ }) {
25
+ const router = useRouter()
26
+
27
+ return (
28
+ <CheckoutProvider orderConfirmedPath="/order/{id}/confirmed">
29
+ <CheckoutClient
30
+ client={browserClient}
31
+ cart={cart}
32
+ customer={null}
33
+ availableShippingMethods={shippingOptions}
34
+ availablePaymentMethods={paymentProviders}
35
+ // NO COUNTRY LIST HERE, on purpose (2026-09-13). This file is the
36
+ // seed for every scaffolded store, and it used to pass
37
+ // `countries={[{iso_2:"bg", display_name:"Bulgaria"}]}` with
38
+ // `countryCode="bg"`, so every store on earth shipped a checkout
39
+ // offering one country, Bulgaria, in a box the shopper could not
40
+ // change. The list belongs to the store, not to this file: omit the
41
+ // prop and the hook reads GET /api/store/countries, which answers
42
+ // with the countries the store's Markets declare, or with the whole
43
+ // world when it has declared none.
44
+ //
45
+ // A single-market store adds `countryCode="xx"` here to preselect
46
+ // its country, and a store that sells to exactly one country gets
47
+ // the read-only field automatically, because its Markets say so.
48
+ // Per-store rule (the documented paymentMethodFilter seam): this
49
+ // reference store checks out offline via its merchant methods only
50
+ // (pp_* kill: method entries carry payment_method_id, processors
51
+ // carry id) — the filter drops any connected processor.
52
+ paymentMethodFilter={(methods) =>
53
+ methods?.filter((m) => "payment_method_id" in m) ?? null
54
+ }
55
+ onOrderPlaced={(order) => {
56
+ // Guest order handle = the completeCart() response (orders.md).
57
+ // Stash it (+ the decorated cart lines for the items list) for
58
+ // the confirmation page, then navigate.
59
+ try {
60
+ sessionStorage.setItem(
61
+ LAST_ORDER_STORAGE_KEY,
62
+ JSON.stringify({ order, cartItems: cart.items ?? [] })
63
+ )
64
+ } catch {
65
+ // best-effort — the confirmation page has a fallback state
66
+ }
67
+ router.push(`/order/${order.id}/confirmed`)
68
+ }}
69
+ onCartChange={() => router.refresh()}
70
+ />
71
+ </CheckoutProvider>
72
+ )
73
+ }
@@ -1,48 +1,48 @@
1
- import { cookies } from "next/headers"
2
- import { redirect } from "next/navigation"
3
- import { retrieveCart } from "@cartbase/storefront/api/carts"
4
- import {
5
- listPaymentProviders,
6
- listShippingOptions,
7
- } from "@cartbase/storefront/api/checkout"
8
- import { readCartCookie } from "@/lib/config"
9
- import { getServerClient } from "@/lib/server-client"
10
- import { CheckoutPageClient } from "./checkout-page-client"
11
-
12
- /**
13
- * Checkout (runbook step 8 / checkout.md): list shipping options and
14
- * payment providers WITH `cart_id` (server-side rule filtering), fetch the
15
- * integrations `cod` block, and hand everything to the orchestrated
16
- * client layout. Redirect completed/missing carts server-side
17
- * (checkout-client mount rule).
18
- */
19
- export default async function CheckoutPage() {
20
- const jar = await cookies()
21
- // Back-compat: prefer `_cartbase_cart`, fall back to the legacy
22
- // `_barter_cart_id` name (platform-fingerprints card).
23
- const cartId = readCartCookie((name) => jar.get(name)?.value)
24
- if (!cartId) redirect("/")
25
-
26
- const client = await getServerClient()
27
- const cart = await retrieveCart(client, cartId)
28
- .then((res) => res.cart)
29
- .catch(() => null)
30
- if (!cart || (cart.items ?? []).length === 0) redirect("/")
31
- if (cart.completed_at) redirect("/")
32
-
33
- const [shippingOptions, paymentProviders] = await Promise.all([
34
- listShippingOptions(client, { cart_id: cart.id }),
35
- listPaymentProviders(client, {
36
- cart_id: cart.id,
37
- region_id: cart.region_id ?? undefined,
38
- }),
39
- ])
40
-
41
- return (
42
- <CheckoutPageClient
43
- cart={cart}
44
- shippingOptions={shippingOptions.shipping_options}
45
- paymentProviders={paymentProviders.payment_providers}
46
- />
47
- )
48
- }
1
+ import { cookies } from "next/headers"
2
+ import { redirect } from "next/navigation"
3
+ import { retrieveCart } from "@cartbase/storefront/api/carts"
4
+ import {
5
+ listPaymentProviders,
6
+ listShippingOptions,
7
+ } from "@cartbase/storefront/api/checkout"
8
+ import { readCartCookie } from "@/lib/config"
9
+ import { getServerClient } from "@/lib/server-client"
10
+ import { CheckoutPageClient } from "./checkout-page-client"
11
+
12
+ /**
13
+ * Checkout (runbook step 8 / checkout.md): list shipping options and
14
+ * payment providers WITH `cart_id` (server-side rule filtering), fetch the
15
+ * integrations `cod` block, and hand everything to the orchestrated
16
+ * client layout. Redirect completed/missing carts server-side
17
+ * (checkout-client mount rule).
18
+ */
19
+ export default async function CheckoutPage() {
20
+ const jar = await cookies()
21
+ // Back-compat: prefer `_cartbase_cart`, fall back to the legacy
22
+ // `_barter_cart_id` name (platform-fingerprints card).
23
+ const cartId = readCartCookie((name) => jar.get(name)?.value)
24
+ if (!cartId) redirect("/")
25
+
26
+ const client = await getServerClient()
27
+ const cart = await retrieveCart(client, cartId)
28
+ .then((res) => res.cart)
29
+ .catch(() => null)
30
+ if (!cart || (cart.items ?? []).length === 0) redirect("/")
31
+ if (cart.completed_at) redirect("/")
32
+
33
+ const [shippingOptions, paymentProviders] = await Promise.all([
34
+ listShippingOptions(client, { cart_id: cart.id }),
35
+ listPaymentProviders(client, {
36
+ cart_id: cart.id,
37
+ region_id: cart.region_id ?? undefined,
38
+ }),
39
+ ])
40
+
41
+ return (
42
+ <CheckoutPageClient
43
+ cart={cart}
44
+ shippingOptions={shippingOptions.shipping_options}
45
+ paymentProviders={paymentProviders.payment_providers}
46
+ />
47
+ )
48
+ }
@@ -1,26 +1,26 @@
1
- /*
2
- * The whole styling setup for a Cartbase storefront: two imports.
3
- *
4
- * Tailwind 4 is CSS-first, so there is no config file and no preset to
5
- * register. The library ships its theme filled, so this app renders as a
6
- * designed store without choosing a single value.
7
- *
8
- * To make the store yours, override the token values below the imports.
9
- * Never redefine the names — components reference them, and the library
10
- * repaints from the values alone.
11
- */
12
- @import "tailwindcss";
13
- @import "@cartbase/storefront/theme";
14
-
15
- /*
16
- * The library ships as TypeScript source rather than a build, so Tailwind has
17
- * to scan it to know which classes exist. Without this the components render
18
- * with no styles at all — the classes are in the markup but never generated.
19
- */
20
- @source "../../node_modules/@cartbase/storefront/src";
21
-
22
- /*
23
- * Dark mode is class-based: put `.dark` on <html> and every token swaps.
24
- * Tailwind 4 needs this declared explicitly.
25
- */
26
- @custom-variant dark (&:where(.dark, .dark *));
1
+ /*
2
+ * The whole styling setup for a Cartbase storefront: two imports.
3
+ *
4
+ * Tailwind 4 is CSS-first, so there is no config file and no preset to
5
+ * register. The library ships its theme filled, so this app renders as a
6
+ * designed store without choosing a single value.
7
+ *
8
+ * To make the store yours, override the token values below the imports.
9
+ * Never redefine the names — components reference them, and the library
10
+ * repaints from the values alone.
11
+ */
12
+ @import "tailwindcss";
13
+ @import "@cartbase/storefront/theme";
14
+
15
+ /*
16
+ * The library ships as TypeScript source rather than a build, so Tailwind has
17
+ * to scan it to know which classes exist. Without this the components render
18
+ * with no styles at all — the classes are in the markup but never generated.
19
+ */
20
+ @source "../../node_modules/@cartbase/storefront/src";
21
+
22
+ /*
23
+ * Dark mode is class-based: put `.dark` on <html> and every token swaps.
24
+ * Tailwind 4 needs this declared explicitly.
25
+ */
26
+ @custom-variant dark (&:where(.dark, .dark *));
@@ -1,28 +1,28 @@
1
- import { StoreTemplate } from "@cartbase/storefront/store/store-template"
2
- import type { SortOptions } from "@cartbase/storefront/lib/sort-products"
3
- import { PRICING_CONTEXT, STORE_LOCALE } from "@/lib/config"
4
- import { getServerClient } from "@/lib/server-client"
5
-
6
- /**
7
- * Home = the all-products listing (runbook step 5: always pass a pricing
8
- * context or prices come back undecorated).
9
- */
10
- export default async function HomePage({
11
- searchParams,
12
- }: {
13
- searchParams: Promise<{ sortBy?: string; page?: string }>
14
- }) {
15
- const { sortBy, page } = await searchParams
16
- const client = await getServerClient()
17
- return (
18
- <StoreTemplate
19
- client={client}
20
- sortBy={sortBy as SortOptions | undefined}
21
- page={page}
22
- pricingContext={PRICING_CONTEXT}
23
- // Server-rendered: the locale provider cannot reach it, so the pack
24
- // comes as a prop, and the prop is required.
25
- labels={STORE_LOCALE.store}
26
- />
27
- )
28
- }
1
+ import { StoreTemplate } from "@cartbase/storefront/store/store-template"
2
+ import type { SortOptions } from "@cartbase/storefront/lib/sort-products"
3
+ import { PRICING_CONTEXT, STORE_LOCALE } from "@/lib/config"
4
+ import { getServerClient } from "@/lib/server-client"
5
+
6
+ /**
7
+ * Home = the all-products listing (runbook step 5: always pass a pricing
8
+ * context or prices come back undecorated).
9
+ */
10
+ export default async function HomePage({
11
+ searchParams,
12
+ }: {
13
+ searchParams: Promise<{ sortBy?: string; page?: string }>
14
+ }) {
15
+ const { sortBy, page } = await searchParams
16
+ const client = await getServerClient()
17
+ return (
18
+ <StoreTemplate
19
+ client={client}
20
+ sortBy={sortBy as SortOptions | undefined}
21
+ page={page}
22
+ pricingContext={PRICING_CONTEXT}
23
+ // Server-rendered: the locale provider cannot reach it, so the pack
24
+ // comes as a prop, and the prop is required.
25
+ labels={STORE_LOCALE.store}
26
+ />
27
+ )
28
+ }
@@ -1,87 +1,87 @@
1
- import { notFound } from "next/navigation"
2
- import { retrieveProduct } from "@cartbase/storefront/api/products"
3
- import { StoreApiError } from "@cartbase/storefront/api/types"
4
- import { ProductTemplate } from "@cartbase/storefront/products/product-template"
5
- import { addToCartAction } from "@/lib/cart-actions"
6
- import { PRICING_CONTEXT, STORE_LOCALE } from "@/lib/config"
7
- import { getServerClient } from "@/lib/server-client"
8
-
9
- /**
10
- * PDP (runbook step 5): fetch by handle WITH the pricing context, render
11
- * the full product template. `addToCart` is the app-owned seam — a server
12
- * action that owns the cart cookie (products family contract).
13
- */
14
- export default async function ProductPage({
15
- params,
16
- searchParams,
17
- }: {
18
- params: Promise<{ handle: string }>
19
- searchParams: Promise<{ variant?: string }>
20
- }) {
21
- const [{ handle }, { variant }] = await Promise.all([params, searchParams])
22
- const client = await getServerClient()
23
-
24
- let product
25
- try {
26
- const res = await retrieveProduct(client, handle, PRICING_CONTEXT)
27
- product = res.product
28
- } catch (e) {
29
- if (e instanceof StoreApiError && e.status === 404) notFound()
30
- throw e
31
- }
32
-
33
- return (
34
- <ProductTemplate
35
- client={client}
36
- product={product}
37
- pricingContext={PRICING_CONTEXT}
38
- addToCart={addToCartAction}
39
- // The product page contract: the address names the variant
40
- // (`?variant=<digits>`, Shopify's parameter), and the page hands it to
41
- // the template so the FIRST paint is that variant's price, code and
42
- // stock. Switching a variant rewrites the address in the browser
43
- // without asking the server.
44
- initialVariantId={variant}
45
- // NO `promises` here, deliberately. Until 0.13.0 the library carried
46
- // delivery, exchange and return promises as label DEFAULTS, so every
47
- // store scaffolded from this file told shoppers "your package will
48
- // arrive in 3-5 business days" and "we'll refund your money" without
49
- // anyone having decided that. Those are YOUR commitments, so you write
50
- // them:
51
- //
52
- // promises={[
53
- // { icon: "delivery", title: "Fast delivery",
54
- // body: "Your order arrives in 2 working days." },
55
- // ]}
56
- //
57
- // Pass nothing and the section does not exist, which is the right
58
- // default for a store that has not decided yet. The physical-facts
59
- // section appears on its own for products that HAVE facts, and is
60
- // skipped for those that do not.
61
- //
62
- // The pack has to be handed over here, not just mounted at the root:
63
- // the related-products strip is a server component and cannot read
64
- // the locale provider, so its heading would stay English. Required.
65
- labels={STORE_LOCALE.products}
66
- />
67
- )
68
- }
69
-
70
- export async function generateMetadata({
71
- params,
72
- }: {
73
- params: Promise<{ handle: string }>
74
- }) {
75
- const { handle } = await params
76
- const client = await getServerClient()
77
- try {
78
- const { product } = await retrieveProduct(client, handle)
79
- // SEO fields with title/description fallbacks (runbook step 5).
80
- return {
81
- title: product.seo_title ?? product.title,
82
- description: product.seo_description ?? product.description ?? undefined,
83
- }
84
- } catch {
85
- return {}
86
- }
87
- }
1
+ import { notFound } from "next/navigation"
2
+ import { retrieveProduct } from "@cartbase/storefront/api/products"
3
+ import { StoreApiError } from "@cartbase/storefront/api/types"
4
+ import { ProductTemplate } from "@cartbase/storefront/products/product-template"
5
+ import { PRICING_CONTEXT, STORE_LOCALE } from "@/lib/config"
6
+ import { getServerClient } from "@/lib/server-client"
7
+
8
+ /**
9
+ * PDP (runbook step 5): fetch by handle WITH the pricing context, render
10
+ * the full product template. No `addToCart` is passed: the add goes
11
+ * through the cart drawer mounted in `providers.tsx`, so the drawer opens
12
+ * at the click with the product in it and no page render runs (the product
13
+ * page contract, rule 6). The drawer's `onCartChange` owns the cart cookie.
14
+ */
15
+ export default async function ProductPage({
16
+ params,
17
+ searchParams,
18
+ }: {
19
+ params: Promise<{ handle: string }>
20
+ searchParams: Promise<{ variant?: string }>
21
+ }) {
22
+ const [{ handle }, { variant }] = await Promise.all([params, searchParams])
23
+ const client = await getServerClient()
24
+
25
+ let product
26
+ try {
27
+ const res = await retrieveProduct(client, handle, PRICING_CONTEXT)
28
+ product = res.product
29
+ } catch (e) {
30
+ if (e instanceof StoreApiError && e.status === 404) notFound()
31
+ throw e
32
+ }
33
+
34
+ return (
35
+ <ProductTemplate
36
+ client={client}
37
+ product={product}
38
+ pricingContext={PRICING_CONTEXT}
39
+ // The product page contract: the address names the variant
40
+ // (`?variant=<digits>`, Shopify's parameter), and the page hands it to
41
+ // the template so the FIRST paint is that variant's price, code and
42
+ // stock. Switching a variant rewrites the address in the browser
43
+ // without asking the server.
44
+ initialVariantId={variant}
45
+ // NO `promises` here, deliberately. Until 0.13.0 the library carried
46
+ // delivery, exchange and return promises as label DEFAULTS, so every
47
+ // store scaffolded from this file told shoppers "your package will
48
+ // arrive in 3-5 business days" and "we'll refund your money" without
49
+ // anyone having decided that. Those are YOUR commitments, so you write
50
+ // them:
51
+ //
52
+ // promises={[
53
+ // { icon: "delivery", title: "Fast delivery",
54
+ // body: "Your order arrives in 2 working days." },
55
+ // ]}
56
+ //
57
+ // Pass nothing and the section does not exist, which is the right
58
+ // default for a store that has not decided yet. The physical-facts
59
+ // section appears on its own for products that HAVE facts, and is
60
+ // skipped for those that do not.
61
+ //
62
+ // The pack has to be handed over here, not just mounted at the root:
63
+ // the related-products strip is a server component and cannot read
64
+ // the locale provider, so its heading would stay English. Required.
65
+ labels={STORE_LOCALE.products}
66
+ />
67
+ )
68
+ }
69
+
70
+ export async function generateMetadata({
71
+ params,
72
+ }: {
73
+ params: Promise<{ handle: string }>
74
+ }) {
75
+ const { handle } = await params
76
+ const client = await getServerClient()
77
+ try {
78
+ const { product } = await retrieveProduct(client, handle)
79
+ // SEO fields with title/description fallbacks (runbook step 5).
80
+ return {
81
+ title: product.seo_title ?? product.title,
82
+ description: product.seo_description ?? product.description ?? undefined,
83
+ }
84
+ } catch {
85
+ return {}
86
+ }
87
+ }