diff --git a/packages/components/src/molecules/CheckboxField/CheckboxField.tsx b/packages/components/src/molecules/CheckboxField/CheckboxField.tsx index 90fba01695..1ed946fd0c 100644 --- a/packages/components/src/molecules/CheckboxField/CheckboxField.tsx +++ b/packages/components/src/molecules/CheckboxField/CheckboxField.tsx @@ -15,6 +15,10 @@ export interface CheckboxFieldProps extends CheckboxProps { * Control the vertical alignment of the checkbox in relation to the label (center, top, bottom). */ alignment?: 'center' | 'top' | 'bottom' + /** + * Checkbox Component's ref. + */ + checkboxRef?: React.MutableRefObject } const CheckboxField = forwardRef( @@ -29,6 +33,7 @@ const CheckboxField = forwardRef( error, disabled, alignment = 'center', + checkboxRef, ...otherProps }, ref @@ -49,6 +54,7 @@ const CheckboxField = forwardRef( name={name} defaultChecked={checked} disabled={disabled} + ref={checkboxRef} {...otherProps} />
diff --git a/packages/components/src/molecules/Rating/Rating.tsx b/packages/components/src/molecules/Rating/Rating.tsx index 359a20c525..fb54d72c72 100644 --- a/packages/components/src/molecules/Rating/Rating.tsx +++ b/packages/components/src/molecules/Rating/Rating.tsx @@ -100,6 +100,7 @@ const Rating = forwardRef(function Rating( onMouseEnter={() => setHover(tempIndex)} onMouseLeave={() => setHover(value)} disabled={disabled} + type="button" /> ) : ( <> diff --git a/packages/core/@generated/gql.ts b/packages/core/@generated/gql.ts index 0e08a53088..1f05ce51de 100644 --- a/packages/core/@generated/gql.ts +++ b/packages/core/@generated/gql.ts @@ -52,6 +52,8 @@ const documents = { types.ClientProductGalleryQueryDocument, '\n query ClientProductQuery($locator: [IStoreSelectedFacet!]!) {\n ...ClientProduct\n product(locator: $locator) {\n ...ProductDetailsFragment_product\n }\n }\n': types.ClientProductQueryDocument, + '\n mutation CreateProductReview($data: ICreateProductReview!) {\n productReviewId: createProductReview(data: $data)\n }\n': + types.CreateProductReviewDocument, '\n query ClientSearchSuggestionsQuery(\n $term: String!\n $selectedFacets: [IStoreSelectedFacet!]\n ) {\n ...ClientSearchSuggestions\n search(first: 5, term: $term, selectedFacets: $selectedFacets) {\n suggestions {\n terms {\n value\n }\n products {\n ...ProductSummary_product\n }\n }\n products {\n pageInfo {\n totalCount\n }\n }\n metadata {\n ...SearchEvent_metadata\n }\n }\n }\n': types.ClientSearchSuggestionsQueryDocument, '\n query ClientTopSearchSuggestionsQuery(\n $term: String!\n $selectedFacets: [IStoreSelectedFacet!]\n ) {\n ...ClientTopSearchSuggestions\n search(first: 5, term: $term, selectedFacets: $selectedFacets) {\n suggestions {\n terms {\n value\n }\n }\n }\n }\n': @@ -182,6 +184,12 @@ export function gql( export function gql( source: '\n query ClientProductQuery($locator: [IStoreSelectedFacet!]!) {\n ...ClientProduct\n product(locator: $locator) {\n ...ProductDetailsFragment_product\n }\n }\n' ): typeof import('./graphql').ClientProductQueryDocument +/** + * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function gql( + source: '\n mutation CreateProductReview($data: ICreateProductReview!) {\n productReviewId: createProductReview(data: $data)\n }\n' +): typeof import('./graphql').CreateProductReviewDocument /** * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/packages/core/@generated/graphql.ts b/packages/core/@generated/graphql.ts index e44c1b8c0c..cb03304483 100644 --- a/packages/core/@generated/graphql.ts +++ b/packages/core/@generated/graphql.ts @@ -1797,6 +1797,12 @@ export type ClientProductQueryQuery = { } } +export type CreateProductReviewMutationVariables = Exact<{ + data: ICreateProductReview +}> + +export type CreateProductReviewMutation = { productReviewId: string } + export type ClientSearchSuggestionsQueryQueryVariables = Exact<{ term: Scalars['String']['input'] selectedFacets: InputMaybe | IStoreSelectedFacet> @@ -2439,6 +2445,15 @@ export const ClientProductQueryDocument = { ClientProductQueryQuery, ClientProductQueryQueryVariables > +export const CreateProductReviewDocument = { + __meta__: { + operationName: 'CreateProductReview', + operationHash: '446f171f0f6ed728b011ae9218d726897f27641f', + }, +} as unknown as TypedDocumentString< + CreateProductReviewMutation, + CreateProductReviewMutationVariables +> export const ClientSearchSuggestionsQueryDocument = { __meta__: { operationName: 'ClientSearchSuggestionsQuery', diff --git a/packages/core/cms/faststore/sections.json b/packages/core/cms/faststore/sections.json index fb2ee17900..0da461337e 100644 --- a/packages/core/cms/faststore/sections.json +++ b/packages/core/cms/faststore/sections.json @@ -1034,6 +1034,86 @@ "type": "string", "default": "Close Review Modal" }, + "ratingField": { + "title": "Rating input field", + "type": "object", + "properties": { + "label": { + "title": "Rating input field label", + "type": "string", + "default": "Rate the product from 1 to 5 stars" + }, + "requiredErrorMessage": { + "title": "Input field error message", + "type": "string", + "default": "This field is required" + } + } + }, + "reviewTitleField": { + "title": "Review title input field", + "type": "object", + "properties": { + "label": { + "title": "Review title input field label", + "type": "string", + "default": "Review headline" + }, + "requiredErrorMessage": { + "title": "Review title field error message", + "type": "string", + "default": "This field is required" + } + } + }, + "reviewerNameField": { + "title": "Reviewer name input field", + "type": "object", + "properties": { + "label": { + "title": "Reviewer name input field label", + "type": "string", + "default": "Name" + }, + "requiredErrorMessage": { + "title": "Reviewer name field error message", + "type": "string", + "default": "This field is required" + } + } + }, + "reviewTextField": { + "title": "Reviewer text input field", + "type": "object", + "properties": { + "label": { + "title": "Reviewer text input field label", + "type": "string", + "default": "Share your thoughts about the product. How would you describe its quality?" + }, + "requiredErrorMessage": { + "title": "Reviewer text field error message", + "type": "string", + "default": "This field is required" + } + } + }, + "privacyPolicyCheckboxField": { + "title": "Privacy Policy Checkbox Field", + "type": "object", + "properties": { + "label": { + "title": "Privacy policy checkbox field label", + "type": "string", + "default": "I confirm that I agree to the Privacy Policy, Terms of Use, and Terms of Service. I acknowledge that my review may be used for marketing purposes by the company or its partners. I understand that my rating and review may be visible publicly, may include a “Verified buyer” badge, and that my data may be associated with my review." + }, + "requiredErrorMessage": { + "title": "Privacy policy checkbox field error message", + "type": "string", + "default": "This field is required" + } + } + }, "cancelButtonLabel": { "title": "Cancel button label", "type": "string", @@ -1043,6 +1123,21 @@ "title": "Submit review button label", "type": "string", "default": "Submit your review" + }, + "successTitle": { + "title": "Success title", + "type": "string", + "default": "Success!" + }, + "successSubtitle": { + "title": "Success subtitle", + "type": "string", + "default": "Your review has been submitted." + }, + "successButtonLabel": { + "title": "Success button label", + "type": "string", + "default": "Back to reviews" } } } diff --git a/packages/core/src/components/reviews/ReviewModal/ProductThumbnail/ProductThumbnail.tsx b/packages/core/src/components/reviews/ReviewModal/ProductThumbnail.tsx similarity index 100% rename from packages/core/src/components/reviews/ReviewModal/ProductThumbnail/ProductThumbnail.tsx rename to packages/core/src/components/reviews/ReviewModal/ProductThumbnail.tsx diff --git a/packages/core/src/components/reviews/ReviewModal/ProductThumbnail/index.ts b/packages/core/src/components/reviews/ReviewModal/ProductThumbnail/index.ts deleted file mode 100644 index 40178a1195..0000000000 --- a/packages/core/src/components/reviews/ReviewModal/ProductThumbnail/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default } from './ProductThumbnail' -export type { ProductThumbnailProps } from './ProductThumbnail' diff --git a/packages/core/src/components/reviews/ReviewModal/ReviewModal.tsx b/packages/core/src/components/reviews/ReviewModal/ReviewModal.tsx index 29a13faf6f..3fd2d93824 100644 --- a/packages/core/src/components/reviews/ReviewModal/ReviewModal.tsx +++ b/packages/core/src/components/reviews/ReviewModal/ReviewModal.tsx @@ -1,15 +1,21 @@ import { - Button as UIButton, type ModalProps as UIModalProps, type ModalHeaderProps as UIModalHeaderProps, - type ModalBodyProps as UIModalBodyProps, - type ModalFooterProps as UIModalFooterProps, useUI, } from '@faststore/ui' import styles from './section.module.scss' import dynamic from 'next/dynamic' +import { useCallback, useEffect, useState } from 'react' +import { useAddReview } from 'src/sdk/reviews/useAddReview' + +import isUUID from 'src/utils/isUUID' +import type { + ReviewModalFormData, + ReviewModalFormProps, +} from './ReviewModalForm' +import type { ReviewModalSuccessProps } from './ReviewModalSuccess' const UIModal = dynamic( () => @@ -27,23 +33,32 @@ const UIModalHeader = dynamic( { ssr: false } ) -const UIModalBody = dynamic( +const ReviewModalSuccess = dynamic( () => - import(/* webpackChunkName: "UIModalBody" */ '@faststore/ui').then( - (module) => module.ModalBody + import( + /* webpackChunkName: "ReviewModalSuccess" */ 'src/components/reviews/ReviewModal/ReviewModalSuccess' ), { ssr: false } ) -const UIModalFooter = dynamic( +const ReviewModalForm = dynamic( () => - import(/* webpackChunkName: "UIModalFooter" */ '@faststore/ui').then( - (module) => module.ModalFooter + import( + /* webpackChunkName: "ReviewModalForm" */ 'src/components/reviews/ReviewModal/ReviewModalForm' ), { ssr: false } ) -export interface ReviewModalProps { +const UIIcon = dynamic( + () => import('@faststore/ui').then((module) => module.Icon), + { + ssr: false, + } +) + +export interface ReviewModalProps + extends Omit, + ReviewModalSuccessProps { /** * The review modal's title. */ @@ -52,37 +67,68 @@ export interface ReviewModalProps { * Close button aria-label. */ closeButtonAriaLabel?: string - /** - * Cancel button label. - */ - cancelButtonLabel?: string - /** - * Submit button label. - */ - submitButtonLabel?: string } function ReviewModal({ title = 'Add a review', closeButtonAriaLabel = 'Close Review Modal', - cancelButtonLabel = 'Cancel', - submitButtonLabel = 'Submit your review', + product, + successTitle, + successSubtitle, + successButtonLabel, + ...formProps }: ReviewModalProps) { - const { closeReviewModal } = useUI() + const { pushToast, closeReviewModal } = useUI() + const { createProductReview, data, error, loading } = useAddReview() + const [submittedReview, setSubmittedReview] = + useState(null) + const [isSuccess, setIsSuccess] = useState(false) - const handleSubmit = async () => { - // TODO: send review + function pushErrorToast(message = 'Something went wrong.') { + pushToast({ + title: 'Oops.', + message, + status: 'ERROR', + icon: , + }) } - function handleOnClose() { - closeReviewModal() - } + const handleSubmit = useCallback( + (formData: ReviewModalFormData) => { + createProductReview({ + data: { + productId: product.id, + rating: formData.rating, + title: formData.title, + text: formData.text, + reviewerName: formData.reviewerName, + }, + }) + .then(() => { + setSubmittedReview(formData) + }) + .catch((error) => { + pushErrorToast(error.message) + }) + }, + [product.id, createProductReview] + ) + + useEffect(() => { + if (submittedReview) { + if (data?.productReviewId && isUUID(data.productReviewId)) { + setIsSuccess(true) + } else { + pushErrorToast(data?.productReviewId) + } + } + }, [submittedReview, data, error]) return ( fade === 'out' && handleOnClose()} + onTransitionEnd={(_, fade) => fade === 'out' && closeReviewModal()} overlayProps={{ className: `section ${styles.section} section-review-modal`, }} @@ -99,18 +145,23 @@ function ReviewModal({ 'aria-label': closeButtonAriaLabel, }} /> - - {/* TODO: ReviewModal form will go here in another PR */} - body - - - - {cancelButtonLabel} - - - {submitButtonLabel} - - + {isSuccess ? ( + + ) : ( + + )} )} diff --git a/packages/core/src/components/reviews/ReviewModal/ReviewModalForm.tsx b/packages/core/src/components/reviews/ReviewModal/ReviewModalForm.tsx new file mode 100644 index 0000000000..5f90937a8c --- /dev/null +++ b/packages/core/src/components/reviews/ReviewModal/ReviewModalForm.tsx @@ -0,0 +1,341 @@ +import dynamic from 'next/dynamic' +import { type FormEvent, useRef, useState, useCallback } from 'react' + +const UIButton = dynamic( + () => + import(/* webpackChunkName: "UIButton" */ '@faststore/ui').then( + (mod) => mod.Button + ), + { ssr: false } +) + +const UIModalBody = dynamic( + () => + import(/* webpackChunkName: "UIModalBody" */ '@faststore/ui').then( + (mod) => mod.ModalBody + ), + { ssr: false } +) + +const UIModalFooter = dynamic( + () => + import(/* webpackChunkName: "UIReviewModalFooter" */ '@faststore/ui').then( + (mod) => mod.ModalFooter + ), + { ssr: false } +) + +const ProductThumbnail = dynamic( + () => + import( + /* webpackChunkName: "ProductThumbnail" */ 'src/components/reviews/ReviewModal/ProductThumbnail' + ), + { ssr: false } +) + +const UICheckboxField = dynamic( + () => + import(/* webpackChunkName: "UICheckboxField" */ '@faststore/ui').then( + (mod) => mod.CheckboxField + ), + { ssr: false } +) + +const UIInputField = dynamic( + () => + import(/* webpackChunkName: "UIInputField" */ '@faststore/ui').then( + (mod) => mod.InputField + ), + { ssr: false } +) + +const UITextareaField = dynamic( + () => + import(/* webpackChunkName: "UITextareaField" */ '@faststore/ui').then( + (mod) => mod.TextareaField + ), + { ssr: false } +) + +const UIRatingField = dynamic( + () => + import(/* webpackChunkName: "UIRatingField" */ '@faststore/ui').then( + (mod) => mod.RatingField + ), + { ssr: false } +) + +export interface ReviewModalFormProps { + product: { + id: string + name: string + image?: { url: string; alternateName: string } + } + ratingField?: { + label: string + requiredErrorMessage: string + } + reviewTitleField?: { + label: string + requiredErrorMessage: string + } + reviewerNameField?: { + label: string + requiredErrorMessage: string + } + reviewTextField?: { + label: string + requiredErrorMessage: string + } + privacyPolicyCheckboxField?: { + label: string + requiredErrorMessage: string + } + cancelButtonLabel?: string + submitButtonLabel?: string + loading?: boolean + onSubmit: (data: ReviewModalFormData) => void + onCancel: () => void +} + +export interface ReviewModalFormData { + rating: number + title: string + reviewerName: string + text: string + privacyPolicy: boolean +} + +function ReviewModalForm({ + product, + ratingField = { + label: 'Rate the product from 1 to 5 stars', + requiredErrorMessage: 'This field is required', + }, + reviewTitleField = { + label: 'Headline', + requiredErrorMessage: 'This field is required', + }, + reviewerNameField = { + label: 'Name', + requiredErrorMessage: 'This field is required', + }, + reviewTextField = { + label: + 'Share your thoughts about the product. How would you describe its quality?', + requiredErrorMessage: 'This field is required', + }, + privacyPolicyCheckboxField = { + label: + 'I confirm that I agree to the Privacy Policy, Terms of Use, and Terms of Service. I acknowledge that my review may be used for marketing purposes by the company or its partners. I understand that my rating and review may be visible publicly, may include a “Verified buyer” badge, and that my data may be associated with my review.', + requiredErrorMessage: 'This field is required', + }, + cancelButtonLabel = 'Cancel', + submitButtonLabel = 'Submit your review', + loading, + onSubmit, + onCancel, +}: ReviewModalFormProps) { + const [values, setValues] = useState({ + rating: 0, + title: '', + reviewerName: '', + text: '', + privacyPolicy: false, + }) + + const [errors, setErrors] = useState< + Record + >({ + rating: undefined, + title: undefined, + reviewerName: undefined, + text: undefined, + privacyPolicy: undefined, + }) + + const refs = { + rating: useRef(null), + title: useRef(null), + reviewerName: useRef(null), + text: useRef(null), + privacyPolicy: useRef(null), + } + + const errorMessages = { + rating: ratingField.requiredErrorMessage, + title: reviewTitleField.requiredErrorMessage, + reviewerName: reviewerNameField.requiredErrorMessage, + text: reviewTextField.requiredErrorMessage, + privacyPolicy: privacyPolicyCheckboxField.requiredErrorMessage, + } + + const isValidValue = useCallback( + ( + field: T, + value: ReviewModalFormData[T] + ): boolean => { + switch (field) { + case 'rating': + return typeof value === 'number' && value > 0 + case 'title': + case 'reviewerName': + case 'text': + return typeof value === 'string' && value.trim().length > 0 + case 'privacyPolicy': + return !!value + default: + return false + } + }, + [] + ) + + const validateField = useCallback( + (field: keyof ReviewModalFormData): boolean => { + const value = values[field] + const isValid = isValidValue(field, value) + + setErrors((prev) => ({ + ...prev, + [field]: isValid ? undefined : errorMessages[field], + })) + + return isValid + }, + [values, errorMessages, isValidValue] + ) + + const setValue = useCallback( + ( + field: T, + value: ReviewModalFormData[T] + ) => { + setValues((prev) => ({ + ...prev, + [field]: value, + })) + + const isValid = isValidValue(field, value) + + setErrors((prev) => ({ + ...prev, + [field]: isValid ? undefined : errorMessages[field], + })) + }, + [errorMessages, isValidValue] + ) + + const validateForm = useCallback((): boolean => { + const fields = Object.keys(values) as Array + const results = fields.map((field) => ({ + field, + isValid: validateField(field), + })) + + const firstInvalidField = results.find((r) => !r.isValid)?.field + const isValid = !firstInvalidField + + if (firstInvalidField && refs[firstInvalidField]?.current) { + if (firstInvalidField && refs[firstInvalidField]?.current) { + // Handle special scrolling for certain fields + if ( + firstInvalidField === 'privacyPolicy' || + firstInvalidField === 'rating' + ) { + refs[firstInvalidField].current?.scrollIntoView() + } + refs[firstInvalidField].current?.focus() + } + } + + return isValid + }, [values, errorMessages, isValidValue, refs]) + + const handleSubmit = (e: FormEvent) => { + e.preventDefault() + if (validateForm()) { + onSubmit(values) + } + } + + return ( +
+ + + + setValue('rating', value)} + ratingRef={refs.rating} + /> + + setValue('title', e.target.value)} + onBlur={() => validateField('title')} + inputRef={refs.title} + name="reviewTitle" + /> + + setValue('reviewerName', e.target.value)} + onBlur={() => validateField('reviewerName')} + inputRef={refs.reviewerName} + name="reviewerName" + /> + + setValue('text', e.target.value)} + onBlur={() => validateField('text')} + textareaRef={refs.text} + name="reviewText" + resize="none" + rows={10} + /> + + setValue('privacyPolicy', e.target.checked)} + checkboxRef={refs.privacyPolicy} + name="privacyPolicy" + alignment="top" + /> + + + + onCancel()} type="button"> + {cancelButtonLabel} + + + {submitButtonLabel} + + +
+ ) +} + +export default ReviewModalForm diff --git a/packages/core/src/components/reviews/ReviewModal/ReviewModalSuccess.tsx b/packages/core/src/components/reviews/ReviewModal/ReviewModalSuccess.tsx new file mode 100644 index 0000000000..c35bb99dbe --- /dev/null +++ b/packages/core/src/components/reviews/ReviewModal/ReviewModalSuccess.tsx @@ -0,0 +1,72 @@ +import dynamic from 'next/dynamic' + +const UIModalBody = dynamic( + () => + import(/* webpackChunkName: "UIModalBody" */ '@faststore/ui').then( + (mod) => mod.ModalBody + ), + { ssr: false } +) + +const UIButton = dynamic( + () => + import(/* webpackChunkName: "UIButton" */ '@faststore/ui').then( + (mod) => mod.Button + ), + { ssr: false } +) + +const UIIcon = dynamic( + () => + import(/* webpackChunkName: "UIIcon" */ '@faststore/ui').then( + (mod) => mod.Icon + ), + { ssr: false } +) + +const UIReviewCard = dynamic( + () => + import(/* webpackChunkName: "UIReviewCard" */ '@faststore/ui').then( + (mod) => mod.ReviewCard + ), + { ssr: false } +) + +// Add interface for form data +export interface ReviewModalSuccessProps { + successTitle?: string + successSubtitle?: string + successButtonLabel?: string + review: { + rating: number + title: string + text: string + } + close(): void +} + +function ReviewModalSuccess({ + successTitle = 'Success!', + successSubtitle = 'Your review has been submitted.', + successButtonLabel = 'Back to reviews', + review, + close, +}: ReviewModalSuccessProps) { + return ( + +
+ +

{successTitle}

+

{successSubtitle}

+
+ + + + + {successButtonLabel} + +
+ ) +} + +export default ReviewModalSuccess diff --git a/packages/core/src/components/reviews/ReviewModal/section.module.scss b/packages/core/src/components/reviews/ReviewModal/section.module.scss index 7080653106..3e84d6446a 100644 --- a/packages/core/src/components/reviews/ReviewModal/section.module.scss +++ b/packages/core/src/components/reviews/ReviewModal/section.module.scss @@ -1,7 +1,18 @@ .section { @import "@faststore/ui/src/components/atoms/Icon/styles.scss"; + @import "@faststore/ui/src/components/atoms/Link/styles.scss"; + @import "@faststore/ui/src/components/atoms/Loader/styles.scss"; @import "@faststore/ui/src/components/atoms/Button/styles.scss"; @import "@faststore/ui/src/components/atoms/Overlay/styles.scss"; + @import "@faststore/ui/src/components/atoms/Input/styles.scss"; + @import "@faststore/ui/src/components/atoms/Textarea/styles.scss"; + @import "@faststore/ui/src/components/atoms/Checkbox/styles.scss"; + @import "@faststore/ui/src/components/molecules/TextareaField/styles.scss"; + @import "@faststore/ui/src/components/molecules/InputField/styles.scss"; + @import "@faststore/ui/src/components/molecules/CheckboxField/styles.scss"; + @import "@faststore/ui/src/components/molecules/Rating/styles.scss"; + @import "@faststore/ui/src/components/molecules/RatingField/styles.scss"; @import "@faststore/ui/src/components/molecules/Modal/styles.scss"; + @import "@faststore/ui/src/components/molecules/ReviewCard/styles.scss"; @import "@faststore/ui/src/components/organisms/ReviewModal/styles.scss"; } diff --git a/packages/core/src/components/ui/ReviewsAndRatings/ReviewsAndRatings.tsx b/packages/core/src/components/ui/ReviewsAndRatings/ReviewsAndRatings.tsx index 2c00853839..e3d81aea73 100644 --- a/packages/core/src/components/ui/ReviewsAndRatings/ReviewsAndRatings.tsx +++ b/packages/core/src/components/ui/ReviewsAndRatings/ReviewsAndRatings.tsx @@ -10,7 +10,7 @@ export type ReviewsAndRatingsProps = { ratingCounter: RatingSummaryProps['textLabels']['ratingCounter'] createReviewButton: RatingSummaryProps['textLabels']['createReviewButton'] } - reviewModal: ReviewModalProps + reviewModal: Omit } function ReviewsAndRatings({ @@ -23,27 +23,37 @@ function ReviewsAndRatings({ const context = usePDP() const { openReviewModal, reviewModal: displayReviewModal } = useUI() const { isDesktop } = useScreenResize() - - const rating = context?.data?.product?.rating + const { product, isValidating } = context.data return ( - rating && ( + product?.rating && ( <>

{title}

- {(isDesktop || rating?.totalCount > 0) && ( + {(isDesktop || product?.rating?.totalCount > 0) && ( )}
- {displayReviewModal && ( - + + {displayReviewModal && !isValidating && ( + )} ) diff --git a/packages/core/src/experimental/index.ts b/packages/core/src/experimental/index.ts index 38a92b51c5..0a686c2e72 100644 --- a/packages/core/src/experimental/index.ts +++ b/packages/core/src/experimental/index.ts @@ -17,6 +17,7 @@ export { useRemoveButton as useRemoveButton_unstable } from '../../src/sdk/cart/ export { useQuery as useQuery_unstable } from '../../src/sdk/graphql/useQuery' export { useLazyQuery as useLazyQuery_unstable } from '../../src/sdk/graphql/useLazyQuery' export { useNewsletter as useNewsletter_unstable } from '../../src/sdk/newsletter/useNewsletter' +export { useAddReview as useAddReview_unstable } from '../../src/sdk/reviews/useAddReview' export { useDiscountPercent as useDiscountPercent_unstable } from '../../src/sdk/product/useDiscountPercent' export { useFormattedPrice as useFormattedPrice_unstable } from '../../src/sdk/product/useFormattedPrice' export { useLocalizedVariables as useLocalizedVariables_unstable } from '../../src/sdk/product/useLocalizedVariables' diff --git a/packages/core/src/sdk/reviews/useAddReview.ts b/packages/core/src/sdk/reviews/useAddReview.ts new file mode 100644 index 0000000000..67df38870f --- /dev/null +++ b/packages/core/src/sdk/reviews/useAddReview.ts @@ -0,0 +1,27 @@ +import { gql } from '@generated' + +import type { + CreateProductReviewMutationVariables as Variables, + CreateProductReviewMutation as Mutation, +} from '../../../@generated/graphql' +import { useLazyQuery } from '../graphql/useLazyQuery' + +export const mutation = gql(` + mutation CreateProductReview($data: ICreateProductReview!) { + productReviewId: createProductReview(data: $data) + } +`) + +export const useAddReview = () => { + const [createProductReview, { data, error, isValidating: loading }] = + useLazyQuery(mutation, { + data: { productId: '', rating: 0, title: '', text: '', reviewerName: '' }, + }) + + return { + createProductReview, + data, + error, + loading, + } +} diff --git a/packages/core/src/utils/isUUID.ts b/packages/core/src/utils/isUUID.ts new file mode 100644 index 0000000000..f81fc632ef --- /dev/null +++ b/packages/core/src/utils/isUUID.ts @@ -0,0 +1,34 @@ +/** + * These were heavily inspired by validator.js isUUID function. + * https://github.com/validatorjs/validator.js/blob/master/src/lib/isUUID.js + */ + +const uuid = { + 1: /^[0-9A-F]{8}-[0-9A-F]{4}-1[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i, + 2: /^[0-9A-F]{8}-[0-9A-F]{4}-2[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i, + 3: /^[0-9A-F]{8}-[0-9A-F]{4}-3[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i, + 4: /^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i, + 5: /^[0-9A-F]{8}-[0-9A-F]{4}-5[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i, + 6: /^[0-9A-F]{8}-[0-9A-F]{4}-6[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i, + 7: /^[0-9A-F]{8}-[0-9A-F]{4}-7[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i, + 8: /^[0-9A-F]{8}-[0-9A-F]{4}-8[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i, + nil: /^00000000-0000-0000-0000-000000000000$/i, + max: /^ffffffff-ffff-ffff-ffff-ffffffffffff$/i, + all: /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$/i, +} + +/** + * Function to check if a string is a valid UUID. + * @param str - The input string to validate. + * @param version - Optional. The specific UUID version to validate against. + * If not provided, defaults to 'all'. + * @returns True if the string is a valid UUID, false otherwise. + */ +export default function isUUID(str: string, version?: string | null): boolean { + const resolvedVersion = version ?? 'all' + + // Check if the version exists in the uuid object and test the string + return resolvedVersion in uuid + ? uuid[resolvedVersion as keyof typeof uuid].test(str) + : false +} diff --git a/packages/ui/src/components/atoms/Textarea/styles.scss b/packages/ui/src/components/atoms/Textarea/styles.scss index 8048c05e35..409e6bcaee 100644 --- a/packages/ui/src/components/atoms/Textarea/styles.scss +++ b/packages/ui/src/components/atoms/Textarea/styles.scss @@ -5,7 +5,7 @@ // Default properties --fs-textarea-padding : var(--fs-spacing-1) var(--fs-spacing-2); - --fs-textarea-height : calc(var(--fs-control-tap-size) * 3); + --fs-textarea-height : 10rem; --fs-textarea-bkg-color : var(--fs-color-body-bkg); --fs-textarea-box-shadow : none; diff --git a/packages/ui/src/components/molecules/CheckboxField/styles.scss b/packages/ui/src/components/molecules/CheckboxField/styles.scss index 3abcd621e1..1936cd66e0 100644 --- a/packages/ui/src/components/molecules/CheckboxField/styles.scss +++ b/packages/ui/src/components/molecules/CheckboxField/styles.scss @@ -8,7 +8,7 @@ // Label --fs-checkbox-field-label-color : var(--fs-color-text-light); - --fs-checkbox-field-label-size : var(--fs-text-size-1); + --fs-checkbox-field-label-size : var(--fs-text-size-2); --fs-checkbox-field-label-weight : var(--fs-text-weight-regular); --fs-checkbox-field-label-line-height : 1.42; @@ -32,27 +32,29 @@ } [data-fs-checkbox-field-label] { - color: var(--fs-checkbox-field-label-color); font-size: var(--fs-checkbox-field-label-size); font-weight: var(--fs-checkbox-field-label-weight); line-height: var(--fs-checkbox-field-label-line-height); + color: var(--fs-checkbox-field-label-color); } // -------------------------------------------------------- // Variants Styles // -------------------------------------------------------- - &[data-fs-checkbox-field-alignment='center'] { + &[data-fs-checkbox-field-alignment="center"] { align-items: center; } - &[data-fs-checkbox-field-alignment='top'] { + + &[data-fs-checkbox-field-alignment="top"] { align-items: flex-start; } - &[data-fs-checkbox-field-alignment='bottom'] { + + &[data-fs-checkbox-field-alignment="bottom"] { align-items: flex-end; } - &[data-fs-checkbox-field-error='true'] { + &[data-fs-checkbox-field-error="true"] { [data-fs-checkbox-field-error-message] { margin-top: var(--fs-checkbox-field-error-message-margin-top); font-size: var(--fs-checkbox-field-error-message-size); diff --git a/packages/ui/src/components/molecules/InputField/styles.scss b/packages/ui/src/components/molecules/InputField/styles.scss index a0f5b90efa..1fd2c95587 100644 --- a/packages/ui/src/components/molecules/InputField/styles.scss +++ b/packages/ui/src/components/molecules/InputField/styles.scss @@ -51,11 +51,12 @@ [data-fs-input] { --fs-input-padding: var(--fs-input-field-padding); + padding: var(--fs-input-field-padding); color: var(--fs-input-field-color); &:placeholder-shown + label { - top: calc(50% - (var(--fs-input-field-size) / 2)); + top: calc((var(--fs-control-tap-size) - var(--fs-input-field-size)) / 2); // (input height - label font size) / 2 overflow: hidden; } @@ -95,6 +96,7 @@ &[data-fs-input-field-error="true"] { [data-fs-input] { border-color: var(--fs-input-field-error-border-color); + @include input-focus-ring( $outline: #{var(--fs-input-field-error-focus-ring)}, $border: #{var(--fs-input-field-error-border-color)} diff --git a/packages/ui/src/components/organisms/ReviewModal/styles.scss b/packages/ui/src/components/organisms/ReviewModal/styles.scss index 9175916024..b33d8b85ba 100644 --- a/packages/ui/src/components/organisms/ReviewModal/styles.scss +++ b/packages/ui/src/components/organisms/ReviewModal/styles.scss @@ -27,9 +27,64 @@ display: flex; flex-direction: column; + [data-fs-review-modal-form] { + display: flex; + flex: 1; + flex-direction: column; + overflow: hidden; + } + [data-fs-review-modal-body] { + display: flex; flex: 1; + flex-direction: column; + gap: 1.25rem; // 20px overflow-y: auto; + + [data-fs-review-modal-step="success"] { + gap: var(--fs-spacing-5); + align-items: center; + padding-top: var(--fs-spacing-11); + background-color: var(--fs-color-neutral-1); + + [data-fs-review-modal-success-feedback] { + display: flex; + flex-direction: column; + gap: 0.375rem; // 6px + align-items: center; + + h3 { + font-size: var(--fs-text-size-5); + font-weight: var(--fs-text-weight-semibold); + color: var(--fs-color-text); + } + + p { + font-size: var(--fs-text-size-2); + font-weight: var(--fs-text-weight-medium); + color: var(--fs-color-text-light); + } + } + + [data-fs-review-card] { + --fs-review-card-padding-mobile: var(--fs-spacing-4); + --fs-review-card-gap-desktop: var(--fs-review-card-gap-mobile); + --fs-review-card-padding-desktop: var(--fs-review-card-padding-mobile); + + max-width: 26.25rem; // 420px + background-color: var(--fs-color-neutral-0); + border: none; + border-radius: var(--fs-border-radius); + + @include media(">=tablet") { + flex-direction: column; + } + } + } + } + + [data-fs-review-modal-footer] { + flex-shrink: 0; } @include media("