diff --git a/src/components/common/Recaptcha/Recaptcha.tsx b/src/components/common/Recaptcha/Recaptcha.tsx index 69d0840..fc2e05c 100644 --- a/src/components/common/Recaptcha/Recaptcha.tsx +++ b/src/components/common/Recaptcha/Recaptcha.tsx @@ -8,7 +8,14 @@ declare global { * reCAPTCHA v2 global helper (loaded from https://www.google.com/recaptcha/api.js) */ ready: (cb: () => void) => void - render: (container: HTMLElement, params: { sitekey: string }) => number + render: ( + container: HTMLElement, + params: { + sitekey: string + callback?: (token: string) => void + 'expired-callback'?: () => void + } + ) => number getResponse: (widgetId?: number) => string reset: (widgetId?: number) => void } @@ -25,13 +32,15 @@ interface RecaptchaProps { siteKey: string className?: string onChange?: (token: string) => void + onExpire?: () => void } export const Recaptcha = forwardRef( - function Recaptcha({ siteKey, className, onChange }, ref) { + function Recaptcha({ siteKey, className, onChange, onExpire }, ref) { const { containerRef, getToken, reset } = useRecaptcha({ siteKey, onChange, + onExpire, }) useImperativeHandle( diff --git a/src/hooks/useContactForm.ts b/src/hooks/useContactForm.ts index 15b6f88..0951d1d 100644 --- a/src/hooks/useContactForm.ts +++ b/src/hooks/useContactForm.ts @@ -8,6 +8,8 @@ import { uploadToPresignedUrl, createServiceRequestWithKeys, } from '@/utils/upload' +import { captureAppException } from '@/lib/sentry' +import { FetchClientError } from '@/utils/fetchClient' import { MAX_TOTAL_UPLOAD_BYTES, MAX_FILES, @@ -116,6 +118,18 @@ export const useContactForm = (options: UseContactFormOptions) => { setRecaptchaCompleted(true) }, []) + const handleRecaptchaExpired = useCallback(() => { + setRecaptchaCompleted(false) + setFieldErrors(prev => ({ + ...prev, + recaptcha: 'reCAPTCHA verification expired. Please verify again.', + })) + captureAppException(new Error('reCAPTCHA verification expired'), { + source: 'app', + tags: { 'recaptcha.event': 'expired' }, + }) + }, []) + const addFiles = useCallback( (newFiles: File[]) => { const nonEmpty = newFiles.filter(file => file.size > 0) @@ -381,26 +395,28 @@ export const useContactForm = (options: UseContactFormOptions) => { size: a.file.size, })) const presigned = await getPresignedUrls(files) - const presignedByFilename = presigned.reduce< - Record - >((acc, current) => { - if (!acc[current.filename]) acc[current.filename] = [] - acc[current.filename].push(current) - return acc - }, {}) const uploadResults = await Promise.allSettled( - pendingItems.map(item => { - const candidateList = presignedByFilename[item.file.name] || [] - const p = candidateList.shift() + pendingItems.map((item, index) => { + const p = presigned[index] if (!p) { setAttachmentStatus(item.id, { status: 'error', error: 'No upload URL was returned for this file.', }) - return Promise.reject( - new Error('No upload URL was returned for this file.') + const error = new Error( + 'No upload URL was returned for this file.' ) + captureAppException(error, { + source: 'support-api', + tags: { 'upload.stage': 'presign_mismatch' }, + extra: { + pendingCount: pendingItems.length, + presignedCount: presigned.length, + missingIndex: index, + }, + }) + return Promise.reject(error) } setAttachmentStatus(item.id, { status: 'uploading', @@ -433,6 +449,10 @@ export const useContactForm = (options: UseContactFormOptions) => { error: err instanceof Error ? err.message : 'Upload failed', }) + captureAppException(err, { + source: 'support-api', + tags: { 'upload.stage': 'presigned_put' }, + }) return Promise.reject(err) }) }) @@ -476,6 +496,13 @@ export const useContactForm = (options: UseContactFormOptions) => { const msg = rawMessage && rawMessage !== 'Failed to fetch' ? rawMessage : fallback trackSupportFormFailed(typeOfEnquiry || 'Unknown', msg) + // FetchClientError failures are already captured with request context in fetchClient.ts + if (!(err instanceof FetchClientError)) { + captureAppException(err, { + source: 'support-api', + tags: { 'contact.stage': 'submit' }, + }) + } setSubmitError(msg) } finally { setIsSubmitting(false) @@ -520,6 +547,7 @@ export const useContactForm = (options: UseContactFormOptions) => { validateTypeOfEnquiry, validateDescription, clearRecaptchaError, + handleRecaptchaExpired, onSubmit, isFormValid, } diff --git a/src/hooks/useRecaptcha.ts b/src/hooks/useRecaptcha.ts index b31c8c2..9b0fb2c 100644 --- a/src/hooks/useRecaptcha.ts +++ b/src/hooks/useRecaptcha.ts @@ -5,22 +5,30 @@ const SCRIPT_ID = 'recaptcha-v2-script' type UseRecaptchaOptions = { siteKey: string | null | undefined onChange?: (token: string) => void + onExpire?: () => void } -export const useRecaptcha = ({ siteKey, onChange }: UseRecaptchaOptions) => { +export const useRecaptcha = ({ + siteKey, + onChange, + onExpire, +}: UseRecaptchaOptions) => { const containerRef = useRef(null) const widgetIdRef = useRef(null) const siteKeyRef = useRef(siteKey) const onChangeRef = useRef(onChange) + const onExpireRef = useRef(onExpire) siteKeyRef.current = siteKey onChangeRef.current = onChange + onExpireRef.current = onExpire useEffect(() => { if (!siteKeyRef.current) return const ensureRendered = () => { if (!containerRef.current || widgetIdRef.current !== null) return + if (!siteKeyRef.current) return const grecaptcha = globalThis.window?.grecaptcha if (!grecaptcha || typeof grecaptcha.render !== 'function') return @@ -29,7 +37,10 @@ export const useRecaptcha = ({ siteKey, onChange }: UseRecaptchaOptions) => { callback: (token: string) => { onChangeRef.current?.(token) }, - } as unknown as { sitekey: string }) + 'expired-callback': () => { + onExpireRef.current?.() + }, + }) } const loadScript = () => { diff --git a/src/index.css b/src/index.css index cb5f3d2..81c6845 100644 --- a/src/index.css +++ b/src/index.css @@ -4331,6 +4331,11 @@ p.small { height: 24px; } +.connect-magiclink-button-icon { + width: 24px; + height: 24px; +} + .connect-metamask-button-text { display: flex; flex-direction: row; diff --git a/src/pages/Contact/index.tsx b/src/pages/Contact/index.tsx index d318f00..88c40fa 100644 --- a/src/pages/Contact/index.tsx +++ b/src/pages/Contact/index.tsx @@ -44,6 +44,7 @@ const Contact = ({ isDarkMode }: ContactProps) => { handleDrop, handleFileInput, clearRecaptchaError, + handleRecaptchaExpired, onSubmit, } = useContactForm({ getRecaptchaToken: () => @@ -169,6 +170,7 @@ const Contact = ({ isDarkMode }: ContactProps) => { siteKey={RECAPTCHA_SITE_KEY} className="flex justify-center" onChange={clearRecaptchaError} + onExpire={handleRecaptchaExpired} /> {fieldErrors.recaptcha && (