diff --git a/frontend/.env.example b/frontend/.env.example index 574a1872..9c2f59cb 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -21,3 +21,8 @@ VITE_OAUTH_REDIRECT_BASE= # Self-serve signup safety fallback used by useAppConfig when GET /config/public # is unreachable. Authoritative value comes from backend SELF_SERVE_ENABLED. VITE_SELF_SERVE_ENABLED=false + +# Calendly link surfaced on the /contact-sales confirmation screen. When unset, +# the "Want to skip ahead?" block is hidden. Vite bakes at build time, so prod +# requires ARG+ENV in frontend/Dockerfile. +VITE_CALENDLY_URL= diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 6bcdc0c8..66b67c4a 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -22,6 +22,7 @@ ARG VITE_GOOGLE_CLIENT_ID ARG VITE_MS_CLIENT_ID ARG VITE_OAUTH_REDIRECT_BASE ARG VITE_SELF_SERVE_ENABLED +ARG VITE_CALENDLY_URL ENV VITE_API_URL=$VITE_API_URL ENV VITE_SENTRY_DSN=$VITE_SENTRY_DSN ENV VITE_PUBLIC_POSTHOG_KEY=$VITE_PUBLIC_POSTHOG_KEY @@ -31,6 +32,7 @@ ENV VITE_GOOGLE_CLIENT_ID=$VITE_GOOGLE_CLIENT_ID ENV VITE_MS_CLIENT_ID=$VITE_MS_CLIENT_ID ENV VITE_OAUTH_REDIRECT_BASE=$VITE_OAUTH_REDIRECT_BASE ENV VITE_SELF_SERVE_ENABLED=$VITE_SELF_SERVE_ENABLED +ENV VITE_CALENDLY_URL=$VITE_CALENDLY_URL # Build the application RUN npm run build diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 64ff005d..3c079da1 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -12,6 +12,12 @@ export { default as accountsApi } from './accounts' export { default as billingApi } from './billing' export { default as plansApi } from './plans' export type { PublicPlanResponse } from './plans' +export { default as salesApi } from './sales' +export type { + SalesLeadCreatePayload, + SalesLeadCreateResponse, + SalesLeadSource, +} from './sales' export { default as usageApi } from './usage' export { default as adminApi } from './admin' export { treeMarkdownApi } from './treeMarkdown' diff --git a/frontend/src/api/sales.ts b/frontend/src/api/sales.ts new file mode 100644 index 00000000..5501d3bc --- /dev/null +++ b/frontend/src/api/sales.ts @@ -0,0 +1,32 @@ +import apiClient from './client' + +export type SalesLeadSource = 'pricing_page' | 'register_footer' | 'landing_page' + +export interface SalesLeadCreatePayload { + email: string + name: string + company: string + team_size?: string + message?: string + source: SalesLeadSource + posthog_distinct_id?: string +} + +export interface SalesLeadCreateResponse { + id: string + status: 'received' +} + +export const salesApi = { + /** + * Public Talk-to-Sales submission. No auth required. Rate-limited per IP + * server-side (5/hour). Server emits PostHog `talk_to_sales_form_submitted` + * — frontend should NOT also fire this event. + */ + async createLead(payload: SalesLeadCreatePayload): Promise { + const response = await apiClient.post('/sales-leads', payload) + return response.data + }, +} + +export default salesApi diff --git a/frontend/src/pages/ContactSalesPage.tsx b/frontend/src/pages/ContactSalesPage.tsx new file mode 100644 index 00000000..dd083ba6 --- /dev/null +++ b/frontend/src/pages/ContactSalesPage.tsx @@ -0,0 +1,396 @@ +import { useMemo, useState, type FormEvent } from 'react' +import { Link } from 'react-router-dom' + +import { salesApi, type SalesLeadSource } from '@/api/sales' +import { PageMeta } from '@/components/common/PageMeta' +import { useAppConfig } from '@/hooks/useAppConfig' +import '@/styles/landing.css' + +/* --------------------------------------------------------------------------- + * Source detection + * + * The backend `/sales-leads` endpoint requires a `source` enum. We classify + * by document.referrer: visitors landing on /contact-sales after viewing the + * pricing page get tagged `pricing_page`; everything else is `landing_page`. + * `register_footer` is reserved for the (future) sign-up footer CTA. + * + * Acceptable for v1 — server-side PostHog event uses this same `source` value. + * ------------------------------------------------------------------------- */ +function detectSource(): SalesLeadSource { + if (typeof document === 'undefined') return 'landing_page' + const ref = document.referrer || '' + if (ref.includes('/pricing')) return 'pricing_page' + return 'landing_page' +} + +const TEAM_SIZE_OPTIONS: Array<{ value: string; label: string }> = [ + { value: '', label: 'Select team size' }, + { value: '1-2', label: '1–2' }, + { value: '3-5', label: '3–5' }, + { value: '6-10', label: '6–10' }, + { value: '11-25', label: '11–25' }, + { value: '26+', label: 'More than 26' }, +] + +interface FormState { + name: string + email: string + company: string + team_size: string + message: string +} + +const INITIAL: FormState = { + name: '', + email: '', + company: '', + team_size: '', + message: '', +} + +function ContactSalesNotFound() { + return ( +
+

Page not found

+

This page is not available.

+ + Go to login + +
+ ) +} + +export function ContactSalesPage() { + const appConfig = useAppConfig() + const [form, setForm] = useState(INITIAL) + const [submitting, setSubmitting] = useState(false) + const [submitted, setSubmitted] = useState(false) + const [error, setError] = useState(null) + + const calendlyUrl = useMemo(() => { + const raw = import.meta.env.VITE_CALENDLY_URL + return typeof raw === 'string' && raw.trim().length > 0 ? raw.trim() : '' + }, []) + + // Self-serve disabled: 404. (Same pattern as PricingPage — done after hooks.) + if (!appConfig.isLoading && !appConfig.self_serve_enabled) { + return ( + <> + + + + ) + } + + const handleChange = + (field: keyof FormState) => + (e: React.ChangeEvent) => { + setForm((prev) => ({ ...prev, [field]: e.target.value })) + } + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault() + if (submitting) return + + const name = form.name.trim() + const email = form.email.trim() + const company = form.company.trim() + + if (!name || !email || !company) { + setError('Please fill in name, work email, and company.') + return + } + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + setError('Enter a valid work email address.') + return + } + + setSubmitting(true) + setError(null) + try { + await salesApi.createLead({ + name, + email, + company, + team_size: form.team_size || undefined, + message: form.message.trim() || undefined, + source: detectSource(), + }) + setSubmitted(true) + } catch { + // The backend may rate-limit (429) or reject for validation; surface a + // generic message and allow retry. Don't leak internal errors. + setError('Something went wrong. Please try again or email hello@resolutionflow.com.') + } finally { + setSubmitting(false) + } + } + + return ( +
+ + +
+
+

+ Talk to Sales +

+

+ Tell us about your MSP. We’ll reach out within 1 business day. +

+
+ +
+ {submitted ? ( +
+

+ Thanks — we’ll reach out within 1 business day. +

+ {calendlyUrl && ( +
+

+ Want to skip ahead? +

+ + Book a time + +
+ )} +
+ ) : ( +
+ + + + + + + + + + + + + + + + + +