@cartbase/storefront 0.1.0

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 (176) hide show
  1. package/package.json +226 -0
  2. package/src/api/auth.ts +108 -0
  3. package/src/api/carts.ts +506 -0
  4. package/src/api/categories.ts +184 -0
  5. package/src/api/checkout.ts +440 -0
  6. package/src/api/collections.ts +130 -0
  7. package/src/api/consent.ts +75 -0
  8. package/src/api/content.ts +125 -0
  9. package/src/api/customers.ts +307 -0
  10. package/src/api/gift-cards.ts +112 -0
  11. package/src/api/http.ts +122 -0
  12. package/src/api/index.ts +29 -0
  13. package/src/api/integrations.ts +130 -0
  14. package/src/api/menus.ts +77 -0
  15. package/src/api/metaobjects.ts +136 -0
  16. package/src/api/orders.ts +290 -0
  17. package/src/api/products.ts +303 -0
  18. package/src/api/redirects.ts +37 -0
  19. package/src/api/regions.ts +141 -0
  20. package/src/api/reviews.ts +259 -0
  21. package/src/api/search.ts +133 -0
  22. package/src/api/types.ts +91 -0
  23. package/src/cart-drawer/cart-drawer.tsx +86 -0
  24. package/src/cart-drawer/context.tsx +569 -0
  25. package/src/cart-drawer/continue-shopping.tsx +28 -0
  26. package/src/cart-drawer/cross-sell-carousel.tsx +212 -0
  27. package/src/cart-drawer/cross-sell-sidebar.tsx +158 -0
  28. package/src/cart-drawer/empty.tsx +52 -0
  29. package/src/cart-drawer/free-gift.tsx +71 -0
  30. package/src/cart-drawer/gift-wrap.tsx +83 -0
  31. package/src/cart-drawer/header.tsx +52 -0
  32. package/src/cart-drawer/index.ts +69 -0
  33. package/src/cart-drawer/item/index.tsx +164 -0
  34. package/src/cart-drawer/item/quantity.tsx +100 -0
  35. package/src/cart-drawer/item/upsell.tsx +110 -0
  36. package/src/cart-drawer/item/variant.tsx +46 -0
  37. package/src/cart-drawer/labels-bg.ts +72 -0
  38. package/src/cart-drawer/labels.ts +119 -0
  39. package/src/cart-drawer/notes.tsx +131 -0
  40. package/src/cart-drawer/payment-badges.tsx +96 -0
  41. package/src/cart-drawer/promo-banner.tsx +43 -0
  42. package/src/cart-drawer/rewards-points.tsx +78 -0
  43. package/src/cart-drawer/sticky-footer.tsx +73 -0
  44. package/src/cart-drawer/summary-breakdown.tsx +196 -0
  45. package/src/cart-drawer/template.tsx +225 -0
  46. package/src/cart-drawer/tiered-progress.tsx +168 -0
  47. package/src/checkout/address-error-copy.ts +119 -0
  48. package/src/checkout/address-form.tsx +224 -0
  49. package/src/checkout/address-select.tsx +79 -0
  50. package/src/checkout/boxnow-locker-selector.tsx +410 -0
  51. package/src/checkout/checkout-client.tsx +222 -0
  52. package/src/checkout/company-details.tsx +94 -0
  53. package/src/checkout/compare-addresses.ts +40 -0
  54. package/src/checkout/context.tsx +76 -0
  55. package/src/checkout/discount-section.tsx +218 -0
  56. package/src/checkout/econt-office-selector.tsx +332 -0
  57. package/src/checkout/error-message.tsx +25 -0
  58. package/src/checkout/geocode.ts +154 -0
  59. package/src/checkout/gift-card-section.tsx +224 -0
  60. package/src/checkout/index.ts +74 -0
  61. package/src/checkout/labels-bg.ts +128 -0
  62. package/src/checkout/labels.ts +263 -0
  63. package/src/checkout/line-item-card.tsx +152 -0
  64. package/src/checkout/order-summary.tsx +524 -0
  65. package/src/checkout/payment-button.tsx +373 -0
  66. package/src/checkout/payment-error-copy.ts +269 -0
  67. package/src/checkout/payment-method-list.tsx +365 -0
  68. package/src/checkout/payment-wrapper.tsx +102 -0
  69. package/src/checkout/promotion-error-copy.ts +124 -0
  70. package/src/checkout/shipping-method-list.tsx +335 -0
  71. package/src/checkout/stripe-wrapper.tsx +165 -0
  72. package/src/checkout/use-checkout-orchestration.ts +1504 -0
  73. package/src/common/cart-button-client.tsx +39 -0
  74. package/src/common/cart-button.tsx +28 -0
  75. package/src/common/country-select.tsx +65 -0
  76. package/src/common/delete-button.tsx +66 -0
  77. package/src/common/index.ts +17 -0
  78. package/src/common/language-select.tsx +78 -0
  79. package/src/common/localized-link.tsx +45 -0
  80. package/src/common/skeleton.tsx +29 -0
  81. package/src/index.ts +12 -0
  82. package/src/lib/cart-helpers.ts +113 -0
  83. package/src/lib/dual-price.tsx +73 -0
  84. package/src/lib/get-percentage-diff.ts +5 -0
  85. package/src/lib/get-product-price.ts +133 -0
  86. package/src/lib/hooks/use-intersection.ts +30 -0
  87. package/src/lib/hooks/use-toggle-state.ts +25 -0
  88. package/src/lib/money.ts +73 -0
  89. package/src/lib/payment-constants.ts +66 -0
  90. package/src/lib/product.ts +22 -0
  91. package/src/lib/sort-products.ts +63 -0
  92. package/src/lib/store-api-error.ts +36 -0
  93. package/src/lib/utils.ts +16 -0
  94. package/src/order/context.tsx +32 -0
  95. package/src/order/index.ts +63 -0
  96. package/src/order/labels-bg.ts +39 -0
  97. package/src/order/labels.ts +79 -0
  98. package/src/order/order-address-card.tsx +47 -0
  99. package/src/order/order-completed-template.tsx +165 -0
  100. package/src/order/order-confirmation-header.tsx +65 -0
  101. package/src/order/order-delivery-card.tsx +258 -0
  102. package/src/order/order-help-section.tsx +47 -0
  103. package/src/order/order-item.tsx +201 -0
  104. package/src/order/order-items-list.tsx +52 -0
  105. package/src/order/order-payment-card.tsx +95 -0
  106. package/src/order/order-timeline.tsx +141 -0
  107. package/src/order/order-totals.tsx +245 -0
  108. package/src/primitives/field.tsx +125 -0
  109. package/src/primitives/select-field.tsx +77 -0
  110. package/src/primitives/ui/accordion.tsx +61 -0
  111. package/src/primitives/ui/button.tsx +68 -0
  112. package/src/primitives/ui/collapsible.tsx +16 -0
  113. package/src/primitives/ui/dialog.tsx +112 -0
  114. package/src/primitives/ui/input.tsx +30 -0
  115. package/src/primitives/ui/label.tsx +31 -0
  116. package/src/primitives/ui/popover.tsx +38 -0
  117. package/src/primitives/ui/select.tsx +163 -0
  118. package/src/primitives/ui/sheet.tsx +131 -0
  119. package/src/primitives/ui/tabs.tsx +62 -0
  120. package/src/products/context.tsx +34 -0
  121. package/src/products/image-gallery.tsx +43 -0
  122. package/src/products/index.ts +44 -0
  123. package/src/products/labels-bg.ts +35 -0
  124. package/src/products/labels.ts +57 -0
  125. package/src/products/mobile-actions.tsx +180 -0
  126. package/src/products/option-select.tsx +67 -0
  127. package/src/products/preview-price.tsx +36 -0
  128. package/src/products/product-actions-wrapper.tsx +58 -0
  129. package/src/products/product-actions.tsx +217 -0
  130. package/src/products/product-info.tsx +43 -0
  131. package/src/products/product-preview.tsx +49 -0
  132. package/src/products/product-price.tsx +69 -0
  133. package/src/products/product-tabs.tsx +169 -0
  134. package/src/products/product-template.tsx +114 -0
  135. package/src/products/purchase-options.tsx +130 -0
  136. package/src/products/related-products.tsx +86 -0
  137. package/src/products/thumbnail.tsx +71 -0
  138. package/src/products/variant-matching.ts +71 -0
  139. package/src/reviews-ui/helpers.ts +174 -0
  140. package/src/reviews-ui/index.ts +74 -0
  141. package/src/reviews-ui/labels-bg.ts +91 -0
  142. package/src/reviews-ui/labels.ts +199 -0
  143. package/src/reviews-ui/photo-upload.tsx +345 -0
  144. package/src/reviews-ui/review-list.tsx +249 -0
  145. package/src/reviews-ui/review-widget.tsx +224 -0
  146. package/src/reviews-ui/review-wizard.tsx +560 -0
  147. package/src/reviews-ui/star-badge.tsx +104 -0
  148. package/src/reviews-ui/wizard-state.ts +81 -0
  149. package/src/store/category-template.tsx +129 -0
  150. package/src/store/collection-template.tsx +139 -0
  151. package/src/store/index.ts +41 -0
  152. package/src/store/labels-bg.ts +22 -0
  153. package/src/store/labels.ts +52 -0
  154. package/src/store/paginated-products.tsx +116 -0
  155. package/src/store/pagination.tsx +103 -0
  156. package/src/store/search-params.ts +256 -0
  157. package/src/store/search-template.tsx +249 -0
  158. package/src/store/skeleton-product-grid.tsx +26 -0
  159. package/src/store/sort-select.tsx +81 -0
  160. package/src/store/store-template.tsx +65 -0
  161. package/src/tracking/attribution.ts +418 -0
  162. package/src/tracking/consent-banner.tsx +355 -0
  163. package/src/tracking/consent-init.tsx +44 -0
  164. package/src/tracking/consent.ts +243 -0
  165. package/src/tracking/fbq.ts +168 -0
  166. package/src/tracking/ga4.tsx +49 -0
  167. package/src/tracking/get-tracking-attribution.ts +224 -0
  168. package/src/tracking/get-tracking-config.ts +50 -0
  169. package/src/tracking/gtag.ts +200 -0
  170. package/src/tracking/index.ts +133 -0
  171. package/src/tracking/meta-pixel.tsx +166 -0
  172. package/src/tracking/rybbit-events.ts +242 -0
  173. package/src/tracking/rybbit.tsx +40 -0
  174. package/src/tracking/types.ts +185 -0
  175. package/src/tracking/use-engagement-time.ts +58 -0
  176. package/tailwind-preset.cjs +72 -0
@@ -0,0 +1,130 @@
1
+ "use client"
2
+
3
+ /**
4
+ * PDP purchase options — one-time vs subscription plans (subscriptions-core
5
+ * card, step 4; barter-native, no @1click/ui ancestor). Presentational and
6
+ * controlled: the host owns the selection and passes the chosen plan id
7
+ * into its add-to-cart seam (`carts.addLineItem` `selling_plan_id`). The
8
+ * SERVER prices the line — the preview here is display-only and computed
9
+ * from the same plan fields the server applies.
10
+ *
11
+ * Consent note (docs/storefront/checkout.md): a plan selection later
12
+ * requires a logged-in customer at payment (`save_payment_method` →
13
+ * 400 `customer_required` for guests) — surface login before checkout when
14
+ * a plan line is in the cart.
15
+ */
16
+ import React from "react"
17
+ import type { StoreSellingPlan } from "../api/products"
18
+ import { cn } from "../lib/utils"
19
+
20
+ export type PurchaseOptionsLabels = {
21
+ title: string
22
+ oneTime: string
23
+ /** Interval nouns, singular/plural: {day: ["day","days"], …} */
24
+ every: string
25
+ }
26
+
27
+ const DEFAULT_LABELS: PurchaseOptionsLabels = {
28
+ title: "Purchase options",
29
+ oneTime: "One-time purchase",
30
+ every: "Every",
31
+ }
32
+
33
+ /** Display-only mirror of the server's plan pricing (the server is truth). */
34
+ export function previewPlanPrice(plan: StoreSellingPlan, basePrice: number): number {
35
+ if (plan.pricing_type === "percent" && plan.pricing_value != null) {
36
+ return Math.max(0, Math.round(basePrice * (1 - plan.pricing_value / 100) * 100) / 100)
37
+ }
38
+ if (plan.pricing_type === "fixed" && plan.pricing_value != null) {
39
+ return plan.pricing_value
40
+ }
41
+ return basePrice
42
+ }
43
+
44
+ export function cadenceLabel(plan: StoreSellingPlan, everyWord = "Every"): string {
45
+ const unit =
46
+ plan.interval_count === 1 ? plan.interval : `${plan.interval_count} ${plan.interval}s`
47
+ return `${everyWord} ${unit}`
48
+ }
49
+
50
+ type PurchaseOptionsProps = {
51
+ plans: StoreSellingPlan[]
52
+ /** Selected plan id; null = one-time. */
53
+ value: string | null
54
+ onChange: (sellingPlanId: string | null) => void
55
+ /** The one-time unit price (from the product's calculated price). */
56
+ basePrice: number
57
+ /** Format an amount for display (host's currency formatting). */
58
+ formatPrice: (amount: number) => string
59
+ disabled?: boolean
60
+ labels?: Partial<PurchaseOptionsLabels>
61
+ "data-testid"?: string
62
+ }
63
+
64
+ export function PurchaseOptions({
65
+ plans,
66
+ value,
67
+ onChange,
68
+ basePrice,
69
+ formatPrice,
70
+ disabled,
71
+ labels: labelOverrides,
72
+ "data-testid": dataTestId,
73
+ }: PurchaseOptionsProps) {
74
+ const labels = { ...DEFAULT_LABELS, ...labelOverrides }
75
+ if (plans.length === 0) return null
76
+
77
+ const options: Array<{
78
+ id: string | null
79
+ title: string
80
+ subtitle: string | null
81
+ price: number
82
+ }> = [
83
+ { id: null, title: labels.oneTime, subtitle: null, price: basePrice },
84
+ ...plans.map((plan) => ({
85
+ id: plan.id,
86
+ title: plan.name,
87
+ subtitle: cadenceLabel(plan, labels.every),
88
+ price: previewPlanPrice(plan, basePrice),
89
+ })),
90
+ ]
91
+
92
+ return (
93
+ <div className="flex flex-col gap-y-3" data-testid={dataTestId}>
94
+ <span className="text-sm text-foreground">{labels.title}</span>
95
+ <div role="radiogroup" aria-label={labels.title} className="flex flex-col gap-2">
96
+ {options.map((opt) => {
97
+ const selected = value === opt.id
98
+ return (
99
+ <button
100
+ key={opt.id ?? "one-time"}
101
+ type="button"
102
+ role="radio"
103
+ aria-checked={selected}
104
+ onClick={() => onChange(opt.id)}
105
+ disabled={disabled}
106
+ className={cn(
107
+ "border border-border bg-muted rounded-lg p-3 flex items-center justify-between gap-3 text-left",
108
+ {
109
+ "border-primary": selected,
110
+ "hover:shadow-sm transition-shadow ease-in-out duration-150": !selected,
111
+ }
112
+ )}
113
+ data-testid="purchase-option"
114
+ >
115
+ <span className="flex flex-col">
116
+ <span className="text-sm text-foreground">{opt.title}</span>
117
+ {opt.subtitle && (
118
+ <span className="text-xs text-muted-foreground">{opt.subtitle}</span>
119
+ )}
120
+ </span>
121
+ <span className="text-sm text-foreground whitespace-nowrap">
122
+ {formatPrice(opt.price)}
123
+ </span>
124
+ </button>
125
+ )
126
+ })}
127
+ </div>
128
+ </div>
129
+ )
130
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Related-products strip — ported from
3
+ * `@1click/ui/src/products/related-products.tsx` (v2.3.1), REWIRED to the
4
+ * barter related-products endpoint (docs/storefront/search.md).
5
+ *
6
+ * The original hand-rolled relatedness client-side (same collection + shared
7
+ * tags via a products listing). barter owns that server-side:
8
+ * `api/search` `listRelatedProducts` returns manual admin picks first
9
+ * (Admin → Product → Related, position order), then a deterministic fallback
10
+ * fills to `limit` — same primary collection (newest first), then
11
+ * most-shared-tags (`auto_filled: true` when any fallback item is present).
12
+ * The anchor product is never in the results, so the source's self-filter is
13
+ * gone. Region resolution via `countryCode` is replaced by the barter
14
+ * pricing context (`currency_code`/`region_id`).
15
+ *
16
+ * Async server component — render inside `<Suspense>`.
17
+ */
18
+ import type { ComponentType } from "react"
19
+ import type { StorefrontClient } from "../api/http"
20
+ import type { PricingContextQuery } from "../api/types"
21
+ import type { StoreProduct } from "../api/products"
22
+ import { listRelatedProducts } from "../api/search"
23
+ import { StoreApiError } from "../api/types"
24
+ import { ProductPreview } from "./product-preview"
25
+ import { defaultProductLabels, type ProductLabels } from "./labels"
26
+
27
+ type RelatedProductsProps = {
28
+ client: StorefrontClient
29
+ product: StoreProduct
30
+ /** Pricing context for `calculated_price` on the related cards. */
31
+ pricingContext?: PricingContextQuery
32
+ /** 1–24, server default 12. */
33
+ limit?: number
34
+ labels?: Pick<ProductLabels, "relatedProducts" | "relatedProductsDescription">
35
+ /** Override the default ProductPreview with a store-specific card. */
36
+ renderProduct?: ComponentType<{ product: StoreProduct }>
37
+ }
38
+
39
+ export async function RelatedProducts({
40
+ client,
41
+ product,
42
+ pricingContext,
43
+ limit,
44
+ labels,
45
+ renderProduct: Card = ProductPreview,
46
+ }: RelatedProductsProps) {
47
+ const l = { ...defaultProductLabels, ...labels }
48
+
49
+ let products: StoreProduct[]
50
+ try {
51
+ const res = await listRelatedProducts(client, product.id, {
52
+ ...(limit !== undefined ? { limit } : {}),
53
+ ...pricingContext,
54
+ })
55
+ products = res.products
56
+ } catch (e) {
57
+ // Anchor invisible to this key/tenant (404) → render nothing, matching
58
+ // the source's null-region behavior. Everything else propagates.
59
+ if (e instanceof StoreApiError && e.status === 404) return null
60
+ throw e
61
+ }
62
+
63
+ if (!products.length) return null
64
+
65
+ return (
66
+ <div>
67
+ <div className="flex flex-col items-center text-center mb-16">
68
+ <span className="text-sm text-muted-foreground mb-6">
69
+ {l.relatedProducts}
70
+ </span>
71
+ <p className="text-2xl text-foreground max-w-lg">
72
+ {l.relatedProductsDescription}
73
+ </p>
74
+ </div>
75
+ <ul className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-x-6 gap-y-8">
76
+ {products.map((p) => (
77
+ <li key={p.id}>
78
+ <Card product={p} />
79
+ </li>
80
+ ))}
81
+ </ul>
82
+ </div>
83
+ )
84
+ }
85
+
86
+ export { type RelatedProductsProps }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Product thumbnail — ported from `@1click/ui/src/products/thumbnail.tsx`
3
+ * (v2.3.1) unchanged (structural props: works with `StoreProduct.thumbnail`
4
+ * + `StoreProduct.images`). Server-safe (no "use client"): renders inside
5
+ * RSC product grids.
6
+ */
7
+ import Image from "next/image"
8
+ import React from "react"
9
+ import { ImageOff } from "lucide-react"
10
+
11
+ import { cn } from "../lib/utils"
12
+
13
+ type ThumbnailProps = {
14
+ thumbnail?: string | null
15
+ images?: { url: string }[] | null
16
+ size?: "small" | "medium" | "large" | "full" | "square"
17
+ isFeatured?: boolean
18
+ className?: string
19
+ "data-testid"?: string
20
+ }
21
+
22
+ const Thumbnail: React.FC<ThumbnailProps> = ({
23
+ thumbnail,
24
+ images,
25
+ size = "small",
26
+ isFeatured,
27
+ className,
28
+ "data-testid": dataTestid,
29
+ }) => {
30
+ const initialImage = thumbnail || images?.[0]?.url
31
+
32
+ return (
33
+ <div
34
+ className={cn(
35
+ "relative w-full overflow-hidden p-4 bg-muted rounded-lg group-hover:shadow-md transition-shadow ease-in-out duration-150",
36
+ {
37
+ "aspect-[11/14]": isFeatured,
38
+ "aspect-[9/16]": !isFeatured && size !== "square",
39
+ "aspect-[1/1]": size === "square",
40
+ "w-[180px]": size === "small",
41
+ "w-[290px]": size === "medium",
42
+ "w-[440px]": size === "large",
43
+ "w-full": size === "full",
44
+ },
45
+ className
46
+ )}
47
+ data-testid={dataTestid}
48
+ >
49
+ {initialImage ? (
50
+ <Image
51
+ src={initialImage}
52
+ alt="Thumbnail"
53
+ className="absolute inset-0 object-cover object-center"
54
+ draggable={false}
55
+ quality={50}
56
+ sizes="(max-width: 576px) 280px, (max-width: 768px) 360px, (max-width: 992px) 480px, 800px"
57
+ fill
58
+ />
59
+ ) : (
60
+ <div className="w-full h-full absolute inset-0 flex items-center justify-center">
61
+ <ImageOff
62
+ className="text-muted-foreground"
63
+ size={size === "small" ? 16 : 24}
64
+ />
65
+ </div>
66
+ )}
67
+ </div>
68
+ )
69
+ }
70
+
71
+ export { Thumbnail, type ThumbnailProps }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Pure variant-matching logic — extracted from
3
+ * `@1click/ui/src/products/product-actions.tsx` (v2.3.1) so the
4
+ * option-choice → variant resolution is testable without React
5
+ * (tests/unit/storefront-catalog.test.ts).
6
+ *
7
+ * Data-seam translation: Medusa served `variant.options[]` as flat
8
+ * `{option_id, value}` rows; barter serves option-value LINKS
9
+ * (`{value: {option_id, value, option}}` — see
10
+ * `@cartbase/storefront/api/products` `StoreVariantOptionValueLink`).
11
+ * `optionsAsKeymap` reads the barter link shape (null-safe) and falls back
12
+ * to the flat Medusa row shape so both wire formats resolve identically.
13
+ */
14
+
15
+ /** Either barter's option-value link or Medusa's flat option-value row. */
16
+ export type VariantOptionLike =
17
+ | {
18
+ option_id?: string
19
+ value?: string | { option_id: string; value: string } | null
20
+ }
21
+ | null
22
+
23
+ /** Chosen options on a PDP: option_id → chosen value (undefined = not chosen). */
24
+ export type OptionChoices = Record<string, string | undefined>
25
+
26
+ /**
27
+ * Flatten a variant's option values into `{[option_id]: value}` for
28
+ * comparison against the user's current choices.
29
+ */
30
+ export function optionsAsKeymap(
31
+ variantOptions: VariantOptionLike[] | null | undefined
32
+ ): Record<string, string> | undefined {
33
+ return variantOptions?.reduce((acc: Record<string, string>, varopt) => {
34
+ if (!varopt) return acc
35
+ const v = varopt.value
36
+ if (v && typeof v === "object") {
37
+ // barter link shape: { value: { option_id, value } }
38
+ if (v.option_id) acc[v.option_id] = v.value
39
+ } else if (varopt.option_id && typeof v === "string") {
40
+ // Medusa flat row shape: { option_id, value }
41
+ acc[varopt.option_id] = v
42
+ }
43
+ return acc
44
+ }, {})
45
+ }
46
+
47
+ /**
48
+ * True when the chosen options exactly cover the variant's keymap — same
49
+ * key count, same values. Partial choices never match (the add-to-cart
50
+ * button stays disabled until every option is picked).
51
+ */
52
+ export function optionsMatch(
53
+ a: OptionChoices,
54
+ b: Record<string, string> | undefined
55
+ ): boolean {
56
+ if (!b) return false
57
+ const keys = Object.keys(a)
58
+ if (keys.length !== Object.keys(b).length) return false
59
+ return keys.every((k) => a[k] === b[k])
60
+ }
61
+
62
+ /** Resolve the variant whose options exactly match the current choices. */
63
+ export function findMatchingVariant<
64
+ V extends { options?: VariantOptionLike[] | null }
65
+ >(
66
+ variants: V[] | null | undefined,
67
+ options: OptionChoices
68
+ ): V | undefined {
69
+ if (!variants || variants.length === 0) return undefined
70
+ return variants.find((v) => optionsMatch(options, optionsAsKeymap(v.options)))
71
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Pure helpers for the reviews-UI family — no React, no fetches. Ported
3
+ * from the Alenika production storefront (src/lib/reviews-api.ts +
4
+ * src/components/product/reviews-list.tsx + review-form.tsx) with the
5
+ * hardcoded Bulgarian copy replaced by the labels pack. Unit-tested in
6
+ * tests/unit/storefront-order-reviews.test.ts.
7
+ */
8
+ import type { ReviewsUiLabels } from "./labels"
9
+
10
+ /** Replace `{key}` slots in a label template. Unknown slots are left as-is. */
11
+ export function formatLabel(
12
+ template: string,
13
+ vars: Record<string, string | number> = {}
14
+ ): string {
15
+ return template.replace(/\{(\w+)\}/g, (match, key: string) =>
16
+ key in vars ? String(vars[key]) : match
17
+ )
18
+ }
19
+
20
+ /**
21
+ * Public display name: first name + last-word initial ("Мария Петрова" →
22
+ * "Мария П."). The full surname is NEVER shown publicly. Shared by the
23
+ * visible reviews list AND any JSON-LD the app renders, so the two can't
24
+ * drift (production fix — the JSON-LD once leaked the full name to Google
25
+ * while the UI abbreviated). `fallback` covers empty names ("Клиент" on
26
+ * Alenika).
27
+ */
28
+ export function reviewDisplayName(full: string, fallback = "Customer"): string {
29
+ const parts = (full || "").trim().split(/\s+/).filter(Boolean)
30
+ if (parts.length === 0) return fallback
31
+ const last = parts[parts.length - 1] as string
32
+ if (parts.length === 1) return last
33
+ return `${parts.slice(0, -1).join(" ")} ${last.charAt(0).toUpperCase()}.`
34
+ }
35
+
36
+ /** First word of a full name — the wizard greeting ("Здравей, Мария"). */
37
+ export function firstNameOf(fullName: string): string {
38
+ return (fullName || "").trim().split(/\s+/)[0] || ""
39
+ }
40
+
41
+ /**
42
+ * Relative-or-absolute review date: today / yesterday / "{n} days ago"
43
+ * under 30 days, then DD.MM.YYYY. Port of the Alenika
44
+ * `fmtRelativeOrAbsolute`; `now` is injectable for tests.
45
+ */
46
+ export function formatReviewDate(
47
+ iso: string,
48
+ labels: Pick<ReviewsUiLabels, "dateToday" | "dateYesterday" | "dateDaysAgo">,
49
+ now: number = Date.now()
50
+ ): string {
51
+ const d = new Date(iso)
52
+ if (isNaN(d.getTime())) return ""
53
+ const ageMs = now - d.getTime()
54
+ const ageDays = ageMs / (24 * 60 * 60 * 1000)
55
+ if (ageDays < 1) return labels.dateToday
56
+ if (ageDays < 2) return labels.dateYesterday
57
+ if (ageDays < 30)
58
+ return formatLabel(labels.dateDaysAgo, { n: Math.floor(ageDays) })
59
+ return `${String(d.getDate()).padStart(2, "0")}.${String(
60
+ d.getMonth() + 1
61
+ ).padStart(2, "0")}.${d.getFullYear()}`
62
+ }
63
+
64
+ /** UI sort keys (the select) — richer than the API's 3-value `sort`. */
65
+ export type ReviewSortKey = "default" | "rating-desc" | "rating-asc" | "date"
66
+
67
+ /**
68
+ * Translate the UI sort key into the API's `(sort, order)` tuple
69
+ * (`api/reviews.ListReviewsQuery`). Port of the Alenika `sortParams`.
70
+ */
71
+ export function sortParamsFor(s: ReviewSortKey): {
72
+ sort: "default" | "rating" | "date"
73
+ order?: "asc" | "desc"
74
+ } {
75
+ if (s === "rating-desc") return { sort: "rating", order: "desc" }
76
+ if (s === "rating-asc") return { sort: "rating", order: "asc" }
77
+ if (s === "date") return { sort: "date" }
78
+ return { sort: "default" }
79
+ }
80
+
81
+ /** "1 review" / "N reviews" — singular/plural noun pick. */
82
+ export function reviewCountLabel(
83
+ count: number,
84
+ labels: Pick<ReviewsUiLabels, "reviewSingular" | "reviewPlural">
85
+ ): string {
86
+ return `${count} ${count === 1 ? labels.reviewSingular : labels.reviewPlural}`
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Upload constraints — MUST mirror the server rules (docs/storefront/
91
+ // reviews.md: image ≤ 8MB, video ≤ 50MB; ≤ 6 images + ≤ 1 video; MIME
92
+ // image/jpeg|jpg|png|webp, video/mp4|quicktime; 60s video cap is a
93
+ // client-side production rule from Alenika).
94
+ // ---------------------------------------------------------------------------
95
+
96
+ export const MAX_IMAGES = 6
97
+ export const MAX_VIDEOS = 1
98
+ export const MAX_BODY = 2000
99
+ export const MAX_IMAGE_BYTES = 8 * 1024 * 1024
100
+ export const MAX_VIDEO_BYTES = 50 * 1024 * 1024
101
+ export const MAX_VIDEO_SECONDS = 60
102
+ export const IMAGE_MIME = new Set([
103
+ "image/jpeg",
104
+ "image/jpg",
105
+ "image/png",
106
+ "image/webp",
107
+ ])
108
+ export const VIDEO_MIME = new Set(["video/mp4", "video/quicktime"])
109
+ export const UPLOAD_ACCEPT =
110
+ "image/jpeg,image/jpg,image/png,image/webp,video/mp4,video/quicktime"
111
+
112
+ export interface FileCheckInput {
113
+ name: string
114
+ type: string
115
+ size: number
116
+ }
117
+
118
+ export type FileCheckResult =
119
+ | { ok: true; kind: "image" | "video" }
120
+ | {
121
+ ok: false
122
+ /** Labels key for the error copy. */
123
+ errorKey:
124
+ | "errUnsupported"
125
+ | "errMaxImages"
126
+ | "errImageTooLarge"
127
+ | "errMaxVideos"
128
+ | "errVideoTooLarge"
129
+ /** `formatLabel` vars for the error template. */
130
+ vars: Record<string, string | number>
131
+ }
132
+
133
+ /**
134
+ * Pure per-file admission check for a multi-select batch. Production fix
135
+ * preserved from Alenika: the caller decrements LOCAL slot counters as
136
+ * files are accepted (`imgSlots`/`vidSlots` start at max − current), so a
137
+ * multi-select of N files can't all pass the cap against stale render
138
+ * state in one pass. Video DURATION is checked separately (async metadata
139
+ * read) in the uploader component.
140
+ */
141
+ export function checkFileForUpload(
142
+ file: FileCheckInput,
143
+ slots: { imgSlots: number; vidSlots: number }
144
+ ): FileCheckResult {
145
+ const isImage = IMAGE_MIME.has(file.type)
146
+ const isVideo = VIDEO_MIME.has(file.type)
147
+ if (!isImage && !isVideo) {
148
+ return { ok: false, errorKey: "errUnsupported", vars: { name: file.name } }
149
+ }
150
+ if (isImage) {
151
+ if (slots.imgSlots <= 0) {
152
+ return { ok: false, errorKey: "errMaxImages", vars: { n: MAX_IMAGES } }
153
+ }
154
+ if (file.size > MAX_IMAGE_BYTES) {
155
+ return {
156
+ ok: false,
157
+ errorKey: "errImageTooLarge",
158
+ vars: { name: file.name, mb: MAX_IMAGE_BYTES / 1024 / 1024 },
159
+ }
160
+ }
161
+ return { ok: true, kind: "image" }
162
+ }
163
+ if (slots.vidSlots <= 0) {
164
+ return { ok: false, errorKey: "errMaxVideos", vars: { n: MAX_VIDEOS } }
165
+ }
166
+ if (file.size > MAX_VIDEO_BYTES) {
167
+ return {
168
+ ok: false,
169
+ errorKey: "errVideoTooLarge",
170
+ vars: { mb: MAX_VIDEO_BYTES / 1024 / 1024 },
171
+ }
172
+ }
173
+ return { ok: true, kind: "video" }
174
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @cartbase/storefront/reviews-ui — verified-purchase review components.
3
+ * NEW family (no @1click/ui counterpart): ported/adapted from the Alenika
4
+ * production storefront (src/components/product/reviews-list.tsx + the
5
+ * /review/[token] page + review-form.tsx) over the batch-9 SDK
6
+ * (`api/reviews` — DTOs mirror alenika/src/lib/reviews-api.ts verbatim).
7
+ * Contracts: docs/storefront/components.md + docs/storefront/reviews.md.
8
+ */
9
+ export {
10
+ defaultReviewsUiLabels,
11
+ type ReviewsUiLabels,
12
+ } from "./labels"
13
+ export { bulgarianReviewsUiLabels } from "./labels-bg"
14
+
15
+ export {
16
+ formatLabel,
17
+ reviewDisplayName,
18
+ firstNameOf,
19
+ formatReviewDate,
20
+ sortParamsFor,
21
+ reviewCountLabel,
22
+ checkFileForUpload,
23
+ MAX_IMAGES,
24
+ MAX_VIDEOS,
25
+ MAX_BODY,
26
+ MAX_IMAGE_BYTES,
27
+ MAX_VIDEO_BYTES,
28
+ MAX_VIDEO_SECONDS,
29
+ IMAGE_MIME,
30
+ VIDEO_MIME,
31
+ UPLOAD_ACCEPT,
32
+ type ReviewSortKey,
33
+ type FileCheckInput,
34
+ type FileCheckResult,
35
+ } from "./helpers"
36
+
37
+ export {
38
+ resolveWizardEntry,
39
+ submitErrorKeyFor,
40
+ canSubmitRating,
41
+ type WizardEntry,
42
+ type WizardStep,
43
+ type SubmitErrorKey,
44
+ } from "./wizard-state"
45
+
46
+ export {
47
+ StarRow,
48
+ StarBadge,
49
+ RatingDistribution,
50
+ type StarBadgeProps,
51
+ type RatingDistributionProps,
52
+ } from "./star-badge"
53
+
54
+ export {
55
+ ReviewList,
56
+ ReviewLightbox,
57
+ type ReviewListProps,
58
+ } from "./review-list"
59
+
60
+ export { ReviewWidget, type ReviewWidgetProps } from "./review-widget"
61
+
62
+ export {
63
+ ReviewWizard,
64
+ ReviewWizardForm,
65
+ type ReviewWizardProps,
66
+ } from "./review-wizard"
67
+
68
+ export {
69
+ ReviewPhotoUpload,
70
+ readyMedia,
71
+ putToSignedUrl,
72
+ type LocalMedia,
73
+ type ReviewPhotoUploadProps,
74
+ } from "./photo-upload"