diff --git a/blog/loaders/options/productsByTerm.ts b/blog/loaders/options/productsByTerm.ts new file mode 100644 index 000000000..e590d5957 --- /dev/null +++ b/blog/loaders/options/productsByTerm.ts @@ -0,0 +1,25 @@ +import { allowCorsFor } from "@deco/deco"; +import { searchProductOptions } from "../../utils/productResolver.ts"; +import { AppContext } from "../../mod.ts"; + +export interface Props { + term?: string; +} + +/** + * @title Products by term + * @description Searchable product picker for blog product blocks. + */ +export default async function productsByTerm( + props: Props, + req: Request, + ctx: AppContext, +) { + Object.entries(allowCorsFor(req)).forEach(([name, value]) => { + ctx.response.headers.set(name, value); + }); + + return await searchProductOptions(props.term ?? "", req, ctx); +} + +export const cache = "no-cache"; diff --git a/blog/manifest.gen.ts b/blog/manifest.gen.ts index 8ccb0c8ce..4c4ced188 100644 --- a/blog/manifest.gen.ts +++ b/blog/manifest.gen.ts @@ -23,6 +23,7 @@ import * as $$$15 from "./loaders/extensions/BlogpostPage.ts"; import * as $$$16 from "./loaders/extensions/BlogpostPage/ratings.ts"; import * as $$$17 from "./loaders/extensions/BlogpostPage/reviews.ts"; import * as $$$8 from "./loaders/GetCategories.ts"; +import * as $$$18 from "./loaders/options/productsByTerm.ts"; import * as $$$$$$3 from "./sections/blocks/BlockImage.tsx"; import * as $$$$$$4 from "./sections/blocks/Callout.tsx"; import * as $$$$$$5 from "./sections/blocks/CardGroup.tsx"; @@ -34,11 +35,14 @@ import * as $$$$$$10 from "./sections/blocks/Divider.tsx"; import * as $$$$$$11 from "./sections/blocks/Heading.tsx"; import * as $$$$$$12 from "./sections/blocks/List.tsx"; import * as $$$$$$13 from "./sections/blocks/Paragraph.tsx"; -import * as $$$$$$14 from "./sections/blocks/Quote.tsx"; -import * as $$$$$$15 from "./sections/blocks/Stat.tsx"; -import * as $$$$$$16 from "./sections/blocks/StatGroup.tsx"; -import * as $$$$$$17 from "./sections/blocks/Steps.tsx"; -import * as $$$$$$18 from "./sections/blocks/Video.tsx"; +import * as $$$$$$14 from "./sections/blocks/ProductCard.tsx"; +import * as $$$$$$15 from "./sections/blocks/ProductHighlight.tsx"; +import * as $$$$$$16 from "./sections/blocks/ProductShelf.tsx"; +import * as $$$$$$17 from "./sections/blocks/Quote.tsx"; +import * as $$$$$$18 from "./sections/blocks/Stat.tsx"; +import * as $$$$$$19 from "./sections/blocks/StatGroup.tsx"; +import * as $$$$$$20 from "./sections/blocks/Steps.tsx"; +import * as $$$$$$21 from "./sections/blocks/Video.tsx"; import * as $$$$$$0 from "./sections/Seo/SeoBlogPost.tsx"; import * as $$$$$$1 from "./sections/Seo/SeoBlogPostListing.tsx"; import * as $$$$$$2 from "./sections/Template.tsx"; @@ -63,6 +67,7 @@ const manifest = { "blog/loaders/extensions/BlogpostPage/ratings.ts": $$$16, "blog/loaders/extensions/BlogpostPage/reviews.ts": $$$17, "blog/loaders/GetCategories.ts": $$$8, + "blog/loaders/options/productsByTerm.ts": $$$18, }, "sections": { "blog/sections/blocks/BlockImage.tsx": $$$$$$3, @@ -76,11 +81,14 @@ const manifest = { "blog/sections/blocks/Heading.tsx": $$$$$$11, "blog/sections/blocks/List.tsx": $$$$$$12, "blog/sections/blocks/Paragraph.tsx": $$$$$$13, - "blog/sections/blocks/Quote.tsx": $$$$$$14, - "blog/sections/blocks/Stat.tsx": $$$$$$15, - "blog/sections/blocks/StatGroup.tsx": $$$$$$16, - "blog/sections/blocks/Steps.tsx": $$$$$$17, - "blog/sections/blocks/Video.tsx": $$$$$$18, + "blog/sections/blocks/ProductCard.tsx": $$$$$$14, + "blog/sections/blocks/ProductHighlight.tsx": $$$$$$15, + "blog/sections/blocks/ProductShelf.tsx": $$$$$$16, + "blog/sections/blocks/Quote.tsx": $$$$$$17, + "blog/sections/blocks/Stat.tsx": $$$$$$18, + "blog/sections/blocks/StatGroup.tsx": $$$$$$19, + "blog/sections/blocks/Steps.tsx": $$$$$$20, + "blog/sections/blocks/Video.tsx": $$$$$$21, "blog/sections/Seo/SeoBlogPost.tsx": $$$$$$0, "blog/sections/Seo/SeoBlogPostListing.tsx": $$$$$$1, "blog/sections/Template.tsx": $$$$$$2, diff --git a/blog/sections/blocks/ProductCard.tsx b/blog/sections/blocks/ProductCard.tsx new file mode 100644 index 000000000..d7f0a2e24 --- /dev/null +++ b/blog/sections/blocks/ProductCard.tsx @@ -0,0 +1,136 @@ +import Image from "../../../website/components/Image.tsx"; +import { getProductDisplay } from "../../utils/productData.ts"; +import { coerceProduct } from "../../utils/coerceProductInput.ts"; +import { sanitizeHtml } from "../../utils/sanitizeHtml.ts"; +import { AppContext } from "../../mod.ts"; +import type { Product } from "../../../commerce/types.ts"; + +/** + * @title Product + * @description Search and select a storefront product dynamically. + * @format dynamic-options + * @options blog/loaders/options/productsByTerm.ts + */ +type ProductReference = string; + +export interface Props { + /** + * @title Product + * @description Product reference as `platform:kind:id` (e.g. `vtex:product:123`). Loader-ref shapes work at runtime but strings are preferred. + * @format dynamic-options + * @options blog/loaders/options/productsByTerm.ts + */ + product?: ProductReference; + /** Badge text (e.g. "New", "Sale") */ + badge?: string; + /** Short description (HTML) */ + description?: string; + /** Call-to-action button text */ + cta?: string; +} + +type RuntimeProps = Omit & { + product: Product | null; +}; + +export async function loader(props: Props, req: Request, ctx: AppContext) { + const product = await coerceProduct(props.product, req, ctx); + return { + badge: props.badge, + description: props.description, + cta: props.cta, + product, + } as RuntimeProps; +} + +/** + * @title Product Card + * @description Displays a shoppable product card with image, name, price and CTA. + */ +export default function ProductCard( + { product, badge, description, cta }: RuntimeProps, +) { + if (!product) return null; + + const { + name, + imageUrl, + width, + height, + price, + listPrice, + safeUrl, + isExternal, + } = getProductDisplay(product); + if (!name) return null; + + const productDescription = description ?? product.description; + + return ( +
+
+ {name} + {badge && ( + + {badge} + + )} +
+
+

{name}

+ {productDescription && ( +
+ )} +
+ {price && ( + + {price} + + )} + {listPrice && listPrice !== price && ( + + {listPrice} + + )} +
+ {safeUrl && ( + + {cta ?? "Comprar"} + + )} +
+
+ ); +} + +export function LoadingFallback() { + return ( +
+
+
+
+
+
+
+ ); +} diff --git a/blog/sections/blocks/ProductHighlight.tsx b/blog/sections/blocks/ProductHighlight.tsx new file mode 100644 index 000000000..744382691 --- /dev/null +++ b/blog/sections/blocks/ProductHighlight.tsx @@ -0,0 +1,143 @@ +import Image from "../../../website/components/Image.tsx"; +import { getProductDisplay } from "../../utils/productData.ts"; +import { coerceProduct } from "../../utils/coerceProductInput.ts"; +import { sanitizeHtml } from "../../utils/sanitizeHtml.ts"; +import { AppContext } from "../../mod.ts"; +import type { Product } from "../../../commerce/types.ts"; + +/** + * @title Product + * @description Search and select a storefront product dynamically. + * @format dynamic-options + * @options blog/loaders/options/productsByTerm.ts + */ +type ProductReference = string; + +export interface Props { + /** + * @title Product + * @description Product reference as `platform:kind:id` (e.g. `vtex:product:123`). Loader-ref shapes work at runtime but strings are preferred. + * @format dynamic-options + * @options blog/loaders/options/productsByTerm.ts + */ + product?: ProductReference; + /** Badge text (e.g. "Destaque", "Limited") */ + badge?: string; + /** Full description (HTML) */ + description?: string; + /** Call-to-action button text */ + cta?: string; +} + +type RuntimeProps = Omit & { + product: Product | null; +}; + +export async function loader(props: Props, req: Request, ctx: AppContext) { + const product = await coerceProduct(props.product, req, ctx); + return { + badge: props.badge, + description: props.description, + cta: props.cta, + product, + } as RuntimeProps; +} + +/** + * @title Product Highlight + * @description Full-width featured product block with image and details side by side. + */ +export default function ProductHighlight( + { product, badge, description, cta }: RuntimeProps, +) { + if (!product) return null; + + const { + name, + imageUrl, + width, + height, + price, + listPrice, + safeUrl, + isExternal, + } = getProductDisplay(product); + if (!name) return null; + + const productDescription = description ?? product.description; + + return ( +
+
+
+ {name} + {badge && ( + + {badge} + + )} +
+
+

{name}

+ {productDescription && ( +
+ )} +
+ {price && ( + + {price} + + )} + {listPrice && listPrice !== price && ( + + {listPrice} + + )} +
+ {safeUrl && ( + + {cta ?? "Comprar"} + + )} +
+
+
+ ); +} + +export function LoadingFallback() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+ ); +} diff --git a/blog/sections/blocks/ProductShelf.tsx b/blog/sections/blocks/ProductShelf.tsx new file mode 100644 index 000000000..012137ad0 --- /dev/null +++ b/blog/sections/blocks/ProductShelf.tsx @@ -0,0 +1,140 @@ +import Image from "../../../website/components/Image.tsx"; +import { getProductDisplay } from "../../utils/productData.ts"; +import { coerceProducts } from "../../utils/coerceProductInput.ts"; +import { AppContext } from "../../mod.ts"; +import type { Product } from "../../../commerce/types.ts"; + +/** + * @title Product + * @description Search and select a storefront product dynamically. + * @format dynamic-options + * @options blog/loaders/options/productsByTerm.ts + */ +type ProductReference = string; + +export interface Props { + /** Section title shown above the shelf */ + title?: string; + /** + * @title Products + * @description Product references as `platform:kind:id` strings (e.g. `vtex:product:123`). Loader-ref shapes work at runtime but strings are preferred. + * @format dynamic-options + * @options blog/loaders/options/productsByTerm.ts + */ + products?: ProductReference[]; +} + +type RuntimeProps = Omit & { + products: Product[]; +}; + +export async function loader(props: Props, req: Request, ctx: AppContext) { + const products = await coerceProducts(props.products, req, ctx); + return { + title: props.title, + products, + } as RuntimeProps; +} + +/** + * @title Product Shelf + * @description Horizontally scrollable row of product cards. + */ +export default function ProductShelf( + { title, products }: RuntimeProps, +) { + if (!products?.length) return null; + + return ( +
+ {title && ( +

{title}

+ )} +
+ {products.map((product, index) => { + const { + name, + imageUrl, + width, + height, + price, + listPrice, + safeUrl, + isExternal, + } = getProductDisplay(product); + if (!name) return null; + + return ( +
+
+ {name} +
+
+ + {name} + +
+ {price && ( + + {price} + + )} + {listPrice && listPrice !== price && ( + + {listPrice} + + )} +
+ {safeUrl && ( + + Ver produto + + )} +
+
+ ); + })} +
+
+ ); +} + +export function LoadingFallback() { + return ( +
+
+
+ {Array.from({ length: 4 }).map((_, index) => ( +
+
+
+
+
+
+
+ ))} +
+
+ ); +} diff --git a/blog/utils/blocksToSections.ts b/blog/utils/blocksToSections.ts deleted file mode 100644 index 8aef79ac5..000000000 --- a/blog/utils/blocksToSections.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { type Section } from "@deco/deco/blocks"; -import { Resolved } from "@deco/deco"; - -/** Minimal Block type — matches the structure returned by the Spire API. */ -interface Block { - type?: string; - position?: number; - content?: Record; - system_block_id?: string; - custom_block_id?: string; -} - -const BASE = "blog/sections/blocks"; - -function toSection( - resolveType: string, - props: Record, -): Section { - return { __resolveType: resolveType, ...props } as unknown as Section; -} - -/** - * Converts a Spire Block array into Deco Section[] using blog/sections/blocks components. - * Sections are sorted by block position before conversion. - * Supports an optional override map to remap block types to custom section renderers. - */ -export function blocksToSections( - blocks: Block[], - overrides: Record> = {}, -): Section[] { - return [...blocks] - .sort((a, b) => (a.position ?? 0) - (b.position ?? 0)) - .map((block) => blockToSection(block, overrides)) - .filter((s): s is Section => s !== null); -} - -function blockToSection( - block: Block, - overrides: Record>, -): Section | null { - const content = block.content as Record; - - if ("type" in block && block.type) { - if (overrides[block.type]) { - return toSection( - overrides[block.type]?.__resolveType ?? block.type, - block.content as Record, - ); - } - - switch (block.type) { - case "paragraph": - return toSection(`${BASE}/Paragraph.tsx`, { - html: content.html, - text: content.text, - }); - - case "heading": - return toSection(`${BASE}/Heading.tsx`, { - text: content.text, - level: content.level, - }); - - case "list": - return toSection(`${BASE}/List.tsx`, { - items: content.items, - style: content.style, - }); - - case "divider": - return toSection(`${BASE}/Divider.tsx`, {}); - - case "quote": - return toSection(`${BASE}/Quote.tsx`, { - quote: content.quote, - text: content.text, - attribution: content.attribution, - source: content.source, - }); - - case "callout": - return toSection(`${BASE}/Callout.tsx`, { - title: content.title, - body: content.body, - variant: content.variant, - }); - - case "checklist": - return toSection(`${BASE}/Checklist.tsx`, { - title: content.title, - items: content.items, - }); - - case "steps": - return toSection(`${BASE}/Steps.tsx`, { - title: content.title, - steps: content.steps, - }); - - case "stat": - return toSection(`${BASE}/Stat.tsx`, { - value: content.value, - label: content.label, - description: content.description, - }); - - case "stat-group": - return toSection(`${BASE}/StatGroup.tsx`, { - stats: content.stats, - }); - - case "card-group": - return toSection(`${BASE}/CardGroup.tsx`, { - cards: content.cards, - }); - - case "comparison": - return toSection(`${BASE}/Comparison.tsx`, { - left: content.left, - right: content.right, - }); - - case "image": - return toSection(`${BASE}/BlockImage.tsx`, { - url: content.url, - alt: content.alt, - caption: content.caption, - size: content.size, - }); - - case "video": - return toSection(`${BASE}/Video.tsx`, { - url: content.url, - caption: content.caption, - }); - - case "code": - return toSection(`${BASE}/Code.tsx`, { - code: content.code, - language: content.language, - filename: content.filename, - }); - - case "cta": - return toSection(`${BASE}/Cta.tsx`, { - text: content.text, - href: content.href, - }); - - default: - return null; - } - } - - // System blocks (no explicit type) — render as paragraph if they have html/text - if ("system_block_id" in block) { - if (content.html) { - return toSection(`${BASE}/Paragraph.tsx`, { - html: content.html as string, - }); - } - if (content.text) { - return toSection(`${BASE}/Paragraph.tsx`, { - text: content.text as string, - }); - } - } - - return null; -} diff --git a/blog/utils/coerceProductInput.ts b/blog/utils/coerceProductInput.ts new file mode 100644 index 000000000..5949b0297 --- /dev/null +++ b/blog/utils/coerceProductInput.ts @@ -0,0 +1,117 @@ +/** + * Coerces persisted `product` / `products` block props into commerce Products. + * + * Canonical format: string or string[] as `platform:kind:id` (e.g. `vtex:product:123`). + * Loader-ref and pre-resolved Product shapes are tolerated for Studio / block-graph compat. + */ +import type { Product } from "../../commerce/types.ts"; +import { + resolveProductByReference, + resolveProductsByReference, +} from "./productResolver.ts"; + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +/** Block graph already resolved a nested loader to a Product. */ +export function isResolvedProduct(value: unknown): value is Product { + return ( + typeof value === "object" && + value !== null && + !("__resolveType" in value) && + ("productID" in value || "sku" in value || + ("name" in value && "offers" in value)) + ); +} + +function idString(value: unknown): string { + return typeof value === "string" || typeof value === "number" + ? String(value) + : ""; +} + +/** + * Extract canonical or plain product reference strings from persisted block + * values: `vtex:product:123`, loader-ref shapes, or plain numeric IDs. + */ +export function referencesFromProductInput(value: unknown): string[] { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed ? [trimmed] : []; + } + + if (isResolvedProduct(value)) { + return []; + } + + if (Array.isArray(value)) { + if (value.length > 0 && isResolvedProduct(value[0])) { + return []; + } + if (value.every((item) => typeof item === "string")) { + return value + .map((item) => (typeof item === "string" ? item.trim() : "")) + .filter(Boolean); + } + return value.flatMap((item) => referencesFromProductInput(item)); + } + + const existing = asRecord(value); + if (!existing) return []; + + const props = asRecord(existing.props); + if (props && Array.isArray(props.ids)) { + return props.ids.map(idString).filter(Boolean); + } + + if ("productId" in existing) { + const id = idString(existing.productId); + return id ? [id] : []; + } + + return []; +} + +export async function coerceProduct( + value: unknown, + req: Request, + ctx: unknown, +): Promise { + if (value == null) return null; + + if (isResolvedProduct(value)) { + return value; + } + + if (Array.isArray(value)) { + const resolved = value.find(isResolvedProduct); + if (resolved) return resolved; + } + + const refs = referencesFromProductInput(value); + if (refs.length === 0) return null; + + return await resolveProductByReference(refs[0], req, ctx); +} + +export async function coerceProducts( + value: unknown, + req: Request, + ctx: unknown, +): Promise { + if (value == null) return []; + + if ( + Array.isArray(value) && value.length > 0 && value.every(isResolvedProduct) + ) { + return value; + } + + const refs = referencesFromProductInput(value); + if (refs.length === 0) return []; + + return await resolveProductsByReference(refs, req, ctx); +} diff --git a/blog/utils/productData.ts b/blog/utils/productData.ts new file mode 100644 index 000000000..972bde633 --- /dev/null +++ b/blog/utils/productData.ts @@ -0,0 +1,100 @@ +import { DEFAULT_IMAGE } from "../../commerce/utils/constants.ts"; +import type { ImageObject, Offer, Product } from "../../commerce/types.ts"; +import { sanitizeHref } from "./sanitizeHtml.ts"; + +const SCHEMA_LIST_PRICE = "https://schema.org/ListPrice"; +const SCHEMA_SALE_PRICE = "https://schema.org/SalePrice"; + +function formatMoney(value: number | undefined, currency = "BRL") { + if (typeof value !== "number" || Number.isNaN(value)) return undefined; + try { + return new Intl.NumberFormat("pt-BR", { + style: "currency", + currency, + }).format(value); + } catch { + return `${currency} ${value.toFixed(2)}`; + } +} + +function priceFromSpecifications( + offers: Offer[], + priceType: string, +): number | undefined { + for (const offer of offers) { + const match = offer.priceSpecification?.find((spec) => + spec.priceType === priceType + ); + if (typeof match?.price === "number") return match.price; + } + return undefined; +} + +export function getProductImage(product: Product): string { + const image = product.image?.[0]; + if (!image) return DEFAULT_IMAGE.url ?? ""; + if (typeof image === "string") return image; + return (image as ImageObject).url ?? DEFAULT_IMAGE.url ?? ""; +} + +export function getProductPrices(product: Product) { + const currency = product.offers?.priceCurrency ?? "BRL"; + const nestedOffers = product.offers?.offers ?? []; + + const salePrice = product.offers?.lowPrice ?? + priceFromSpecifications(nestedOffers, SCHEMA_SALE_PRICE) ?? + nestedOffers[0]?.price; + + const listPriceValue = priceFromSpecifications( + nestedOffers, + SCHEMA_LIST_PRICE, + ); + + const price = formatMoney(salePrice, currency); + const listPrice = typeof listPriceValue === "number" && + typeof salePrice === "number" && + listPriceValue > salePrice + ? formatMoney(listPriceValue, currency) + : undefined; + + return { price, listPrice }; +} + +export function getProductImageDimensions(product: Product): { + width: number; + height: number; +} { + const image = product.image?.[0]; + if (image && typeof image !== "string") { + const width = Number((image as ImageObject & { width?: number }).width); + const height = Number((image as ImageObject & { height?: number }).height); + if (width > 0 && height > 0) return { width, height }; + } + return { width: 600, height: 600 }; +} + +export function getProductLink(product: Product) { + const safeUrl = sanitizeHref(product.url); + return { + safeUrl, + isExternal: /^https?:\/\//i.test(safeUrl), + }; +} + +export function getProductDisplay(product: Product) { + const name = product.name ?? ""; + const { width, height } = getProductImageDimensions(product); + const { price, listPrice } = getProductPrices(product); + const { safeUrl, isExternal } = getProductLink(product); + + return { + name, + imageUrl: getProductImage(product), + width, + height, + price, + listPrice, + safeUrl, + isExternal, + }; +} diff --git a/blog/utils/productReference.ts b/blog/utils/productReference.ts new file mode 100644 index 000000000..5cff8a892 --- /dev/null +++ b/blog/utils/productReference.ts @@ -0,0 +1,69 @@ +type ProductPlatform = "vtex" | "shopify" | "wake" | "wap"; +type ProductReferenceKind = "sku" | "product" | "handle"; + +export interface ParsedProductReference { + platform: ProductPlatform; + kind: ProductReferenceKind; + id: string; +} + +const PRODUCT_REF_RE = /^([a-z]+):([a-z]+):(.+)$/i; + +export const PLATFORM_ORDER: ProductPlatform[] = [ + "vtex", + "shopify", + "wake", + "wap", +]; + +export function parseReference( + value: string, +): ParsedProductReference | null { + const match = value.match(PRODUCT_REF_RE); + if (!match) return null; + + const platform = match[1].toLowerCase() as ProductPlatform; + const kind = match[2].toLowerCase() as ProductReferenceKind; + const id = match[3]; + + if (!PLATFORM_ORDER.includes(platform)) return null; + return { platform, kind, id }; +} + +export function formatReference( + platform: ProductPlatform, + kind: ProductReferenceKind, + id: string, +): string { + return `${platform}:${kind}:${id}`; +} + +export function normalizeReference( + raw: string, + platform?: ProductPlatform | null, +): string | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + + if (parseReference(trimmed)) return trimmed; + if (!platform) return null; + + if (platform === "vtex") { + if (/^\d+$/.test(trimmed)) { + return formatReference("vtex", "product", trimmed); + } + return trimmed; + } + + if (platform === "shopify") { + return formatReference("shopify", "handle", trimmed); + } + + if (platform === "wake" || platform === "wap") { + return formatReference(platform, "product", trimmed); + } + + return null; +} + +export type { ProductPlatform, ProductReferenceKind }; diff --git a/blog/utils/productResolver.ts b/blog/utils/productResolver.ts new file mode 100644 index 000000000..d7f1e6389 --- /dev/null +++ b/blog/utils/productResolver.ts @@ -0,0 +1,413 @@ +import type { Product } from "../../commerce/types.ts"; +import { + formatReference, + normalizeReference, + parseReference, + PLATFORM_ORDER, + type ProductPlatform, +} from "./productReference.ts"; +import { getProductDisplay, getProductImage } from "./productData.ts"; + +export interface ProductOption { + value: string; + label: string; + image?: string; +} + +type InvokeSurface = { + vtex?: { + loaders: { + intelligentSearch: { + suggestions: ( + props: { query: string; count: number }, + ) => Promise<{ products?: Product[] }>; + productList: ( + props: { props: Record }, + req: Request, + ) => Promise; + }; + legacy: { + productList: ( + props: { props: Record }, + req: Request, + ) => Promise; + }; + }; + }; + shopify?: { + loaders: { + ProductList: ( + props: { props: { query: string; count: number } }, + req: Request, + ) => Promise; + ProductDetailsPage: ( + props: { slug: string }, + req: Request, + ) => Promise<{ product: Product } | null>; + }; + }; + wake?: { + loaders: { + suggestion: ( + props: { query: string; limit?: number }, + req: Request, + ) => Promise<{ products?: Product[] } | null>; + productList: ( + props: { + first: number; + sortDirection: "ASC" | "DESC"; + sortKey: + | "NAME" + | "PRICE" + | "DISCOUNT" + | "RANDOM" + | "RELEASE_DATE" + | "SALES" + | "STOCK"; + filters: { productId: number[] }; + }, + req: Request, + ) => Promise; + }; + }; + wap?: { + loaders: { + suggestions: ( + props: { query?: string; count?: number }, + req: Request, + ) => Promise<{ products?: Product[] } | null>; + productList: ( + props: { props: { busca: string; limit: number } }, + req: Request, + ) => Promise; + }; + }; +}; + +const platformBySite = new Map(); + +function siteKey(req: Request): string { + return new URL(req.url).origin; +} + +function invoke(ctx: unknown): InvokeSurface { + return (ctx as { invoke: InvokeSurface }).invoke; +} + +function hasInvokePlatform( + inv: InvokeSurface, + platform: ProductPlatform, +): boolean { + if (platform === "vtex") return !!inv.vtex; + if (platform === "shopify") return !!inv.shopify; + if (platform === "wake") return !!inv.wake; + return !!inv.wap; +} + +function extractHandle(url?: string) { + if (!url) return undefined; + try { + const parsed = new URL(url); + const parts = parsed.pathname.split("/").filter(Boolean); + const index = parts.findIndex((part) => part === "products"); + return index >= 0 ? parts[index + 1] : parts.at(-1); + } catch { + return undefined; + } +} + +function toOption(platform: ProductPlatform, product: Product): ProductOption { + const { name, price, imageUrl } = getProductDisplay(product); + const label = price ? `${name} - ${price}` : name; + + if (platform === "vtex") { + return { + value: formatReference("vtex", "product", product.productID), + label, + image: imageUrl, + }; + } + + if (platform === "shopify") { + const handle = extractHandle(product.url) ?? product.productID; + return { + value: formatReference("shopify", "handle", handle), + label, + image: imageUrl, + }; + } + + return { + value: formatReference(platform, "product", product.productID), + label, + image: imageUrl, + }; +} + +async function probePlatform( + platform: ProductPlatform, + ctx: unknown, + req: Request, +): Promise { + try { + const inv = invoke(ctx); + if (!hasInvokePlatform(inv, platform)) return false; + + if (platform === "vtex") { + const res = await inv.vtex!.loaders.intelligentSearch.suggestions({ + query: "a", + count: 1, + }); + return (res?.products?.length ?? 0) > 0; + } + if (platform === "shopify") { + const res = await inv.shopify!.loaders.ProductList( + { props: { query: "*", count: 1 } }, + req, + ); + return Array.isArray(res); + } + if (platform === "wake") { + const res = await inv.wake!.loaders.suggestion( + { query: "a", limit: 1 }, + req, + ); + return (res?.products?.length ?? 0) > 0; + } + const res = await inv.wap!.loaders.suggestions( + { query: "a", count: 1 }, + req, + ); + return (res?.products?.length ?? 0) > 0; + } catch { + return false; + } +} + +async function detectPlatform( + ctx: unknown, + req: Request, +): Promise { + for (const platform of PLATFORM_ORDER) { + if (await probePlatform(platform, ctx, req)) return platform; + } + return null; +} + +async function getPlatform( + ctx: unknown, + req: Request, +): Promise { + const key = siteKey(req); + if (platformBySite.has(key)) { + return platformBySite.get(key) ?? null; + } + const platform = await detectPlatform(ctx, req); + platformBySite.set(key, platform); + return platform; +} + +async function searchPlatform( + platform: ProductPlatform, + term: string, + ctx: unknown, + req: Request, +): Promise { + const inv = invoke(ctx); + + if (platform === "vtex") { + const res = await inv.vtex!.loaders.intelligentSearch.suggestions({ + query: term, + count: 10, + }); + return (res?.products ?? []).map((product) => toOption("vtex", product)); + } + + if (platform === "shopify") { + const res = await inv.shopify!.loaders.ProductList( + { props: { query: term || "*", count: 10 } }, + req, + ); + return (res ?? []).map((product) => toOption("shopify", product)); + } + + if (platform === "wake") { + const res = await inv.wake!.loaders.suggestion( + { query: term, limit: 10 }, + req, + ); + return (res?.products ?? []).map((product) => toOption("wake", product)); + } + + const res = await inv.wap!.loaders.suggestions( + { query: term, count: 10 }, + req, + ); + return (res?.products ?? []).map((product) => toOption("wap", product)); +} + +async function resolvePlatform( + platform: ProductPlatform, + kind: string, + id: string, + ctx: unknown, + req: Request, +): Promise { + const inv = invoke(ctx); + + if (platform === "vtex") { + if (kind === "product") { + const products = await inv.vtex!.loaders.legacy.productList( + { props: { productIds: [id] } }, + req, + ); + return products?.[0] ?? null; + } + if (kind === "sku") { + const products = await inv.vtex!.loaders.intelligentSearch.productList( + { props: { ids: [id] } }, + req, + ); + return products?.[0] ?? null; + } + const products = await inv.vtex!.loaders.intelligentSearch.productList( + { props: { query: id, count: 1 } }, + req, + ); + return products?.[0] ?? null; + } + + if (platform === "shopify") { + const slug = kind === "handle" ? id : extractHandle(id) ?? id; + const page = await inv.shopify!.loaders.ProductDetailsPage({ slug }, req); + return page?.product ?? null; + } + + if (platform === "wake") { + const productId = Number(id); + if (!Number.isFinite(productId)) return null; + const products = await inv.wake!.loaders.productList({ + first: 1, + sortDirection: "ASC", + sortKey: "NAME", + filters: { productId: [productId] }, + }, req); + return products?.[0] ?? null; + } + + const products = await inv.wap!.loaders.productList( + { props: { busca: id, limit: 1 } }, + req, + ); + return products?.[0] ?? null; +} + +export async function searchProductOptions( + term: string, + req: Request, + ctx: unknown, +): Promise { + const query = term.trim(); + const parsed = parseReference(query); + + if (parsed) { + const resolved = await resolveProductByReference(query, req, ctx); + if (resolved) { + const { name, price } = getProductDisplay(resolved); + return [{ + value: query, + label: price ? `${name} - ${price}` : name, + image: getProductImage(resolved), + }]; + } + } + + const platform = await getPlatform(ctx, req); + if (!platform) { + return query + ? [{ value: "", label: "No commerce platform installed" }] + : [{ value: "", label: "Type to search products" }]; + } + + try { + const options = await searchPlatform(platform, query, ctx, req); + if (options.length > 0) return options; + } catch (error) { + console.warn( + `[productResolver:${platform}]`, + error instanceof Error ? error.message : error, + ); + } + + if (!query) { + return [{ value: "", label: "Type to search products" }]; + } + + return [{ value: "", label: "No products found" }]; +} + +export async function resolveProductByReference( + reference: string | undefined, + req: Request, + ctx: unknown, + platformHint?: ProductPlatform | null, +): Promise { + if (!reference?.trim()) return null; + + const raw = reference.trim(); + const parsed = parseReference(raw); + + if (parsed) { + try { + return await resolvePlatform( + parsed.platform, + parsed.kind, + parsed.id, + ctx, + req, + ); + } catch (error) { + console.warn( + `[productResolver:${parsed.platform}]`, + error instanceof Error ? error.message : error, + ); + return null; + } + } + + const platform = platformHint === undefined + ? await getPlatform(ctx, req) + : platformHint; + if (!platform) return null; + + const normalized = normalizeReference(raw, platform); + if (!normalized) return null; + + if (normalized === raw) { + try { + return await resolvePlatform(platform, "product", raw, ctx, req); + } catch (error) { + console.warn( + `[productResolver:${platform}]`, + error instanceof Error ? error.message : error, + ); + return null; + } + } + + return await resolveProductByReference(normalized, req, ctx, platform); +} + +export async function resolveProductsByReference( + references: string[], + req: Request, + ctx: unknown, +): Promise { + const platform = await getPlatform(ctx, req); + const resolved = await Promise.all( + references.map((reference) => + resolveProductByReference(reference, req, ctx, platform) + ), + ); + return resolved.filter((product): product is Product => Boolean(product)); +}