From 3b8d33827a8324ad16d6005b26b8f6f8bf03bcf3 Mon Sep 17 00:00:00 2001 From: waleed Date: Wed, 1 Jul 2026 00:39:01 -0700 Subject: [PATCH 1/5] feat(landing): reintroduce /contact page styled like /demo - Restore the /contact page (removed in #5181) with a two-column layout mirroring /demo: value prop + trusted-by logos on the left, a message form card on the right, on the platform light tokens and chip components - Restore the contact contract, /api/contact route (rate-limit, honeypot, Turnstile, help-inbox notification + visitor confirmation), now fully contract-bound via parseRequest - Add a useSubmitContact React Query mutation hook - Link Contact from the footer Resources column and add it to the sitemap --- .../(landing)/components/footer/footer.tsx | 1 + .../components/contact-form/contact-form.tsx | 349 ++++++++++++++++++ .../contact/components/contact-form/index.ts | 1 + apps/sim/app/(landing)/contact/contact.tsx | 75 ++++ apps/sim/app/(landing)/contact/page.tsx | 18 + apps/sim/app/api/contact/route.ts | 193 ++++++++++ apps/sim/app/sitemap.ts | 3 + apps/sim/hooks/queries/contact.ts | 25 ++ apps/sim/lib/api/contracts/contact.ts | 110 ++++++ scripts/check-api-validation-contracts.ts | 4 +- 10 files changed, 777 insertions(+), 2 deletions(-) create mode 100644 apps/sim/app/(landing)/contact/components/contact-form/contact-form.tsx create mode 100644 apps/sim/app/(landing)/contact/components/contact-form/index.ts create mode 100644 apps/sim/app/(landing)/contact/contact.tsx create mode 100644 apps/sim/app/(landing)/contact/page.tsx create mode 100644 apps/sim/app/api/contact/route.ts create mode 100644 apps/sim/hooks/queries/contact.ts create mode 100644 apps/sim/lib/api/contracts/contact.ts diff --git a/apps/sim/app/(landing)/components/footer/footer.tsx b/apps/sim/app/(landing)/components/footer/footer.tsx index 3a3e131403b..6eddc8f736e 100644 --- a/apps/sim/app/(landing)/components/footer/footer.tsx +++ b/apps/sim/app/(landing)/components/footer/footer.tsx @@ -43,6 +43,7 @@ const RESOURCES_LINKS: FooterItem[] = [ { label: 'Partners', href: '/partners' }, { label: 'Careers', href: 'https://jobs.ashbyhq.com/sim', external: true }, { label: 'Changelog', href: '/changelog' }, + { label: 'Contact', href: '/contact' }, ] /** Top model providers, sourced from the catalog so labels/hrefs never drift. */ diff --git a/apps/sim/app/(landing)/contact/components/contact-form/contact-form.tsx b/apps/sim/app/(landing)/contact/components/contact-form/contact-form.tsx new file mode 100644 index 00000000000..3025ce091f8 --- /dev/null +++ b/apps/sim/app/(landing)/contact/components/contact-form/contact-form.tsx @@ -0,0 +1,349 @@ +'use client' + +import { type ReactNode, useId, useRef, useState } from 'react' +import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile' +import { Chip, ChipDropdown, ChipInput, ChipTextarea, Label } from '@sim/emcn' +import { Check } from '@sim/emcn/icons' +import { toError } from '@sim/utils/errors' +import { + CONTACT_TOPIC_OPTIONS, + type ContactRequestPayload, + contactRequestSchema, +} from '@/lib/api/contracts/contact' +import { flattenFieldErrors } from '@/lib/api/contracts/primitives' +import { getEnv } from '@/lib/core/config/env' +import { captureClientEvent } from '@/lib/posthog/client' +import { useSubmitContact } from '@/hooks/queries/contact' + +/** + * Field control height — slightly taller than the 30px in-app chip default and + * just under the 36px auth field, so the form reads as a roomy landing surface. + * Applied to each control's `className`, the sanctioned way to own only a chip + * field's height (mirrors the demo form). + */ +const FIELD_HEIGHT = 'h-[34px]' + +type ContactField = keyof ContactRequestPayload +type ContactErrors = Partial> + +interface ContactFormState { + name: string + email: string + company: string + topic: ContactRequestPayload['topic'] | '' + subject: string + message: string +} + +const INITIAL_STATE: ContactFormState = { + name: '', + email: '', + company: '', + topic: '', + subject: '', + message: '', +} + +interface ContactFieldProps { + label: string + /** Set for native controls (inputs/textarea) to associate the label by `id`. */ + htmlFor?: string + required?: boolean + error?: string + /** The control. Dropdowns (no `htmlFor`) are wrapped in a labeled group. */ + children: ReactNode +} + +/** + * A labeled field row matching the chip field rhythm (`gap-[9px]`, muted label, + * caption-sized error). Native controls associate via `htmlFor`/`id`; controls + * that can't take a label `id` (the dropdown) become a `role='group'` named by + * the label instead, so every field has an accessible name. + */ +function ContactField({ label, htmlFor, required, error, children }: ContactFieldProps) { + const labelId = useId() + const isGroup = htmlFor === undefined + return ( +
+ + {children} + {error ?

{error}

: null} +
+ ) +} + +/** + * The `/contact` form — rendered inside the card chrome owned by the page, so it + * returns just its heading and fields. Fields are hand-composed at the slightly + * taller {@link FIELD_HEIGHT}, stacked at the platform `gap-4` rhythm with no + * divider lines, mirroring the demo booking form. + * + * On submit it validates against the shared {@link contactRequestSchema}, runs an + * invisible Turnstile challenge (falling back gracefully when the widget is + * unavailable), and posts through {@link useSubmitContact}, which emails the help + * inbox and sends the visitor a confirmation. A honeypot `website` field and the + * captcha token ride along on the payload. A successful submit swaps the card to a + * confirmation state. + */ +export function ContactForm() { + const turnstileRef = useRef(null) + + const contactMutation = useSubmitContact() + + const [form, setForm] = useState(INITIAL_STATE) + const [errors, setErrors] = useState({}) + const [isSubmitting, setIsSubmitting] = useState(false) + const [website, setWebsite] = useState('') + const [widgetReady, setWidgetReady] = useState(false) + const [turnstileSiteKey] = useState(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY')) + + function updateField( + field: TField, + value: ContactFormState[TField] + ) { + setForm((prev) => ({ ...prev, [field]: value })) + setErrors((prev) => { + if (!prev[field as ContactField]) { + return prev + } + const nextErrors = { ...prev } + delete nextErrors[field as ContactField] + return nextErrors + }) + if (contactMutation.isError) { + contactMutation.reset() + } + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault() + if (contactMutation.isPending || isSubmitting) return + setIsSubmitting(true) + + const parsed = contactRequestSchema.safeParse({ + ...form, + company: form.company || undefined, + }) + + if (!parsed.success) { + setErrors(flattenFieldErrors(parsed.error)) + setIsSubmitting(false) + return + } + + let captchaToken: string | undefined + let captchaUnavailable: boolean | undefined + const widget = turnstileRef.current + + if (turnstileSiteKey) { + if (widgetReady && widget) { + try { + widget.reset() + widget.execute() + captchaToken = await widget.getResponsePromise(30_000) + } catch { + captchaUnavailable = true + } + } else { + captchaUnavailable = true + } + } + + contactMutation.mutate( + { ...parsed.data, website, captchaToken, captchaUnavailable }, + { + onSuccess: () => { + captureClientEvent('landing_contact_submitted', { topic: parsed.data.topic }) + setForm(INITIAL_STATE) + setErrors({}) + }, + onError: () => { + turnstileRef.current?.reset() + }, + } + ) + setIsSubmitting(false) + } + + const isBusy = contactMutation.isPending || isSubmitting + + const submitError = contactMutation.isError + ? toError(contactMutation.error).message || 'Failed to send message. Please try again.' + : null + + if (contactMutation.isSuccess) { + return ( +
+
+ +
+

Message received

+

+ Thanks for reaching out. We've sent a confirmation to your inbox and will get back to you + shortly. +

+ +
+ ) + } + + return ( + <> +

+ Send us a message +

+

+ Ask a question, request an integration, or get help — we'll get back to you shortly. +

+ +
+ {/* Honeypot */} + + +
+ + updateField('name', event.target.value)} + error={Boolean(errors.name)} + placeholder='Jane Doe' + autoComplete='name' + /> + + + updateField('email', event.target.value)} + error={Boolean(errors.email)} + placeholder='jane@acme.co' + autoComplete='email' + /> + +
+ +
+ + updateField('company', event.target.value)} + error={Boolean(errors.company)} + placeholder='Acme Inc.' + autoComplete='organization' + /> + + + updateField('topic', value as ContactRequestPayload['topic'])} + options={CONTACT_TOPIC_OPTIONS} + placeholder='Select a topic' + /> + +
+ + + updateField('subject', event.target.value)} + error={Boolean(errors.subject)} + placeholder='How can we help?' + /> + + + + updateField('message', event.target.value)} + error={Boolean(errors.message)} + placeholder='Share details so we can help as quickly as possible.' + rows={4} + /> + + + {turnstileSiteKey ? ( + setWidgetReady(true)} + onExpire={() => setWidgetReady(false)} + onError={() => setWidgetReady(false)} + onUnsupported={() => setWidgetReady(false)} + /> + ) : null} + + {submitError ? ( +

+ {submitError} +

+ ) : null} + + {/* + `Chip` gives its label span `flex-1`; under `fullWidth` that left-aligns the + label, so center it with `justify-center` + a `flex-none` span override. + */} + + {isBusy ? 'Sending…' : 'Send message'} + + + + ) +} diff --git a/apps/sim/app/(landing)/contact/components/contact-form/index.ts b/apps/sim/app/(landing)/contact/components/contact-form/index.ts new file mode 100644 index 00000000000..ab5151da9c7 --- /dev/null +++ b/apps/sim/app/(landing)/contact/components/contact-form/index.ts @@ -0,0 +1 @@ +export { ContactForm } from './contact-form' diff --git a/apps/sim/app/(landing)/contact/contact.tsx b/apps/sim/app/(landing)/contact/contact.tsx new file mode 100644 index 00000000000..ff15713e77d --- /dev/null +++ b/apps/sim/app/(landing)/contact/contact.tsx @@ -0,0 +1,75 @@ +import { chipBorderShadowRing, cn } from '@sim/emcn' +import { TrustedBy } from '@/app/(landing)/components/trusted-by' +import { ContactForm } from '@/app/(landing)/contact/components/contact-form' + +/** + * Contact page — mirrors the demo page's two-column split: value proposition and + * customer proof on the left, the message form in a content-height card on the + * right. + * + * The section is a two-column CSS grid capped and centered at the shared + * `max-w-[1446px]` with the navbar-aligned `px-12` gutter, so the headline starts + * on the same vertical line as the wordmark. The desktop split is `xl:grid-cols-2` + * with `xl:gap-x-0` — the columns split at the exact horizontal center, so the + * right card occupies the same rectangle as the hero's right panel. The card is + * inset from the section's top and bottom by 32px (`xl:pt-8`/`xl:pb-8`), spans both + * rows (`xl:row-span-2`), and its content drives the column height — the left + * column stretches to match, bottom-anchoring the logos to the card's lower edge. + * + * Three grid children, ordered in the DOM as headline → form → logos so the + * COLLAPSE below `xl` (single column) yields the best mobile reading order: value + * proposition first, the form immediately after it, then the customer logos as + * reinforcing social proof. On desktop the headline cell adds `xl:pt-[80px]` so its + * text sits on the hero's line, while the card top stays on the higher `top-8` + * line. The customer proof reuses the shared {@link TrustedBy} block, + * bottom-anchored (`xl:row-start-2 xl:self-end`). The gutter follows the navbar + * convention (`px-12 max-lg:px-8 max-sm:px-5`), and `max-sm` drops to the smallest + * type scale. + * + * Carries an sr-only product summary for AI citation (landing CLAUDE.md → GEO). + */ +export default function Contact() { + return ( +
+
+
+

+ Get in touch with Sim, the open-source AI workspace where teams build, deploy, and + manage AI agents and workflows. Ask a question, request an integration, or get help from + the team — send a message and we'll get back to you shortly. +

+ +

+ Get in touch with Sim,
+ the AI agent workspace. +

+

+ Ask a question, request an integration, or get help from the team. Tell us what you need + and we'll get back to you shortly. +

+
+ +
+
+ +
+
+ + +
+
+ ) +} diff --git a/apps/sim/app/(landing)/contact/page.tsx b/apps/sim/app/(landing)/contact/page.tsx new file mode 100644 index 00000000000..3876509d709 --- /dev/null +++ b/apps/sim/app/(landing)/contact/page.tsx @@ -0,0 +1,18 @@ +import { buildLandingMetadata } from '@/lib/landing/seo' +import Contact from '@/app/(landing)/contact/contact' + +export const revalidate = 3600 + +const TITLE = 'Contact Us | Sim, the AI Workspace' +const DESCRIPTION = + 'Get in touch with Sim, the open-source AI workspace where teams build, deploy, and manage AI agents. Ask a question, request an integration, or get help from the team.' + +export const metadata = buildLandingMetadata({ + title: TITLE, + description: DESCRIPTION, + path: '/contact', +}) + +export default function Page() { + return +} diff --git a/apps/sim/app/api/contact/route.ts b/apps/sim/app/api/contact/route.ts new file mode 100644 index 00000000000..c4e6bc8d451 --- /dev/null +++ b/apps/sim/app/api/contact/route.ts @@ -0,0 +1,193 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { renderHelpConfirmationEmail } from '@/components/emails' +import { + getContactTopicLabel, + mapContactTopicToHelpType, + submitContactContract, +} from '@/lib/api/contracts/contact' +import { parseRequest } from '@/lib/api/server' +import { env } from '@/lib/core/config/env' +import type { TokenBucketConfig } from '@/lib/core/rate-limiter' +import { RateLimiter } from '@/lib/core/rate-limiter' +import { isTurnstileConfigured, verifyTurnstileToken } from '@/lib/core/security/turnstile' +import { generateRequestId, getClientIp } from '@/lib/core/utils/request' +import { getEmailDomain, SITE_URL } from '@/lib/core/utils/urls' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { sendEmail } from '@/lib/messaging/email/mailer' +import { getFromEmailAddress } from '@/lib/messaging/email/utils' + +const logger = createLogger('ContactAPI') +const rateLimiter = new RateLimiter() +const SITE_HOSTNAME = new URL(SITE_URL).hostname + +const PUBLIC_ENDPOINT_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 10, + refillRate: 5, + refillIntervalMs: 60_000, +} + +const CAPTCHA_UNAVAILABLE_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 3, + refillRate: 1, + refillIntervalMs: 60_000, +} + +const SUCCESS_RESPONSE = { success: true, message: "Thanks — we'll be in touch soon." } + +export const POST = withRouteHandler(async (req: NextRequest) => { + const requestId = generateRequestId() + + try { + const ip = getClientIp(req) + const storageKey = `public:contact:${ip}` + + const { allowed, remaining, resetAt } = await rateLimiter.checkRateLimitDirect( + storageKey, + PUBLIC_ENDPOINT_RATE_LIMIT + ) + + if (!allowed) { + logger.warn(`[${requestId}] Rate limit exceeded for IP ${ip}`, { remaining, resetAt }) + return NextResponse.json( + { error: 'Too many requests. Please try again later.' }, + { + status: 429, + headers: { 'Retry-After': String(Math.ceil((resetAt.getTime() - Date.now()) / 1000)) }, + } + ) + } + + const parsed = await parseRequest(submitContactContract, req, {}) + if (!parsed.success) { + logger.warn(`[${requestId}] Invalid contact request data`) + return parsed.response + } + + const { name, email, company, topic, subject, message, website, captchaToken } = + parsed.data.body + + if (typeof website === 'string' && website.trim().length > 0) { + logger.warn(`[${requestId}] Honeypot triggered, discarding`, { ip }) + return NextResponse.json(SUCCESS_RESPONSE, { status: 201 }) + } + + const captchaUnavailable = parsed.data.body.captchaUnavailable === true + + if (captchaUnavailable) { + const nocaptchaKey = `public:contact:nocaptcha:${ip}` + const { allowed: nocaptchaAllowed } = await rateLimiter.checkRateLimitDirect( + nocaptchaKey, + CAPTCHA_UNAVAILABLE_RATE_LIMIT + ) + if (!nocaptchaAllowed) { + logger.warn(`[${requestId}] Rate limit exceeded (no-captcha) for IP ${ip}`) + return NextResponse.json( + { error: 'Too many requests. Please try again later.' }, + { status: 429 } + ) + } + } + + if (isTurnstileConfigured() && !captchaUnavailable) { + const token = typeof captchaToken === 'string' ? captchaToken : null + const verification = await verifyTurnstileToken({ + token, + remoteIp: ip, + expectedHostname: SITE_HOSTNAME, + }) + if (!verification.success && verification.transportError) { + logger.warn( + `[${requestId}] Captcha transport error, falling back to no-captcha rate limit`, + { ip } + ) + const nocaptchaKey = `public:contact:nocaptcha:${ip}` + const { allowed: nocaptchaAllowed } = await rateLimiter.checkRateLimitDirect( + nocaptchaKey, + CAPTCHA_UNAVAILABLE_RATE_LIMIT + ) + if (!nocaptchaAllowed) { + logger.warn(`[${requestId}] Rate limit exceeded (transport-error fallback) for IP ${ip}`) + return NextResponse.json( + { error: 'Too many requests. Please try again later.' }, + { status: 429 } + ) + } + } else if (!verification.success) { + logger.warn(`[${requestId}] Captcha verification failed`, { + ip, + errorCodes: verification.errorCodes, + }) + return NextResponse.json( + { error: 'Captcha verification failed. Please try again.' }, + { status: 400 } + ) + } + } + + const topicLabel = getContactTopicLabel(topic) + + logger.info(`[${requestId}] Processing contact request`, { + email: `${email.substring(0, 3)}***`, + topic, + }) + + const emailText = `Contact form submission +Submitted: ${new Date().toISOString()} +Topic: ${topicLabel} +Name: ${name} +Email: ${email} +Company: ${company ?? 'Not provided'} + +Subject: ${subject} + +Message: +${message} +` + + const helpInboxDomain = env.EMAIL_DOMAIN || getEmailDomain() + const emailResult = await sendEmail({ + to: [`help@${helpInboxDomain}`], + subject: `[CONTACT:${topic.toUpperCase()}] ${subject}`, + text: emailText, + from: getFromEmailAddress(), + replyTo: email, + emailType: 'transactional', + }) + + if (!emailResult.success) { + logger.error(`[${requestId}] Error sending contact request email`, emailResult.message) + return NextResponse.json({ error: 'Failed to send message' }, { status: 500 }) + } + + logger.info(`[${requestId}] Contact request email sent successfully`) + + try { + const confirmationHtml = await renderHelpConfirmationEmail( + mapContactTopicToHelpType(topic), + 0 + ) + + await sendEmail({ + to: [email], + subject: `We've received your message: ${subject}`, + html: confirmationHtml, + from: getFromEmailAddress(), + replyTo: `help@${helpInboxDomain}`, + emailType: 'transactional', + }) + } catch (err) { + logger.warn(`[${requestId}] Failed to send contact confirmation email`, err) + } + + return NextResponse.json(SUCCESS_RESPONSE, { status: 201 }) + } catch (error) { + if (error instanceof Error && error.message.includes('not configured')) { + logger.error(`[${requestId}] Email service configuration error`, error) + return NextResponse.json({ error: 'Email service configuration error.' }, { status: 500 }) + } + + logger.error(`[${requestId}] Error processing contact request`, error) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/sitemap.ts b/apps/sim/app/sitemap.ts index 150caf3ba7c..668ff239095 100644 --- a/apps/sim/app/sitemap.ts +++ b/apps/sim/app/sitemap.ts @@ -42,6 +42,9 @@ export default async function sitemap(): Promise { { url: `${baseUrl}/demo`, }, + { + url: `${baseUrl}/contact`, + }, { url: `${baseUrl}/enterprise`, }, diff --git a/apps/sim/hooks/queries/contact.ts b/apps/sim/hooks/queries/contact.ts new file mode 100644 index 00000000000..17daab2470f --- /dev/null +++ b/apps/sim/hooks/queries/contact.ts @@ -0,0 +1,25 @@ +import { createLogger } from '@sim/logger' +import { useMutation } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import { + type SubmitContactBody, + type SubmitContactResult, + submitContactContract, +} from '@/lib/api/contracts/contact' + +const logger = createLogger('ContactMutation') + +/** + * Submit an inbound contact request. The route emails the help inbox (replying to + * the visitor) and sends the visitor a confirmation. Used by the public `/contact` + * form; the honeypot and captcha fields ride along on the same payload. + */ +export function useSubmitContact() { + return useMutation({ + mutationFn: (variables: SubmitContactBody): Promise => + requestJson(submitContactContract, { body: variables }), + onError: (error) => { + logger.error('Failed to submit contact request:', error) + }, + }) +} diff --git a/apps/sim/lib/api/contracts/contact.ts b/apps/sim/lib/api/contracts/contact.ts new file mode 100644 index 00000000000..2c80d0d5a2f --- /dev/null +++ b/apps/sim/lib/api/contracts/contact.ts @@ -0,0 +1,110 @@ +import { z } from 'zod' +import type { ContractBodyInput } from '@/lib/api/contracts/types' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { NO_EMAIL_HEADER_CONTROL_CHARS_REGEX } from '@/lib/messaging/email/utils' +import { quickValidateEmail } from '@/lib/messaging/email/validation' + +export const CONTACT_TOPIC_VALUES = [ + 'general', + 'support', + 'integration', + 'feature_request', + 'sales', + 'partnership', + 'billing', + 'other', +] as const + +export const CONTACT_TOPIC_OPTIONS = [ + { value: 'general', label: 'General question' }, + { value: 'support', label: 'Technical support' }, + { value: 'integration', label: 'Integration request' }, + { value: 'feature_request', label: 'Feature request' }, + { value: 'sales', label: 'Sales & pricing' }, + { value: 'partnership', label: 'Partnership' }, + { value: 'billing', label: 'Billing' }, + { value: 'other', label: 'Other' }, +] as const + +export const contactRequestSchema = z.object({ + name: z + .string() + .trim() + .min(1, 'Name is required') + .max(120, 'Name must be 120 characters or less') + .regex(NO_EMAIL_HEADER_CONTROL_CHARS_REGEX, 'Invalid characters'), + email: z + .string() + .trim() + .min(1, 'Email is required') + .max(320) + .transform((value) => value.toLowerCase()) + .refine((value) => quickValidateEmail(value).isValid, 'Enter a valid email'), + company: z + .string() + .trim() + .max(120, 'Company must be 120 characters or less') + .optional() + .transform((value) => (value && value.length > 0 ? value : undefined)), + topic: z.enum(CONTACT_TOPIC_VALUES, { + error: 'Please select a topic', + }), + subject: z + .string() + .trim() + .min(1, 'Subject is required') + .max(200, 'Subject must be 200 characters or less') + .regex(NO_EMAIL_HEADER_CONTROL_CHARS_REGEX, 'Invalid characters'), + message: z + .string() + .trim() + .min(1, 'Message is required') + .max(5000, 'Message must be 5,000 characters or less'), +}) + +export type ContactRequestPayload = z.infer +export type ContactRequestBody = z.input + +export const submitContactBodySchema = contactRequestSchema.extend({ + website: z.string().optional(), + captchaToken: z.string().optional(), + captchaUnavailable: z.boolean().optional(), +}) + +export function getContactTopicLabel(value: ContactRequestPayload['topic']): string { + return CONTACT_TOPIC_OPTIONS.find((option) => option.value === value)?.label ?? value +} + +export type HelpEmailType = 'bug' | 'feedback' | 'feature_request' | 'other' + +export function mapContactTopicToHelpType(topic: ContactRequestPayload['topic']): HelpEmailType { + switch (topic) { + case 'feature_request': + return 'feature_request' + case 'support': + return 'bug' + case 'integration': + return 'feedback' + default: + return 'other' + } +} + +export const contactResponseSchema = z.object({ + success: z.literal(true), + message: z.string(), +}) + +export type SubmitContactResult = z.output + +export const submitContactContract = defineRouteContract({ + method: 'POST', + path: '/api/contact', + body: submitContactBodySchema, + response: { + mode: 'json', + schema: contactResponseSchema, + }, +}) + +export type SubmitContactBody = ContractBodyInput diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 987f7f578de..0d0d94230aa 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 881, - zodRoutes: 881, + totalRoutes: 882, + zodRoutes: 882, nonZodRoutes: 0, } as const From fcdda4447b36e353bb54d9a27c293edac9e972be Mon Sep 17 00:00:00 2001 From: waleed Date: Wed, 1 Jul 2026 00:50:48 -0700 Subject: [PATCH 2/5] fix(contact): server-authoritative captcha + review fixes - Make captcha server-authoritative: drop the client-trusted captchaUnavailable flag; a valid Turnstile token is the only way past the stricter fallback bucket, so callers can't opt out of the challenge - Re-execute the Turnstile widget on every submit (incl. after expiry) instead of falling into the no-captcha path once the token expires - Reset the pre-submit gate on mutation settle so rapid double-clicks can't fire a duplicate /api/contact request - Map only feature_request to its email type; every other topic resolves to a General Inquiry confirmation so support requests aren't labeled bug reports - Drop the confirmation-email promise from the success copy (it's best-effort) - Collapse the duplicated no-captcha rate-limit branch; hoist shared response constants; read the Turnstile site key as a module constant --- .../components/contact-form/contact-form.tsx | 52 ++++++----- apps/sim/app/api/contact/route.ts | 89 +++++++++---------- apps/sim/lib/api/contracts/contact.ts | 18 ++-- 3 files changed, 76 insertions(+), 83 deletions(-) diff --git a/apps/sim/app/(landing)/contact/components/contact-form/contact-form.tsx b/apps/sim/app/(landing)/contact/components/contact-form/contact-form.tsx index 3025ce091f8..ae85635f442 100644 --- a/apps/sim/app/(landing)/contact/components/contact-form/contact-form.tsx +++ b/apps/sim/app/(landing)/contact/components/contact-form/contact-form.tsx @@ -23,6 +23,9 @@ import { useSubmitContact } from '@/hooks/queries/contact' */ const FIELD_HEIGHT = 'h-[34px]' +/** Build-time-inlined Turnstile site key; absent when captcha isn't configured. */ +const TURNSTILE_SITE_KEY = getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY') + type ContactField = keyof ContactRequestPayload type ContactErrors = Partial> @@ -105,8 +108,7 @@ export function ContactForm() { const [errors, setErrors] = useState({}) const [isSubmitting, setIsSubmitting] = useState(false) const [website, setWebsite] = useState('') - const [widgetReady, setWidgetReady] = useState(false) - const [turnstileSiteKey] = useState(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY')) + const [widgetLoaded, setWidgetLoaded] = useState(false) function updateField( field: TField, @@ -142,26 +144,25 @@ export function ContactForm() { return } + // Best-effort Turnstile execution: re-run the (invisible) widget on every + // submit, including after an expiry, since reset + execute yields a fresh + // token. If the widget never loaded or execution fails, the token is omitted + // and the server applies its stricter no-captcha rate limit. let captchaToken: string | undefined - let captchaUnavailable: boolean | undefined const widget = turnstileRef.current - if (turnstileSiteKey) { - if (widgetReady && widget) { - try { - widget.reset() - widget.execute() - captchaToken = await widget.getResponsePromise(30_000) - } catch { - captchaUnavailable = true - } - } else { - captchaUnavailable = true + if (TURNSTILE_SITE_KEY && widgetLoaded && widget) { + try { + widget.reset() + widget.execute() + captchaToken = await widget.getResponsePromise(30_000) + } catch { + captchaToken = undefined } } contactMutation.mutate( - { ...parsed.data, website, captchaToken, captchaUnavailable }, + { ...parsed.data, website, captchaToken }, { onSuccess: () => { captureClientEvent('landing_contact_submitted', { topic: parsed.data.topic }) @@ -171,9 +172,14 @@ export function ContactForm() { onError: () => { turnstileRef.current?.reset() }, + // Reset the pre-submit gate only once the mutation settles, so `isBusy` + // stays true continuously (the captcha window then `isPending`) and a + // second click can never slip through to fire a duplicate request. + onSettled: () => { + setIsSubmitting(false) + }, } ) - setIsSubmitting(false) } const isBusy = contactMutation.isPending || isSubmitting @@ -190,8 +196,7 @@ export function ContactForm() {

Message received

- Thanks for reaching out. We've sent a confirmation to your inbox and will get back to you - shortly. + Thanks for reaching out. Our team will get back to you shortly.