@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,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
|