@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,201 @@
1
+ import Image from "next/image"
2
+ import { ImageOff } from "lucide-react"
3
+ import { DualPrice } from "../lib/dual-price"
4
+ import type { StoreOrderItem } from "../api/orders"
5
+ import { defaultOrderLabels, type OrderLabels } from "./labels"
6
+
7
+ /**
8
+ * Order line row. Ported from `@1click/ui/src/order/order-item.tsx`
9
+ * (v2.3.1) with the data seam translated to barter:
10
+ *
11
+ * Medusa's `StoreOrderLineItem` carried per-line computed totals
12
+ * (`total` / `original_total`). Barter's store order surface does NOT —
13
+ * `StoreOrderDetail.items` are version-pivot rows (`{quantity, line_item}`)
14
+ * whose embedded `order_line_items` row carries only `unit_price` (see
15
+ * docs/storefront/orders.md). The component therefore renders a normalized
16
+ * `OrderDisplayItem`, produced by one of the pure converters below:
17
+ *
18
+ * - `displayItemFromOrderItem(pivot)` — from the order detail. `total` is
19
+ * `unit_price × quantity` (the pre-adjustment line amount — the order
20
+ * surface exposes no per-line adjustment data; totals rows remain the
21
+ * money authority).
22
+ * - `displayItemFromCartLine(line)` — from the decorated cart the app
23
+ * still holds on the confirmation page (`api/carts` `CartLineItem`);
24
+ * this path keeps the SERVER-computed `total`/`original_total` and so
25
+ * the strike-through discount display. Prefer it right after
26
+ * `completeCart()`.
27
+ */
28
+
29
+ /** Normalized, render-ready order line. */
30
+ export interface OrderDisplayItem {
31
+ id: string
32
+ title: string | null
33
+ /** Resolved variant caption (options joined, or variant_title). */
34
+ variantLabel: string | null
35
+ thumbnail: string | null
36
+ quantity: number
37
+ /** SERVER line total when available (cart path); else unit_price × qty. */
38
+ total: number
39
+ /** Pre-discount total — only present when the source had it (cart path). */
40
+ originalTotal?: number
41
+ metadata?: Record<string, unknown> | null
42
+ /** Preserved for the items-list newest-first sort, when the source had it. */
43
+ createdAt?: string | null
44
+ }
45
+
46
+ /**
47
+ * Variant caption resolution — port of the source's `variantLabel` IIFE:
48
+ * option values joined with " / " when present, else the variant title
49
+ * unless it is the Medusa placeholder "Default variant".
50
+ */
51
+ export function variantLabelFrom(input: {
52
+ variant_option_values?: Record<string, unknown> | null
53
+ variant_title?: string | null
54
+ }): string | null {
55
+ const values = input.variant_option_values
56
+ if (values && typeof values === "object") {
57
+ const parts = Object.values(values).filter(
58
+ (v): v is string | number => typeof v === "string" || typeof v === "number"
59
+ )
60
+ if (parts.length) return parts.map(String).join(" / ")
61
+ }
62
+ const title = input.variant_title
63
+ if (title && title !== "Default variant") return title
64
+ return null
65
+ }
66
+
67
+ /** Pure converter: order-detail pivot row → display line. */
68
+ export function displayItemFromOrderItem(
69
+ item: StoreOrderItem
70
+ ): OrderDisplayItem {
71
+ const li = item.line_item
72
+ const quantity = Number(item.quantity ?? 0)
73
+ const unitPrice = Number(li?.unit_price ?? 0)
74
+ return {
75
+ id: item.id,
76
+ title: li?.product_title ?? li?.title ?? null,
77
+ variantLabel: li
78
+ ? variantLabelFrom({
79
+ variant_option_values:
80
+ (li["variant_option_values"] as Record<string, unknown> | null) ??
81
+ null,
82
+ variant_title: li.variant_title,
83
+ })
84
+ : null,
85
+ thumbnail: li?.thumbnail ?? null,
86
+ quantity,
87
+ total: unitPrice * quantity,
88
+ metadata: li?.metadata ?? null,
89
+ createdAt: (li?.["created_at"] as string | undefined) ?? null,
90
+ }
91
+ }
92
+
93
+ /** Structural cart-line shape (satisfied by `api/carts` `CartLineItem`). */
94
+ export interface CartLineLike {
95
+ id: string
96
+ product_title?: string | null
97
+ title?: string | null
98
+ variant_title?: string | null
99
+ thumbnail?: string | null
100
+ quantity: number
101
+ total?: number | null
102
+ original_total?: number | null
103
+ unit_price?: number | null
104
+ metadata?: Record<string, unknown> | null
105
+ created_at?: string | null
106
+ [key: string]: unknown
107
+ }
108
+
109
+ /** Pure converter: decorated cart line → display line (keeps server totals). */
110
+ export function displayItemFromCartLine(line: CartLineLike): OrderDisplayItem {
111
+ const quantity = Number(line.quantity ?? 0)
112
+ return {
113
+ id: line.id,
114
+ title: line.product_title ?? line.title ?? null,
115
+ variantLabel: variantLabelFrom({
116
+ variant_option_values:
117
+ (line["variant_option_values"] as Record<string, unknown> | null) ??
118
+ null,
119
+ variant_title: line.variant_title,
120
+ }),
121
+ thumbnail: line.thumbnail ?? null,
122
+ quantity,
123
+ total: Number(line.total ?? Number(line.unit_price ?? 0) * quantity),
124
+ originalTotal:
125
+ line.original_total !== null && line.original_total !== undefined
126
+ ? Number(line.original_total)
127
+ : undefined,
128
+ metadata: line.metadata ?? null,
129
+ createdAt: line.created_at ?? null,
130
+ }
131
+ }
132
+
133
+ type OrderItemProps = {
134
+ item: OrderDisplayItem
135
+ currencyCode: string
136
+ labels?: Pick<OrderLabels, "qty">
137
+ }
138
+
139
+ export function OrderItem({ item, currencyCode, labels }: OrderItemProps) {
140
+ const l = { ...defaultOrderLabels, ...labels }
141
+ const hasDiscount =
142
+ item.originalTotal !== undefined && item.total < item.originalTotal
143
+
144
+ return (
145
+ <div className="flex gap-4 py-4">
146
+ <div className="w-[64px] h-[64px] rounded-xl overflow-hidden bg-muted relative flex-shrink-0 shadow-sm">
147
+ {item.thumbnail ? (
148
+ <Image
149
+ src={item.thumbnail}
150
+ alt={item.title || ""}
151
+ fill
152
+ className="object-cover"
153
+ sizes="64px"
154
+ />
155
+ ) : (
156
+ <div className="w-full h-full flex items-center justify-center">
157
+ <ImageOff className="w-5 h-5 text-muted-foreground" />
158
+ </div>
159
+ )}
160
+ </div>
161
+
162
+ <div className="flex-1 min-w-0 flex flex-col justify-center">
163
+ <div className="flex items-baseline justify-between gap-3">
164
+ <div className="min-w-0">
165
+ <span className="text-sm font-semibold text-foreground line-clamp-1 leading-snug block">
166
+ {item.title}
167
+ </span>
168
+ {item.variantLabel && (
169
+ <span className="text-xs text-muted-foreground mt-0.5 block">
170
+ {item.variantLabel}
171
+ </span>
172
+ )}
173
+ <span className="text-xs text-muted-foreground mt-0.5 block">
174
+ {item.quantity} {l.qty}
175
+ </span>
176
+ </div>
177
+ <div className="text-right flex-shrink-0">
178
+ {hasDiscount && (
179
+ <span className="text-xs text-muted-foreground line-through block leading-none mb-0.5">
180
+ <DualPrice
181
+ amount={item.originalTotal ?? 0}
182
+ currencyCode={currencyCode}
183
+ className="text-xs text-muted-foreground"
184
+ />
185
+ </span>
186
+ )}
187
+ <DualPrice
188
+ amount={item.total}
189
+ currencyCode={currencyCode}
190
+ className={`text-sm font-bold ${
191
+ hasDiscount ? "text-success" : "text-foreground"
192
+ }`}
193
+ />
194
+ </div>
195
+ </div>
196
+ </div>
197
+ </div>
198
+ )
199
+ }
200
+
201
+ export { type OrderItemProps }
@@ -0,0 +1,52 @@
1
+ import { OrderItem, type OrderDisplayItem } from "./order-item"
2
+ import { isProductLine } from "../lib/cart-helpers"
3
+ import { defaultOrderLabels, type OrderLabels } from "./labels"
4
+
5
+ /**
6
+ * Order items list. Ported from `@1click/ui/src/order/order-items-list.tsx`
7
+ * (v2.3.1); data seam: takes normalized `OrderDisplayItem[]` (see
8
+ * `order-item.tsx` converters) + the currency, since barter's
9
+ * `StoreOrderDetail` carries no order-level computed fields beyond
10
+ * `currency_code`.
11
+ */
12
+ type OrderItemsListProps = {
13
+ items: OrderDisplayItem[]
14
+ currencyCode: string
15
+ labels?: Pick<OrderLabels, "summary" | "qty">
16
+ }
17
+
18
+ export function OrderItemsList({
19
+ items,
20
+ currencyCode,
21
+ labels,
22
+ }: OrderItemsListProps) {
23
+ const l = { ...defaultOrderLabels, ...labels }
24
+ // Render product lines only — backend-injected fee lines (legacy COD-fee
25
+ // line items tagged `metadata.is_cod_fee`) are hidden here and surfaced
26
+ // as a dedicated row in OrderTotals instead. barter's NATIVE COD fee is
27
+ // never a line item (order_summaries.totals.cod_fee_total), so on pure
28
+ // barter data this filter is a no-op safety net.
29
+ const visible = (items || []).filter((i) => isProductLine(i))
30
+
31
+ return (
32
+ <div>
33
+ <h2 className="text-sm font-semibold text-foreground mb-1">
34
+ {l.summary}
35
+ </h2>
36
+ <div className="divide-y divide-border">
37
+ {visible
38
+ .sort((a, b) => ((a.createdAt ?? "") > (b.createdAt ?? "") ? -1 : 1))
39
+ .map((item) => (
40
+ <OrderItem
41
+ key={item.id}
42
+ item={item}
43
+ currencyCode={currencyCode}
44
+ labels={labels}
45
+ />
46
+ ))}
47
+ </div>
48
+ </div>
49
+ )
50
+ }
51
+
52
+ export { type OrderItemsListProps }
@@ -0,0 +1,95 @@
1
+ import { CreditCard } from "lucide-react"
2
+ import {
3
+ isManual,
4
+ isPaypal,
5
+ isStripeLike,
6
+ paymentInfoMap,
7
+ } from "../lib/payment-constants"
8
+ import { defaultOrderLabels, type OrderLabels } from "./labels"
9
+
10
+ /**
11
+ * Payment-method card. Ported from
12
+ * `@1click/ui/src/order/order-payment-card.tsx` (v2.3.1); data seam:
13
+ * Medusa's `order.payment_collections[0].payments[0]` embed does not exist
14
+ * on barter's `StoreOrderDetail` (payment internals never cross the store
15
+ * surface) — the provider id arrives via props instead: from
16
+ * `prepareCheckout().payment_provider`, the decorated cart's
17
+ * `payment_collection` sessions, or the app's checkout state.
18
+ */
19
+
20
+ /**
21
+ * Resolve a human-readable, locale-correct title for a payment provider id.
22
+ * Resolution order (verbatim from the source):
23
+ *
24
+ * 1. Bucketed lookup against `labels.paymentMethodTitles` (the locale
25
+ * pack the storefront passes in). This is the canonical localization
26
+ * surface — Bulgarian stores get "Наложен платеж" here, English
27
+ * stores get "Cash on delivery", etc.
28
+ * 2. Hardcoded `paymentInfoMap` fallback for known provider ids (English
29
+ * defaults). Keeps the library safe for storefronts that haven't
30
+ * adopted the labels pack yet.
31
+ * 3. The raw provider id as a last resort, so the card never renders
32
+ * empty if a brand-new provider lands.
33
+ *
34
+ * The bucket is determined by the same `isStripeLike` / `isManual` /
35
+ * `isPaypal` predicates used everywhere else in the library (translated to
36
+ * barter ids in `lib/payment-constants`), so a single change to those
37
+ * propagates here automatically.
38
+ */
39
+ export function resolvePaymentTitle(
40
+ providerId: string | undefined,
41
+ paymentMethodTitles: OrderLabels["paymentMethodTitles"]
42
+ ): string {
43
+ if (!providerId) return ""
44
+ if (isStripeLike(providerId)) return paymentMethodTitles.card
45
+ // COD before manual: pp_cod is its own bucket (payments-registry card) —
46
+ // an offline manual payment must never render as "Cash on delivery".
47
+ if (providerId === "pp_cod") return paymentMethodTitles.cod
48
+ if (isManual(providerId)) return paymentMethodTitles.manual
49
+ if (isPaypal(providerId)) return paymentMethodTitles.paypal
50
+ return paymentInfoMap[providerId]?.title ?? providerId
51
+ }
52
+
53
+ type OrderPaymentCardProps = {
54
+ /** Payment provider id (e.g. `pp_stripe`, `pp_system_default`). */
55
+ providerId?: string | null
56
+ /** Last 4 card digits when known (Stripe checkout state). */
57
+ cardLast4?: string | null
58
+ labels?: Pick<OrderLabels, "paymentMethod" | "paymentMethodTitles">
59
+ }
60
+
61
+ export function OrderPaymentCard({
62
+ providerId,
63
+ cardLast4,
64
+ labels,
65
+ }: OrderPaymentCardProps) {
66
+ const l = { ...defaultOrderLabels, ...labels }
67
+
68
+ if (!providerId) return null
69
+
70
+ const title = resolvePaymentTitle(providerId, l.paymentMethodTitles)
71
+ const icon = paymentInfoMap[providerId]?.icon ?? null
72
+
73
+ return (
74
+ <div className="bg-card rounded-xl p-5 shadow-sm">
75
+ <h3 className="text-sm font-semibold text-foreground mb-3">
76
+ {l.paymentMethod}
77
+ </h3>
78
+ <div className="flex items-center gap-3">
79
+ <div className="w-9 h-9 rounded-lg bg-muted flex items-center justify-center text-muted-foreground">
80
+ {icon || <CreditCard className="w-[18px] h-[18px]" />}
81
+ </div>
82
+ <div>
83
+ <p className="text-sm text-foreground">{title}</p>
84
+ {isStripeLike(providerId) && cardLast4 && (
85
+ <p className="text-xs text-muted-foreground mt-0.5">
86
+ **** **** **** {cardLast4}
87
+ </p>
88
+ )}
89
+ </div>
90
+ </div>
91
+ </div>
92
+ )
93
+ }
94
+
95
+ export { type OrderPaymentCardProps }
@@ -0,0 +1,141 @@
1
+ "use client"
2
+
3
+ import type { StoreOrderFulfillment } from "../api/orders"
4
+ import { defaultOrderLabels, type OrderLabels } from "./labels"
5
+
6
+ /**
7
+ * Fulfillment progress timeline. Ported from
8
+ * `@1click/ui/src/order/order-timeline.tsx` (v2.3.1) — the step state
9
+ * machine and rendering are unchanged. Barter seam: there is no
10
+ * `order.fulfillment_status` column on the store surface; derive the
11
+ * status from the fulfillments' lifecycle timestamps with
12
+ * `deriveFulfillmentStatus(order.fulfillments)` and pass it in.
13
+ */
14
+
15
+ /**
16
+ * Derive a Medusa-style fulfillment status from barter's fulfillment
17
+ * lifecycle timestamps (packed_at → fulfilled, shipped_at → shipped,
18
+ * delivered_at → delivered; canceled fulfillments are ignored). Returns
19
+ * `undefined` for "not fulfilled yet" (timeline shows "Placed"). Partial
20
+ * variants are emitted when only SOME active fulfillments reached the
21
+ * stage — the timeline maps both to the same step. Unit-tested.
22
+ */
23
+ export function deriveFulfillmentStatus(
24
+ fulfillments: readonly StoreOrderFulfillment[] | null | undefined
25
+ ): string | undefined {
26
+ const active = (fulfillments ?? [])
27
+ .map((row) => row?.fulfillment)
28
+ .filter((f): f is NonNullable<StoreOrderFulfillment["fulfillment"]> =>
29
+ Boolean(f && !f.canceled_at)
30
+ )
31
+ if (!active.length) return undefined
32
+
33
+ const all = (key: "packed_at" | "shipped_at" | "delivered_at") =>
34
+ active.every((f) => Boolean(f[key]))
35
+ const some = (key: "packed_at" | "shipped_at" | "delivered_at") =>
36
+ active.some((f) => Boolean(f[key]))
37
+
38
+ if (all("delivered_at")) return "delivered"
39
+ if (some("delivered_at")) return "partially_delivered"
40
+ if (all("shipped_at")) return "shipped"
41
+ if (some("shipped_at")) return "partially_shipped"
42
+ if (all("packed_at")) return "fulfilled"
43
+ if (some("packed_at")) return "partially_fulfilled"
44
+ return undefined
45
+ }
46
+
47
+ type OrderTimelineProps = {
48
+ fulfillmentStatus?: string
49
+ labels?: Pick<OrderLabels, "orderPlaced" | "processing" | "shipped" | "delivered">
50
+ }
51
+
52
+ export function OrderTimeline({
53
+ fulfillmentStatus,
54
+ labels,
55
+ }: OrderTimelineProps) {
56
+ const l = { ...defaultOrderLabels, ...labels }
57
+
58
+ const steps = [
59
+ { label: l.orderPlaced, key: "placed" },
60
+ { label: l.processing, key: "processing" },
61
+ { label: l.shipped, key: "shipped" },
62
+ { label: l.delivered, key: "delivered" },
63
+ ]
64
+
65
+ const getActiveIndex = (): number => {
66
+ switch (fulfillmentStatus) {
67
+ case "delivered":
68
+ case "partially_delivered":
69
+ return 3
70
+ case "shipped":
71
+ case "partially_shipped":
72
+ return 2
73
+ case "fulfilled":
74
+ case "partially_fulfilled":
75
+ return 1
76
+ default:
77
+ return 0
78
+ }
79
+ }
80
+
81
+ const activeIndex = getActiveIndex()
82
+ const lastStep = steps.length - 1
83
+
84
+ return (
85
+ <div className="px-6 py-5 bg-card rounded-xl shadow-sm">
86
+ <div className="relative">
87
+ <div className="absolute top-[5px] left-[6px] right-[6px] h-[2px] bg-muted rounded-full" />
88
+ <div
89
+ className="absolute top-[5px] left-[6px] h-[2px] bg-success rounded-full transition-all duration-700 ease-out"
90
+ style={{
91
+ width:
92
+ activeIndex === 0
93
+ ? "0"
94
+ : `calc((100% - 12px) * ${activeIndex / lastStep})`,
95
+ }}
96
+ />
97
+
98
+ <div className="relative flex justify-between">
99
+ {steps.map((step, i) => {
100
+ const isActive = i === activeIndex
101
+ const isComplete = i < activeIndex
102
+ const isFuture = i > activeIndex
103
+
104
+ return (
105
+ <div
106
+ key={step.key}
107
+ className={`flex flex-col gap-2 ${
108
+ i === 0
109
+ ? "items-start"
110
+ : i === lastStep
111
+ ? "items-end"
112
+ : "items-center"
113
+ }`}
114
+ >
115
+ <div className="relative w-3 h-3">
116
+ {isActive && (
117
+ <span className="absolute inset-0 rounded-full bg-success/40 animate-ping [animation-duration:2s]" />
118
+ )}
119
+ <div
120
+ className={`relative w-3 h-3 rounded-full z-10 ${
121
+ isComplete || isActive ? "bg-success" : "bg-muted"
122
+ }`}
123
+ />
124
+ </div>
125
+ <span
126
+ className={`text-[11px] sm:text-xs font-medium whitespace-nowrap ${
127
+ isFuture ? "text-muted-foreground" : "text-success"
128
+ }`}
129
+ >
130
+ {step.label}
131
+ </span>
132
+ </div>
133
+ )
134
+ })}
135
+ </div>
136
+ </div>
137
+ </div>
138
+ )
139
+ }
140
+
141
+ export { type OrderTimelineProps }