Skip to content

Commit 3820a75

Browse files
jordanarldtmatthewvolkchanceaclark
authored
feat(reviews): add reviews form enabling shoppers to submit reviews (#2709)
* feat(reviews): add product review submission form to pdp * fix(reviews): remove unused eslint directives * fix(reviews): make rating radio group label required * fix(reviews): rename component to ReviewForm * refactor(reviews): make entire review form controlled * fix(reviews): reset form after successful submission * fix(reviews): simplify logic for obfuscating name --------- Co-authored-by: Matthew Volk <[email protected]> Co-authored-by: Chancellor Clark <[email protected]>
1 parent fcd0836 commit 3820a75

File tree

11 files changed

+654
-7
lines changed

11 files changed

+654
-7
lines changed

.changeset/eleven-bugs-deny.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@bigcommerce/catalyst-core": minor
3+
---
4+
5+
Adds product review submission functionality to the product detail page via a modal form with validation for rating, title, review text, name, and email fields. Integrates with BigCommerce's GraphQL API using Conform and Zod for form validation and real-time feedback.
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
'use server';
2+
3+
import { BigCommerceGQLError } from '@bigcommerce/catalyst-client';
4+
import { SubmissionResult } from '@conform-to/react';
5+
import { parseWithZod } from '@conform-to/zod';
6+
import { getTranslations } from 'next-intl/server';
7+
8+
import { schema } from '@/vibes/soul/sections/reviews/schema';
9+
import { getSessionCustomerAccessToken } from '~/auth';
10+
import { client } from '~/client';
11+
import { graphql } from '~/client/graphql';
12+
13+
const AddProductReviewMutation = graphql(`
14+
mutation AddProductReviewMutation($input: AddProductReviewInput!) {
15+
catalog {
16+
addProductReview(input: $input) {
17+
__typename
18+
errors {
19+
__typename
20+
... on Error {
21+
message
22+
}
23+
}
24+
}
25+
}
26+
}
27+
`);
28+
29+
export async function submitReview(
30+
prevState: { lastResult: SubmissionResult | null; successMessage?: string },
31+
payload: FormData,
32+
) {
33+
const t = await getTranslations('Product.Reviews.Form');
34+
const customerAccessToken = await getSessionCustomerAccessToken();
35+
const submission = parseWithZod(payload, { schema });
36+
37+
if (submission.status !== 'success') {
38+
return { ...prevState, lastResult: submission.reply() };
39+
}
40+
41+
const { productEntityId, ...input } = submission.value;
42+
43+
try {
44+
const response = await client.fetch({
45+
document: AddProductReviewMutation,
46+
customerAccessToken,
47+
fetchOptions: { cache: 'no-store' },
48+
variables: {
49+
input: {
50+
review: {
51+
...input,
52+
},
53+
productEntityId,
54+
},
55+
},
56+
});
57+
58+
const result = response.data.catalog.addProductReview;
59+
60+
if (result.errors.length > 0) {
61+
return {
62+
...prevState,
63+
lastResult: submission.reply({ formErrors: result.errors.map(({ message }) => message) }),
64+
};
65+
}
66+
67+
return {
68+
...prevState,
69+
lastResult: submission.reply(),
70+
successMessage: t('successMessage'),
71+
};
72+
} catch (error) {
73+
if (error instanceof BigCommerceGQLError) {
74+
return {
75+
...prevState,
76+
lastResult: submission.reply({ formErrors: error.errors.map(({ message }) => message) }),
77+
};
78+
}
79+
80+
if (error instanceof Error) {
81+
return {
82+
...prevState,
83+
lastResult: submission.reply({ formErrors: [error.message] }),
84+
};
85+
}
86+
87+
return {
88+
...prevState,
89+
lastResult: submission.reply({ formErrors: [t('somethingWentWrong')] }),
90+
};
91+
}
92+
}

core/app/[locale]/(default)/product/[slug]/_components/reviews.tsx

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,16 @@ import { cache } from 'react';
55

66
import { Stream, Streamable } from '@/vibes/soul/lib/streamable';
77
import { Reviews as ReviewsSection } from '@/vibes/soul/sections/reviews';
8+
import { auth } from '~/auth';
89
import { client } from '~/client';
910
import { PaginationFragment } from '~/client/fragments/pagination';
1011
import { graphql } from '~/client/graphql';
1112
import { revalidate } from '~/client/revalidate-target';
1213
import { defaultPageInfo, pageInfoTransformer } from '~/data-transformers/page-info-transformer';
1314

15+
import { submitReview } from '../_actions/submit-review';
16+
import { getStreamableProduct } from '../page-data';
17+
1418
import { ProductReviewSchemaFragment } from './product-review-schema/fragment';
1519
import { ProductReviewSchema } from './product-review-schema/product-review-schema';
1620

@@ -72,9 +76,16 @@ const getReviews = cache(async (productId: number, paginationArgs: object) => {
7276
interface Props {
7377
productId: number;
7478
searchParams: Promise<SearchParams>;
79+
streamableImages: Streamable<Array<{ src: string; alt: string }>>;
80+
streamableProduct: Streamable<Awaited<ReturnType<typeof getStreamableProduct>>>;
7581
}
7682

77-
export const Reviews = async ({ productId, searchParams }: Props) => {
83+
export const Reviews = async ({
84+
productId,
85+
searchParams,
86+
streamableProduct,
87+
streamableImages,
88+
}: Props) => {
7889
const t = await getTranslations('Product.Reviews');
7990

8091
const streamableReviewsData = Streamable.from(async () => {
@@ -129,16 +140,50 @@ export const Reviews = async ({ productId, searchParams }: Props) => {
129140
});
130141
});
131142

143+
const streamableProductName = Streamable.from(async () => {
144+
const product = await streamableProduct;
145+
146+
return { name: product?.name ?? '' };
147+
});
148+
149+
const streamableUser = Streamable.from(async () => {
150+
const session = await auth();
151+
const firstName = session?.user?.firstName ?? '';
152+
const lastName = session?.user?.lastName ?? '';
153+
154+
if (!firstName || !lastName) {
155+
return { email: session?.user?.email ?? '', name: '' };
156+
}
157+
158+
const lastInitial = lastName.charAt(0).toUpperCase();
159+
const obfuscatedName = `${firstName} ${lastInitial}.`;
160+
161+
return { email: session?.user?.email ?? '', name: obfuscatedName };
162+
});
163+
132164
return (
133165
<>
134166
<ReviewsSection
167+
action={submitReview}
135168
averageRating={streamableAvergeRating}
136169
emptyStateMessage={t('empty')}
170+
formButtonLabel={t('Form.button')}
171+
formEmailLabel={t('Form.emailLabel')}
172+
formModalTitle={t('Form.title')}
173+
formNameLabel={t('Form.nameLabel')}
174+
formRatingLabel={t('Form.ratingLabel')}
175+
formReviewLabel={t('Form.reviewLabel')}
176+
formSubmitLabel={t('Form.submit')}
177+
formTitleLabel={t('Form.titleLabel')}
137178
nextLabel={t('next')}
138179
paginationInfo={streamablePaginationInfo}
139180
previousLabel={t('previous')}
181+
productId={productId}
140182
reviews={streamableReviews}
141183
reviewsLabel={t('title')}
184+
streamableImages={streamableImages}
185+
streamableProduct={streamableProductName}
186+
streamableUser={streamableUser}
142187
/>
143188
<Stream fallback={null} value={streamableReviewsData}>
144189
{(product) =>

core/app/[locale]/(default)/product/[slug]/page.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -387,7 +387,12 @@ export default async function Product({ params, searchParams }: Props) {
387387
title={t('RelatedProducts.title')}
388388
/>
389389

390-
<Reviews productId={productId} searchParams={searchParams} />
390+
<Reviews
391+
productId={productId}
392+
searchParams={searchParams}
393+
streamableImages={streamableImages}
394+
streamableProduct={streamableProduct}
395+
/>
391396

392397
<Stream
393398
fallback={null}

core/messages/en.json

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,19 @@
411411
"title": "Reviews",
412412
"empty": "No reviews have been added for this product.",
413413
"previous": "Previous reviews",
414-
"next": "Next reviews"
414+
"next": "Next reviews",
415+
"Form": {
416+
"button": "Write a review",
417+
"title": "Write a review",
418+
"submit": "Submit",
419+
"ratingLabel": "Rating",
420+
"titleLabel": "Title",
421+
"reviewLabel": "Review",
422+
"nameLabel": "Name",
423+
"emailLabel": "Email",
424+
"successMessage": "Your review has been submitted successfully!",
425+
"somethingWentWrong": "Something went wrong. Please try again later."
426+
}
415427
}
416428
},
417429
"WebPages": {
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group';
2+
import { clsx } from 'clsx';
3+
import * as React from 'react';
4+
5+
import { FieldError } from '@/vibes/soul/form/field-error';
6+
import { Label } from '@/vibes/soul/form/label';
7+
import { Star } from '@/vibes/soul/primitives/rating';
8+
9+
/**
10+
* This component supports various CSS variables for theming. Here's a comprehensive list, along
11+
* with their default values:
12+
*
13+
* ```css
14+
* :root {
15+
* --rating-radio-group-focus: hsl(var(--primary));
16+
* --rating-radio-group-light-star-empty: hsl(var(--contrast-200));
17+
* --rating-radio-group-light-star-filled: hsl(var(--foreground));
18+
* --rating-radio-group-light-star-hover: hsl(var(--contrast-300));
19+
* --rating-radio-group-dark-star-empty: hsl(var(--contrast-400));
20+
* --rating-radio-group-dark-star-filled: hsl(var(--background));
21+
* --rating-radio-group-dark-star-hover: hsl(var(--contrast-300));
22+
* }
23+
* ```
24+
*/
25+
export const RatingRadioGroup = React.forwardRef<
26+
React.ComponentRef<typeof RadioGroupPrimitive.Root>,
27+
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root> & {
28+
label: string;
29+
max?: number;
30+
errors?: string[];
31+
onOptionMouseEnter?: (value: string) => void;
32+
onOptionMouseLeave?: () => void;
33+
colorScheme?: 'light' | 'dark';
34+
}
35+
>(
36+
(
37+
{
38+
label,
39+
max = 5,
40+
errors,
41+
className,
42+
onOptionMouseEnter,
43+
onOptionMouseLeave,
44+
colorScheme = 'light',
45+
...rest
46+
},
47+
ref,
48+
) => {
49+
const groupId = React.useId();
50+
const [previewValue, setPreviewValue] = React.useState<string | null>(null);
51+
const isMouseDownRef = React.useRef(false);
52+
53+
const currentValue = rest.value?.toString() ?? '0';
54+
const displayRating = parseInt(previewValue ?? currentValue, 10) || 0;
55+
56+
const handleMouseLeave = () => {
57+
setPreviewValue(null);
58+
onOptionMouseLeave?.();
59+
};
60+
61+
const handleMouseDown = () => {
62+
isMouseDownRef.current = true;
63+
};
64+
65+
const handleMouseUp = () => {
66+
isMouseDownRef.current = false;
67+
};
68+
69+
const handleBlur = () => {
70+
if (!isMouseDownRef.current) {
71+
setPreviewValue(null);
72+
}
73+
};
74+
75+
return (
76+
<div className={clsx('rating-radio-group space-y-2', className)}>
77+
<Label colorScheme={colorScheme} id={groupId}>
78+
{label}
79+
</Label>
80+
81+
<RadioGroupPrimitive.Root
82+
{...rest}
83+
aria-labelledby={groupId}
84+
className="flex items-center gap-1"
85+
onMouseDown={handleMouseDown}
86+
onMouseLeave={handleMouseLeave}
87+
onMouseUp={handleMouseUp}
88+
ref={ref}
89+
>
90+
<div className="flex items-center gap-1">
91+
{Array.from({ length: max }, (_, i) => {
92+
const ratingValue = i + 1;
93+
const filled = displayRating >= ratingValue;
94+
const valueStr = ratingValue.toString();
95+
const itemId = `${groupId}-${ratingValue}`;
96+
97+
return (
98+
<div className="relative" key={ratingValue}>
99+
<RadioGroupPrimitive.Item
100+
className={clsx(
101+
'peer sr-only',
102+
'data-disabled:pointer-events-none data-disabled:opacity-50 transition-colors focus-visible:outline-0 focus-visible:ring-2',
103+
{
104+
light:
105+
'focus-visible:ring-[var(--rating-radio-group-focus,hsl(var(--primary)))]',
106+
dark: 'focus-visible:ring-[var(--rating-radio-group-focus,hsl(var(--primary)))]',
107+
}[colorScheme],
108+
)}
109+
id={itemId}
110+
onBlur={handleBlur}
111+
value={valueStr}
112+
/>
113+
<label
114+
aria-label={`${ratingValue} ${ratingValue === 1 ? 'star' : 'stars'}`}
115+
className="flex shrink-0 cursor-pointer rounded-full transition-colors focus-visible:outline-0 focus-visible:ring-2 peer-focus-visible:ring-2 peer-focus-visible:ring-[var(--rating-radio-group-focus,hsl(var(--primary)))]"
116+
htmlFor={itemId}
117+
onMouseEnter={() => {
118+
setPreviewValue(valueStr);
119+
onOptionMouseEnter?.(valueStr);
120+
}}
121+
>
122+
<Star type={filled ? 'full' : 'empty'} />
123+
</label>
124+
</div>
125+
);
126+
})}
127+
</div>
128+
</RadioGroupPrimitive.Root>
129+
{errors?.map((error) => (
130+
<FieldError key={error}>{error}</FieldError>
131+
))}
132+
</div>
133+
);
134+
},
135+
);
136+
137+
RatingRadioGroup.displayName = 'RatingRadioGroup';

core/vibes/soul/primitives/modal/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export const Modal = ({
6060
<div className="flex flex-col">
6161
<div
6262
className={clsx(
63-
'mb-5 flex min-h-10 flex-row items-center border-b border-b-contrast-200 py-3 pl-5',
63+
'flex min-h-10 flex-row items-center py-3 pl-5',
6464
hideHeader ? 'sr-only' : '',
6565
)}
6666
>

core/vibes/soul/primitives/rating/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ interface StarType {
1010
type: 'empty' | 'half' | 'full';
1111
}
1212

13-
const Star = ({ type }: StarType) => {
13+
export const Star = ({ type }: StarType) => {
1414
const paths = {
1515
empty: (
1616
<path

0 commit comments

Comments
 (0)