@86d-app/products 0.0.4 → 0.0.13
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/.turbo/turbo-build.log +1 -0
- package/AGENTS.md +41 -41
- package/README.md +266 -5
- package/dist/__tests__/controllers.test.d.ts +2 -0
- package/dist/__tests__/controllers.test.d.ts.map +1 -0
- package/dist/__tests__/endpoint-security.test.d.ts +2 -0
- package/dist/__tests__/endpoint-security.test.d.ts.map +1 -0
- package/dist/__tests__/service-impl.test.d.ts +2 -0
- package/dist/__tests__/service-impl.test.d.ts.map +1 -0
- package/dist/__tests__/state.test.d.ts +2 -0
- package/dist/__tests__/state.test.d.ts.map +1 -0
- package/dist/admin/components/categories-admin.d.ts +2 -0
- package/dist/admin/components/categories-admin.d.ts.map +1 -0
- package/dist/admin/components/category-form.d.ts +7 -0
- package/dist/admin/components/category-form.d.ts.map +1 -0
- package/dist/admin/components/category-list.d.ts +7 -0
- package/dist/admin/components/category-list.d.ts.map +1 -0
- package/dist/admin/components/collections-admin.d.ts +2 -0
- package/dist/admin/components/collections-admin.d.ts.map +1 -0
- package/dist/admin/components/index.d.ts +9 -0
- package/dist/admin/components/index.d.ts.map +1 -0
- package/dist/admin/components/product-detail.d.ts +7 -0
- package/dist/admin/components/product-detail.d.ts.map +1 -0
- package/dist/admin/components/product-edit.d.ts +6 -0
- package/dist/admin/components/product-edit.d.ts.map +1 -0
- package/dist/admin/components/product-form.d.ts +7 -0
- package/dist/admin/components/product-form.d.ts.map +1 -0
- package/dist/admin/components/product-list.d.ts +2 -0
- package/dist/admin/components/product-list.d.ts.map +1 -0
- package/dist/admin/components/product-new.d.ts +2 -0
- package/dist/admin/components/product-new.d.ts.map +1 -0
- package/dist/admin/endpoints/add-collection-product.d.ts +15 -0
- package/dist/admin/endpoints/add-collection-product.d.ts.map +1 -0
- package/dist/admin/endpoints/bulk-action.d.ts +17 -0
- package/dist/admin/endpoints/bulk-action.d.ts.map +1 -0
- package/dist/admin/endpoints/create-category.d.ts +23 -0
- package/dist/admin/endpoints/create-category.d.ts.map +1 -0
- package/dist/admin/endpoints/create-collection.d.ts +22 -0
- package/dist/admin/endpoints/create-collection.d.ts.map +1 -0
- package/dist/admin/endpoints/create-product.d.ts +44 -0
- package/dist/admin/endpoints/create-product.d.ts.map +1 -0
- package/dist/admin/endpoints/create-variant.d.ts +35 -0
- package/dist/admin/endpoints/create-variant.d.ts.map +1 -0
- package/dist/admin/endpoints/delete-category.d.ts +18 -0
- package/dist/admin/endpoints/delete-category.d.ts.map +1 -0
- package/dist/admin/endpoints/delete-collection.d.ts +8 -0
- package/dist/admin/endpoints/delete-collection.d.ts.map +1 -0
- package/dist/admin/endpoints/delete-product.d.ts +18 -0
- package/dist/admin/endpoints/delete-product.d.ts.map +1 -0
- package/dist/admin/endpoints/delete-variant.d.ts +18 -0
- package/dist/admin/endpoints/delete-variant.d.ts.map +1 -0
- package/dist/admin/endpoints/get-product.d.ts +16 -0
- package/dist/admin/endpoints/get-product.d.ts.map +1 -0
- package/dist/admin/endpoints/import-products.d.ts +36 -0
- package/dist/admin/endpoints/import-products.d.ts.map +1 -0
- package/dist/admin/endpoints/index.d.ts +418 -0
- package/dist/admin/endpoints/index.d.ts.map +1 -0
- package/dist/admin/endpoints/list-categories.d.ts +11 -0
- package/dist/admin/endpoints/list-categories.d.ts.map +1 -0
- package/dist/admin/endpoints/list-collections.d.ts +11 -0
- package/dist/admin/endpoints/list-collections.d.ts.map +1 -0
- package/dist/admin/endpoints/list-products.d.ts +27 -0
- package/dist/admin/endpoints/list-products.d.ts.map +1 -0
- package/dist/admin/endpoints/remove-collection-product.d.ts +9 -0
- package/dist/admin/endpoints/remove-collection-product.d.ts.map +1 -0
- package/dist/admin/endpoints/update-category.d.ts +26 -0
- package/dist/admin/endpoints/update-category.d.ts.map +1 -0
- package/dist/admin/endpoints/update-collection.d.ts +19 -0
- package/dist/admin/endpoints/update-collection.d.ts.map +1 -0
- package/dist/admin/endpoints/update-product.d.ts +47 -0
- package/dist/admin/endpoints/update-product.d.ts.map +1 -0
- package/dist/admin/endpoints/update-variant.d.ts +35 -0
- package/dist/admin/endpoints/update-variant.d.ts.map +1 -0
- package/dist/controllers.d.ts +130 -0
- package/dist/controllers.d.ts.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/markdown.d.ts +6 -0
- package/dist/markdown.d.ts.map +1 -0
- package/dist/schema.d.ts +351 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/service-impl.d.ts +4 -0
- package/dist/service-impl.d.ts.map +1 -0
- package/dist/service.d.ts +280 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/state.d.ts +38 -0
- package/dist/state.d.ts.map +1 -0
- package/dist/store/components/_hooks.d.ts +88 -0
- package/dist/store/components/_hooks.d.ts.map +1 -0
- package/dist/store/components/_types.d.ts +70 -0
- package/dist/store/components/_types.d.ts.map +1 -0
- package/dist/store/components/_utils.d.ts +5 -0
- package/dist/store/components/_utils.d.ts.map +1 -0
- package/dist/store/components/back-in-stock-notify.d.ts +8 -0
- package/dist/store/components/back-in-stock-notify.d.ts.map +1 -0
- package/dist/store/components/collection-card.d.ts +6 -0
- package/dist/store/components/collection-card.d.ts.map +1 -0
- package/dist/store/components/collection-detail.d.ts +6 -0
- package/dist/store/components/collection-detail.d.ts.map +1 -0
- package/dist/store/components/collection-grid.d.ts +6 -0
- package/dist/store/components/collection-grid.d.ts.map +1 -0
- package/dist/store/components/featured-products.d.ts +6 -0
- package/dist/store/components/featured-products.d.ts.map +1 -0
- package/dist/store/components/filter-chip.d.ts +6 -0
- package/dist/store/components/filter-chip.d.ts.map +1 -0
- package/dist/store/components/index.d.ts +37 -0
- package/dist/store/components/index.d.ts.map +1 -0
- package/dist/store/components/product-card.d.ts +7 -0
- package/dist/store/components/product-card.d.ts.map +1 -0
- package/dist/store/components/product-detail.d.ts +6 -0
- package/dist/store/components/product-detail.d.ts.map +1 -0
- package/dist/store/components/product-listing.d.ts +7 -0
- package/dist/store/components/product-listing.d.ts.map +1 -0
- package/dist/store/components/product-qa-section.d.ts +5 -0
- package/dist/store/components/product-qa-section.d.ts.map +1 -0
- package/dist/store/components/product-reviews-section.d.ts +5 -0
- package/dist/store/components/product-reviews-section.d.ts.map +1 -0
- package/dist/store/components/recently-viewed.d.ts +12 -0
- package/dist/store/components/recently-viewed.d.ts.map +1 -0
- package/dist/store/components/recommended-products.d.ts +7 -0
- package/dist/store/components/recommended-products.d.ts.map +1 -0
- package/dist/store/components/related-products.d.ts +7 -0
- package/dist/store/components/related-products.d.ts.map +1 -0
- package/dist/store/components/star-display.d.ts +6 -0
- package/dist/store/components/star-display.d.ts.map +1 -0
- package/dist/store/components/star-picker.d.ts +6 -0
- package/dist/store/components/star-picker.d.ts.map +1 -0
- package/dist/store/components/stock-badge.d.ts +5 -0
- package/dist/store/components/stock-badge.d.ts.map +1 -0
- package/dist/store/endpoints/get-category.d.ts +22 -0
- package/dist/store/endpoints/get-category.d.ts.map +1 -0
- package/dist/store/endpoints/get-collection.d.ts +17 -0
- package/dist/store/endpoints/get-collection.d.ts.map +1 -0
- package/dist/store/endpoints/get-featured.d.ts +10 -0
- package/dist/store/endpoints/get-featured.d.ts.map +1 -0
- package/dist/store/endpoints/get-product.d.ts +17 -0
- package/dist/store/endpoints/get-product.d.ts.map +1 -0
- package/dist/store/endpoints/get-related.d.ts +11 -0
- package/dist/store/endpoints/get-related.d.ts.map +1 -0
- package/dist/store/endpoints/index.d.ts +129 -0
- package/dist/store/endpoints/index.d.ts.map +1 -0
- package/dist/store/endpoints/list-categories.d.ts +6 -0
- package/dist/store/endpoints/list-categories.d.ts.map +1 -0
- package/dist/store/endpoints/list-collections.d.ts +10 -0
- package/dist/store/endpoints/list-collections.d.ts.map +1 -0
- package/dist/store/endpoints/list-products.d.ts +26 -0
- package/dist/store/endpoints/list-products.d.ts.map +1 -0
- package/dist/store/endpoints/search-products.d.ts +11 -0
- package/dist/store/endpoints/search-products.d.ts.map +1 -0
- package/dist/store/endpoints/store-search.d.ts +18 -0
- package/dist/store/endpoints/store-search.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/__tests__/endpoint-security.test.ts +457 -0
- package/src/__tests__/service-impl.test.ts +1745 -0
- package/src/admin/endpoints/create-category.ts +5 -2
- package/src/admin/endpoints/create-collection.ts +1 -1
- package/src/admin/endpoints/create-product.ts +5 -2
- package/src/admin/endpoints/delete-category.ts +1 -1
- package/src/admin/endpoints/delete-collection.ts +1 -1
- package/src/admin/endpoints/delete-product.ts +1 -1
- package/src/admin/endpoints/delete-variant.ts +1 -1
- package/src/admin/endpoints/list-categories.ts +1 -1
- package/src/admin/endpoints/list-collections.ts +1 -1
- package/src/admin/endpoints/list-products.ts +1 -1
- package/src/admin/endpoints/remove-collection-product.ts +1 -1
- package/src/admin/endpoints/update-category.ts +5 -2
- package/src/admin/endpoints/update-collection.ts +1 -1
- package/src/admin/endpoints/update-product.ts +5 -2
- package/src/admin/endpoints/update-variant.ts +1 -1
- package/src/service-impl.ts +1139 -0
- package/src/service.ts +312 -0
- package/src/store/components/_hooks.ts +81 -0
- package/src/store/components/_utils.ts +8 -0
- package/src/store/components/collection-detail.tsx +21 -1
- package/src/store/components/collection-grid.tsx +5 -1
- package/src/store/components/featured-products.tsx +5 -1
- package/src/store/components/index.tsx +2 -0
- package/src/store/components/product-card.mdx +1 -1
- package/src/store/components/product-card.tsx +25 -5
- package/src/store/components/product-detail.mdx +2 -0
- package/src/store/components/product-detail.tsx +55 -8
- package/src/store/components/product-listing.tsx +25 -4
- package/src/store/components/product-qa-section.mdx +21 -0
- package/src/store/components/product-qa-section.tsx +503 -0
- package/src/store/components/recommended-products.mdx +6 -0
- package/src/store/components/recommended-products.tsx +119 -0
- package/src/store/endpoints/get-category.ts +2 -2
- package/src/store/endpoints/get-collection.ts +1 -1
- package/src/store/endpoints/get-featured.ts +1 -1
- package/src/store/endpoints/get-product.ts +1 -1
- package/src/store/endpoints/get-related.ts +2 -2
- package/src/store/endpoints/list-collections.ts +3 -3
- package/src/store/endpoints/list-products.ts +9 -9
- package/src/store/endpoints/search-products.ts +4 -6
- package/src/store/endpoints/store-search.ts +1 -1
- package/COMPONENTS.md +0 -231
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useState } from "react";
|
|
4
|
+
import { useProductQaApi } from "./_hooks";
|
|
5
|
+
import { formatDate } from "./_utils";
|
|
6
|
+
import ProductQASectionTemplate from "./product-qa-section.mdx";
|
|
7
|
+
|
|
8
|
+
interface Answer {
|
|
9
|
+
id: string;
|
|
10
|
+
questionId: string;
|
|
11
|
+
productId: string;
|
|
12
|
+
customerId?: string | undefined;
|
|
13
|
+
authorName: string;
|
|
14
|
+
authorEmail: string;
|
|
15
|
+
body: string;
|
|
16
|
+
isOfficial: boolean;
|
|
17
|
+
upvoteCount: number;
|
|
18
|
+
status: string;
|
|
19
|
+
createdAt: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface Question {
|
|
23
|
+
id: string;
|
|
24
|
+
productId: string;
|
|
25
|
+
customerId?: string | undefined;
|
|
26
|
+
authorName: string;
|
|
27
|
+
authorEmail: string;
|
|
28
|
+
body: string;
|
|
29
|
+
status: string;
|
|
30
|
+
upvoteCount: number;
|
|
31
|
+
answerCount: number;
|
|
32
|
+
createdAt: string;
|
|
33
|
+
answers?: Answer[] | undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface QuestionsResponse {
|
|
37
|
+
questions: Question[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface SummaryResponse {
|
|
41
|
+
summary: {
|
|
42
|
+
questionCount: number;
|
|
43
|
+
answeredCount: number;
|
|
44
|
+
unansweredCount: number;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface AnswersResponse {
|
|
49
|
+
answers: Answer[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const PAGE_SIZE = 10;
|
|
53
|
+
|
|
54
|
+
export interface ProductQASectionProps {
|
|
55
|
+
productId: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function ProductQASection({ productId }: ProductQASectionProps) {
|
|
59
|
+
const api = useProductQaApi();
|
|
60
|
+
|
|
61
|
+
const { data: initialData, isLoading: loading } =
|
|
62
|
+
api.listProductQuestions.useQuery({
|
|
63
|
+
params: { productId },
|
|
64
|
+
take: String(PAGE_SIZE),
|
|
65
|
+
skip: "0",
|
|
66
|
+
}) as { data: QuestionsResponse | undefined; isLoading: boolean };
|
|
67
|
+
|
|
68
|
+
const { data: summaryData } = api.productQaSummary.useQuery({
|
|
69
|
+
params: { productId },
|
|
70
|
+
}) as { data: SummaryResponse | undefined };
|
|
71
|
+
|
|
72
|
+
const [extraQuestions, setExtraQuestions] = useState<Question[]>([]);
|
|
73
|
+
const [loadingMore, setLoadingMore] = useState(false);
|
|
74
|
+
const [skip, setSkip] = useState(0);
|
|
75
|
+
const [loadedAll, setLoadedAll] = useState(false);
|
|
76
|
+
const [showForm, setShowForm] = useState(false);
|
|
77
|
+
|
|
78
|
+
const [qName, setQName] = useState("");
|
|
79
|
+
const [qEmail, setQEmail] = useState("");
|
|
80
|
+
const [qBody, setQBody] = useState("");
|
|
81
|
+
|
|
82
|
+
const allQuestions = [...(initialData?.questions ?? []), ...extraQuestions];
|
|
83
|
+
const summary = summaryData?.summary ?? null;
|
|
84
|
+
const hasMore =
|
|
85
|
+
!loadedAll &&
|
|
86
|
+
initialData !== undefined &&
|
|
87
|
+
(initialData.questions.length === PAGE_SIZE || extraQuestions.length > 0);
|
|
88
|
+
|
|
89
|
+
const submitMutation = api.submitQuestion.useMutation({
|
|
90
|
+
onSuccess: () => {
|
|
91
|
+
setShowForm(false);
|
|
92
|
+
setQName("");
|
|
93
|
+
setQEmail("");
|
|
94
|
+
setQBody("");
|
|
95
|
+
void api.listProductQuestions.invalidate();
|
|
96
|
+
void api.productQaSummary.invalidate();
|
|
97
|
+
setExtraQuestions([]);
|
|
98
|
+
setSkip(0);
|
|
99
|
+
setLoadedAll(false);
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const handleLoadMore = useCallback(async () => {
|
|
104
|
+
const nextSkip = skip === 0 ? PAGE_SIZE : skip + PAGE_SIZE;
|
|
105
|
+
setLoadingMore(true);
|
|
106
|
+
try {
|
|
107
|
+
const fresh = (await api.listProductQuestions.fetch({
|
|
108
|
+
params: { productId },
|
|
109
|
+
take: String(PAGE_SIZE),
|
|
110
|
+
skip: String(nextSkip),
|
|
111
|
+
})) as QuestionsResponse;
|
|
112
|
+
setExtraQuestions((prev) => [...prev, ...fresh.questions]);
|
|
113
|
+
setSkip(nextSkip);
|
|
114
|
+
if (fresh.questions.length < PAGE_SIZE) setLoadedAll(true);
|
|
115
|
+
} catch {
|
|
116
|
+
// silently ignore
|
|
117
|
+
} finally {
|
|
118
|
+
setLoadingMore(false);
|
|
119
|
+
}
|
|
120
|
+
}, [api.listProductQuestions, productId, skip]);
|
|
121
|
+
|
|
122
|
+
const handleSubmitQuestion = (e: React.FormEvent) => {
|
|
123
|
+
e.preventDefault();
|
|
124
|
+
submitMutation.mutate({
|
|
125
|
+
productId,
|
|
126
|
+
authorName: qName,
|
|
127
|
+
authorEmail: qEmail,
|
|
128
|
+
body: qBody,
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
if (loading) {
|
|
133
|
+
return (
|
|
134
|
+
<section id="questions" className="border-border/50 border-t py-10">
|
|
135
|
+
<div className="mb-6 h-7 w-48 animate-pulse rounded-lg bg-muted" />
|
|
136
|
+
<div className="space-y-4">
|
|
137
|
+
{[1, 2, 3].map((n) => (
|
|
138
|
+
<div key={n} className="space-y-2 border-border border-b pb-4">
|
|
139
|
+
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
|
|
140
|
+
<div className="h-4 w-full animate-pulse rounded bg-muted" />
|
|
141
|
+
<div className="h-4 w-2/3 animate-pulse rounded bg-muted" />
|
|
142
|
+
</div>
|
|
143
|
+
))}
|
|
144
|
+
</div>
|
|
145
|
+
</section>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const noQuestions = allQuestions.length === 0;
|
|
150
|
+
const submitError = submitMutation.isError
|
|
151
|
+
? "Failed to submit question."
|
|
152
|
+
: "";
|
|
153
|
+
|
|
154
|
+
const summaryDisplay =
|
|
155
|
+
summary && summary.questionCount > 0 ? (
|
|
156
|
+
<div className="mt-1 flex items-center gap-3">
|
|
157
|
+
<span className="text-muted-foreground text-sm">
|
|
158
|
+
{summary.questionCount} question
|
|
159
|
+
{summary.questionCount !== 1 ? "s" : ""}
|
|
160
|
+
</span>
|
|
161
|
+
<span className="text-muted-foreground/40">|</span>
|
|
162
|
+
<span className="text-muted-foreground text-sm">
|
|
163
|
+
{summary.answeredCount} answered
|
|
164
|
+
</span>
|
|
165
|
+
</div>
|
|
166
|
+
) : null;
|
|
167
|
+
|
|
168
|
+
const toggleFormButton = (
|
|
169
|
+
<button
|
|
170
|
+
type="button"
|
|
171
|
+
onClick={() => setShowForm((v) => !v)}
|
|
172
|
+
className="rounded-md border border-border bg-background px-4 py-2 font-medium text-foreground text-sm transition-colors hover:bg-muted"
|
|
173
|
+
>
|
|
174
|
+
{showForm ? "Cancel" : "Ask a Question"}
|
|
175
|
+
</button>
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
let formContent: React.ReactNode = null;
|
|
179
|
+
if (showForm) {
|
|
180
|
+
formContent = (
|
|
181
|
+
<div className="mb-8">
|
|
182
|
+
{submitMutation.isSuccess ? (
|
|
183
|
+
<div className="rounded-lg border border-emerald-200 bg-emerald-50 p-5 text-center dark:border-emerald-800 dark:bg-emerald-950/30">
|
|
184
|
+
<p className="font-semibold text-emerald-800 dark:text-emerald-200">
|
|
185
|
+
Thank you for your question!
|
|
186
|
+
</p>
|
|
187
|
+
<p className="mt-1 text-emerald-700 text-sm dark:text-emerald-300">
|
|
188
|
+
Your question will appear once approved.
|
|
189
|
+
</p>
|
|
190
|
+
</div>
|
|
191
|
+
) : (
|
|
192
|
+
<form
|
|
193
|
+
onSubmit={handleSubmitQuestion}
|
|
194
|
+
className="space-y-4 rounded-lg border border-border bg-muted/30 p-5"
|
|
195
|
+
>
|
|
196
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
197
|
+
<div>
|
|
198
|
+
<label
|
|
199
|
+
htmlFor="pdp-qa-name"
|
|
200
|
+
className="mb-1 block font-medium text-foreground text-sm"
|
|
201
|
+
>
|
|
202
|
+
Name <span className="text-destructive">*</span>
|
|
203
|
+
</label>
|
|
204
|
+
<input
|
|
205
|
+
id="pdp-qa-name"
|
|
206
|
+
type="text"
|
|
207
|
+
required
|
|
208
|
+
maxLength={200}
|
|
209
|
+
value={qName}
|
|
210
|
+
onChange={(e) => setQName(e.target.value)}
|
|
211
|
+
placeholder="Your name"
|
|
212
|
+
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:border-foreground/20 focus:ring-1 focus:ring-foreground/10"
|
|
213
|
+
/>
|
|
214
|
+
</div>
|
|
215
|
+
<div>
|
|
216
|
+
<label
|
|
217
|
+
htmlFor="pdp-qa-email"
|
|
218
|
+
className="mb-1 block font-medium text-foreground text-sm"
|
|
219
|
+
>
|
|
220
|
+
Email <span className="text-destructive">*</span>
|
|
221
|
+
</label>
|
|
222
|
+
<input
|
|
223
|
+
id="pdp-qa-email"
|
|
224
|
+
type="email"
|
|
225
|
+
required
|
|
226
|
+
value={qEmail}
|
|
227
|
+
onChange={(e) => setQEmail(e.target.value)}
|
|
228
|
+
placeholder="you@example.com"
|
|
229
|
+
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:border-foreground/20 focus:ring-1 focus:ring-foreground/10"
|
|
230
|
+
/>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
<div>
|
|
235
|
+
<label
|
|
236
|
+
htmlFor="pdp-qa-body"
|
|
237
|
+
className="mb-1 block font-medium text-foreground text-sm"
|
|
238
|
+
>
|
|
239
|
+
Your question <span className="text-destructive">*</span>
|
|
240
|
+
</label>
|
|
241
|
+
<textarea
|
|
242
|
+
id="pdp-qa-body"
|
|
243
|
+
required
|
|
244
|
+
maxLength={5000}
|
|
245
|
+
rows={4}
|
|
246
|
+
value={qBody}
|
|
247
|
+
onChange={(e) => setQBody(e.target.value)}
|
|
248
|
+
placeholder="What would you like to know about this product?"
|
|
249
|
+
className="w-full resize-y rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:border-foreground/20 focus:ring-1 focus:ring-foreground/10"
|
|
250
|
+
/>
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
{submitError && (
|
|
254
|
+
<p className="text-destructive text-sm" role="alert">
|
|
255
|
+
{submitError}
|
|
256
|
+
</p>
|
|
257
|
+
)}
|
|
258
|
+
|
|
259
|
+
<button
|
|
260
|
+
type="submit"
|
|
261
|
+
disabled={submitMutation.isPending}
|
|
262
|
+
className="rounded-md bg-foreground px-5 py-2 font-medium text-background text-sm transition-opacity hover:opacity-85 disabled:opacity-50"
|
|
263
|
+
>
|
|
264
|
+
{submitMutation.isPending ? "Submitting..." : "Submit Question"}
|
|
265
|
+
</button>
|
|
266
|
+
</form>
|
|
267
|
+
)}
|
|
268
|
+
</div>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const emptyState =
|
|
273
|
+
noQuestions && !showForm ? (
|
|
274
|
+
<div className="rounded-lg border border-border bg-muted/30 py-12 text-center">
|
|
275
|
+
<p className="font-medium text-foreground text-sm">No questions yet</p>
|
|
276
|
+
<p className="mt-1 text-muted-foreground text-sm">
|
|
277
|
+
Be the first to ask a question about this product.
|
|
278
|
+
</p>
|
|
279
|
+
</div>
|
|
280
|
+
) : null;
|
|
281
|
+
|
|
282
|
+
let questionsContent: React.ReactNode = null;
|
|
283
|
+
if (!noQuestions) {
|
|
284
|
+
questionsContent = (
|
|
285
|
+
<div>
|
|
286
|
+
{allQuestions.map((question) => (
|
|
287
|
+
<QuestionRow key={question.id} question={question} />
|
|
288
|
+
))}
|
|
289
|
+
{hasMore && (
|
|
290
|
+
<button
|
|
291
|
+
type="button"
|
|
292
|
+
onClick={() => void handleLoadMore()}
|
|
293
|
+
disabled={loadingMore}
|
|
294
|
+
className="mt-4 text-foreground text-sm underline-offset-4 hover:underline disabled:opacity-60"
|
|
295
|
+
>
|
|
296
|
+
{loadingMore ? "Loading..." : "Load more questions"}
|
|
297
|
+
</button>
|
|
298
|
+
)}
|
|
299
|
+
</div>
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return (
|
|
304
|
+
<ProductQASectionTemplate
|
|
305
|
+
summaryDisplay={summaryDisplay}
|
|
306
|
+
toggleFormButton={toggleFormButton}
|
|
307
|
+
formContent={formContent}
|
|
308
|
+
emptyState={emptyState}
|
|
309
|
+
questionsContent={questionsContent}
|
|
310
|
+
/>
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/* ── Question row with expandable answers ────────────────── */
|
|
315
|
+
|
|
316
|
+
function QuestionRow({ question }: { question: Question }) {
|
|
317
|
+
const api = useProductQaApi();
|
|
318
|
+
const [expanded, setExpanded] = useState(false);
|
|
319
|
+
const [voted, setVoted] = useState(false);
|
|
320
|
+
const [localUpvotes, setLocalUpvotes] = useState(question.upvoteCount);
|
|
321
|
+
const [upvoting, setUpvoting] = useState(false);
|
|
322
|
+
|
|
323
|
+
const handleUpvote = useCallback(async () => {
|
|
324
|
+
if (voted || upvoting) return;
|
|
325
|
+
setUpvoting(true);
|
|
326
|
+
try {
|
|
327
|
+
await api.upvoteQuestion.mutate({ params: { id: question.id } });
|
|
328
|
+
setVoted(true);
|
|
329
|
+
setLocalUpvotes((c) => c + 1);
|
|
330
|
+
} catch {
|
|
331
|
+
// silently ignore
|
|
332
|
+
} finally {
|
|
333
|
+
setUpvoting(false);
|
|
334
|
+
}
|
|
335
|
+
}, [api.upvoteQuestion, question.id, voted, upvoting]);
|
|
336
|
+
|
|
337
|
+
return (
|
|
338
|
+
<article className="border-border border-b py-5 last:border-0">
|
|
339
|
+
<div className="mb-2 flex items-start justify-between gap-3">
|
|
340
|
+
<p className="flex-1 text-foreground text-sm leading-relaxed">
|
|
341
|
+
{question.body}
|
|
342
|
+
</p>
|
|
343
|
+
<span className="shrink-0 text-muted-foreground text-xs">
|
|
344
|
+
{formatDate(question.createdAt)}
|
|
345
|
+
</span>
|
|
346
|
+
</div>
|
|
347
|
+
<div className="mt-2 flex items-center gap-3">
|
|
348
|
+
<p className="text-muted-foreground/60 text-xs">
|
|
349
|
+
Asked by {question.authorName}
|
|
350
|
+
</p>
|
|
351
|
+
<button
|
|
352
|
+
type="button"
|
|
353
|
+
disabled={voted || upvoting}
|
|
354
|
+
onClick={() => void handleUpvote()}
|
|
355
|
+
className={`inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs transition-colors ${
|
|
356
|
+
voted
|
|
357
|
+
? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-300"
|
|
358
|
+
: "border-border text-muted-foreground hover:border-foreground hover:text-foreground"
|
|
359
|
+
} disabled:opacity-60`}
|
|
360
|
+
>
|
|
361
|
+
<span>{voted ? "+" : "^"}</span>
|
|
362
|
+
<span>{localUpvotes}</span>
|
|
363
|
+
</button>
|
|
364
|
+
<button
|
|
365
|
+
type="button"
|
|
366
|
+
onClick={() => setExpanded((v) => !v)}
|
|
367
|
+
className="inline-flex items-center gap-1 rounded-md border border-border px-2.5 py-1 text-muted-foreground text-xs transition-colors hover:border-foreground hover:text-foreground"
|
|
368
|
+
>
|
|
369
|
+
<span>
|
|
370
|
+
{question.answerCount} answer
|
|
371
|
+
{question.answerCount !== 1 ? "s" : ""}
|
|
372
|
+
</span>
|
|
373
|
+
<span className="text-[10px]">{expanded ? "\u25B2" : "\u25BC"}</span>
|
|
374
|
+
</button>
|
|
375
|
+
</div>
|
|
376
|
+
{expanded && (
|
|
377
|
+
<div className="mt-4 border-foreground/10 border-l-2 pl-4">
|
|
378
|
+
<AnswersList
|
|
379
|
+
questionId={question.id}
|
|
380
|
+
inlineAnswers={question.answers}
|
|
381
|
+
/>
|
|
382
|
+
</div>
|
|
383
|
+
)}
|
|
384
|
+
</article>
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/* ── Answers list with upvoting ──────────────────────────── */
|
|
389
|
+
|
|
390
|
+
function AnswersList({
|
|
391
|
+
questionId,
|
|
392
|
+
inlineAnswers,
|
|
393
|
+
}: {
|
|
394
|
+
questionId: string;
|
|
395
|
+
inlineAnswers?: Answer[] | undefined;
|
|
396
|
+
}) {
|
|
397
|
+
const api = useProductQaApi();
|
|
398
|
+
|
|
399
|
+
const { data, isLoading } = api.listAnswers.useQuery(
|
|
400
|
+
{ params: { questionId } },
|
|
401
|
+
{ enabled: !inlineAnswers },
|
|
402
|
+
) as { data: AnswersResponse | undefined; isLoading: boolean };
|
|
403
|
+
|
|
404
|
+
const answers = inlineAnswers ?? data?.answers ?? [];
|
|
405
|
+
|
|
406
|
+
const [votedIds, setVotedIds] = useState<Set<string>>(new Set());
|
|
407
|
+
const [localUpvotes, setLocalUpvotes] = useState<Record<string, number>>({});
|
|
408
|
+
const [upvotingIds, setUpvotingIds] = useState<Set<string>>(new Set());
|
|
409
|
+
|
|
410
|
+
const handleUpvote = useCallback(
|
|
411
|
+
async (id: string) => {
|
|
412
|
+
if (votedIds.has(id) || upvotingIds.has(id)) return;
|
|
413
|
+
setUpvotingIds((prev) => new Set([...prev, id]));
|
|
414
|
+
try {
|
|
415
|
+
await api.upvoteAnswer.mutate({ params: { id } });
|
|
416
|
+
setVotedIds((prev) => new Set([...prev, id]));
|
|
417
|
+
setLocalUpvotes((prev) => ({
|
|
418
|
+
...prev,
|
|
419
|
+
[id]: (prev[id] ?? 0) + 1,
|
|
420
|
+
}));
|
|
421
|
+
} catch {
|
|
422
|
+
// silently ignore
|
|
423
|
+
} finally {
|
|
424
|
+
setUpvotingIds((prev) => {
|
|
425
|
+
const next = new Set(prev);
|
|
426
|
+
next.delete(id);
|
|
427
|
+
return next;
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
},
|
|
431
|
+
[api.upvoteAnswer, votedIds, upvotingIds],
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
if (!inlineAnswers && isLoading) {
|
|
435
|
+
return (
|
|
436
|
+
<div className="space-y-3">
|
|
437
|
+
{[1, 2].map((n) => (
|
|
438
|
+
<div key={n} className="space-y-1">
|
|
439
|
+
<div className="h-3 w-20 animate-pulse rounded bg-muted" />
|
|
440
|
+
<div className="h-3 w-full animate-pulse rounded bg-muted" />
|
|
441
|
+
</div>
|
|
442
|
+
))}
|
|
443
|
+
</div>
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (answers.length === 0) {
|
|
448
|
+
return <p className="text-muted-foreground text-sm">No answers yet.</p>;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return (
|
|
452
|
+
<div className="space-y-3">
|
|
453
|
+
{answers.map((answer) => {
|
|
454
|
+
const didVote = votedIds.has(answer.id);
|
|
455
|
+
const upvotes = answer.upvoteCount + (localUpvotes[answer.id] ?? 0);
|
|
456
|
+
const isUpvoting = upvotingIds.has(answer.id);
|
|
457
|
+
|
|
458
|
+
return (
|
|
459
|
+
<div
|
|
460
|
+
key={answer.id}
|
|
461
|
+
className={`rounded-lg border p-3 ${
|
|
462
|
+
answer.isOfficial
|
|
463
|
+
? "border-foreground/15 bg-foreground/[0.03]"
|
|
464
|
+
: "border-border bg-background"
|
|
465
|
+
}`}
|
|
466
|
+
>
|
|
467
|
+
<div className="mb-1 flex items-center gap-2">
|
|
468
|
+
<span className="font-medium text-foreground text-xs">
|
|
469
|
+
{answer.authorName}
|
|
470
|
+
</span>
|
|
471
|
+
{answer.isOfficial && (
|
|
472
|
+
<span className="rounded-full bg-foreground/10 px-2 py-0.5 font-medium text-[10px] text-foreground/70">
|
|
473
|
+
Official
|
|
474
|
+
</span>
|
|
475
|
+
)}
|
|
476
|
+
<span className="text-muted-foreground/60 text-xs">
|
|
477
|
+
{formatDate(answer.createdAt)}
|
|
478
|
+
</span>
|
|
479
|
+
</div>
|
|
480
|
+
<p className="text-muted-foreground text-sm leading-relaxed">
|
|
481
|
+
{answer.body}
|
|
482
|
+
</p>
|
|
483
|
+
<div className="mt-2">
|
|
484
|
+
<button
|
|
485
|
+
type="button"
|
|
486
|
+
disabled={didVote || isUpvoting}
|
|
487
|
+
onClick={() => void handleUpvote(answer.id)}
|
|
488
|
+
className={`inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs transition-colors ${
|
|
489
|
+
didVote
|
|
490
|
+
? "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950 dark:text-emerald-300"
|
|
491
|
+
: "border-border text-muted-foreground hover:border-foreground hover:text-foreground"
|
|
492
|
+
} disabled:opacity-60`}
|
|
493
|
+
>
|
|
494
|
+
<span>{didVote ? "+" : "^"}</span>
|
|
495
|
+
<span>{upvotes}</span>
|
|
496
|
+
</button>
|
|
497
|
+
</div>
|
|
498
|
+
</div>
|
|
499
|
+
);
|
|
500
|
+
})}
|
|
501
|
+
</div>
|
|
502
|
+
);
|
|
503
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRecommendationsApi } from "./_hooks";
|
|
4
|
+
import { formatPrice } from "./_utils";
|
|
5
|
+
import RecommendedProductsTemplate from "./recommended-products.mdx";
|
|
6
|
+
|
|
7
|
+
interface RecommendationItem {
|
|
8
|
+
productId: string;
|
|
9
|
+
productName: string;
|
|
10
|
+
productSlug: string;
|
|
11
|
+
productImage?: string | undefined;
|
|
12
|
+
productPrice?: number | undefined;
|
|
13
|
+
score: number;
|
|
14
|
+
strategy: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface RecommendedProductsProps {
|
|
18
|
+
productId: string;
|
|
19
|
+
limit?: number;
|
|
20
|
+
title?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function RecommendedProducts({
|
|
24
|
+
productId,
|
|
25
|
+
limit = 4,
|
|
26
|
+
title = "Recommended for you",
|
|
27
|
+
}: RecommendedProductsProps) {
|
|
28
|
+
const api = useRecommendationsApi();
|
|
29
|
+
|
|
30
|
+
const { data, isLoading } = api.getForProduct.useQuery({
|
|
31
|
+
params: { productId },
|
|
32
|
+
take: String(limit),
|
|
33
|
+
}) as {
|
|
34
|
+
data: { recommendations: RecommendationItem[] } | undefined;
|
|
35
|
+
isLoading: boolean;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const recommendations = data?.recommendations ?? [];
|
|
39
|
+
|
|
40
|
+
if (isLoading) {
|
|
41
|
+
return (
|
|
42
|
+
<section className="border-border/50 border-t py-12 sm:py-14">
|
|
43
|
+
<h2 className="mb-6 font-display font-semibold text-foreground text-lg tracking-tight sm:text-xl">
|
|
44
|
+
{title}
|
|
45
|
+
</h2>
|
|
46
|
+
<div className="grid grid-cols-2 gap-x-4 gap-y-8 sm:grid-cols-3 lg:grid-cols-4">
|
|
47
|
+
{Array.from({ length: limit }).map((_, i) => (
|
|
48
|
+
<div key={i}>
|
|
49
|
+
<div className="aspect-[3/4] animate-pulse rounded-lg bg-muted" />
|
|
50
|
+
<div className="mt-3 space-y-1.5">
|
|
51
|
+
<div className="h-3.5 w-3/4 animate-pulse rounded bg-muted-foreground/10" />
|
|
52
|
+
<div className="h-3.5 w-1/3 animate-pulse rounded bg-muted-foreground/10" />
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
))}
|
|
56
|
+
</div>
|
|
57
|
+
</section>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (recommendations.length === 0) return null;
|
|
62
|
+
|
|
63
|
+
const gridContent = (
|
|
64
|
+
<div className="grid grid-cols-2 gap-x-4 gap-y-8 sm:grid-cols-3 lg:grid-cols-4">
|
|
65
|
+
{recommendations.map((r) => (
|
|
66
|
+
<a
|
|
67
|
+
key={r.productId}
|
|
68
|
+
href={`/products/${r.productSlug}`}
|
|
69
|
+
className="group"
|
|
70
|
+
>
|
|
71
|
+
{r.productImage ? (
|
|
72
|
+
<div className="aspect-[3/4] overflow-hidden rounded-lg bg-muted">
|
|
73
|
+
<img
|
|
74
|
+
src={r.productImage}
|
|
75
|
+
alt={r.productName}
|
|
76
|
+
className="h-full w-full object-cover transition-transform group-hover:scale-105"
|
|
77
|
+
loading="lazy"
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
) : (
|
|
81
|
+
<div className="flex aspect-[3/4] items-center justify-center rounded-lg bg-muted">
|
|
82
|
+
<svg
|
|
83
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
84
|
+
width="24"
|
|
85
|
+
height="24"
|
|
86
|
+
viewBox="0 0 24 24"
|
|
87
|
+
fill="none"
|
|
88
|
+
stroke="currentColor"
|
|
89
|
+
strokeWidth="1.5"
|
|
90
|
+
strokeLinecap="round"
|
|
91
|
+
strokeLinejoin="round"
|
|
92
|
+
className="text-muted-foreground/30"
|
|
93
|
+
aria-hidden="true"
|
|
94
|
+
>
|
|
95
|
+
<rect width="18" height="18" x="3" y="3" rx="2" />
|
|
96
|
+
<circle cx="9" cy="9" r="2" />
|
|
97
|
+
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
|
|
98
|
+
</svg>
|
|
99
|
+
</div>
|
|
100
|
+
)}
|
|
101
|
+
<div className="mt-3">
|
|
102
|
+
<p className="truncate font-medium text-foreground text-sm group-hover:underline">
|
|
103
|
+
{r.productName}
|
|
104
|
+
</p>
|
|
105
|
+
{r.productPrice != null && (
|
|
106
|
+
<p className="mt-0.5 text-muted-foreground text-sm tabular-nums">
|
|
107
|
+
{formatPrice(r.productPrice)}
|
|
108
|
+
</p>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
</a>
|
|
112
|
+
))}
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<RecommendedProductsTemplate title={title} gridContent={gridContent} />
|
|
118
|
+
);
|
|
119
|
+
}
|
|
@@ -6,11 +6,11 @@ export const getCategory = createStoreEndpoint(
|
|
|
6
6
|
{
|
|
7
7
|
method: "GET",
|
|
8
8
|
params: z.object({
|
|
9
|
-
id: z.string(),
|
|
9
|
+
id: z.string().max(200),
|
|
10
10
|
}),
|
|
11
11
|
query: z
|
|
12
12
|
.object({
|
|
13
|
-
includeProducts: z.string().optional(),
|
|
13
|
+
includeProducts: z.string().max(10).optional(),
|
|
14
14
|
})
|
|
15
15
|
.optional(),
|
|
16
16
|
},
|
|
@@ -5,11 +5,11 @@ export const getRelatedProducts = createStoreEndpoint(
|
|
|
5
5
|
{
|
|
6
6
|
method: "GET",
|
|
7
7
|
params: z.object({
|
|
8
|
-
id: z.string(),
|
|
8
|
+
id: z.string().max(200),
|
|
9
9
|
}),
|
|
10
10
|
query: z
|
|
11
11
|
.object({
|
|
12
|
-
limit: z.string().optional(),
|
|
12
|
+
limit: z.string().max(10).optional(),
|
|
13
13
|
})
|
|
14
14
|
.optional(),
|
|
15
15
|
},
|
|
@@ -6,9 +6,9 @@ export const listCollections = createStoreEndpoint(
|
|
|
6
6
|
method: "GET",
|
|
7
7
|
query: z
|
|
8
8
|
.object({
|
|
9
|
-
page: z.string().optional(),
|
|
10
|
-
limit: z.string().optional(),
|
|
11
|
-
featured: z.string().optional(),
|
|
9
|
+
page: z.string().max(10).optional(),
|
|
10
|
+
limit: z.string().max(10).optional(),
|
|
11
|
+
featured: z.string().max(10).optional(),
|
|
12
12
|
})
|
|
13
13
|
.optional(),
|
|
14
14
|
},
|