@86d-app/products 0.0.3
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/AGENTS.md +65 -0
- package/COMPONENTS.md +231 -0
- package/README.md +201 -0
- package/package.json +46 -0
- package/src/__tests__/controllers.test.ts +2227 -0
- package/src/__tests__/state.test.ts +138 -0
- package/src/admin/components/categories-admin.mdx +3 -0
- package/src/admin/components/categories-admin.tsx +449 -0
- package/src/admin/components/category-form.mdx +9 -0
- package/src/admin/components/category-form.tsx +490 -0
- package/src/admin/components/category-list.mdx +75 -0
- package/src/admin/components/category-list.tsx +168 -0
- package/src/admin/components/collections-admin.mdx +3 -0
- package/src/admin/components/collections-admin.tsx +771 -0
- package/src/admin/components/index.tsx +8 -0
- package/src/admin/components/product-detail.mdx +12 -0
- package/src/admin/components/product-detail.tsx +790 -0
- package/src/admin/components/product-edit.tsx +60 -0
- package/src/admin/components/product-form.tsx +793 -0
- package/src/admin/components/product-list.mdx +3 -0
- package/src/admin/components/product-list.tsx +1125 -0
- package/src/admin/components/product-new.tsx +38 -0
- package/src/admin/endpoints/add-collection-product.ts +17 -0
- package/src/admin/endpoints/bulk-action.ts +43 -0
- package/src/admin/endpoints/create-category.ts +52 -0
- package/src/admin/endpoints/create-collection.ts +35 -0
- package/src/admin/endpoints/create-product.ts +50 -0
- package/src/admin/endpoints/create-variant.ts +45 -0
- package/src/admin/endpoints/delete-category.ts +27 -0
- package/src/admin/endpoints/delete-collection.ts +12 -0
- package/src/admin/endpoints/delete-product.ts +27 -0
- package/src/admin/endpoints/delete-variant.ts +27 -0
- package/src/admin/endpoints/get-product.ts +23 -0
- package/src/admin/endpoints/import-products.ts +47 -0
- package/src/admin/endpoints/index.ts +43 -0
- package/src/admin/endpoints/list-categories.ts +21 -0
- package/src/admin/endpoints/list-collections.ts +20 -0
- package/src/admin/endpoints/list-products.ts +25 -0
- package/src/admin/endpoints/remove-collection-product.ts +15 -0
- package/src/admin/endpoints/update-category.ts +82 -0
- package/src/admin/endpoints/update-collection.ts +22 -0
- package/src/admin/endpoints/update-product.ts +67 -0
- package/src/admin/endpoints/update-variant.ts +41 -0
- package/src/controllers.ts +1410 -0
- package/src/index.ts +120 -0
- package/src/markdown.ts +150 -0
- package/src/mdx.d.ts +5 -0
- package/src/schema.ts +352 -0
- package/src/state.ts +84 -0
- package/src/store/components/_hooks.ts +78 -0
- package/src/store/components/_types.ts +73 -0
- package/src/store/components/_utils.ts +14 -0
- package/src/store/components/back-in-stock-notify.tsx +97 -0
- package/src/store/components/collection-card.mdx +42 -0
- package/src/store/components/collection-card.tsx +12 -0
- package/src/store/components/collection-detail.mdx +12 -0
- package/src/store/components/collection-detail.tsx +149 -0
- package/src/store/components/collection-grid.mdx +9 -0
- package/src/store/components/collection-grid.tsx +80 -0
- package/src/store/components/featured-products.mdx +9 -0
- package/src/store/components/featured-products.tsx +75 -0
- package/src/store/components/filter-chip.mdx +25 -0
- package/src/store/components/filter-chip.tsx +12 -0
- package/src/store/components/index.tsx +39 -0
- package/src/store/components/product-card.mdx +69 -0
- package/src/store/components/product-card.tsx +71 -0
- package/src/store/components/product-detail.mdx +30 -0
- package/src/store/components/product-detail.tsx +488 -0
- package/src/store/components/product-listing.mdx +7 -0
- package/src/store/components/product-listing.tsx +423 -0
- package/src/store/components/product-reviews-section.mdx +21 -0
- package/src/store/components/product-reviews-section.tsx +372 -0
- package/src/store/components/recently-viewed.tsx +100 -0
- package/src/store/components/related-products.mdx +6 -0
- package/src/store/components/related-products.tsx +62 -0
- package/src/store/components/star-display.mdx +18 -0
- package/src/store/components/star-display.tsx +27 -0
- package/src/store/components/star-picker.mdx +21 -0
- package/src/store/components/star-picker.tsx +21 -0
- package/src/store/components/stock-badge.mdx +12 -0
- package/src/store/components/stock-badge.tsx +19 -0
- package/src/store/endpoints/get-category.ts +61 -0
- package/src/store/endpoints/get-collection.ts +46 -0
- package/src/store/endpoints/get-featured.ts +18 -0
- package/src/store/endpoints/get-product.ts +52 -0
- package/src/store/endpoints/get-related.ts +20 -0
- package/src/store/endpoints/index.ts +23 -0
- package/src/store/endpoints/list-categories.ts +13 -0
- package/src/store/endpoints/list-collections.ts +22 -0
- package/src/store/endpoints/list-products.ts +28 -0
- package/src/store/endpoints/search-products.ts +18 -0
- package/src/store/endpoints/store-search.ts +111 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useModuleClient } from "@86d-app/core/client";
|
|
4
|
+
import { useCallback, useRef } from "react";
|
|
5
|
+
|
|
6
|
+
export function useProductsApi() {
|
|
7
|
+
const client = useModuleClient();
|
|
8
|
+
return {
|
|
9
|
+
listProducts: client.module("products").store["/products"],
|
|
10
|
+
getFeaturedProducts: client.module("products").store["/products/featured"],
|
|
11
|
+
getProduct: client.module("products").store["/products/:id"],
|
|
12
|
+
getRelatedProducts:
|
|
13
|
+
client.module("products").store["/products/:id/related"],
|
|
14
|
+
listCategories: client.module("products").store["/categories"],
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function useCartMutation() {
|
|
19
|
+
const client = useModuleClient();
|
|
20
|
+
return {
|
|
21
|
+
addToCart: client.module("cart").store["/cart"],
|
|
22
|
+
getCart: client.module("cart").store["/cart/get"],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function useReviewsApi() {
|
|
27
|
+
const client = useModuleClient();
|
|
28
|
+
return {
|
|
29
|
+
listProductReviews:
|
|
30
|
+
client.module("reviews").store["/reviews/products/:productId"],
|
|
31
|
+
submitReview: client.module("reviews").store["/reviews"],
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function useInventoryApi() {
|
|
36
|
+
const client = useModuleClient();
|
|
37
|
+
return {
|
|
38
|
+
checkStock: client.module("inventory").store["/inventory/check"],
|
|
39
|
+
subscribeBackInStock:
|
|
40
|
+
client.module("inventory").store["/inventory/back-in-stock/subscribe"],
|
|
41
|
+
checkBackInStock:
|
|
42
|
+
client.module("inventory").store["/inventory/back-in-stock/check"],
|
|
43
|
+
unsubscribeBackInStock:
|
|
44
|
+
client.module("inventory").store["/inventory/back-in-stock/unsubscribe"],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function useAnalyticsApi() {
|
|
49
|
+
const client = useModuleClient();
|
|
50
|
+
return {
|
|
51
|
+
recentlyViewed:
|
|
52
|
+
client.module("analytics").store["/analytics/recently-viewed"],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Fire-and-forget analytics event via the analytics module endpoint. */
|
|
57
|
+
export function useTrack() {
|
|
58
|
+
const client = useModuleClient();
|
|
59
|
+
const tracker = client.module("analytics").store["/analytics/events"];
|
|
60
|
+
const ref = useRef(tracker);
|
|
61
|
+
ref.current = tracker;
|
|
62
|
+
|
|
63
|
+
return useCallback(
|
|
64
|
+
(params: {
|
|
65
|
+
type: string;
|
|
66
|
+
productId?: string;
|
|
67
|
+
value?: number;
|
|
68
|
+
data?: Record<string, unknown>;
|
|
69
|
+
}) => {
|
|
70
|
+
try {
|
|
71
|
+
void ref.current.mutate(params);
|
|
72
|
+
} catch {
|
|
73
|
+
// Analytics is best-effort
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
[],
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export interface Product {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
slug: string;
|
|
5
|
+
price: number;
|
|
6
|
+
compareAtPrice?: number | null;
|
|
7
|
+
shortDescription?: string | null;
|
|
8
|
+
description?: string | null;
|
|
9
|
+
images: string[];
|
|
10
|
+
isFeatured: boolean;
|
|
11
|
+
status: string;
|
|
12
|
+
inventory: number;
|
|
13
|
+
categoryId?: string | null;
|
|
14
|
+
tags: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ProductVariant {
|
|
18
|
+
id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
price: number;
|
|
21
|
+
compareAtPrice?: number | null;
|
|
22
|
+
inventory: number;
|
|
23
|
+
options: Record<string, string>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ProductWithVariants extends Product {
|
|
27
|
+
variants: ProductVariant[];
|
|
28
|
+
category?: { id: string; name: string; slug: string } | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface Category {
|
|
32
|
+
id: string;
|
|
33
|
+
name: string;
|
|
34
|
+
slug: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ListResult {
|
|
38
|
+
products: Product[];
|
|
39
|
+
total: number;
|
|
40
|
+
page: number;
|
|
41
|
+
limit: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CollectionCardData {
|
|
45
|
+
id: string;
|
|
46
|
+
name: string;
|
|
47
|
+
slug: string;
|
|
48
|
+
description?: string | null;
|
|
49
|
+
image?: string | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface Review {
|
|
53
|
+
id: string;
|
|
54
|
+
authorName: string;
|
|
55
|
+
rating: number;
|
|
56
|
+
title?: string | undefined;
|
|
57
|
+
body: string;
|
|
58
|
+
isVerifiedPurchase: boolean;
|
|
59
|
+
helpfulCount: number;
|
|
60
|
+
createdAt: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface RatingSummary {
|
|
64
|
+
average: number;
|
|
65
|
+
count: number;
|
|
66
|
+
distribution: Record<string, number>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ReviewsResponse {
|
|
70
|
+
reviews: Review[];
|
|
71
|
+
summary: RatingSummary;
|
|
72
|
+
total: number;
|
|
73
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function formatPrice(cents: number): string {
|
|
2
|
+
return new Intl.NumberFormat("en-US", {
|
|
3
|
+
style: "currency",
|
|
4
|
+
currency: "USD",
|
|
5
|
+
}).format(cents / 100);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function formatDate(iso: string): string {
|
|
9
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
10
|
+
month: "short",
|
|
11
|
+
day: "numeric",
|
|
12
|
+
year: "numeric",
|
|
13
|
+
}).format(new Date(iso));
|
|
14
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { useInventoryApi } from "./_hooks";
|
|
5
|
+
|
|
6
|
+
interface BackInStockNotifyProps {
|
|
7
|
+
productId: string;
|
|
8
|
+
variantId?: string | undefined;
|
|
9
|
+
productName: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function BackInStockNotify({
|
|
13
|
+
productId,
|
|
14
|
+
variantId,
|
|
15
|
+
productName,
|
|
16
|
+
}: BackInStockNotifyProps) {
|
|
17
|
+
const [email, setEmail] = useState("");
|
|
18
|
+
const [submitted, setSubmitted] = useState(false);
|
|
19
|
+
const [error, setError] = useState<string | null>(null);
|
|
20
|
+
const api = useInventoryApi();
|
|
21
|
+
|
|
22
|
+
const subscribeMutation = api.subscribeBackInStock.useMutation({
|
|
23
|
+
onSuccess: () => {
|
|
24
|
+
setSubmitted(true);
|
|
25
|
+
setError(null);
|
|
26
|
+
},
|
|
27
|
+
onError: () => {
|
|
28
|
+
setError("Something went wrong. Please try again.");
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
33
|
+
e.preventDefault();
|
|
34
|
+
if (!email.trim()) return;
|
|
35
|
+
setError(null);
|
|
36
|
+
subscribeMutation.mutate({
|
|
37
|
+
productId,
|
|
38
|
+
variantId,
|
|
39
|
+
email: email.trim(),
|
|
40
|
+
productName,
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
if (submitted) {
|
|
45
|
+
return (
|
|
46
|
+
<div className="mt-3 rounded-md border border-border bg-muted/30 p-3">
|
|
47
|
+
<div className="flex items-center gap-2">
|
|
48
|
+
<svg
|
|
49
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
50
|
+
width="16"
|
|
51
|
+
height="16"
|
|
52
|
+
viewBox="0 0 256 256"
|
|
53
|
+
fill="currentColor"
|
|
54
|
+
className="text-foreground"
|
|
55
|
+
aria-hidden="true"
|
|
56
|
+
>
|
|
57
|
+
<path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48ZM98.71,128,40,181.81V74.19Zm11.84,10.85,12,11.05a8,8,0,0,0,10.82,0l12-11.05,58,53.15H52.57ZM157.29,128,216,74.18V181.82Z" />
|
|
58
|
+
</svg>
|
|
59
|
+
<p className="font-medium text-foreground text-xs">
|
|
60
|
+
We'll notify you
|
|
61
|
+
</p>
|
|
62
|
+
</div>
|
|
63
|
+
<p className="mt-1 text-muted-foreground text-xs">
|
|
64
|
+
We'll send an email to <strong>{email}</strong> when this item is back
|
|
65
|
+
in stock.
|
|
66
|
+
</p>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<div className="mt-3 rounded-md border border-border bg-muted/30 p-3">
|
|
73
|
+
<p className="font-medium text-foreground text-xs">Out of stock</p>
|
|
74
|
+
<p className="mt-0.5 text-muted-foreground text-xs">
|
|
75
|
+
Get notified when this item is available again.
|
|
76
|
+
</p>
|
|
77
|
+
<form onSubmit={handleSubmit} className="mt-2 flex items-center gap-2">
|
|
78
|
+
<input
|
|
79
|
+
type="email"
|
|
80
|
+
value={email}
|
|
81
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
82
|
+
placeholder="your@email.com"
|
|
83
|
+
required
|
|
84
|
+
className="h-9 flex-1 rounded-md border border-border bg-background px-3 text-foreground text-sm placeholder:text-muted-foreground/50 focus:border-foreground/30 focus:outline-none"
|
|
85
|
+
/>
|
|
86
|
+
<button
|
|
87
|
+
type="submit"
|
|
88
|
+
disabled={subscribeMutation.isPending || !email.trim()}
|
|
89
|
+
className="h-9 rounded-md bg-foreground px-3.5 font-medium text-background text-sm transition-opacity hover:opacity-85 disabled:opacity-40"
|
|
90
|
+
>
|
|
91
|
+
{subscribeMutation.isPending ? "..." : "Notify me"}
|
|
92
|
+
</button>
|
|
93
|
+
</form>
|
|
94
|
+
{error && <p className="mt-1.5 text-red-500 text-xs">{error}</p>}
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
<a
|
|
2
|
+
href={`/collections/${props.collection.slug}`}
|
|
3
|
+
className="group relative flex flex-col"
|
|
4
|
+
>
|
|
5
|
+
<div className="relative aspect-[16/10] overflow-hidden rounded-lg bg-muted">
|
|
6
|
+
{props.collection.image ? (
|
|
7
|
+
<img
|
|
8
|
+
src={props.collection.image}
|
|
9
|
+
alt={props.collection.name}
|
|
10
|
+
className="h-full w-full object-cover object-center transition-transform duration-700 ease-out group-hover:scale-105"
|
|
11
|
+
/>
|
|
12
|
+
) : (
|
|
13
|
+
<div className="flex h-full w-full items-center justify-center bg-muted text-muted-foreground/25">
|
|
14
|
+
<svg
|
|
15
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
16
|
+
width="28"
|
|
17
|
+
height="28"
|
|
18
|
+
viewBox="0 0 24 24"
|
|
19
|
+
fill="none"
|
|
20
|
+
stroke="currentColor"
|
|
21
|
+
strokeWidth="1.5"
|
|
22
|
+
strokeLinecap="round"
|
|
23
|
+
strokeLinejoin="round"
|
|
24
|
+
aria-hidden="true"
|
|
25
|
+
>
|
|
26
|
+
<rect width="7" height="7" x="3" y="3" rx="1" />
|
|
27
|
+
<rect width="7" height="7" x="14" y="3" rx="1" />
|
|
28
|
+
<rect width="7" height="7" x="3" y="14" rx="1" />
|
|
29
|
+
<rect width="7" height="7" x="14" y="14" rx="1" />
|
|
30
|
+
</svg>
|
|
31
|
+
</div>
|
|
32
|
+
)}
|
|
33
|
+
</div>
|
|
34
|
+
<div className="mt-3">
|
|
35
|
+
<p className="text-foreground text-sm">{props.collection.name}</p>
|
|
36
|
+
{props.collection.description && (
|
|
37
|
+
<p className="mt-0.5 line-clamp-1 text-muted-foreground text-xs">
|
|
38
|
+
{props.collection.description}
|
|
39
|
+
</p>
|
|
40
|
+
)}
|
|
41
|
+
</div>
|
|
42
|
+
</a>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { CollectionCardData } from "./_types";
|
|
4
|
+
import CollectionCardTemplate from "./collection-card.mdx";
|
|
5
|
+
|
|
6
|
+
export interface CollectionCardProps {
|
|
7
|
+
collection: CollectionCardData;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function CollectionCard({ collection }: CollectionCardProps) {
|
|
11
|
+
return <CollectionCardTemplate collection={collection} />;
|
|
12
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<div className="py-4 sm:py-6">
|
|
2
|
+
{props.breadcrumbs}
|
|
3
|
+
{props.heroImage}
|
|
4
|
+
<div className="mb-6">
|
|
5
|
+
<h1 className="font-display font-semibold text-foreground text-xl tracking-tight sm:text-2xl">
|
|
6
|
+
{props.name}
|
|
7
|
+
</h1>
|
|
8
|
+
{props.description}
|
|
9
|
+
{props.productCount}
|
|
10
|
+
</div>
|
|
11
|
+
{props.gridContent}
|
|
12
|
+
</div>
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useModuleClient } from "@86d-app/core/client";
|
|
4
|
+
import type { CollectionCardData, Product } from "./_types";
|
|
5
|
+
import CollectionDetailTemplate from "./collection-detail.mdx";
|
|
6
|
+
import { ProductCard } from "./product-card";
|
|
7
|
+
|
|
8
|
+
export interface CollectionDetailProps {
|
|
9
|
+
slug?: string;
|
|
10
|
+
params?: Record<string, string>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function CollectionDetail(props: CollectionDetailProps) {
|
|
14
|
+
const slug = props.slug ?? props.params?.slug;
|
|
15
|
+
const client = useModuleClient();
|
|
16
|
+
const getCollection = client.module("products").store["/collections/:id"];
|
|
17
|
+
|
|
18
|
+
const { data, isLoading } = getCollection.useQuery(
|
|
19
|
+
{ params: { id: slug ?? "" } },
|
|
20
|
+
{ enabled: !!slug },
|
|
21
|
+
) as {
|
|
22
|
+
data:
|
|
23
|
+
| {
|
|
24
|
+
collection: CollectionCardData & { products: Product[] };
|
|
25
|
+
}
|
|
26
|
+
| undefined;
|
|
27
|
+
isLoading: boolean;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const collection = data?.collection ?? null;
|
|
31
|
+
|
|
32
|
+
if (!slug) {
|
|
33
|
+
return (
|
|
34
|
+
<div className="rounded-md border border-border bg-muted/30 p-4 text-muted-foreground">
|
|
35
|
+
<p className="font-medium">Collection not found</p>
|
|
36
|
+
<p className="mt-1 text-sm">No collection was specified.</p>
|
|
37
|
+
<a href="/collections" className="mt-3 inline-block text-sm underline">
|
|
38
|
+
Back to collections
|
|
39
|
+
</a>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (isLoading) {
|
|
45
|
+
return (
|
|
46
|
+
<div className="py-6">
|
|
47
|
+
<div className="mb-6 space-y-2">
|
|
48
|
+
<div className="h-6 w-1/4 animate-pulse rounded bg-muted" />
|
|
49
|
+
<div className="h-4 w-1/3 animate-pulse rounded bg-muted" />
|
|
50
|
+
</div>
|
|
51
|
+
<div className="grid grid-cols-2 gap-x-4 gap-y-8 sm:grid-cols-3 lg:grid-cols-4">
|
|
52
|
+
{Array.from({ length: 4 }).map((_, i) => (
|
|
53
|
+
<div key={i}>
|
|
54
|
+
<div className="aspect-[3/4] animate-pulse rounded-lg bg-muted" />
|
|
55
|
+
<div className="mt-3 space-y-1.5">
|
|
56
|
+
<div className="h-3.5 w-3/4 animate-pulse rounded bg-muted-foreground/10" />
|
|
57
|
+
<div className="h-3.5 w-1/3 animate-pulse rounded bg-muted-foreground/10" />
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
))}
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!collection) {
|
|
67
|
+
return (
|
|
68
|
+
<div className="flex flex-col items-center justify-center py-24 text-center">
|
|
69
|
+
<p className="font-medium text-foreground text-sm">
|
|
70
|
+
Collection not found
|
|
71
|
+
</p>
|
|
72
|
+
<a
|
|
73
|
+
href="/collections"
|
|
74
|
+
className="mt-2 text-muted-foreground text-sm transition-colors hover:text-foreground"
|
|
75
|
+
>
|
|
76
|
+
Back to collections
|
|
77
|
+
</a>
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// --- Pre-computed JSX blocks for template ---
|
|
83
|
+
|
|
84
|
+
const breadcrumbs = (
|
|
85
|
+
<nav className="mb-6 flex items-center gap-1.5 text-muted-foreground text-xs">
|
|
86
|
+
<a href="/" className="transition-colors hover:text-foreground">
|
|
87
|
+
Home
|
|
88
|
+
</a>
|
|
89
|
+
<span className="text-border">/</span>
|
|
90
|
+
<a
|
|
91
|
+
href="/collections"
|
|
92
|
+
className="transition-colors hover:text-foreground"
|
|
93
|
+
>
|
|
94
|
+
Collections
|
|
95
|
+
</a>
|
|
96
|
+
<span className="text-border">/</span>
|
|
97
|
+
<span className="truncate text-foreground">{collection.name}</span>
|
|
98
|
+
</nav>
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const heroImage = collection.image ? (
|
|
102
|
+
<div className="mb-6 aspect-[3/1] overflow-hidden rounded-lg">
|
|
103
|
+
<img
|
|
104
|
+
src={collection.image}
|
|
105
|
+
alt={collection.name}
|
|
106
|
+
className="h-full w-full object-cover"
|
|
107
|
+
/>
|
|
108
|
+
</div>
|
|
109
|
+
) : null;
|
|
110
|
+
|
|
111
|
+
const description = collection.description ? (
|
|
112
|
+
<p className="mt-1.5 max-w-xl text-muted-foreground text-sm leading-relaxed">
|
|
113
|
+
{collection.description}
|
|
114
|
+
</p>
|
|
115
|
+
) : null;
|
|
116
|
+
|
|
117
|
+
const productCount = (
|
|
118
|
+
<p className="mt-1.5 text-muted-foreground/60 text-xs tabular-nums">
|
|
119
|
+
{collection.products.length}{" "}
|
|
120
|
+
{collection.products.length === 1 ? "product" : "products"}
|
|
121
|
+
</p>
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const gridContent =
|
|
125
|
+
collection.products.length === 0 ? (
|
|
126
|
+
<div className="flex flex-col items-center justify-center py-20 text-center">
|
|
127
|
+
<p className="text-muted-foreground text-sm">
|
|
128
|
+
No products in this collection yet
|
|
129
|
+
</p>
|
|
130
|
+
</div>
|
|
131
|
+
) : (
|
|
132
|
+
<div className="grid grid-cols-2 gap-x-4 gap-y-8 sm:grid-cols-3 lg:grid-cols-4">
|
|
133
|
+
{collection.products.map((product) => (
|
|
134
|
+
<ProductCard key={product.id} product={product} />
|
|
135
|
+
))}
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<CollectionDetailTemplate
|
|
141
|
+
breadcrumbs={breadcrumbs}
|
|
142
|
+
heroImage={heroImage}
|
|
143
|
+
name={collection.name}
|
|
144
|
+
description={description}
|
|
145
|
+
productCount={productCount}
|
|
146
|
+
gridContent={gridContent}
|
|
147
|
+
/>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<section className="py-12 sm:py-14">
|
|
2
|
+
<div className="mb-6 flex items-baseline justify-between">
|
|
3
|
+
<h2 className="font-display font-semibold text-foreground text-lg tracking-tight sm:text-xl">
|
|
4
|
+
{props.title}
|
|
5
|
+
</h2>
|
|
6
|
+
{props.viewAllLink}
|
|
7
|
+
</div>
|
|
8
|
+
{props.gridContent}
|
|
9
|
+
</section>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useModuleClient } from "@86d-app/core/client";
|
|
4
|
+
import type { CollectionCardData } from "./_types";
|
|
5
|
+
import { CollectionCard } from "./collection-card";
|
|
6
|
+
import CollectionGridTemplate from "./collection-grid.mdx";
|
|
7
|
+
|
|
8
|
+
export interface CollectionGridProps {
|
|
9
|
+
title?: string;
|
|
10
|
+
featured?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function CollectionGrid({
|
|
14
|
+
title = "Collections",
|
|
15
|
+
featured,
|
|
16
|
+
}: CollectionGridProps) {
|
|
17
|
+
const client = useModuleClient();
|
|
18
|
+
const listCollections = client.module("products").store["/collections"];
|
|
19
|
+
|
|
20
|
+
// biome-ignore lint/suspicious/noExplicitAny: query input requires string values
|
|
21
|
+
const queryInput: Record<string, any> = {};
|
|
22
|
+
if (featured) queryInput.featured = "true";
|
|
23
|
+
|
|
24
|
+
const { data, isLoading } = listCollections.useQuery(queryInput) as {
|
|
25
|
+
data: { collections: CollectionCardData[] } | undefined;
|
|
26
|
+
isLoading: boolean;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const collections = data?.collections ?? [];
|
|
30
|
+
|
|
31
|
+
if (isLoading) {
|
|
32
|
+
return (
|
|
33
|
+
<section className="py-12 sm:py-14">
|
|
34
|
+
<div className="mb-6">
|
|
35
|
+
<h2 className="font-display font-semibold text-foreground text-lg tracking-tight sm:text-xl">
|
|
36
|
+
{title}
|
|
37
|
+
</h2>
|
|
38
|
+
</div>
|
|
39
|
+
<div className="grid grid-cols-1 gap-x-4 gap-y-8 sm:grid-cols-2 lg:grid-cols-3">
|
|
40
|
+
{Array.from({ length: 3 }).map((_, i) => (
|
|
41
|
+
<div key={i}>
|
|
42
|
+
<div className="aspect-[16/10] animate-pulse rounded-lg bg-muted" />
|
|
43
|
+
<div className="mt-3 space-y-1.5">
|
|
44
|
+
<div className="h-3.5 w-1/2 animate-pulse rounded bg-muted-foreground/10" />
|
|
45
|
+
<div className="h-3 w-3/4 animate-pulse rounded bg-muted-foreground/10" />
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
))}
|
|
49
|
+
</div>
|
|
50
|
+
</section>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (collections.length === 0) return null;
|
|
55
|
+
|
|
56
|
+
const viewAllLink = (
|
|
57
|
+
<a
|
|
58
|
+
href="/collections"
|
|
59
|
+
className="text-muted-foreground text-sm transition-colors hover:text-foreground"
|
|
60
|
+
>
|
|
61
|
+
View all
|
|
62
|
+
</a>
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const gridContent = (
|
|
66
|
+
<div className="grid grid-cols-1 gap-x-4 gap-y-8 sm:grid-cols-2 lg:grid-cols-3">
|
|
67
|
+
{collections.map((collection) => (
|
|
68
|
+
<CollectionCard key={collection.id} collection={collection} />
|
|
69
|
+
))}
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<CollectionGridTemplate
|
|
75
|
+
title={title}
|
|
76
|
+
viewAllLink={viewAllLink}
|
|
77
|
+
gridContent={gridContent}
|
|
78
|
+
/>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<section className="py-12 sm:py-14">
|
|
2
|
+
<div className="mb-6 flex items-baseline justify-between">
|
|
3
|
+
<h2 className="font-display font-semibold text-foreground text-lg tracking-tight sm:text-xl">
|
|
4
|
+
{props.title}
|
|
5
|
+
</h2>
|
|
6
|
+
{props.viewAllLink}
|
|
7
|
+
</div>
|
|
8
|
+
{props.gridContent}
|
|
9
|
+
</section>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useProductsApi } from "./_hooks";
|
|
4
|
+
import FeaturedProductsTemplate from "./featured-products.mdx";
|
|
5
|
+
import { ProductCard } from "./product-card";
|
|
6
|
+
|
|
7
|
+
export interface FeaturedProductsProps {
|
|
8
|
+
limit?: number;
|
|
9
|
+
title?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function FeaturedProducts({
|
|
13
|
+
limit = 4,
|
|
14
|
+
title = "Featured Products",
|
|
15
|
+
}: FeaturedProductsProps) {
|
|
16
|
+
const api = useProductsApi();
|
|
17
|
+
const { data, isLoading } = api.getFeaturedProducts.useQuery({
|
|
18
|
+
limit: String(limit),
|
|
19
|
+
}) as {
|
|
20
|
+
data: { products: import("./_types").Product[] } | undefined;
|
|
21
|
+
isLoading: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const products = data?.products ?? [];
|
|
25
|
+
|
|
26
|
+
if (isLoading) {
|
|
27
|
+
return (
|
|
28
|
+
<section className="py-12 sm:py-14">
|
|
29
|
+
<div className="mb-6 flex items-baseline justify-between">
|
|
30
|
+
<h2 className="font-display font-semibold text-foreground text-lg tracking-tight sm:text-xl">
|
|
31
|
+
{title}
|
|
32
|
+
</h2>
|
|
33
|
+
</div>
|
|
34
|
+
<div className="grid grid-cols-2 gap-x-4 gap-y-8 sm:grid-cols-3 lg:grid-cols-4">
|
|
35
|
+
{Array.from({ length: limit }).map((_, i) => (
|
|
36
|
+
<div key={i}>
|
|
37
|
+
<div className="aspect-[3/4] animate-pulse rounded-lg bg-muted" />
|
|
38
|
+
<div className="mt-3 space-y-1.5">
|
|
39
|
+
<div className="h-3.5 w-3/4 animate-pulse rounded bg-muted-foreground/10" />
|
|
40
|
+
<div className="h-3.5 w-1/3 animate-pulse rounded bg-muted-foreground/10" />
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
))}
|
|
44
|
+
</div>
|
|
45
|
+
</section>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (products.length === 0) return null;
|
|
50
|
+
|
|
51
|
+
const viewAllLink = (
|
|
52
|
+
<a
|
|
53
|
+
href="/products"
|
|
54
|
+
className="text-muted-foreground text-sm transition-colors hover:text-foreground"
|
|
55
|
+
>
|
|
56
|
+
View all
|
|
57
|
+
</a>
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const gridContent = (
|
|
61
|
+
<div className="grid grid-cols-2 gap-x-4 gap-y-8 sm:grid-cols-3 lg:grid-cols-4">
|
|
62
|
+
{products.map((product) => (
|
|
63
|
+
<ProductCard key={product.id} product={product} />
|
|
64
|
+
))}
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<FeaturedProductsTemplate
|
|
70
|
+
title={title}
|
|
71
|
+
viewAllLink={viewAllLink}
|
|
72
|
+
gridContent={gridContent}
|
|
73
|
+
/>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<span className="inline-flex items-center gap-1 rounded-full border border-border bg-muted/50 py-0.5 pr-1.5 pl-2.5 text-foreground text-xs">
|
|
2
|
+
{props.label}
|
|
3
|
+
<button
|
|
4
|
+
type="button"
|
|
5
|
+
onClick={() => props.onRemove()}
|
|
6
|
+
className="flex h-4 w-4 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted-foreground/20 hover:text-foreground"
|
|
7
|
+
aria-label={`Remove ${props.label} filter`}
|
|
8
|
+
>
|
|
9
|
+
<svg
|
|
10
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
11
|
+
width="10"
|
|
12
|
+
height="10"
|
|
13
|
+
viewBox="0 0 24 24"
|
|
14
|
+
fill="none"
|
|
15
|
+
stroke="currentColor"
|
|
16
|
+
strokeWidth="3"
|
|
17
|
+
strokeLinecap="round"
|
|
18
|
+
strokeLinejoin="round"
|
|
19
|
+
aria-hidden="true"
|
|
20
|
+
>
|
|
21
|
+
<path d="M18 6 6 18" />
|
|
22
|
+
<path d="m6 6 12 12" />
|
|
23
|
+
</svg>
|
|
24
|
+
</button>
|
|
25
|
+
</span>
|