@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,94 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState, type ChangeEvent } from "react"
|
|
4
|
+
|
|
5
|
+
import { Field } from "../primitives/field"
|
|
6
|
+
import { cn } from "../lib/utils"
|
|
7
|
+
import { useCheckoutLabels } from "./context"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* CompanyDetails — collapsible "Company invoice" section shown under the
|
|
11
|
+
* address form. Four fields: name, VAT, MOL (responsible person), address.
|
|
12
|
+
*
|
|
13
|
+
* Ported from `@1click/ui/src/checkout/company-details.tsx` (v2.3.1)
|
|
14
|
+
* unchanged — pure form UI; the values ride `formData` and the
|
|
15
|
+
* orchestration hook persists them into `cart.metadata`
|
|
16
|
+
* (company_name/company_vat/company_mol/company_address). Barter upgrade
|
|
17
|
+
* over the @1click KNOWN_ISSUES state: the hook ALSO syncs
|
|
18
|
+
* `company_name`/`company_eik` onto the signed-in customer profile
|
|
19
|
+
* (PATCH /api/store/customers/me — first-class columns,
|
|
20
|
+
* docs/storefront/customers.md), so invoice data is no longer
|
|
21
|
+
* cart-metadata-only.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
type CompanyDetailsProps = {
|
|
25
|
+
formData: Record<string, string>
|
|
26
|
+
onChange: (e: ChangeEvent<HTMLInputElement>) => void
|
|
27
|
+
onBlur: () => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function CompanyDetails({ formData, onChange, onBlur }: CompanyDetailsProps) {
|
|
31
|
+
const labels = useCheckoutLabels()
|
|
32
|
+
const [open, setOpen] = useState(!!formData.company_name?.trim())
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className="mt-3">
|
|
36
|
+
<button
|
|
37
|
+
type="button"
|
|
38
|
+
onClick={() => setOpen(!open)}
|
|
39
|
+
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
40
|
+
>
|
|
41
|
+
<svg
|
|
42
|
+
className={cn(
|
|
43
|
+
"w-4 h-4 transition-transform duration-200",
|
|
44
|
+
open && "rotate-90"
|
|
45
|
+
)}
|
|
46
|
+
fill="none"
|
|
47
|
+
viewBox="0 0 24 24"
|
|
48
|
+
stroke="currentColor"
|
|
49
|
+
strokeWidth={2}
|
|
50
|
+
>
|
|
51
|
+
<path
|
|
52
|
+
strokeLinecap="round"
|
|
53
|
+
strokeLinejoin="round"
|
|
54
|
+
d="M8.25 4.5l7.5 7.5-7.5 7.5"
|
|
55
|
+
/>
|
|
56
|
+
</svg>
|
|
57
|
+
{labels.companyInvoice}
|
|
58
|
+
</button>
|
|
59
|
+
|
|
60
|
+
{open && (
|
|
61
|
+
<div className="mt-3 space-y-2.5">
|
|
62
|
+
<Field
|
|
63
|
+
label={labels.companyName}
|
|
64
|
+
name="company_name"
|
|
65
|
+
value={formData.company_name ?? ""}
|
|
66
|
+
onChange={onChange}
|
|
67
|
+
onBlur={onBlur}
|
|
68
|
+
/>
|
|
69
|
+
<Field
|
|
70
|
+
label={labels.companyVat}
|
|
71
|
+
name="company_vat"
|
|
72
|
+
value={formData.company_vat ?? ""}
|
|
73
|
+
onChange={onChange}
|
|
74
|
+
onBlur={onBlur}
|
|
75
|
+
/>
|
|
76
|
+
<Field
|
|
77
|
+
label={labels.companyMol}
|
|
78
|
+
name="company_mol"
|
|
79
|
+
value={formData.company_mol ?? ""}
|
|
80
|
+
onChange={onChange}
|
|
81
|
+
onBlur={onBlur}
|
|
82
|
+
/>
|
|
83
|
+
<Field
|
|
84
|
+
label={labels.companyAddress}
|
|
85
|
+
name="company_address"
|
|
86
|
+
value={formData.company_address ?? ""}
|
|
87
|
+
onChange={onChange}
|
|
88
|
+
onBlur={onBlur}
|
|
89
|
+
/>
|
|
90
|
+
</div>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compare two address objects for shallow equality across the canonical
|
|
3
|
+
* shipping-address fields. Returns true if every field matches.
|
|
4
|
+
*
|
|
5
|
+
* Ported from `@1click/ui/src/data/util/compare-addresses.ts` (v2.3.1)
|
|
6
|
+
* unchanged. Lives in the checkout family because the barter SDK has no
|
|
7
|
+
* data/util layer (the @1click data layer became `@cartbase/storefront/api`).
|
|
8
|
+
* Extra file beyond the pre-declared export list → exported from the
|
|
9
|
+
* family barrel only.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const FIELDS = [
|
|
13
|
+
"first_name",
|
|
14
|
+
"last_name",
|
|
15
|
+
"address_1",
|
|
16
|
+
"company",
|
|
17
|
+
"postal_code",
|
|
18
|
+
"city",
|
|
19
|
+
"country_code",
|
|
20
|
+
"province",
|
|
21
|
+
"phone",
|
|
22
|
+
] as const
|
|
23
|
+
|
|
24
|
+
type AddressLike = Record<string, unknown> | null | undefined
|
|
25
|
+
|
|
26
|
+
export default function compareAddresses(
|
|
27
|
+
address1: AddressLike,
|
|
28
|
+
address2: AddressLike
|
|
29
|
+
): boolean {
|
|
30
|
+
if (address1 === address2) return true
|
|
31
|
+
if (!address1 || !address2) return false
|
|
32
|
+
|
|
33
|
+
for (const key of FIELDS) {
|
|
34
|
+
const a = (address1 as Record<string, unknown>)[key] ?? null
|
|
35
|
+
const b = (address2 as Record<string, unknown>)[key] ?? null
|
|
36
|
+
if (a !== b) return false
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return true
|
|
40
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext, type ReactNode } from "react"
|
|
4
|
+
|
|
5
|
+
import { defaultCheckoutLabels, type CheckoutLabels } from "./labels"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* CheckoutContext — supplies labels to every checkout primitive without
|
|
9
|
+
* prop drilling. Stores wrap the checkout page or their own composition
|
|
10
|
+
* with `<CheckoutProvider labels={...}>` and every primitive inside reads
|
|
11
|
+
* from context.
|
|
12
|
+
*
|
|
13
|
+
* Ported from `@1click/ui/src/checkout/context.tsx` (v2.3.1) unchanged —
|
|
14
|
+
* this module has no data seam.
|
|
15
|
+
*
|
|
16
|
+
* Unlike the cart drawer, the checkout context is labels-only. Form
|
|
17
|
+
* state, cart data, shipping methods, and payment sessions are passed
|
|
18
|
+
* through as props to `CheckoutClient` because they are request-specific.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
type CheckoutContextValue = {
|
|
22
|
+
labels: CheckoutLabels
|
|
23
|
+
/**
|
|
24
|
+
* Template for the post-success redirect URL after placeOrder.
|
|
25
|
+
* Substitutions:
|
|
26
|
+
* {id} → order.id
|
|
27
|
+
* {country} → lowercase ISO country code from shipping_address
|
|
28
|
+
*
|
|
29
|
+
* Default: "/{country}/order/{id}/confirmed" — for stores whose
|
|
30
|
+
* URL structure includes a country segment (e.g. MindPages).
|
|
31
|
+
*
|
|
32
|
+
* Stores with flattened single-country URLs (e.g. Alenika) should
|
|
33
|
+
* pass "/order/{id}/confirmed" via CheckoutProvider.
|
|
34
|
+
*/
|
|
35
|
+
orderConfirmedPath: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const DEFAULT_CONFIRMED_PATH = "/{country}/order/{id}/confirmed"
|
|
39
|
+
|
|
40
|
+
const CheckoutContext = createContext<CheckoutContextValue>({
|
|
41
|
+
labels: defaultCheckoutLabels,
|
|
42
|
+
orderConfirmedPath: DEFAULT_CONFIRMED_PATH,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
export function useCheckoutLabels(): CheckoutLabels {
|
|
46
|
+
return useContext(CheckoutContext).labels
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function useOrderConfirmedPath(): string {
|
|
50
|
+
return useContext(CheckoutContext).orderConfirmedPath
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function CheckoutProvider({
|
|
54
|
+
labels: labelOverrides,
|
|
55
|
+
orderConfirmedPath,
|
|
56
|
+
children,
|
|
57
|
+
}: {
|
|
58
|
+
labels?: Partial<CheckoutLabels>
|
|
59
|
+
orderConfirmedPath?: string
|
|
60
|
+
children: ReactNode
|
|
61
|
+
}) {
|
|
62
|
+
const labels: CheckoutLabels = {
|
|
63
|
+
...defaultCheckoutLabels,
|
|
64
|
+
...labelOverrides,
|
|
65
|
+
}
|
|
66
|
+
return (
|
|
67
|
+
<CheckoutContext.Provider
|
|
68
|
+
value={{
|
|
69
|
+
labels,
|
|
70
|
+
orderConfirmedPath: orderConfirmedPath ?? DEFAULT_CONFIRMED_PATH,
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
{children}
|
|
74
|
+
</CheckoutContext.Provider>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState } from "react"
|
|
4
|
+
|
|
5
|
+
import type { StorefrontClient } from "../api/http"
|
|
6
|
+
import { applyPromotions, type Cart } from "../api/carts"
|
|
7
|
+
import { convertToLocale } from "../lib/money"
|
|
8
|
+
import { cn } from "../lib/utils"
|
|
9
|
+
import { useCheckoutLabels } from "./context"
|
|
10
|
+
import { translatePromotionError } from "./promotion-error-copy"
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* DiscountSection — collapsible promo-code input shown inside the order
|
|
14
|
+
* summary card. Lists currently applied promotions with their discount
|
|
15
|
+
* amount.
|
|
16
|
+
*
|
|
17
|
+
* Ported from `@1click/ui/src/checkout/discount-section.tsx` (v2.3.1) with
|
|
18
|
+
* the barter data seam:
|
|
19
|
+
*
|
|
20
|
+
* - `applyPromotions(codes)` (Medusa js-sdk server action, full-list
|
|
21
|
+
* replace) → `POST /api/store/carts/:id/promotions {promo_codes}` — the
|
|
22
|
+
* barter route is ADDITIVE (upserts each code's link, then re-applies),
|
|
23
|
+
* so only the NEW code is sent; already-applied codes stay put. The SDK
|
|
24
|
+
* carts module ships no wrapper for this route yet, so the component
|
|
25
|
+
* calls it through the client transport with the documented DTO
|
|
26
|
+
* (verified against src/app/api/store/carts/[id]/promotions/route.ts).
|
|
27
|
+
* - `cart.promotions` on the barter wire is the raw pivot embed
|
|
28
|
+
* (`[{promotion: {..., application_method}}]`) — unwrapped here, with a
|
|
29
|
+
* flat-row fallback should the decoration ever flatten it.
|
|
30
|
+
* - Errors are code-first (`promotion_not_found` / `promotion_inactive` /
|
|
31
|
+
* `promotion_misconfigured` / `promotion_unsupported_type` + cart-level
|
|
32
|
+
* codes) via `translatePromotionError`.
|
|
33
|
+
*
|
|
34
|
+
* The route returns the freshly decorated cart; `onCartChange` hands it to
|
|
35
|
+
* the host (or the host calls `router.refresh()` in an RSC app).
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** Structural shape of one applied promotion (promotions row). */
|
|
39
|
+
type AppliedPromotion = {
|
|
40
|
+
id: string
|
|
41
|
+
code: string | null
|
|
42
|
+
application_method?: {
|
|
43
|
+
type?: string | null
|
|
44
|
+
value?: number | string | null
|
|
45
|
+
currency_code?: string | null
|
|
46
|
+
} | null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Unwrap the barter pivot embed; tolerate an already-flat row. */
|
|
50
|
+
function extractPromotions(cart: Cart): AppliedPromotion[] {
|
|
51
|
+
const raw = (cart.promotions ?? []) as Array<Record<string, unknown>>
|
|
52
|
+
return raw
|
|
53
|
+
.map((row) =>
|
|
54
|
+
row && typeof row === "object" && "promotion" in row
|
|
55
|
+
? (row.promotion as AppliedPromotion | null)
|
|
56
|
+
: (row as unknown as AppliedPromotion)
|
|
57
|
+
)
|
|
58
|
+
.filter((p): p is AppliedPromotion => !!p && typeof p.id === "string")
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type DiscountSectionProps = {
|
|
62
|
+
/** The SDK transport — the promotions route is called through it. */
|
|
63
|
+
client: StorefrontClient
|
|
64
|
+
cart: Cart
|
|
65
|
+
/** Receives the decorated cart returned by a successful apply. */
|
|
66
|
+
onCartChange?: (cart: Cart) => void
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function DiscountSection({
|
|
70
|
+
client,
|
|
71
|
+
cart,
|
|
72
|
+
onCartChange,
|
|
73
|
+
}: DiscountSectionProps) {
|
|
74
|
+
const labels = useCheckoutLabels()
|
|
75
|
+
const [code, setCode] = useState("")
|
|
76
|
+
const [loading, setLoading] = useState(false)
|
|
77
|
+
const [error, setError] = useState("")
|
|
78
|
+
const [open, setOpen] = useState(false)
|
|
79
|
+
|
|
80
|
+
const promotions = extractPromotions(cart)
|
|
81
|
+
|
|
82
|
+
const handleApply = async () => {
|
|
83
|
+
if (!code.trim()) return
|
|
84
|
+
setLoading(true)
|
|
85
|
+
setError("")
|
|
86
|
+
try {
|
|
87
|
+
// Additive apply — the barter route upserts the new code's link and
|
|
88
|
+
// re-applies every cart promotion; already-applied codes stay.
|
|
89
|
+
const { cart: updated } = await applyPromotions(client, cart.id, [code.trim()])
|
|
90
|
+
onCartChange?.(updated)
|
|
91
|
+
setCode("")
|
|
92
|
+
} catch (e: unknown) {
|
|
93
|
+
// Never surface the raw API error string to the shopper — map to a
|
|
94
|
+
// proper localized message (code-first, pattern fallback).
|
|
95
|
+
setError(translatePromotionError(e, { hasEmail: !!cart.email }))
|
|
96
|
+
} finally {
|
|
97
|
+
setLoading(false)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<div>
|
|
103
|
+
<button
|
|
104
|
+
type="button"
|
|
105
|
+
onClick={() => setOpen(!open)}
|
|
106
|
+
className="flex items-center gap-3 w-full p-4 rounded-xl border border-border bg-muted/50 hover:bg-muted transition-colors"
|
|
107
|
+
>
|
|
108
|
+
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-warning to-warning/70 flex items-center justify-center flex-shrink-0">
|
|
109
|
+
<svg
|
|
110
|
+
className="w-5 h-5 text-card"
|
|
111
|
+
fill="none"
|
|
112
|
+
viewBox="0 0 24 24"
|
|
113
|
+
stroke="currentColor"
|
|
114
|
+
strokeWidth={2}
|
|
115
|
+
>
|
|
116
|
+
<path
|
|
117
|
+
strokeLinecap="round"
|
|
118
|
+
strokeLinejoin="round"
|
|
119
|
+
d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z"
|
|
120
|
+
/>
|
|
121
|
+
<path
|
|
122
|
+
strokeLinecap="round"
|
|
123
|
+
strokeLinejoin="round"
|
|
124
|
+
d="M6 6h.008v.008H6V6z"
|
|
125
|
+
/>
|
|
126
|
+
</svg>
|
|
127
|
+
</div>
|
|
128
|
+
<div className="flex-1 text-left">
|
|
129
|
+
<p className="text-sm font-semibold text-foreground">
|
|
130
|
+
{labels.discountCode}
|
|
131
|
+
</p>
|
|
132
|
+
<p className="text-xs text-muted-foreground">{labels.discountSub}</p>
|
|
133
|
+
</div>
|
|
134
|
+
<span className="text-sm font-medium text-foreground flex items-center gap-1.5">
|
|
135
|
+
{labels.addCode}
|
|
136
|
+
<svg
|
|
137
|
+
className={cn(
|
|
138
|
+
"w-4 h-4 text-muted-foreground transition-transform duration-200",
|
|
139
|
+
open && "rotate-180"
|
|
140
|
+
)}
|
|
141
|
+
fill="none"
|
|
142
|
+
viewBox="0 0 24 24"
|
|
143
|
+
stroke="currentColor"
|
|
144
|
+
strokeWidth={2}
|
|
145
|
+
>
|
|
146
|
+
<path
|
|
147
|
+
strokeLinecap="round"
|
|
148
|
+
strokeLinejoin="round"
|
|
149
|
+
d="M19.5 8.25l-7.5 7.5-7.5-7.5"
|
|
150
|
+
/>
|
|
151
|
+
</svg>
|
|
152
|
+
</span>
|
|
153
|
+
</button>
|
|
154
|
+
|
|
155
|
+
{open && (
|
|
156
|
+
<div className="mt-3 flex gap-2">
|
|
157
|
+
<input
|
|
158
|
+
type="text"
|
|
159
|
+
value={code}
|
|
160
|
+
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
|
161
|
+
placeholder="PROMO2024"
|
|
162
|
+
className="flex-1 h-11 px-4 text-sm bg-card border border-border rounded-xl focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-all"
|
|
163
|
+
/>
|
|
164
|
+
<button
|
|
165
|
+
type="button"
|
|
166
|
+
onClick={handleApply}
|
|
167
|
+
disabled={loading || !code.trim()}
|
|
168
|
+
className="px-5 h-11 text-sm font-semibold bg-foreground text-card rounded-xl hover:bg-foreground/90 transition-all disabled:opacity-40"
|
|
169
|
+
>
|
|
170
|
+
{labels.addCode}
|
|
171
|
+
</button>
|
|
172
|
+
</div>
|
|
173
|
+
)}
|
|
174
|
+
|
|
175
|
+
{error && <p className="text-xs text-destructive mt-2">{error}</p>}
|
|
176
|
+
|
|
177
|
+
{promotions.length > 0 && (
|
|
178
|
+
<div className="mt-3 space-y-1.5">
|
|
179
|
+
{promotions.map((p) => (
|
|
180
|
+
<div
|
|
181
|
+
key={p.id}
|
|
182
|
+
className="flex items-center justify-between px-4 py-2.5 bg-success/10 border border-success/20 rounded-lg"
|
|
183
|
+
>
|
|
184
|
+
<div className="flex items-center gap-2">
|
|
185
|
+
<svg
|
|
186
|
+
className="w-4 h-4 text-success"
|
|
187
|
+
fill="none"
|
|
188
|
+
viewBox="0 0 24 24"
|
|
189
|
+
stroke="currentColor"
|
|
190
|
+
strokeWidth={2}
|
|
191
|
+
>
|
|
192
|
+
<path
|
|
193
|
+
strokeLinecap="round"
|
|
194
|
+
strokeLinejoin="round"
|
|
195
|
+
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
196
|
+
/>
|
|
197
|
+
</svg>
|
|
198
|
+
<span className="text-sm font-semibold text-success">
|
|
199
|
+
{p.code}
|
|
200
|
+
</span>
|
|
201
|
+
</div>
|
|
202
|
+
<span className="text-sm font-medium text-success">
|
|
203
|
+
{p.application_method?.type === "percentage"
|
|
204
|
+
? `-${p.application_method.value}%`
|
|
205
|
+
: p.application_method?.value && p.application_method?.currency_code
|
|
206
|
+
? `-${convertToLocale({
|
|
207
|
+
amount: +p.application_method.value,
|
|
208
|
+
currency_code: p.application_method.currency_code,
|
|
209
|
+
})}`
|
|
210
|
+
: ""}
|
|
211
|
+
</span>
|
|
212
|
+
</div>
|
|
213
|
+
))}
|
|
214
|
+
</div>
|
|
215
|
+
)}
|
|
216
|
+
</div>
|
|
217
|
+
)
|
|
218
|
+
}
|