@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,39 @@
1
+ "use client"
2
+
3
+ import { ShoppingBag } from "lucide-react"
4
+ import type { Cart } from "../api/carts"
5
+ import { productItemCount } from "../lib/cart-helpers"
6
+ import { useCartDrawer } from "../cart-drawer/context"
7
+
8
+ /**
9
+ * Header cart button (badge + opens the cart drawer). Ported from
10
+ * `@1click/ui/src/common/cart-button-client.tsx` (v2.3.1); seams:
11
+ *
12
+ * - `cart` is the barter decorated cart (`api/carts` `Cart`).
13
+ * - Badge count uses `productItemCount` (fee-line-aware) instead of the
14
+ * source's raw `reduce` — keeps the badge consistent with the cart
15
+ * drawer's own count, which already excludes backend-injected fee
16
+ * lines (see `lib/cart-helpers` provenance note).
17
+ *
18
+ * NOTE: depends on the sibling `cart-drawer` family's context (ported in
19
+ * the same batch); mount inside `<CartDrawerProvider>`.
20
+ */
21
+ export function CartButtonClient({ cart }: { cart: Cart | null }) {
22
+ const { open } = useCartDrawer()
23
+ const totalItems = productItemCount(cart?.items)
24
+
25
+ return (
26
+ <button
27
+ onClick={open}
28
+ className="flex items-center gap-1.5 hover:text-foreground transition-colors relative"
29
+ data-testid="nav-cart-link"
30
+ >
31
+ <ShoppingBag className="w-5 h-5" />
32
+ {totalItems > 0 && (
33
+ <span className="absolute -top-1.5 -right-2 min-w-[18px] h-[18px] bg-foreground text-card text-[10px] font-semibold rounded-full flex items-center justify-center px-1">
34
+ {totalItems}
35
+ </span>
36
+ )}
37
+ </button>
38
+ )
39
+ }
@@ -0,0 +1,28 @@
1
+ import { retrieveCart } from "../api/carts"
2
+ import type { StorefrontClient } from "../api/http"
3
+ import { CartButtonClient } from "./cart-button-client"
4
+
5
+ /**
6
+ * Server wrapper for the header cart button. Ported from
7
+ * `@1click/ui/src/common/cart-button.tsx` (v2.3.1); data seam: the source
8
+ * called the cookie-reading `retrieveCart()` server action — barter takes
9
+ * the `StorefrontClient` + the app-persisted cart id explicitly (the app
10
+ * owns the cart-id cookie, see docs/storefront/carts.md) and calls
11
+ * `api/carts.retrieveCart`. Any fetch failure (stale id, 404) degrades to
12
+ * an empty button, same as the source's `.catch(() => null)`.
13
+ */
14
+ export async function CartButton({
15
+ client,
16
+ cartId,
17
+ }: {
18
+ client: StorefrontClient
19
+ /** The app's persisted cart id; null/undefined renders an empty button. */
20
+ cartId?: string | null
21
+ }) {
22
+ const cart = cartId
23
+ ? await retrieveCart(client, cartId)
24
+ .then((res) => res.cart)
25
+ .catch(() => null)
26
+ : null
27
+ return <CartButtonClient cart={cart} />
28
+ }
@@ -0,0 +1,65 @@
1
+ "use client"
2
+
3
+ import { useMemo } from "react"
4
+ import type { StoreRegion } from "../api/regions"
5
+
6
+ /**
7
+ * Region picker for the storefront footer/header. Ported from
8
+ * `@1click/ui/src/common/country-select.tsx` (v2.3.1) with a REAL model
9
+ * change forced by the SDK (SDK wins over the source):
10
+ *
11
+ * - Medusa regions embedded a `countries[]` array and the select listed
12
+ * countries; barter regions carry NO countries on the store surface
13
+ * (divergence documented in `api/regions` — tax scoping is by
14
+ * `tax_regions` server-side). The select therefore lists the REGIONS
15
+ * themselves (`regions.listRegions`), valued by region id.
16
+ * - The source called the `updateRegion(countryCode, path)` server
17
+ * action bound to URL `[countryCode]` routing; barter region
18
+ * persistence is app-owned (usually `carts.updateCart(client, cartId,
19
+ * {region_id})` + a cookie) — so the change surfaces via `onChange`.
20
+ */
21
+ type CountrySelectProps = {
22
+ regions: StoreRegion[]
23
+ /** Currently active region id. */
24
+ value?: string | null
25
+ /** Fired with the selected region; persist it app-side. */
26
+ onChange: (region: StoreRegion) => void
27
+ className?: string
28
+ }
29
+
30
+ export function CountrySelect({
31
+ regions,
32
+ value,
33
+ onChange,
34
+ className,
35
+ }: CountrySelectProps) {
36
+ const options = useMemo(
37
+ () =>
38
+ [...regions].sort((a, b) => (a.name ?? "").localeCompare(b.name ?? "")),
39
+ [regions]
40
+ )
41
+
42
+ const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
43
+ const selected = options.find((r) => r.id === e.target.value)
44
+ if (selected) onChange(selected)
45
+ }
46
+
47
+ return (
48
+ <select
49
+ value={value || ""}
50
+ onChange={handleChange}
51
+ className={
52
+ className ??
53
+ "text-sm text-foreground bg-card border border-border rounded-lg px-3 py-2"
54
+ }
55
+ >
56
+ {options.map((r) => (
57
+ <option key={r.id} value={r.id}>
58
+ {r.name}
59
+ </option>
60
+ ))}
61
+ </select>
62
+ )
63
+ }
64
+
65
+ export { type CountrySelectProps }
@@ -0,0 +1,66 @@
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+ import { Loader2, Trash2 } from "lucide-react"
5
+ import { deleteLineItem } from "../api/carts"
6
+ import type { StorefrontClient } from "../api/http"
7
+ import { cn } from "../lib/utils"
8
+
9
+ /**
10
+ * Cart-line delete button with spinner. Ported from
11
+ * `@1click/ui/src/common/delete-button.tsx` (v2.3.1); data seam: the
12
+ * source called the cookie-scoped `deleteLineItem(id)` server action —
13
+ * barter calls `api/carts.deleteLineItem(client, cartId, lineItemId)`
14
+ * (idempotent server-side) and hands the refreshed cart to `onDeleted`
15
+ * so the app can update its state / `router.refresh()`. The spinner
16
+ * resets on failure exactly like the source's `.catch`.
17
+ */
18
+ type DeleteButtonProps = {
19
+ client: StorefrontClient
20
+ cartId: string
21
+ /** Line item id to remove. */
22
+ id: string
23
+ /** Called with the refreshed cart response after a successful delete. */
24
+ onDeleted?: (cart: Awaited<ReturnType<typeof deleteLineItem>>) => void
25
+ children?: React.ReactNode
26
+ className?: string
27
+ }
28
+
29
+ export function DeleteButton({
30
+ client,
31
+ cartId,
32
+ id,
33
+ onDeleted,
34
+ children,
35
+ className,
36
+ }: DeleteButtonProps) {
37
+ const [isDeleting, setIsDeleting] = useState(false)
38
+
39
+ const handleDelete = async () => {
40
+ setIsDeleting(true)
41
+ try {
42
+ const res = await deleteLineItem(client, cartId, id)
43
+ onDeleted?.(res)
44
+ } catch {
45
+ setIsDeleting(false)
46
+ }
47
+ }
48
+
49
+ return (
50
+ <div className={cn("flex items-center justify-between text-sm", className)}>
51
+ <button
52
+ className="flex gap-x-1 text-muted-foreground hover:text-foreground cursor-pointer"
53
+ onClick={handleDelete}
54
+ >
55
+ {isDeleting ? (
56
+ <Loader2 className="w-4 h-4 animate-spin" />
57
+ ) : (
58
+ <Trash2 className="w-4 h-4" />
59
+ )}
60
+ <span>{children}</span>
61
+ </button>
62
+ </div>
63
+ )
64
+ }
65
+
66
+ export { type DeleteButtonProps }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @cartbase/storefront/common — shared storefront chrome. Ported from
3
+ * `@1click/ui/src/common` (v2.3.1) + `LocalizedLink` (from the
4
+ * mindpages-storefront production component); barter data seams per
5
+ * module JSDoc.
6
+ */
7
+ export { LocalizedLink, type LocalizedLinkProps } from "./localized-link"
8
+ export { CartButton } from "./cart-button"
9
+ export { CartButtonClient } from "./cart-button-client"
10
+ export { DeleteButton, type DeleteButtonProps } from "./delete-button"
11
+ export { CountrySelect, type CountrySelectProps } from "./country-select"
12
+ export {
13
+ LanguageSelect,
14
+ localeDisplayName,
15
+ type LanguageSelectProps,
16
+ } from "./language-select"
17
+ export { Skeleton, SkeletonProductPreview } from "./skeleton"
@@ -0,0 +1,78 @@
1
+ "use client"
2
+
3
+ import { useTransition } from "react"
4
+
5
+ /**
6
+ * Storefront language switcher. Ported from
7
+ * `@1click/ui/src/common/language-select.tsx` (v2.3.1); data seam:
8
+ *
9
+ * - The locale list feeds from `api/regions.listLocales(client)` — an
10
+ * array of bare codes, store default first (barter has no `{code,
11
+ * name}` locale objects on the store surface; SDK wins). Display
12
+ * names resolve via `localeDisplayName()` below (native-language
13
+ * autonym through `Intl.DisplayNames`), overridable per store with
14
+ * the `labels` map.
15
+ * - The source awaited an `updateLocale(code)` server action then
16
+ * `router.refresh()`; barter locale persistence is app-owned (the
17
+ * cookie read by `StorefrontClient.getLocale`) — the async `onChange`
18
+ * callback carries it, and the `useTransition` pending-disable UX is
19
+ * preserved around it.
20
+ */
21
+
22
+ /** Best-effort native display name for a locale code ("bg" → "български"). */
23
+ export function localeDisplayName(code: string): string {
24
+ try {
25
+ const name = new Intl.DisplayNames([code], { type: "language" }).of(code)
26
+ return name || code
27
+ } catch {
28
+ return code
29
+ }
30
+ }
31
+
32
+ type LanguageSelectProps = {
33
+ /** Locale codes from `regions.listLocales` (store default first). */
34
+ locales: string[]
35
+ currentLocale: string | null
36
+ /** Persist the choice (cookie / server action) — awaited in a transition. */
37
+ onChange: (code: string) => void | Promise<void>
38
+ /** Optional per-store display-name overrides, keyed by code. */
39
+ labels?: Record<string, string>
40
+ className?: string
41
+ }
42
+
43
+ export function LanguageSelect({
44
+ locales,
45
+ currentLocale,
46
+ onChange,
47
+ labels,
48
+ className,
49
+ }: LanguageSelectProps) {
50
+ const [isPending, startTransition] = useTransition()
51
+
52
+ const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
53
+ const code = e.target.value
54
+ startTransition(async () => {
55
+ await onChange(code)
56
+ })
57
+ }
58
+
59
+ return (
60
+ <select
61
+ value={currentLocale || ""}
62
+ onChange={handleChange}
63
+ disabled={isPending}
64
+ className={
65
+ className ??
66
+ "text-sm text-foreground bg-card border border-border rounded-lg px-3 py-2 disabled:opacity-50"
67
+ }
68
+ >
69
+ {locales.map((code) => (
70
+ <option key={code} value={code}>
71
+ {labels?.[code] ?? localeDisplayName(code)}
72
+ </option>
73
+ ))}
74
+ </select>
75
+ )
76
+ }
77
+
78
+ export { type LanguageSelectProps }
@@ -0,0 +1,45 @@
1
+ "use client"
2
+
3
+ import Link from "next/link"
4
+ import { useParams } from "next/navigation"
5
+ import type { ReactNode } from "react"
6
+
7
+ /**
8
+ * `<Link />` that persists the current URL locale/country segment without
9
+ * the caller passing it explicitly. Ported from the mindpages-storefront
10
+ * `LocalizedClientLink` (src/modules/common/components/localized-client-link)
11
+ * — the component `@1click/ui` consumers used for the same job.
12
+ *
13
+ * barter seam: barter storefronts default to COOKIE-based locale
14
+ * (`StorefrontClient.getLocale`, see docs/storefront/BUILD-A-STOREFRONT.md)
15
+ * with no mandatory URL segment — so the prefix is applied ONLY when the
16
+ * route actually has a `[countryCode]` (or custom `paramName`) dynamic
17
+ * param. On non-prefixed apps this renders a plain `next/link`.
18
+ */
19
+ export interface LocalizedLinkProps {
20
+ children?: ReactNode
21
+ href: string
22
+ className?: string
23
+ onClick?: () => void
24
+ passHref?: true
25
+ /** Route param that carries the URL prefix. Default: `countryCode`. */
26
+ paramName?: string
27
+ [x: string]: unknown
28
+ }
29
+
30
+ export function LocalizedLink({
31
+ children,
32
+ href,
33
+ paramName = "countryCode",
34
+ ...props
35
+ }: LocalizedLinkProps) {
36
+ const params = useParams()
37
+ const raw = params?.[paramName]
38
+ const prefix = typeof raw === "string" && raw ? `/${raw}` : ""
39
+
40
+ return (
41
+ <Link href={`${prefix}${href}`} {...props}>
42
+ {children}
43
+ </Link>
44
+ )
45
+ }
@@ -0,0 +1,29 @@
1
+ import { cn } from "../lib/utils"
2
+
3
+ /**
4
+ * Loading skeletons. Verbatim port of `@1click/ui/src/common/skeleton.tsx`
5
+ * (v2.3.1) — pure UI over theme tokens.
6
+ */
7
+ type SkeletonProps = {
8
+ className?: string
9
+ }
10
+
11
+ export function Skeleton({ className }: SkeletonProps) {
12
+ return (
13
+ <div
14
+ className={cn("animate-pulse rounded bg-muted", className)}
15
+ />
16
+ )
17
+ }
18
+
19
+ export function SkeletonProductPreview() {
20
+ return (
21
+ <div className="animate-pulse">
22
+ <div className="aspect-[9/16] w-full bg-muted rounded-lg" />
23
+ <div className="flex justify-between mt-2">
24
+ <div className="w-2/5 h-6 bg-muted rounded" />
25
+ <div className="w-1/5 h-6 bg-muted rounded" />
26
+ </div>
27
+ </div>
28
+ )
29
+ }
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @cartbase/storefront — root barrel.
3
+ *
4
+ * `@cartbase/storefront/api` is the SDK layer (typed store-API client).
5
+ * Component families ship under their own subpath exports (stage 3) and are
6
+ * NOT re-exported here — UI imports stay per-module so tree-shaking and
7
+ * client/server boundaries stay explicit, same discipline as @1click/ui.
8
+ */
9
+
10
+ export * from "./api/types"
11
+ export { StorefrontClient } from "./api/http"
12
+ export type { StorefrontClientConfig } from "./api/http"
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Cart line predicates and product-only totals helpers.
3
+ *
4
+ * Ported from `@1click/ui/src/lib/cart-helpers.ts` (v2.3.1) with its
5
+ * production reasoning intact; the `@medusajs/types` generic constraint is
6
+ * replaced by the structural `LineLike` shape so this module stays decoupled
7
+ * from any SDK layer (barter cart/order line items are structurally
8
+ * compatible: they carry `metadata`, `total`, `quantity`).
9
+ *
10
+ * Why this exists. There is no native "fee" / "surcharge" primitive in the
11
+ * order model — the canonical way to add a non-product charge that flows
12
+ * through totals, tax, and refund pipelines is a line item with a metadata
13
+ * flag, which is what the COD-fee flow does. The trade-off: that line item
14
+ * appears in `cart.items` everywhere — the cart drawer, order page, mobile
15
+ * bars, anything that iterates the cart. Without filtering it appears as a
16
+ * fake product (empty thumbnail + qty stepper). With each surface filtering
17
+ * inline we get 8+ copies of the same predicate that drift apart over time.
18
+ *
19
+ * Single source of truth. Every customer-facing render surface that
20
+ * iterates `cart.items` (or `order.items`) calls `isProductLine`.
21
+ * Every total computed for a customer-facing context (drawer footer,
22
+ * pre-checkout subtotal display) calls `productTotal`. If we ever add
23
+ * another surcharge type, the predicate gets one new check; every
24
+ * surface inherits it.
25
+ *
26
+ * Where this is NOT used. The checkout summary and the order totals
27
+ * intentionally render the fee — there it's a separate "Cash on
28
+ * delivery fee" row in the totals breakdown, sourced from
29
+ * `findFeeLine`. Customer SHOULD see the fee in checkout / on the
30
+ * order; they should NOT see it in the cart drawer pre-checkout.
31
+ *
32
+ * Money note (barter): amounts are decimal EUR major units everywhere in
33
+ * the store API — the arithmetic below is unit-agnostic and unchanged.
34
+ */
35
+
36
+ export const COD_FEE_METADATA_KEY = "is_cod_fee"
37
+
38
+ /** Structural shape of a cart/order line — any richer DTO satisfies it. */
39
+ export type LineLike = {
40
+ metadata?: Record<string, unknown> | null
41
+ total?: number | null
42
+ subtotal?: number | null
43
+ unit_price?: number | null
44
+ quantity?: number
45
+ }
46
+
47
+ /**
48
+ * Returns true when the line is a customer-facing product (anything
49
+ * the customer chose to buy), false for backend-injected fee lines.
50
+ *
51
+ * Tight check: only excludes lines explicitly tagged `is_cod_fee=true`.
52
+ * Lines with no metadata, null metadata, or any other metadata pass
53
+ * through unchanged — never filter a real product because of a
54
+ * missing flag.
55
+ */
56
+ export function isProductLine(item: LineLike): boolean {
57
+ const meta = item.metadata
58
+ if (!meta || typeof meta !== "object") return true
59
+ return (meta as Record<string, unknown>)[COD_FEE_METADATA_KEY] !== true
60
+ }
61
+
62
+ /** Inverse of `isProductLine`. Useful when the caller needs the fee. */
63
+ export function isFeeLine(item: LineLike): boolean {
64
+ return !isProductLine(item)
65
+ }
66
+
67
+ /**
68
+ * Sum of line totals for product lines only. Suitable for cart-drawer
69
+ * surfaces in pre-checkout context where shipping/tax/fees should NOT
70
+ * leak into the displayed subtotal.
71
+ *
72
+ * Reads `item.total` (gross of tax for tax-inclusive items, post-line-
73
+ * adjustments) which matches what every cart surface already shows on
74
+ * a per-line basis — keeps the visible per-line price and the visible
75
+ * subtotal arithmetically consistent.
76
+ */
77
+ export function productTotal(items: readonly LineLike[] | null | undefined): number {
78
+ if (!items?.length) return 0
79
+ let sum = 0
80
+ for (const item of items) {
81
+ if (!isProductLine(item)) continue
82
+ sum += Number(item.total ?? 0)
83
+ }
84
+ return sum
85
+ }
86
+
87
+ /** Sum of `quantity` over product lines only. */
88
+ export function productItemCount(
89
+ items: readonly LineLike[] | null | undefined
90
+ ): number {
91
+ if (!items?.length) return 0
92
+ let count = 0
93
+ for (const item of items) {
94
+ if (!isProductLine(item)) continue
95
+ count += Number(item.quantity ?? 0)
96
+ }
97
+ return count
98
+ }
99
+
100
+ /**
101
+ * The fee line item from a cart or order, if present. Returns null
102
+ * when no fee was applied (Card flow, or the COD-fee step skipped due
103
+ * to config / currency mismatch).
104
+ */
105
+ export function findFeeLine<T extends LineLike>(
106
+ items: readonly T[] | null | undefined
107
+ ): T | null {
108
+ if (!items?.length) return null
109
+ for (const item of items) {
110
+ if (isFeeLine(item)) return item
111
+ }
112
+ return null
113
+ }
@@ -0,0 +1,73 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+
5
+ import { cn } from "./utils"
6
+ import { convertToLocale } from "./money"
7
+
8
+ /**
9
+ * DualPrice — render a EUR price with BGN next to it.
10
+ *
11
+ * Bulgarian law (Дв, effective through 2026-08) requires showing both EUR
12
+ * and BGN on every price displayed to consumers. Fixed statutory rate:
13
+ * 1 EUR = 1.95583 BGN
14
+ *
15
+ * For non-EUR currencies the BGN leg is skipped and only the primary locale
16
+ * currency is shown. The component is stateless and side-effect-free — safe
17
+ * to render in server components and client components alike.
18
+ *
19
+ * The EUR-to-BGN rate and the law's expiry date are baked in for now. When
20
+ * the law expires (or when a store outside Bulgaria consumes this library),
21
+ * we'll move both to a BrandingContext value so each store can override.
22
+ *
23
+ * @example
24
+ * <DualPrice amount={19.99} currencyCode="eur" />
25
+ * // → "€19.99 39,10 лв."
26
+ */
27
+
28
+ const EUR_TO_BGN = 1.95583
29
+
30
+ function toBGN(eurAmount: number): string {
31
+ const bgn = eurAmount * EUR_TO_BGN
32
+ return bgn.toFixed(2).replace(".", ",") + " лв."
33
+ }
34
+
35
+ export type DualPriceProps = {
36
+ /** Amount in major units of the primary currency (e.g. 19.99 for €19.99) */
37
+ amount: number
38
+ /** ISO currency code (case-insensitive). Only "eur" triggers the BGN leg. */
39
+ currencyCode: string
40
+ /** Optional class for the outer span */
41
+ className?: string
42
+ /** Optional class for the BGN leg (defaults to small muted) */
43
+ bgnClassName?: string
44
+ }
45
+
46
+ export function DualPrice({
47
+ amount,
48
+ currencyCode,
49
+ className,
50
+ bgnClassName,
51
+ }: DualPriceProps): React.ReactElement {
52
+ const isEur = currencyCode?.toLowerCase() === "eur"
53
+
54
+ if (!isEur) {
55
+ return (
56
+ <span className={className}>
57
+ {convertToLocale({ amount, currency_code: currencyCode })}
58
+ </span>
59
+ )
60
+ }
61
+
62
+ return (
63
+ <span className={className}>
64
+ {convertToLocale({ amount, currency_code: currencyCode })}
65
+ <span className={cn("ml-1.5 text-muted-foreground/70 font-normal", bgnClassName)}>
66
+ {toBGN(amount)}
67
+ </span>
68
+ </span>
69
+ )
70
+ }
71
+
72
+ /** Exposed for advanced callers that need the rate directly. */
73
+ export const EUR_TO_BGN_RATE = EUR_TO_BGN
@@ -0,0 +1,5 @@
1
+ export const getPercentageDiff = (original: number, calculated: number) => {
2
+ const diff = original - calculated
3
+ const decrease = (diff / original) * 100
4
+ return decrease.toFixed()
5
+ }