Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,14 @@
import { FieldNameToFieldId } from '~/data-transformers/form-field-transformer/utils';
import { redirect } from '~/i18n/routing';
import { getCartId } from '~/lib/cart';
import { RECAPTCHA_TOKEN_FORM_KEY } from '~/lib/recaptcha';

import { ADDRESS_FIELDS_NAME_PREFIX, CUSTOMER_FIELDS_NAME_PREFIX } from './prefixes';

const RegisterCustomerMutation = graphql(`
mutation RegisterCustomerMutation($input: RegisterCustomerInput!) {
mutation RegisterCustomerMutation($input: RegisterCustomerInput!, $reCaptchaV2: ReCaptchaV2Input) {

Check warning on line 22 in core/app/[locale]/(default)/(auth)/register/_actions/register-customer.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, and gql.tada

Replace `$input:·RegisterCustomerInput!,·$reCaptchaV2:·ReCaptchaV2Input` with `⏎····$input:·RegisterCustomerInput!⏎····$reCaptchaV2:·ReCaptchaV2Input⏎··`
customer {
registerCustomer(input: $input) {
registerCustomer(input: $input, reCaptchaV2: $reCaptchaV2) {
customer {
firstName
lastName
Expand Down Expand Up @@ -358,10 +359,15 @@

try {
const input = parseRegisterCustomerInput(submission.value, fields);
const recaptchaToken = formData.get(RECAPTCHA_TOKEN_FORM_KEY);
const response = await client.fetch({
document: RegisterCustomerMutation,
variables: {
input,
reCaptchaV2:
typeof recaptchaToken === 'string' && recaptchaToken
? { token: recaptchaToken }
: undefined,
},
fetchOptions: { cache: 'no-store' },
});
Expand Down
8 changes: 6 additions & 2 deletions core/app/[locale]/(default)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { PropsWithChildren } from 'react';

import { Footer } from '~/components/footer';
import { Header } from '~/components/header';
import { ReCaptchaProvider } from '~/components/recaptcha-provider';
import { getReCaptchaSettings } from '~/lib/recaptcha';

interface Props extends PropsWithChildren {
params: Promise<{ locale: string }>;
Expand All @@ -13,13 +15,15 @@ export default async function DefaultLayout({ params, children }: Props) {

setRequestLocale(locale);

const reCaptchaSettings = await getReCaptchaSettings();

return (
<>
<ReCaptchaProvider settings={reCaptchaSettings}>
<Header />

<main>{children}</main>

<Footer />
</>
</ReCaptchaProvider>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@
import { getSessionCustomerAccessToken } from '~/auth';
import { client } from '~/client';
import { graphql } from '~/client/graphql';
import { RECAPTCHA_TOKEN_FORM_KEY } from '~/lib/recaptcha';

const AddProductReviewMutation = graphql(`
mutation AddProductReviewMutation($input: AddProductReviewInput!) {
mutation AddProductReviewMutation($input: AddProductReviewInput!, $reCaptchaV2: ReCaptchaV2Input) {

Check warning on line 15 in core/app/[locale]/(default)/product/[slug]/_actions/submit-review.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, and gql.tada

Replace `$input:·AddProductReviewInput!,·$reCaptchaV2:·ReCaptchaV2Input` with `⏎····$input:·AddProductReviewInput!⏎····$reCaptchaV2:·ReCaptchaV2Input⏎··`
catalog {
addProductReview(input: $input) {
addProductReview(input: $input, reCaptchaV2: $reCaptchaV2) {
__typename
errors {
__typename
Expand All @@ -39,6 +40,7 @@
}

const { productEntityId, ...input } = submission.value;
const recaptchaToken = payload.get(RECAPTCHA_TOKEN_FORM_KEY);

try {
const response = await client.fetch({
Expand All @@ -52,6 +54,10 @@
},
productEntityId,
},
reCaptchaV2:
typeof recaptchaToken === 'string' && recaptchaToken
? { token: recaptchaToken }
: undefined,
},
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Field, schema } from '@/vibes/soul/form/dynamic-form/schema';
import { client } from '~/client';
import { graphql, VariablesOf } from '~/client/graphql';
import { redirect } from '~/i18n/routing';
import { RECAPTCHA_TOKEN_FORM_KEY } from '~/lib/recaptcha';

const inputSchema = z.object({
data: z.object({
Expand All @@ -26,8 +27,8 @@ const inputSchema = z.object({
});

const SubmitContactUsMutation = graphql(`
mutation SubmitContactUsMutation($input: SubmitContactUsInput!) {
submitContactUs(input: $input) {
mutation SubmitContactUsMutation($input: SubmitContactUsInput!, $reCaptchaV2: ReCaptchaV2Input) {
submitContactUs(input: $input, reCaptchaV2: $reCaptchaV2) {
__typename
errors {
__typename
Expand Down Expand Up @@ -76,10 +77,15 @@ export async function submitContactForm<F extends Field>(

try {
const input = parseContactFormInput(submission.value);
const recaptchaToken = formData.get(RECAPTCHA_TOKEN_FORM_KEY);
const response = await client.fetch({
document: SubmitContactUsMutation,
variables: {
input,
reCaptchaV2:
typeof recaptchaToken === 'string' && recaptchaToken
? { token: recaptchaToken }
: undefined,
},
fetchOptions: { cache: 'no-store' },
});
Expand Down
33 changes: 33 additions & 0 deletions core/components/recaptcha-provider/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
'use client';

import { createContext, useContext, type PropsWithChildren } from 'react';

Check failure on line 3 in core/components/recaptcha-provider/index.tsx

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, and gql.tada

Member 'PropsWithChildren' of the import declaration should be sorted alphabetically

import type { ReCaptchaSettings } from '~/lib/recaptcha/constants';

const ReCaptchaContext = createContext<ReCaptchaSettings | null>(null);

export function useReCaptchaSettings(): ReCaptchaSettings | null {
return useContext(ReCaptchaContext);
}

/** Site key when reCAPTCHA is enabled on the storefront; undefined otherwise. */

Check failure on line 13 in core/components/recaptcha-provider/index.tsx

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, and gql.tada

Missing JSDoc @returns for function
export function useReCaptchaSiteKey(): string | undefined {
const settings = useReCaptchaSettings();
return settings?.isEnabledOnStorefront === true && settings?.siteKey

Check failure on line 16 in core/components/recaptcha-provider/index.tsx

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, and gql.tada

Unnecessary optional chain on a non-nullish value

Check warning on line 16 in core/components/recaptcha-provider/index.tsx

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, and gql.tada

Expected blank line before this statement
? settings.siteKey
: undefined;
}

interface ReCaptchaProviderProps extends PropsWithChildren {
settings: ReCaptchaSettings | null;
}

/**

Check failure on line 25 in core/components/recaptcha-provider/index.tsx

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, and gql.tada

Missing JSDoc @returns for function
* Provides reCAPTCHA settings (e.g. siteKey) via context.
* Forms use react-google-recaptcha (v2) and receive siteKey via props from server.
*/
export function ReCaptchaProvider({ settings, children }: ReCaptchaProviderProps) {
return (

Check warning on line 30 in core/components/recaptcha-provider/index.tsx

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, and gql.tada

Replace `(⏎····<ReCaptchaContext.Provider·value={settings}>{children}</ReCaptchaContext.Provider>⏎··)` with `<ReCaptchaContext.Provider·value={settings}>{children}</ReCaptchaContext.Provider>`
<ReCaptchaContext.Provider value={settings}>{children}</ReCaptchaContext.Provider>
);
}
46 changes: 46 additions & 0 deletions core/lib/recaptcha.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import 'server-only';

import { cache } from 'react';

import { client } from '~/client';
import { graphql } from '~/client/graphql';
import { revalidate } from '~/client/revalidate-target';

Check warning on line 7 in core/lib/recaptcha.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, and gql.tada

There should be at least one empty line between import groups
import type { ReCaptchaSettings } from './recaptcha/constants';

export { RECAPTCHA_TOKEN_FORM_KEY } from './recaptcha/constants';
export type { ReCaptchaSettings } from './recaptcha/constants';

export const ReCaptchaSettingsQuery = graphql(`
query ReCaptchaSettingsQuery {
site {
settings {
reCaptcha {
failedLoginLockoutDurationSeconds
isEnabledOnCheckout
isEnabledOnStorefront
siteKey
}
}
}
}
`);

export const getReCaptchaSettings = cache(async (): Promise<ReCaptchaSettings | null> => {
const { data } = await client.fetch({
document: ReCaptchaSettingsQuery,
fetchOptions: { next: { revalidate } },
});

const reCaptcha = data.site.settings?.reCaptcha;

if (!reCaptcha?.siteKey) {
return null;
}

return {
failedLoginLockoutDurationSeconds: reCaptcha.failedLoginLockoutDurationSeconds ?? null,
isEnabledOnCheckout: reCaptcha.isEnabledOnCheckout,
isEnabledOnStorefront: reCaptcha.isEnabledOnStorefront,
siteKey: reCaptcha.siteKey,
};
});
9 changes: 9 additions & 0 deletions core/lib/recaptcha/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export type ReCaptchaSettings = {

Check failure on line 1 in core/lib/recaptcha/constants.ts

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, and gql.tada

Use an `interface` instead of a `type`
failedLoginLockoutDurationSeconds: number | null;
isEnabledOnCheckout: boolean;
isEnabledOnStorefront: boolean;
siteKey: string;
};

/** FormData key used to pass the reCAPTCHA token from client to server actions */
export const RECAPTCHA_TOKEN_FORM_KEY = 'recaptchaToken';
1 change: 1 addition & 0 deletions core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"react": "19.1.5",
"react-day-picker": "^9.7.0",
"react-dom": "19.1.5",
"react-google-recaptcha": "^3.1.0",
"react-headroom": "^3.2.1",
"schema-dts": "^1.1.5",
"server-only": "^0.0.1",
Expand Down
33 changes: 30 additions & 3 deletions core/vibes/soul/form/dynamic-form/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,15 @@
startTransition,
useActionState,
useEffect,
} from 'react';

Check warning on line 22 in core/vibes/soul/form/dynamic-form/index.tsx

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, and gql.tada

'/home/runner/work/catalyst/catalyst/node_modules/.pnpm/@types+react@19.2.7/node_modules/@types/react/index.d.ts' imported multiple times
import { useRef } from 'react';

Check warning on line 23 in core/vibes/soul/form/dynamic-form/index.tsx

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, and gql.tada

'/home/runner/work/catalyst/catalyst/node_modules/.pnpm/@types+react@19.2.7/node_modules/@types/react/index.d.ts' imported multiple times
import { useFormStatus } from 'react-dom';
import ReCAPTCHA from 'react-google-recaptcha';

Check warning on line 25 in core/vibes/soul/form/dynamic-form/index.tsx

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, and gql.tada

Using exported name 'ReCAPTCHA' as identifier for default import
import { z } from 'zod';

import { useReCaptchaSiteKey } from '~/components/recaptcha-provider';

Check warning on line 28 in core/vibes/soul/form/dynamic-form/index.tsx

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, and gql.tada

`~/components/recaptcha-provider` import should occur after import of `@/vibes/soul/primitives/button`
import { RECAPTCHA_TOKEN_FORM_KEY } from '~/lib/recaptcha/constants';

Check warning on line 29 in core/vibes/soul/form/dynamic-form/index.tsx

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, and gql.tada

There should be no empty line within import group

import { ButtonRadioGroup } from '@/vibes/soul/form/button-radio-group';
import { CardRadioGroup } from '@/vibes/soul/form/card-radio-group';
import { Checkbox } from '@/vibes/soul/form/checkbox';
Expand Down Expand Up @@ -79,7 +84,11 @@
errorTranslations?: FormErrorTranslationMap;
}

export function DynamicForm<F extends Field>({
export function DynamicForm<F extends Field>(props: DynamicFormProps<F>) {
return <DynamicFormInner {...props} />;
}

function DynamicFormInner<F extends Field>({
action,
fields,
buttonSize = 'medium',
Expand All @@ -94,6 +103,7 @@
errorTranslations,
}: DynamicFormProps<F>) {
const t = useTranslations('Form');
const recaptchaSiteKey = useReCaptchaSiteKey();
// Remove options from fields before passing to action to reduce payload size
// Options are only needed for rendering, not for processing form submissions
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
Expand All @@ -104,6 +114,8 @@
lastResult: null,
});

const recaptchaRef = useRef<ReCAPTCHA | null>(null);

Check failure on line 117 in core/vibes/soul/form/dynamic-form/index.tsx

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, and gql.tada

'ReCAPTCHA' is an 'error' type that acts as 'any' and overrides all other types in this union type

const dynamicSchema = schema(fields, passwordComplexity, errorTranslations);
const defaultValue = fields
.flatMap((f) => (Array.isArray(f) ? f : [f]))
Expand Down Expand Up @@ -145,11 +157,25 @@
defaultValue,
shouldValidate: 'onSubmit',
shouldRevalidate: 'onInput',
onSubmit(event, { formData }) {
async onSubmit(event, { formData }) {

Check failure on line 160 in core/vibes/soul/form/dynamic-form/index.tsx

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, and gql.tada

Promise-returning function provided to property where a void return was expected
event.preventDefault();

let payload: FormData = formData;
if (recaptchaSiteKey && recaptchaRef.current) {
try {
const token = await recaptchaRef.current.executeAsync();

Check failure on line 166 in core/vibes/soul/form/dynamic-form/index.tsx

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, and gql.tada

Unsafe call of a(n) `error` type typed value

Check failure on line 166 in core/vibes/soul/form/dynamic-form/index.tsx

View workflow job for this annotation

GitHub Actions / Lint, Typecheck, and gql.tada

Unsafe assignment of an error typed value
if (token) {
payload = new FormData(event.currentTarget);
payload.set(RECAPTCHA_TOKEN_FORM_KEY, token);
}
recaptchaRef.current.reset();
} catch {
// Proceed without token
}
}

startTransition(() => {
formAction(formData);
formAction(payload);
});
},
});
Expand Down Expand Up @@ -191,6 +217,7 @@

return <DynamicFormField field={field} formField={formField} key={formField.id} />;
})}
{recaptchaSiteKey && <ReCAPTCHA ref={recaptchaRef} sitekey={recaptchaSiteKey} />}
<div className="flex gap-1 pt-3">
{onCancel && (
<Button
Expand Down
26 changes: 24 additions & 2 deletions core/vibes/soul/sections/reviews/review-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { getZodConstraint } from '@conform-to/zod';
import { useTranslations } from 'next-intl';
import { startTransition, useActionState, useEffect, useRef, useState } from 'react';
import { useFormStatus } from 'react-dom';
import ReCAPTCHA from 'react-google-recaptcha';

import { FormStatus } from '@/vibes/soul/form/form-status';
import { Input } from '@/vibes/soul/form/input';
Expand All @@ -15,7 +16,9 @@ import { Button } from '@/vibes/soul/primitives/button';
import { Modal } from '@/vibes/soul/primitives/modal';
import { toast } from '@/vibes/soul/primitives/toaster';
import { Image } from '~/components/image';
import { useReCaptchaSiteKey } from '~/components/recaptcha-provider';
import { parseWithZodTranslatedErrors } from '~/i18n/utils';
import { RECAPTCHA_TOKEN_FORM_KEY } from '~/lib/recaptcha/constants';

import { reviewFormErrorTranslations, schema } from './schema';

Expand Down Expand Up @@ -63,12 +66,14 @@ export const ReviewForm = ({
streamableUser,
}: Props) => {
const t = useTranslations('Product.Reviews.Form');
const recaptchaSiteKey = useReCaptchaSiteKey();
const errorTranslations = reviewFormErrorTranslations(t);
const [isOpen, setIsOpen] = useState(false);
const [{ lastResult, successMessage }, formAction] = useActionState(action, {
lastResult: null,
});
const formRef = useRef<HTMLFormElement>(null);
const recaptchaRef = useRef<ReCAPTCHA | null>(null);

const user = useStreamable(streamableUser);

Expand All @@ -84,11 +89,23 @@ export const ReviewForm = ({
onValidate({ formData }) {
return parseWithZodTranslatedErrors(formData, { schema, errorTranslations });
},
onSubmit(event, { formData }) {
async onSubmit(event, { formData }) {
event.preventDefault();

let payload: FormData = formData;
if (recaptchaSiteKey && recaptchaRef.current) {
try {
const token = await recaptchaRef.current.execute();
if (token) {
payload = new FormData(event.currentTarget);
payload.set(RECAPTCHA_TOKEN_FORM_KEY, token);
}
recaptchaRef.current.reset();
} catch {}
}

startTransition(() => {
formAction(formData);
formAction(payload);
});
},
});
Expand Down Expand Up @@ -220,6 +237,11 @@ export const ReviewForm = ({
{error}
</FormStatus>
))}
{recaptchaSiteKey && (
<div>
<ReCAPTCHA ref={recaptchaRef} sitekey={recaptchaSiteKey} />
</div>
)}
<div className="mt-auto flex justify-end gap-3">
<Button onClick={() => setIsOpen(false)} size="small" type="button" variant="ghost">
{formCancelLabel}
Expand Down
Loading
Loading