Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 3 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,6 @@ ANTHROPIC_API_KEY=
NEXT_PUBLIC_ALGOLIA_APP_ID=
NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY=
NEXT_PUBLIC_ALGOLIA_INDEX_NAME=

# Required for Contact Sales form submission (n8n → Attio pipeline)
NEXT_PUBLIC_ATTIO_WEBHOOK_URL=
268 changes: 245 additions & 23 deletions app/en/resources/contact-us/contact-cards.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,247 @@
"use client";

import {
Button,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
Discord,
Github,
Input,
Textarea,
} from "@arcadeai/design-system";
import { HeartPulse, Mail, Shield, Users } from "lucide-react";
import {
AlertOctagon,
CheckCircle,
HeartPulse,
Mail,
Shield,
Users,
} from "lucide-react";
import posthog from "posthog-js";
import { useEffect, useRef, useState } from "react";
import type React from "react";
import { useState } from "react";
import { QuickStartCard } from "../../../_components/quick-start-card";

const WEBHOOK_URL = process.env.NEXT_PUBLIC_ATTIO_WEBHOOK_URL;

function getUtmParams(): Record<string, string> {
if (typeof window === "undefined") {
return {};
}
const params = new URLSearchParams(window.location.search);
const utms: Record<string, string> = {};
for (const key of [
"utm_source",
"utm_medium",
"utm_campaign",
"utm_content",
]) {
const value = params.get(key);
if (value) {
utms[key] = value;
}
}
return utms;
}

function collectFormFields(formData: FormData): Record<string, string> {
const fields: Record<string, string> = {};
for (const [key, value] of formData.entries()) {
if (key === "website") {
continue;
}
if (typeof value === "string" && value.trim()) {
fields[key] = value.trim();
}
}
return fields;
}

async function submitForm(
fields: Record<string, string>
): Promise<{ success: boolean; error?: string }> {
if (!WEBHOOK_URL) {
return { success: false, error: "Webhook URL not configured" };
}

const payload = {
submission_id: crypto.randomUUID(),
form_type: "contact_sales",
fields,
context: {
pageUri: window.location.href,
pageName: "Contact Sales",
timestamp: new Date().toISOString(),
...getUtmParams(),
},
};

const response = await fetch(WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});

if (response.ok) {
return { success: true };
}
return {
success: false,
error: `HTTP ${response.status}: ${response.statusText}`,
};
}

async function submitHoneypot(): Promise<void> {
if (!WEBHOOK_URL) {
return;
}
try {
await fetch(WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ _hp: true }),
});
} catch {
// Swallow errors for spam submissions
}
}

function ContactSalesForm({ onSuccess }: { onSuccess: () => void }) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState("");

const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsSubmitting(true);
setError("");

const formData = new FormData(e.currentTarget);
const honeypot = formData.get("website") as string;

if (honeypot) {
await submitHoneypot();
onSuccess();
Comment thread
teallarson marked this conversation as resolved.
return;
}

const fields = collectFormFields(formData);

try {
const result = await submitForm(fields);

if (result.success) {
posthog.capture("contact_sales_form_submitted", {
form_type: "contact_sales",
page: "Contact Sales",
source: "contact_us_page",
});
onSuccess();
} else {
posthog.capture("contact_sales_form_submit_failed", {
form_type: "contact_sales",
page: "Contact Sales",
error: result.error,
});
setError("Oops! Something went wrong. Please try again.");
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Network error";
posthog.capture("contact_sales_form_submit_failed", {
form_type: "contact_sales",
page: "Contact Sales",
error: errorMessage,
});
setError("Oops! Something went wrong. Please try again.");
} finally {
setIsSubmitting(false);
}
};

return (
<form className="flex flex-col gap-3" onSubmit={handleSubmit}>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should use the react-hook-form library for this form.

It will simplify a lot of the isSubmitting/error handling manually done above.

And these inputs should have labels (not just the aria-label)

<div className="flex gap-3 max-[767px]:flex-col">
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tailwind's built in breakpoint is 768 px. so using max-md:flex-col would be nearly identical to this.

Is there a reason for this specific number?

<Input
aria-label="First Name"
className="flex-1 bg-gray-800/50 text-white"
maxLength={256}
name="firstname"
placeholder="First Name"
required
type="text"
/>
<Input
aria-label="Last Name"
className="flex-1 bg-gray-800/50 text-white"
maxLength={256}
name="lastname"
placeholder="Last Name"
required
type="text"
/>
</div>
<Input
aria-label="Work Email"
className="bg-gray-800/50 text-white"
maxLength={256}
name="email"
placeholder="Work Email"
required
type="email"
/>
<Input
aria-label="Company"
className="bg-gray-800/50 text-white"
maxLength={256}
name="company"
placeholder="Company"
required
type="text"
/>
<Textarea
aria-label="How can we help?"
className="min-h-[100px] resize-y bg-gray-800/50 text-white"
maxLength={5000}
name="message"
placeholder="How can we help?"
rows={4}
/>
{/* Honeypot field — hidden from real users */}
<div aria-hidden="true" className="absolute -left-[9999px]">
<input autoComplete="off" name="website" tabIndex={-1} type="text" />
</div>
{error && (
<div className="flex items-center gap-3 rounded-lg bg-red-500/10 p-3 text-white">
<AlertOctagon className="h-5 w-5 shrink-0" />
<span className="text-sm">{error}</span>
</div>
)}
<Button className="w-full" disabled={isSubmitting} type="submit">
{isSubmitting ? "Please wait..." : "Get in touch"}
</Button>
</form>
);
}

function SuccessMessage({ onClose }: { onClose: () => void }) {
return (
<div className="flex flex-col items-center justify-center pt-6 pb-2 text-center">
<div className="mb-4 rounded-full bg-green-500/10 p-3">
<CheckCircle className="h-6 w-6 text-green-500" />
</div>
<h3 className="mb-1 font-semibold text-gray-100 text-lg">Thank you!</h3>
<p className="text-gray-400 text-sm">We'll be in touch shortly.</p>
<Button className="mt-6 w-full" onClick={onClose} variant="default">
Close
</Button>
</div>
);
}

export function ContactCards() {
const [isSalesModalOpen, setIsSalesModalOpen] = useState(false);
const scriptLoadedRef = useRef(false);

useEffect(() => {
// Load HubSpot script once
if (!scriptLoadedRef.current) {
const script = document.createElement("script");
script.src = "https://js-na2.hsforms.net/forms/embed/39979532.js";
script.defer = true;
document.body.appendChild(script);
scriptLoadedRef.current = true;
}
}, []);
const [isSubmitted, setIsSubmitted] = useState(false);

const handleContactSalesClick = () => {
posthog.capture("Contact sales modal opened", {
Expand All @@ -33,6 +250,11 @@ export function ContactCards() {
setIsSalesModalOpen(true);
};

const handleClose = () => {
setIsSalesModalOpen(false);
setIsSubmitted(false);
};

return (
<>
<div className="mt-16 grid gap-8 md:grid-cols-2 lg:grid-cols-3">
Expand Down Expand Up @@ -74,18 +296,18 @@ export function ContactCards() {
/>
</div>
<Dialog
onOpenChange={(open) => !open && setIsSalesModalOpen(false)}
onOpenChange={(open) => !open && handleClose()}
open={isSalesModalOpen}
>
<DialogContent className="border-gray-800 bg-gray-900 sm:max-w-[500px]">
<div className="py-4">
<div
className="hs-form-frame"
data-form-id="aa1d8f09-6368-461d-bb27-d49bc056e3df"
data-portal-id="39979532"
data-region="na2"
/>
</div>
<DialogHeader>
<DialogTitle className="text-gray-100">Contact Sales</DialogTitle>
</DialogHeader>
{isSubmitted ? (
<SuccessMessage onClose={handleClose} />
) : (
<ContactSalesForm onSuccess={() => setIsSubmitted(true)} />
)}
</DialogContent>
</Dialog>
</>
Expand Down