@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,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wizard step resolution — the pure state machine behind the review token
|
|
3
|
+
* wizard. Ported from the Alenika /review/[token] server page's branching
|
|
4
|
+
* (page.tsx) so the SAME rules hold wherever the wizard mounts.
|
|
5
|
+
* Unit-tested in tests/unit/storefront-order-reviews.test.ts.
|
|
6
|
+
*/
|
|
7
|
+
import type { TokenValidation } from "../api/reviews"
|
|
8
|
+
|
|
9
|
+
export type WizardStep = "rate" | "photo" | "done"
|
|
10
|
+
|
|
11
|
+
export type WizardEntry =
|
|
12
|
+
/** Token bad — show the invalid/expired panel. */
|
|
13
|
+
| { kind: "invalid"; reason: "not_found" | "expired" | "invalid" }
|
|
14
|
+
/**
|
|
15
|
+
* Consumed token with NO review row (deleted / never created) — the
|
|
16
|
+
* terminal "thanks, already done" panel. NOT resumable.
|
|
17
|
+
*/
|
|
18
|
+
| { kind: "already-submitted" }
|
|
19
|
+
/** Render the form at `step`. */
|
|
20
|
+
| {
|
|
21
|
+
kind: "form"
|
|
22
|
+
step: WizardStep
|
|
23
|
+
reviewId: string | null
|
|
24
|
+
rewardCode: string | null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* THE resume rule (production behavior, verified against barter's token
|
|
29
|
+
* endpoint in batch 9): a consumed token does NOT mean "done".
|
|
30
|
+
*
|
|
31
|
+
* - `valid: false` → invalid panel
|
|
32
|
+
* - consumed + review row + `reward_code` set → "done" (show code)
|
|
33
|
+
* - consumed + review row + `reward_code` null → resume at "photo"
|
|
34
|
+
* - consumed + NO review row → terminal thanks panel
|
|
35
|
+
* - not consumed → fresh "rate"
|
|
36
|
+
*/
|
|
37
|
+
export function resolveWizardEntry(validation: TokenValidation): WizardEntry {
|
|
38
|
+
if (!validation.valid) {
|
|
39
|
+
return { kind: "invalid", reason: validation.reason }
|
|
40
|
+
}
|
|
41
|
+
const review = validation.already_submitted ? validation.review : null
|
|
42
|
+
if (validation.already_submitted && !review) {
|
|
43
|
+
return { kind: "already-submitted" }
|
|
44
|
+
}
|
|
45
|
+
const step: WizardStep = review?.reward_code
|
|
46
|
+
? "done"
|
|
47
|
+
: review
|
|
48
|
+
? "photo"
|
|
49
|
+
: "rate"
|
|
50
|
+
return {
|
|
51
|
+
kind: "form",
|
|
52
|
+
step,
|
|
53
|
+
reviewId: review?.id ?? null,
|
|
54
|
+
rewardCode: review?.reward_code ?? null,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Labels keys usable as submit-error copy. */
|
|
59
|
+
export type SubmitErrorKey =
|
|
60
|
+
| "errRateLimited"
|
|
61
|
+
| "errAlreadySubmitted"
|
|
62
|
+
| "errExpired"
|
|
63
|
+
| "errGeneric"
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* HTTP status → labels key for the submit step (POST /api/store/reviews
|
|
67
|
+
* error contract: 429 rate_limited · 409 conflict (token replay) · 410
|
|
68
|
+
* gone (expired) · anything else generic). Port of Alenika's
|
|
69
|
+
* `submitErrorMessage`.
|
|
70
|
+
*/
|
|
71
|
+
export function submitErrorKeyFor(status: number): SubmitErrorKey {
|
|
72
|
+
if (status === 429) return "errRateLimited"
|
|
73
|
+
if (status === 409) return "errAlreadySubmitted"
|
|
74
|
+
if (status === 410) return "errExpired"
|
|
75
|
+
return "errGeneric"
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Step-1 gate: a rating AND a non-blank body are both required. */
|
|
79
|
+
export function canSubmitRating(rating: number, body: string): boolean {
|
|
80
|
+
return rating >= 1 && rating <= 5 && body.trim().length > 0
|
|
81
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Category page — ported from
|
|
3
|
+
* `@1click/ui/src/store/category-template.tsx` (v2.3.1). Data seam:
|
|
4
|
+
* `HttpTypes.StoreProductCategory` → barter `StoreProductCategory`
|
|
5
|
+
* (`@cartbase/storefront/api/categories`); products come from the plain
|
|
6
|
+
* listing filtered by `category_id` (membership), via `PaginatedProducts`.
|
|
7
|
+
*
|
|
8
|
+
* Breadcrumbs need `parent_category` chains and the child list needs
|
|
9
|
+
* `category_children` — fetch the category with `include_ancestors_tree` +
|
|
10
|
+
* `include_descendants_tree` (`retrieveCategory` query flags), otherwise
|
|
11
|
+
* those sections simply don't render. Skeleton count is the fixed default
|
|
12
|
+
* (barter categories don't embed a products array).
|
|
13
|
+
*/
|
|
14
|
+
import { Suspense } from "react"
|
|
15
|
+
import { notFound } from "next/navigation"
|
|
16
|
+
import Link from "next/link"
|
|
17
|
+
import type { StorefrontClient } from "../api/http"
|
|
18
|
+
import type { PricingContextQuery } from "../api/types"
|
|
19
|
+
import type { StoreProductCategory } from "../api/categories"
|
|
20
|
+
import type { SortOptions } from "../lib/sort-products"
|
|
21
|
+
import type { StoreLabels } from "./labels"
|
|
22
|
+
import { SortSelect } from "./sort-select"
|
|
23
|
+
import { PaginatedProducts, type ProductCardComponent } from "./paginated-products"
|
|
24
|
+
import { SkeletonProductGrid } from "./skeleton-product-grid"
|
|
25
|
+
|
|
26
|
+
type CategoryTemplateProps = {
|
|
27
|
+
client: StorefrontClient
|
|
28
|
+
category: StoreProductCategory
|
|
29
|
+
sortBy?: SortOptions
|
|
30
|
+
/** From the `page` query param. */
|
|
31
|
+
page?: string
|
|
32
|
+
pricingContext?: PricingContextQuery
|
|
33
|
+
labels?: Partial<StoreLabels>
|
|
34
|
+
/** Override the default ProductPreview with a store-specific card. */
|
|
35
|
+
renderProduct?: ProductCardComponent
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function CategoryTemplate({
|
|
39
|
+
client,
|
|
40
|
+
category,
|
|
41
|
+
sortBy,
|
|
42
|
+
page,
|
|
43
|
+
pricingContext,
|
|
44
|
+
labels,
|
|
45
|
+
renderProduct,
|
|
46
|
+
}: CategoryTemplateProps) {
|
|
47
|
+
const pageNumber = page ? parseInt(page) : 1
|
|
48
|
+
const sort = sortBy || "created_at"
|
|
49
|
+
|
|
50
|
+
if (!category) notFound()
|
|
51
|
+
|
|
52
|
+
const parents: StoreProductCategory[] = []
|
|
53
|
+
const getParents = (cat: StoreProductCategory) => {
|
|
54
|
+
if (cat.parent_category) {
|
|
55
|
+
parents.push(cat.parent_category)
|
|
56
|
+
getParents(cat.parent_category)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
getParents(category)
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div
|
|
63
|
+
className="flex flex-col sm:flex-row sm:items-start py-6 max-w-7xl mx-auto px-4"
|
|
64
|
+
data-testid="category-container"
|
|
65
|
+
>
|
|
66
|
+
<div className="flex sm:flex-col gap-12 py-4 mb-8 sm:px-0 pl-6 sm:min-w-[250px]">
|
|
67
|
+
<SortSelect
|
|
68
|
+
sortBy={sort}
|
|
69
|
+
labels={labels}
|
|
70
|
+
data-testid="sort-by-container"
|
|
71
|
+
/>
|
|
72
|
+
</div>
|
|
73
|
+
<div className="w-full">
|
|
74
|
+
<div className="flex flex-row mb-8 gap-4">
|
|
75
|
+
{parents.map((parent) => (
|
|
76
|
+
<span key={parent.id} className="text-muted-foreground text-2xl">
|
|
77
|
+
<Link
|
|
78
|
+
className="mr-4 hover:text-foreground"
|
|
79
|
+
href={`/categories/${parent.handle}`}
|
|
80
|
+
>
|
|
81
|
+
{parent.name}
|
|
82
|
+
</Link>
|
|
83
|
+
/
|
|
84
|
+
</span>
|
|
85
|
+
))}
|
|
86
|
+
<h1
|
|
87
|
+
className="text-2xl font-semibold text-foreground"
|
|
88
|
+
data-testid="category-page-title"
|
|
89
|
+
>
|
|
90
|
+
{category.name}
|
|
91
|
+
</h1>
|
|
92
|
+
</div>
|
|
93
|
+
{category.description && (
|
|
94
|
+
<div className="mb-8 text-sm text-muted-foreground">
|
|
95
|
+
<p>{category.description}</p>
|
|
96
|
+
</div>
|
|
97
|
+
)}
|
|
98
|
+
{category.category_children && (
|
|
99
|
+
<div className="mb-8">
|
|
100
|
+
<ul className="grid grid-cols-1 gap-2">
|
|
101
|
+
{category.category_children.map((c) => (
|
|
102
|
+
<li key={c.id}>
|
|
103
|
+
<Link
|
|
104
|
+
href={`/categories/${c.handle}`}
|
|
105
|
+
className="text-primary hover:underline"
|
|
106
|
+
>
|
|
107
|
+
{c.name}
|
|
108
|
+
</Link>
|
|
109
|
+
</li>
|
|
110
|
+
))}
|
|
111
|
+
</ul>
|
|
112
|
+
</div>
|
|
113
|
+
)}
|
|
114
|
+
<Suspense fallback={<SkeletonProductGrid />}>
|
|
115
|
+
<PaginatedProducts
|
|
116
|
+
client={client}
|
|
117
|
+
sortBy={sort}
|
|
118
|
+
page={pageNumber}
|
|
119
|
+
categoryId={category.id}
|
|
120
|
+
pricingContext={pricingContext}
|
|
121
|
+
renderProduct={renderProduct}
|
|
122
|
+
/>
|
|
123
|
+
</Suspense>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export { type CategoryTemplateProps }
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collection page — ported from
|
|
3
|
+
* `@1click/ui/src/store/collection-template.tsx` (v2.3.1), REWIRED to the
|
|
4
|
+
* barter collection MEMBERSHIP listing (docs/storefront/collections.md).
|
|
5
|
+
*
|
|
6
|
+
* The original filtered the plain products listing by `collection_id`
|
|
7
|
+
* (Medusa primary collection). barter collections are curated groupings
|
|
8
|
+
* (manual or smart) with an admin-set `default_sort` — the membership
|
|
9
|
+
* endpoint `api/collections` `listCollectionProducts` honors it SERVER-side
|
|
10
|
+
* (manual position, title, EUR price asc/desc, newest/oldest, best_selling).
|
|
11
|
+
* So: when the shopper has NOT picked a sort, send NO `order` — the admin's
|
|
12
|
+
* `default_sort` applies, and nothing is re-sorted client-side. A shopper
|
|
13
|
+
* override maps to the `order` param (still server-side, incl. price).
|
|
14
|
+
*
|
|
15
|
+
* `SortSelect` therefore receives `sortBy` unset until the shopper picks
|
|
16
|
+
* (no highlight = admin default).
|
|
17
|
+
*/
|
|
18
|
+
import { Suspense } from "react"
|
|
19
|
+
import type { StorefrontClient } from "../api/http"
|
|
20
|
+
import type { PricingContextQuery } from "../api/types"
|
|
21
|
+
import type { StoreProduct } from "../api/products"
|
|
22
|
+
import type { CollectionSortMode, StoreCollection } from "../api/collections"
|
|
23
|
+
import { listCollectionProducts } from "../api/collections"
|
|
24
|
+
import type { SortOptions } from "../lib/sort-products"
|
|
25
|
+
import type { StoreLabels } from "./labels"
|
|
26
|
+
import { SortSelect } from "./sort-select"
|
|
27
|
+
import type { ProductCardComponent } from "./paginated-products"
|
|
28
|
+
import { ProductPreview } from "../products/product-preview"
|
|
29
|
+
import { Pagination } from "./pagination"
|
|
30
|
+
import { SkeletonProductGrid } from "./skeleton-product-grid"
|
|
31
|
+
|
|
32
|
+
const PRODUCT_LIMIT = 12
|
|
33
|
+
|
|
34
|
+
/** Shopper sort override → membership-listing `order` param. */
|
|
35
|
+
const ORDER_BY_SORT: Record<SortOptions, CollectionSortMode> = {
|
|
36
|
+
created_at: "newest",
|
|
37
|
+
price_asc: "price_asc",
|
|
38
|
+
price_desc: "price_desc",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type CollectionTemplateProps = {
|
|
42
|
+
client: StorefrontClient
|
|
43
|
+
collection: StoreCollection
|
|
44
|
+
/** Shopper override from the `sortBy` query param; unset = admin default_sort. */
|
|
45
|
+
sortBy?: SortOptions
|
|
46
|
+
/** From the `page` query param. */
|
|
47
|
+
page?: string
|
|
48
|
+
pricingContext?: PricingContextQuery
|
|
49
|
+
labels?: Partial<StoreLabels>
|
|
50
|
+
/** Override the default ProductPreview with a store-specific card. */
|
|
51
|
+
renderProduct?: ProductCardComponent
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function CollectionProducts({
|
|
55
|
+
client,
|
|
56
|
+
collectionId,
|
|
57
|
+
sortBy,
|
|
58
|
+
page,
|
|
59
|
+
pricingContext,
|
|
60
|
+
renderProduct: Card = ProductPreview,
|
|
61
|
+
}: {
|
|
62
|
+
client: StorefrontClient
|
|
63
|
+
collectionId: string
|
|
64
|
+
sortBy?: SortOptions
|
|
65
|
+
page: number
|
|
66
|
+
pricingContext?: PricingContextQuery
|
|
67
|
+
renderProduct?: ProductCardComponent
|
|
68
|
+
}) {
|
|
69
|
+
const res = await listCollectionProducts(client, collectionId, {
|
|
70
|
+
limit: PRODUCT_LIMIT,
|
|
71
|
+
offset: (page - 1) * PRODUCT_LIMIT,
|
|
72
|
+
...(sortBy ? { order: ORDER_BY_SORT[sortBy] } : {}),
|
|
73
|
+
...pricingContext,
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const products: StoreProduct[] = res.products
|
|
77
|
+
const totalPages = Math.ceil(res.count / PRODUCT_LIMIT)
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<>
|
|
81
|
+
<ul
|
|
82
|
+
className="grid grid-cols-2 w-full sm:grid-cols-3 md:grid-cols-4 gap-x-6 gap-y-8"
|
|
83
|
+
data-testid="products-list"
|
|
84
|
+
>
|
|
85
|
+
{products.map((p) => (
|
|
86
|
+
<li key={p.id}>
|
|
87
|
+
<Card product={p} />
|
|
88
|
+
</li>
|
|
89
|
+
))}
|
|
90
|
+
</ul>
|
|
91
|
+
{totalPages > 1 && (
|
|
92
|
+
<Pagination
|
|
93
|
+
data-testid="product-pagination"
|
|
94
|
+
page={page}
|
|
95
|
+
totalPages={totalPages}
|
|
96
|
+
/>
|
|
97
|
+
)}
|
|
98
|
+
</>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function CollectionTemplate({
|
|
103
|
+
client,
|
|
104
|
+
collection,
|
|
105
|
+
sortBy,
|
|
106
|
+
page,
|
|
107
|
+
pricingContext,
|
|
108
|
+
labels,
|
|
109
|
+
renderProduct,
|
|
110
|
+
}: CollectionTemplateProps) {
|
|
111
|
+
const pageNumber = page ? parseInt(page) : 1
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<div className="flex flex-col sm:flex-row sm:items-start py-6 max-w-7xl mx-auto px-4">
|
|
115
|
+
<div className="flex sm:flex-col gap-12 py-4 mb-8 sm:px-0 pl-6 sm:min-w-[250px]">
|
|
116
|
+
<SortSelect sortBy={sortBy} labels={labels} />
|
|
117
|
+
</div>
|
|
118
|
+
<div className="w-full">
|
|
119
|
+
<div className="mb-8">
|
|
120
|
+
<h1 className="text-2xl font-semibold text-foreground">
|
|
121
|
+
{collection.title}
|
|
122
|
+
</h1>
|
|
123
|
+
</div>
|
|
124
|
+
<Suspense fallback={<SkeletonProductGrid />}>
|
|
125
|
+
<CollectionProducts
|
|
126
|
+
client={client}
|
|
127
|
+
collectionId={collection.id}
|
|
128
|
+
sortBy={sortBy}
|
|
129
|
+
page={pageNumber}
|
|
130
|
+
pricingContext={pricingContext}
|
|
131
|
+
renderProduct={renderProduct}
|
|
132
|
+
/>
|
|
133
|
+
</Suspense>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export { type CollectionTemplateProps }
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @cartbase/storefront — store (listing) family barrel. Prefer subpath
|
|
3
|
+
* imports in apps; the barrel exists for convenience and for the extra pure
|
|
4
|
+
* module (search-params).
|
|
5
|
+
*/
|
|
6
|
+
export {
|
|
7
|
+
defaultStoreLabels,
|
|
8
|
+
sortOptionLabelKeys,
|
|
9
|
+
type StoreLabels,
|
|
10
|
+
} from "./labels"
|
|
11
|
+
export { bulgarianStoreLabels } from "./labels-bg"
|
|
12
|
+
|
|
13
|
+
export { Pagination } from "./pagination"
|
|
14
|
+
export { SortSelect, type SortSelectProps } from "./sort-select"
|
|
15
|
+
export { PaginatedProducts, type ProductCardComponent } from "./paginated-products"
|
|
16
|
+
export { SkeletonProductGrid } from "./skeleton-product-grid"
|
|
17
|
+
export { StoreTemplate, type StoreTemplateProps } from "./store-template"
|
|
18
|
+
export {
|
|
19
|
+
CollectionTemplate,
|
|
20
|
+
type CollectionTemplateProps,
|
|
21
|
+
} from "./collection-template"
|
|
22
|
+
export {
|
|
23
|
+
CategoryTemplate,
|
|
24
|
+
type CategoryTemplateProps,
|
|
25
|
+
} from "./category-template"
|
|
26
|
+
export { SearchTemplate, type SearchTemplateProps } from "./search-template"
|
|
27
|
+
|
|
28
|
+
export {
|
|
29
|
+
parseSearchParams,
|
|
30
|
+
buildSearchQueryString,
|
|
31
|
+
fromQueryString,
|
|
32
|
+
toggleFacetSelection,
|
|
33
|
+
isFacetSelected,
|
|
34
|
+
clearFilters,
|
|
35
|
+
toSearchQuery,
|
|
36
|
+
type SearchPageState,
|
|
37
|
+
type RawSearchParams,
|
|
38
|
+
type FacetBucketRef,
|
|
39
|
+
} from "./search-params"
|
|
40
|
+
|
|
41
|
+
export { type SortOptions } from "../lib/sort-products"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bulgarian store labels — ported from `@1click/ui/src/store/labels-bg.ts`
|
|
3
|
+
* (v2.3.1), extended with the search-template keys. Typed against the full
|
|
4
|
+
* `StoreLabels` shape so a missing translation is a compile error.
|
|
5
|
+
*/
|
|
6
|
+
import type { StoreLabels } from "./labels"
|
|
7
|
+
|
|
8
|
+
export const bulgarianStoreLabels: StoreLabels = {
|
|
9
|
+
allProducts: "Всички продукти",
|
|
10
|
+
sortBy: "Сортирай по",
|
|
11
|
+
latestArrivals: "Най-нови",
|
|
12
|
+
priceLowToHigh: "Цена: ниска → висока",
|
|
13
|
+
priceHighToLow: "Цена: висока → ниска",
|
|
14
|
+
searchTitle: "Търсене",
|
|
15
|
+
searchPlaceholder: "Търсете продукти…",
|
|
16
|
+
searchAction: "Търси",
|
|
17
|
+
resultsFor: "Резултати за",
|
|
18
|
+
resultsCount: "резултата",
|
|
19
|
+
noResults: "Няма намерени продукти",
|
|
20
|
+
filters: "Филтри",
|
|
21
|
+
clearFilters: "Изчисти филтрите",
|
|
22
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Store-family label map — ported from `@1click/ui/src/store/labels.ts`
|
|
3
|
+
* (v2.3.1), EXTENDED with the search-template keys (search-template is new
|
|
4
|
+
* in barter — no @1click source). i18n discipline: components merge
|
|
5
|
+
* `labels` props over these defaults; `labels-bg.ts` must cover every key
|
|
6
|
+
* (typed against the full shape — completeness is unit-tested).
|
|
7
|
+
*/
|
|
8
|
+
import type { SortOptions } from "../lib/sort-products"
|
|
9
|
+
|
|
10
|
+
export type StoreLabels = {
|
|
11
|
+
allProducts: string
|
|
12
|
+
sortBy: string
|
|
13
|
+
latestArrivals: string
|
|
14
|
+
priceLowToHigh: string
|
|
15
|
+
priceHighToLow: string
|
|
16
|
+
// search-template (barter addition)
|
|
17
|
+
searchTitle: string
|
|
18
|
+
searchPlaceholder: string
|
|
19
|
+
searchAction: string
|
|
20
|
+
resultsFor: string
|
|
21
|
+
resultsCount: string
|
|
22
|
+
noResults: string
|
|
23
|
+
filters: string
|
|
24
|
+
clearFilters: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const defaultStoreLabels: StoreLabels = {
|
|
28
|
+
allProducts: "All products",
|
|
29
|
+
sortBy: "Sort by",
|
|
30
|
+
latestArrivals: "Latest Arrivals",
|
|
31
|
+
priceLowToHigh: "Price: Low → High",
|
|
32
|
+
priceHighToLow: "Price: High → Low",
|
|
33
|
+
searchTitle: "Search",
|
|
34
|
+
searchPlaceholder: "Search products…",
|
|
35
|
+
searchAction: "Search",
|
|
36
|
+
resultsFor: "Results for",
|
|
37
|
+
resultsCount: "results",
|
|
38
|
+
noResults: "No products found",
|
|
39
|
+
filters: "Filters",
|
|
40
|
+
clearFilters: "Clear filters",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Sort mode → label key. THE map `SortSelect` renders from — kept here
|
|
45
|
+
* (pure module) so completeness against `SortOptions` is unit-testable
|
|
46
|
+
* without importing React components.
|
|
47
|
+
*/
|
|
48
|
+
export const sortOptionLabelKeys: Record<SortOptions, keyof StoreLabels> = {
|
|
49
|
+
created_at: "latestArrivals",
|
|
50
|
+
price_asc: "priceLowToHigh",
|
|
51
|
+
price_desc: "priceHighToLow",
|
|
52
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paginated product grid — ported from
|
|
3
|
+
* `@1click/ui/src/store/paginated-products.tsx` (v2.3.1). Data seam: the
|
|
4
|
+
* Medusa `listProductsWithSort` server action (region-resolved via
|
|
5
|
+
* `countryCode`) becomes `api/products` `listProducts` with the barter
|
|
6
|
+
* pricing context (`currency_code`/`region_id`).
|
|
7
|
+
*
|
|
8
|
+
* Sorting (server truth first):
|
|
9
|
+
* - `created_at` (default) → server `order: "-created_at"` with real
|
|
10
|
+
* limit/offset pagination.
|
|
11
|
+
* - `price_asc`/`price_desc` → price is NOT a products-table column, so the
|
|
12
|
+
* @1click production approach is preserved: fetch the sort window
|
|
13
|
+
* (100 items, the `listProductsWithSort` cap), re-sort client-side on the
|
|
14
|
+
* server-computed `calculated_price` (`lib/sort-products`), slice the
|
|
15
|
+
* page. Catalogs past 100 items should sort via the collection membership
|
|
16
|
+
* listing (collection-template), where price sorting IS server-side.
|
|
17
|
+
*
|
|
18
|
+
* Async server component — render inside `<Suspense>`.
|
|
19
|
+
*/
|
|
20
|
+
import type { ComponentType } from "react"
|
|
21
|
+
import type { StorefrontClient } from "../api/http"
|
|
22
|
+
import type { PricingContextQuery } from "../api/types"
|
|
23
|
+
import type { ListProductsQuery, StoreProduct } from "../api/products"
|
|
24
|
+
import { listProducts } from "../api/products"
|
|
25
|
+
import { sortProducts, type SortOptions } from "../lib/sort-products"
|
|
26
|
+
import { ProductPreview } from "../products/product-preview"
|
|
27
|
+
import { Pagination } from "./pagination"
|
|
28
|
+
|
|
29
|
+
/** A component that renders a single product card. */
|
|
30
|
+
export type ProductCardComponent = ComponentType<{
|
|
31
|
+
product: StoreProduct
|
|
32
|
+
}>
|
|
33
|
+
|
|
34
|
+
const PRODUCT_LIMIT = 12
|
|
35
|
+
/** The @1click client-sort window (listProductsWithSort cap). */
|
|
36
|
+
const SORT_WINDOW = 100
|
|
37
|
+
|
|
38
|
+
export async function PaginatedProducts({
|
|
39
|
+
client,
|
|
40
|
+
sortBy,
|
|
41
|
+
page,
|
|
42
|
+
collectionId,
|
|
43
|
+
categoryId,
|
|
44
|
+
productsIds,
|
|
45
|
+
pricingContext,
|
|
46
|
+
renderProduct: Card = ProductPreview,
|
|
47
|
+
}: {
|
|
48
|
+
client: StorefrontClient
|
|
49
|
+
sortBy?: SortOptions
|
|
50
|
+
page: number
|
|
51
|
+
/** Filters by PRIMARY collection (`products.collection_id`) — for the
|
|
52
|
+
* membership join use collection-template / `listCollectionProducts`. */
|
|
53
|
+
collectionId?: string
|
|
54
|
+
categoryId?: string
|
|
55
|
+
productsIds?: string[]
|
|
56
|
+
/** Pricing context so cards carry `calculated_price`. */
|
|
57
|
+
pricingContext?: PricingContextQuery
|
|
58
|
+
/** Override the default ProductPreview with a store-specific card. */
|
|
59
|
+
renderProduct?: ProductCardComponent
|
|
60
|
+
}) {
|
|
61
|
+
const queryParams: ListProductsQuery = {
|
|
62
|
+
limit: PRODUCT_LIMIT,
|
|
63
|
+
...pricingContext,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (collectionId) queryParams.collection_id = [collectionId]
|
|
67
|
+
if (categoryId) queryParams.category_id = [categoryId]
|
|
68
|
+
if (productsIds) queryParams.id = productsIds
|
|
69
|
+
|
|
70
|
+
let products: StoreProduct[]
|
|
71
|
+
let count: number
|
|
72
|
+
|
|
73
|
+
if (sortBy === "price_asc" || sortBy === "price_desc") {
|
|
74
|
+
const res = await listProducts(client, {
|
|
75
|
+
...queryParams,
|
|
76
|
+
limit: SORT_WINDOW,
|
|
77
|
+
offset: 0,
|
|
78
|
+
})
|
|
79
|
+
const sorted = sortProducts(res.products, sortBy)
|
|
80
|
+
const offset = (page - 1) * PRODUCT_LIMIT
|
|
81
|
+
products = sorted.slice(offset, offset + PRODUCT_LIMIT)
|
|
82
|
+
count = res.count
|
|
83
|
+
} else {
|
|
84
|
+
const res = await listProducts(client, {
|
|
85
|
+
...queryParams,
|
|
86
|
+
order: "-created_at",
|
|
87
|
+
offset: (page - 1) * PRODUCT_LIMIT,
|
|
88
|
+
})
|
|
89
|
+
products = res.products
|
|
90
|
+
count = res.count
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const totalPages = Math.ceil(count / PRODUCT_LIMIT)
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<>
|
|
97
|
+
<ul
|
|
98
|
+
className="grid grid-cols-2 w-full sm:grid-cols-3 md:grid-cols-4 gap-x-6 gap-y-8"
|
|
99
|
+
data-testid="products-list"
|
|
100
|
+
>
|
|
101
|
+
{products.map((p) => (
|
|
102
|
+
<li key={p.id}>
|
|
103
|
+
<Card product={p} />
|
|
104
|
+
</li>
|
|
105
|
+
))}
|
|
106
|
+
</ul>
|
|
107
|
+
{totalPages > 1 && (
|
|
108
|
+
<Pagination
|
|
109
|
+
data-testid="product-pagination"
|
|
110
|
+
page={page}
|
|
111
|
+
totalPages={totalPages}
|
|
112
|
+
/>
|
|
113
|
+
)}
|
|
114
|
+
</>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Page-number pagination — ported from `@1click/ui/src/store/pagination.tsx`
|
|
5
|
+
* (v2.3.1) unchanged. Writes the `page` query param and pushes the route;
|
|
6
|
+
* the server templates read it back and convert to `offset`. Windowed
|
|
7
|
+
* layout: ≤7 pages inline, otherwise ellipsis around the current window.
|
|
8
|
+
*/
|
|
9
|
+
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
|
10
|
+
import { cn } from "../lib/utils"
|
|
11
|
+
|
|
12
|
+
export function Pagination({
|
|
13
|
+
page,
|
|
14
|
+
totalPages,
|
|
15
|
+
"data-testid": dataTestid,
|
|
16
|
+
}: {
|
|
17
|
+
page: number
|
|
18
|
+
totalPages: number
|
|
19
|
+
"data-testid"?: string
|
|
20
|
+
}) {
|
|
21
|
+
const router = useRouter()
|
|
22
|
+
const pathname = usePathname()
|
|
23
|
+
const searchParams = useSearchParams()
|
|
24
|
+
|
|
25
|
+
const arrayRange = (start: number, stop: number) =>
|
|
26
|
+
Array.from({ length: stop - start + 1 }, (_, index) => start + index)
|
|
27
|
+
|
|
28
|
+
const handlePageChange = (newPage: number) => {
|
|
29
|
+
const params = new URLSearchParams(searchParams)
|
|
30
|
+
params.set("page", newPage.toString())
|
|
31
|
+
router.push(`${pathname}?${params.toString()}`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const renderPageButton = (
|
|
35
|
+
p: number,
|
|
36
|
+
label: string | number,
|
|
37
|
+
isCurrent: boolean
|
|
38
|
+
) => (
|
|
39
|
+
<button
|
|
40
|
+
key={p}
|
|
41
|
+
className={cn("text-xl text-muted-foreground", {
|
|
42
|
+
"text-foreground font-semibold": isCurrent,
|
|
43
|
+
"hover:text-muted-foreground": !isCurrent,
|
|
44
|
+
})}
|
|
45
|
+
disabled={isCurrent}
|
|
46
|
+
onClick={() => handlePageChange(p)}
|
|
47
|
+
>
|
|
48
|
+
{label}
|
|
49
|
+
</button>
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
const renderEllipsis = (key: string) => (
|
|
53
|
+
<span key={key} className="text-xl text-muted-foreground cursor-default">
|
|
54
|
+
...
|
|
55
|
+
</span>
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
const renderPageButtons = () => {
|
|
59
|
+
const buttons: React.ReactNode[] = []
|
|
60
|
+
|
|
61
|
+
if (totalPages <= 7) {
|
|
62
|
+
buttons.push(
|
|
63
|
+
...arrayRange(1, totalPages).map((p) =>
|
|
64
|
+
renderPageButton(p, p, p === page)
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
} else if (page <= 4) {
|
|
68
|
+
buttons.push(
|
|
69
|
+
...arrayRange(1, 5).map((p) => renderPageButton(p, p, p === page))
|
|
70
|
+
)
|
|
71
|
+
buttons.push(renderEllipsis("e1"))
|
|
72
|
+
buttons.push(renderPageButton(totalPages, totalPages, false))
|
|
73
|
+
} else if (page >= totalPages - 3) {
|
|
74
|
+
buttons.push(renderPageButton(1, 1, false))
|
|
75
|
+
buttons.push(renderEllipsis("e2"))
|
|
76
|
+
buttons.push(
|
|
77
|
+
...arrayRange(totalPages - 4, totalPages).map((p) =>
|
|
78
|
+
renderPageButton(p, p, p === page)
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
} else {
|
|
82
|
+
buttons.push(renderPageButton(1, 1, false))
|
|
83
|
+
buttons.push(renderEllipsis("e3"))
|
|
84
|
+
buttons.push(
|
|
85
|
+
...arrayRange(page - 1, page + 1).map((p) =>
|
|
86
|
+
renderPageButton(p, p, p === page)
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
buttons.push(renderEllipsis("e4"))
|
|
90
|
+
buttons.push(renderPageButton(totalPages, totalPages, false))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return buttons
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div className="flex justify-center w-full mt-12">
|
|
98
|
+
<div className="flex gap-3 items-end" data-testid={dataTestid}>
|
|
99
|
+
{renderPageButtons()}
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
)
|
|
103
|
+
}
|