@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.
- package/package.json +226 -0
- package/src/api/auth.ts +108 -0
- package/src/api/carts.ts +506 -0
- package/src/api/categories.ts +184 -0
- package/src/api/checkout.ts +440 -0
- package/src/api/collections.ts +130 -0
- package/src/api/consent.ts +75 -0
- package/src/api/content.ts +125 -0
- package/src/api/customers.ts +307 -0
- package/src/api/gift-cards.ts +112 -0
- package/src/api/http.ts +122 -0
- package/src/api/index.ts +29 -0
- package/src/api/integrations.ts +130 -0
- package/src/api/menus.ts +77 -0
- package/src/api/metaobjects.ts +136 -0
- package/src/api/orders.ts +290 -0
- package/src/api/products.ts +303 -0
- package/src/api/redirects.ts +37 -0
- package/src/api/regions.ts +141 -0
- package/src/api/reviews.ts +259 -0
- package/src/api/search.ts +133 -0
- package/src/api/types.ts +91 -0
- package/src/cart-drawer/cart-drawer.tsx +86 -0
- package/src/cart-drawer/context.tsx +569 -0
- package/src/cart-drawer/continue-shopping.tsx +28 -0
- package/src/cart-drawer/cross-sell-carousel.tsx +212 -0
- package/src/cart-drawer/cross-sell-sidebar.tsx +158 -0
- package/src/cart-drawer/empty.tsx +52 -0
- package/src/cart-drawer/free-gift.tsx +71 -0
- package/src/cart-drawer/gift-wrap.tsx +83 -0
- package/src/cart-drawer/header.tsx +52 -0
- package/src/cart-drawer/index.ts +69 -0
- package/src/cart-drawer/item/index.tsx +164 -0
- package/src/cart-drawer/item/quantity.tsx +100 -0
- package/src/cart-drawer/item/upsell.tsx +110 -0
- package/src/cart-drawer/item/variant.tsx +46 -0
- package/src/cart-drawer/labels-bg.ts +72 -0
- package/src/cart-drawer/labels.ts +119 -0
- package/src/cart-drawer/notes.tsx +131 -0
- package/src/cart-drawer/payment-badges.tsx +96 -0
- package/src/cart-drawer/promo-banner.tsx +43 -0
- package/src/cart-drawer/rewards-points.tsx +78 -0
- package/src/cart-drawer/sticky-footer.tsx +73 -0
- package/src/cart-drawer/summary-breakdown.tsx +196 -0
- package/src/cart-drawer/template.tsx +225 -0
- package/src/cart-drawer/tiered-progress.tsx +168 -0
- package/src/checkout/address-error-copy.ts +119 -0
- package/src/checkout/address-form.tsx +224 -0
- package/src/checkout/address-select.tsx +79 -0
- package/src/checkout/boxnow-locker-selector.tsx +410 -0
- package/src/checkout/checkout-client.tsx +222 -0
- package/src/checkout/company-details.tsx +94 -0
- package/src/checkout/compare-addresses.ts +40 -0
- package/src/checkout/context.tsx +76 -0
- package/src/checkout/discount-section.tsx +218 -0
- package/src/checkout/econt-office-selector.tsx +332 -0
- package/src/checkout/error-message.tsx +25 -0
- package/src/checkout/geocode.ts +154 -0
- package/src/checkout/gift-card-section.tsx +224 -0
- package/src/checkout/index.ts +74 -0
- package/src/checkout/labels-bg.ts +128 -0
- package/src/checkout/labels.ts +263 -0
- package/src/checkout/line-item-card.tsx +152 -0
- package/src/checkout/order-summary.tsx +524 -0
- package/src/checkout/payment-button.tsx +373 -0
- package/src/checkout/payment-error-copy.ts +269 -0
- package/src/checkout/payment-method-list.tsx +365 -0
- package/src/checkout/payment-wrapper.tsx +102 -0
- package/src/checkout/promotion-error-copy.ts +124 -0
- package/src/checkout/shipping-method-list.tsx +335 -0
- package/src/checkout/stripe-wrapper.tsx +165 -0
- package/src/checkout/use-checkout-orchestration.ts +1504 -0
- package/src/common/cart-button-client.tsx +39 -0
- package/src/common/cart-button.tsx +28 -0
- package/src/common/country-select.tsx +65 -0
- package/src/common/delete-button.tsx +66 -0
- package/src/common/index.ts +17 -0
- package/src/common/language-select.tsx +78 -0
- package/src/common/localized-link.tsx +45 -0
- package/src/common/skeleton.tsx +29 -0
- package/src/index.ts +12 -0
- package/src/lib/cart-helpers.ts +113 -0
- package/src/lib/dual-price.tsx +73 -0
- package/src/lib/get-percentage-diff.ts +5 -0
- package/src/lib/get-product-price.ts +133 -0
- package/src/lib/hooks/use-intersection.ts +30 -0
- package/src/lib/hooks/use-toggle-state.ts +25 -0
- package/src/lib/money.ts +73 -0
- package/src/lib/payment-constants.ts +66 -0
- package/src/lib/product.ts +22 -0
- package/src/lib/sort-products.ts +63 -0
- package/src/lib/store-api-error.ts +36 -0
- package/src/lib/utils.ts +16 -0
- package/src/order/context.tsx +32 -0
- package/src/order/index.ts +63 -0
- package/src/order/labels-bg.ts +39 -0
- package/src/order/labels.ts +79 -0
- package/src/order/order-address-card.tsx +47 -0
- package/src/order/order-completed-template.tsx +165 -0
- package/src/order/order-confirmation-header.tsx +65 -0
- package/src/order/order-delivery-card.tsx +258 -0
- package/src/order/order-help-section.tsx +47 -0
- package/src/order/order-item.tsx +201 -0
- package/src/order/order-items-list.tsx +52 -0
- package/src/order/order-payment-card.tsx +95 -0
- package/src/order/order-timeline.tsx +141 -0
- package/src/order/order-totals.tsx +245 -0
- package/src/primitives/field.tsx +125 -0
- package/src/primitives/select-field.tsx +77 -0
- package/src/primitives/ui/accordion.tsx +61 -0
- package/src/primitives/ui/button.tsx +68 -0
- package/src/primitives/ui/collapsible.tsx +16 -0
- package/src/primitives/ui/dialog.tsx +112 -0
- package/src/primitives/ui/input.tsx +30 -0
- package/src/primitives/ui/label.tsx +31 -0
- package/src/primitives/ui/popover.tsx +38 -0
- package/src/primitives/ui/select.tsx +163 -0
- package/src/primitives/ui/sheet.tsx +131 -0
- package/src/primitives/ui/tabs.tsx +62 -0
- package/src/products/context.tsx +34 -0
- package/src/products/image-gallery.tsx +43 -0
- package/src/products/index.ts +44 -0
- package/src/products/labels-bg.ts +35 -0
- package/src/products/labels.ts +57 -0
- package/src/products/mobile-actions.tsx +180 -0
- package/src/products/option-select.tsx +67 -0
- package/src/products/preview-price.tsx +36 -0
- package/src/products/product-actions-wrapper.tsx +58 -0
- package/src/products/product-actions.tsx +217 -0
- package/src/products/product-info.tsx +43 -0
- package/src/products/product-preview.tsx +49 -0
- package/src/products/product-price.tsx +69 -0
- package/src/products/product-tabs.tsx +169 -0
- package/src/products/product-template.tsx +114 -0
- package/src/products/purchase-options.tsx +130 -0
- package/src/products/related-products.tsx +86 -0
- package/src/products/thumbnail.tsx +71 -0
- package/src/products/variant-matching.ts +71 -0
- package/src/reviews-ui/helpers.ts +174 -0
- package/src/reviews-ui/index.ts +74 -0
- package/src/reviews-ui/labels-bg.ts +91 -0
- package/src/reviews-ui/labels.ts +199 -0
- package/src/reviews-ui/photo-upload.tsx +345 -0
- package/src/reviews-ui/review-list.tsx +249 -0
- package/src/reviews-ui/review-widget.tsx +224 -0
- package/src/reviews-ui/review-wizard.tsx +560 -0
- package/src/reviews-ui/star-badge.tsx +104 -0
- package/src/reviews-ui/wizard-state.ts +81 -0
- package/src/store/category-template.tsx +129 -0
- package/src/store/collection-template.tsx +139 -0
- package/src/store/index.ts +41 -0
- package/src/store/labels-bg.ts +22 -0
- package/src/store/labels.ts +52 -0
- package/src/store/paginated-products.tsx +116 -0
- package/src/store/pagination.tsx +103 -0
- package/src/store/search-params.ts +256 -0
- package/src/store/search-template.tsx +249 -0
- package/src/store/skeleton-product-grid.tsx +26 -0
- package/src/store/sort-select.tsx +81 -0
- package/src/store/store-template.tsx +65 -0
- package/src/tracking/attribution.ts +418 -0
- package/src/tracking/consent-banner.tsx +355 -0
- package/src/tracking/consent-init.tsx +44 -0
- package/src/tracking/consent.ts +243 -0
- package/src/tracking/fbq.ts +168 -0
- package/src/tracking/ga4.tsx +49 -0
- package/src/tracking/get-tracking-attribution.ts +224 -0
- package/src/tracking/get-tracking-config.ts +50 -0
- package/src/tracking/gtag.ts +200 -0
- package/src/tracking/index.ts +133 -0
- package/src/tracking/meta-pixel.tsx +166 -0
- package/src/tracking/rybbit-events.ts +242 -0
- package/src/tracking/rybbit.tsx +40 -0
- package/src/tracking/types.ts +185 -0
- package/src/tracking/use-engagement-time.ts +58 -0
- 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"
|