Conference contact form for contact.neireva.com

This commit is contained in:
Tej Rengha 2026-06-09 23:08:36 +02:00
commit 85c4976852
17 changed files with 2741 additions and 0 deletions

4
.env.example Normal file
View file

@ -0,0 +1,4 @@
# Supabase — DEV project (jgyibweqcygoirfwofyp), per Phase 1 decision.
# Both are SERVER-SIDE ONLY. Never prefix with NEXT_PUBLIC_.
SUPABASE_URL=https://jgyibweqcygoirfwofyp.supabase.co
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key-here

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
node_modules
.next
.env
.env.local
*.log
.DS_Store
next-env.d.ts

34
README.md Normal file
View file

@ -0,0 +1,34 @@
# neireva-contact
Temporary conference lead-capture form for `contact.neireva.com`.
Writes to the `marketing_leads` table in the Neireva **dev** Supabase project.
Decommission after the conference; keep the table + data.
## Run locally
```bash
npm install
cp .env.example .env.local # fill in SUPABASE_SERVICE_ROLE_KEY
npm run dev # http://localhost:3000
```
Submit a test entry, then confirm a row appears:
`select * from marketing_leads order by created_at desc limit 1;`
## Environment variables (set in Coolify, never committed)
| Var | Notes |
| --- | --- |
| `SUPABASE_URL` | `https://jgyibweqcygoirfwofyp.supabase.co` (dev) |
| `SUPABASE_SERVICE_ROLE_KEY` | Server-side only. Do **not** prefix `NEXT_PUBLIC_`. |
## Notes
- Consent wording lives in `lib/consent.ts` and is stored server-side on insert,
so the saved `consent_text` always matches what was displayed. Bump
`CONSENT_VERSION` if you change the wording.
- Honeypot + in-memory rate limit (5/IP/hour). The limiter is per-instance — fine
for a single Coolify container; would need Redis if scaled out.
- The page is set to `noindex` (temporary). To switch the UI to French, translate
the labels in `components/ContactForm.tsx` and the copy in `app/page.tsx`; the
consent text in `lib/consent.ts` is intentionally left as supplied.

87
app/api/lead/route.ts Normal file
View file

@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from "next/server";
import { supabaseAdmin } from "@/lib/supabaseAdmin";
import { rateLimit } from "@/lib/rateLimit";
import { CONSENT_TEXT, CONSENT_VERSION } from "@/lib/consent";
export const runtime = "nodejs";
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function getIp(req: NextRequest): string {
const fwd = req.headers.get("x-forwarded-for");
if (fwd) return fwd.split(",")[0].trim();
return req.headers.get("x-real-ip") ?? "unknown";
}
export async function POST(req: NextRequest) {
const ip = getIp(req);
if (!rateLimit(ip).allowed) {
return NextResponse.json(
{ error: "Too many submissions. Please try again later." },
{ status: 429 }
);
}
let body: Record<string, unknown>;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid request." }, { status: 400 });
}
// Honeypot: real users never fill this hidden field. Bots do.
// Return success so the bot gets no signal, but write nothing.
if (typeof body.website === "string" && body.website.trim() !== "") {
return NextResponse.json({ ok: true });
}
const fullName = String(body.fullName ?? "").trim();
const companyName = String(body.companyName ?? "").trim();
const role = String(body.role ?? "").trim();
const email = String(body.email ?? "").trim();
const consentGiven = body.consentGiven === true;
const consentAcknowledged = body.consentAcknowledged === true;
if (!fullName || !companyName || !role) {
return NextResponse.json(
{ error: "Please complete all fields." },
{ status: 400 }
);
}
if (!EMAIL_RE.test(email)) {
return NextResponse.json(
{ error: "Please enter a valid email address." },
{ status: 400 }
);
}
if (!consentGiven || !consentAcknowledged) {
return NextResponse.json(
{ error: "Both consent boxes must be ticked to submit." },
{ status: 400 }
);
}
const { error } = await supabaseAdmin.from("marketing_leads").insert({
full_name: fullName,
company_name: companyName,
role,
email,
consent_given: consentGiven,
consent_acknowledged: consentAcknowledged,
consent_text: CONSENT_TEXT, // stored server-side, not from client
consent_version: CONSENT_VERSION,
ip_address: ip,
user_agent: req.headers.get("user-agent") ?? null,
});
if (error) {
console.error("Insert failed:", error.message);
return NextResponse.json(
{ error: "Something went wrong saving your details. Please try again." },
{ status: 500 }
);
}
return NextResponse.json({ ok: true });
}

27
app/globals.css Normal file
View file

@ -0,0 +1,27 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color-scheme: light;
}
html,
body {
background-color: #fbf9f6;
color: #1b1c1a;
-webkit-font-smoothing: antialiased;
}
/* Terracotta focus ring — SPEC-DS-001 interactive focus style */
*:focus-visible {
outline: 2px solid #8b4b2f;
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
transition: none !important;
}
}

36
app/layout.tsx Normal file
View file

@ -0,0 +1,36 @@
import type { Metadata } from "next";
import { Plus_Jakarta_Sans, JetBrains_Mono } from "next/font/google";
import "./globals.css";
const jakarta = Plus_Jakarta_Sans({
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
variable: "--font-jakarta",
display: "swap",
});
const mono = JetBrains_Mono({
subsets: ["latin"],
weight: ["400", "500"],
variable: "--font-mono",
display: "swap",
});
export const metadata: Metadata = {
title: "Neireva — Get in touch",
description:
"Real estate management, reimagined. Leave your details and we'll reach out.",
robots: { index: false, follow: false }, // temporary conference page
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className={`${jakarta.variable} ${mono.variable}`}>
<body className="font-sans">{children}</body>
</html>
);
}

45
app/page.tsx Normal file
View file

@ -0,0 +1,45 @@
import ContactForm from "@/components/ContactForm";
export default function Page() {
return (
<main className="flex min-h-screen items-center justify-center px-5 py-12">
<div className="w-full max-w-xl">
{/* Wordmark */}
<div className="mb-8 flex items-baseline gap-2">
<span className="font-mono text-[13px] uppercase tracking-[0.25em] text-terracotta">
Neireva
</span>
</div>
{/* Card — 4px terracotta left accent echoes the Tactile Archive AI-card */}
<div className="overflow-hidden rounded-card border border-line bg-card shadow-[0_1px_2px_rgba(27,28,26,0.04),0_8px_24px_rgba(27,28,26,0.06)]">
<div className="border-l-4 border-terracotta px-7 py-8 sm:px-9 sm:py-10">
<h1 className="text-[26px] font-semibold leading-tight text-ink sm:text-[30px]">
Let&apos;s keep in touch
</h1>
<p className="mt-2 text-[15px] leading-relaxed text-ink-soft">
Leave your details and we&apos;ll reach out about how Neireva can
work for your agency.
</p>
<div className="mt-7">
<ContactForm />
</div>
</div>
</div>
{/* Controller / legal footer */}
<p className="mt-5 px-1 text-[12px] leading-relaxed text-ink-faint">
Neireva is the data controller for the information you provide.{" "}
<a
href="/privacy"
className="underline decoration-line-strong underline-offset-2 hover:text-ink-soft"
>
Privacy policy
</a>
.
</p>
</div>
</main>
);
}

186
components/ContactForm.tsx Normal file
View file

@ -0,0 +1,186 @@
"use client";
import { useState } from "react";
import { CONSENT_TEXT, ACKNOWLEDGMENT_TEXT } from "@/lib/consent";
type Status = "idle" | "submitting" | "success" | "error";
const FIELD =
"w-full rounded-control border border-line bg-card px-3.5 py-2.5 text-[15px] text-ink placeholder:text-ink-faint transition-colors focus:border-terracotta";
const LABEL = "block text-[13px] font-medium text-ink-soft mb-1.5";
export default function ContactForm() {
const [fullName, setFullName] = useState("");
const [companyName, setCompanyName] = useState("");
const [role, setRole] = useState("");
const [email, setEmail] = useState("");
const [consentGiven, setConsentGiven] = useState(false);
const [consentAcknowledged, setConsentAcknowledged] = useState(false);
const [website, setWebsite] = useState(""); // honeypot
const [status, setStatus] = useState<Status>("idle");
const [error, setError] = useState("");
const canSubmit =
fullName.trim() &&
companyName.trim() &&
role.trim() &&
email.trim() &&
consentGiven &&
consentAcknowledged &&
status !== "submitting";
async function handleSubmit() {
setStatus("submitting");
setError("");
try {
const res = await fetch("/api/lead", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
fullName,
companyName,
role,
email,
consentGiven,
consentAcknowledged,
website,
}),
});
const data = await res.json();
if (!res.ok) {
setError(data.error ?? "Something went wrong. Please try again.");
setStatus("error");
return;
}
setStatus("success");
} catch {
setError("Network error. Please try again.");
setStatus("error");
}
}
if (status === "success") {
return (
<div className="rounded-card border border-ok/30 bg-ok-bg px-6 py-8 text-center">
<p className="text-[17px] font-semibold text-ok">Thank you.</p>
<p className="mt-1.5 text-[15px] text-ink-soft">
We&apos;ve got your details and will be in touch shortly.
</p>
</div>
);
}
return (
<div className="space-y-5">
<div className="grid gap-5 sm:grid-cols-2">
<div>
<label className={LABEL} htmlFor="fullName">
Full name
</label>
<input
id="fullName"
className={FIELD}
value={fullName}
onChange={(e) => setFullName(e.target.value)}
autoComplete="name"
/>
</div>
<div>
<label className={LABEL} htmlFor="companyName">
Company
</label>
<input
id="companyName"
className={FIELD}
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
autoComplete="organization"
/>
</div>
</div>
<div className="grid gap-5 sm:grid-cols-2">
<div>
<label className={LABEL} htmlFor="role">
Role
</label>
<input
id="role"
className={FIELD}
value={role}
onChange={(e) => setRole(e.target.value)}
autoComplete="organization-title"
/>
</div>
<div>
<label className={LABEL} htmlFor="email">
Email
</label>
<input
id="email"
type="email"
className={FIELD}
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
/>
</div>
</div>
{/* Honeypot — visually hidden, off the tab order */}
<div aria-hidden className="absolute left-[-9999px] h-0 w-0 overflow-hidden">
<label htmlFor="website">Website</label>
<input
id="website"
tabIndex={-1}
autoComplete="off"
value={website}
onChange={(e) => setWebsite(e.target.value)}
/>
</div>
<div className="space-y-3 pt-1">
<label className="flex cursor-pointer items-start gap-3">
<input
type="checkbox"
className="mt-0.5 h-4 w-4 shrink-0 accent-terracotta"
checked={consentGiven}
onChange={(e) => setConsentGiven(e.target.checked)}
/>
<span className="text-[13px] leading-relaxed text-ink-soft">
{CONSENT_TEXT}
</span>
</label>
<label className="flex cursor-pointer items-start gap-3">
<input
type="checkbox"
className="mt-0.5 h-4 w-4 shrink-0 accent-terracotta"
checked={consentAcknowledged}
onChange={(e) => setConsentAcknowledged(e.target.checked)}
/>
<span className="text-[13px] leading-relaxed text-ink-soft">
{ACKNOWLEDGMENT_TEXT}
</span>
</label>
</div>
{status === "error" && (
<div
role="alert"
className="rounded-control border border-danger/30 bg-danger-bg px-3.5 py-2.5 text-[13px] text-danger"
>
{error}
</div>
)}
<button
type="button"
onClick={handleSubmit}
disabled={!canSubmit}
className="w-full rounded-control bg-gradient-to-br from-terracotta to-terracotta-end px-4 py-3 text-[15px] font-medium text-white transition-opacity hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-40"
>
{status === "submitting" ? "Sending…" : "Send my details"}
</button>
</div>
);
}

11
lib/consent.ts Normal file
View file

@ -0,0 +1,11 @@
// Single source of truth for the exact wording shown to the user.
// The API stores CONSENT_TEXT server-side rather than trusting the client,
// so the stored proof always matches what was actually displayed.
export const CONSENT_TEXT =
"I consent to Neireva collecting and using my personal data (name, company, position and email address) for the purpose of contacting me regarding its services and sending me marketing communication including newsletters.";
export const ACKNOWLEDGMENT_TEXT =
"I understand I may withdraw my consent at any time by clicking the unsubscribe link in any email or by contacting Neireva directly.";
// Bump this whenever the wording above changes.
export const CONSENT_VERSION = "2026-06";

21
lib/rateLimit.ts Normal file
View file

@ -0,0 +1,21 @@
// In-memory rate limiter: 5 submissions per IP per hour.
// Adequate for a single-instance, time-boxed deployment. If this ever runs on
// multiple instances it would need a shared store (Redis); not needed here.
const WINDOW_MS = 60 * 60 * 1000;
const MAX_PER_WINDOW = 5;
const hits = new Map<string, number[]>();
export function rateLimit(ip: string): { allowed: boolean } {
const now = Date.now();
const recent = (hits.get(ip) ?? []).filter((t) => now - t < WINDOW_MS);
if (recent.length >= MAX_PER_WINDOW) {
hits.set(ip, recent);
return { allowed: false };
}
recent.push(now);
hits.set(ip, recent);
return { allowed: true };
}

16
lib/supabaseAdmin.ts Normal file
View file

@ -0,0 +1,16 @@
import { createClient } from "@supabase/supabase-js";
// Server-only. Uses the service-role key, which bypasses RLS — so this module
// must never be imported into a client component.
const url = process.env.SUPABASE_URL;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!url || !serviceRoleKey) {
throw new Error(
"Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY environment variables."
);
}
export const supabaseAdmin = createClient(url, serviceRoleKey, {
auth: { persistSession: false, autoRefreshToken: false },
});

3
next.config.mjs Normal file
View file

@ -0,0 +1,3 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;

2173
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

26
package.json Normal file
View file

@ -0,0 +1,26 @@
{
"name": "neireva-contact",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@supabase/supabase-js": "^2.45.0",
"next": "15.1.0",
"react": "19.0.0",
"react-dom": "19.0.0"
},
"devDependencies": {
"@types/node": "^22",
"@types/react": "^19",
"@types/react-dom": "^19",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "^5"
}
}

6
postcss.config.mjs Normal file
View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

38
tailwind.config.ts Normal file
View file

@ -0,0 +1,38 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
// Tactile Archive — SPEC-DS-001 v1.2.1
linen: "#FBF9F6",
stone: "#F5F3F0",
card: "#FFFFFF",
ink: "#1B1C1A",
"ink-soft": "#4A4B48",
"ink-faint": "#8A8B88",
terracotta: "#8B4B2F",
"terracotta-end": "#A86245",
"terracotta-tint": "#F5EDE8",
line: "#E8E4E0",
"line-strong": "#D4CFCA",
danger: "#991B1B",
"danger-bg": "#FEE2E2",
ok: "#2D6A4F",
"ok-bg": "#E8F5EE",
},
fontFamily: {
sans: ["var(--font-jakarta)", "system-ui", "sans-serif"],
mono: ["var(--font-mono)", "monospace"],
},
borderRadius: {
card: "16px",
control: "8px",
},
},
},
plugins: [],
};
export default config;

21
tsconfig.json Normal file
View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}