Conference contact form for contact.neireva.com
This commit is contained in:
commit
85c4976852
17 changed files with 2741 additions and 0 deletions
4
.env.example
Normal file
4
.env.example
Normal 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
7
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
node_modules
|
||||
.next
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.DS_Store
|
||||
next-env.d.ts
|
||||
34
README.md
Normal file
34
README.md
Normal 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
87
app/api/lead/route.ts
Normal 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
27
app/globals.css
Normal 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
36
app/layout.tsx
Normal 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
45
app/page.tsx
Normal 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's keep in touch
|
||||
</h1>
|
||||
<p className="mt-2 text-[15px] leading-relaxed text-ink-soft">
|
||||
Leave your details and we'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
186
components/ContactForm.tsx
Normal 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'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
11
lib/consent.ts
Normal 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
21
lib/rateLimit.ts
Normal 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
16
lib/supabaseAdmin.ts
Normal 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
3
next.config.mjs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {};
|
||||
export default nextConfig;
|
||||
2173
package-lock.json
generated
Normal file
2173
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
26
package.json
Normal file
26
package.json
Normal 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
6
postcss.config.mjs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
38
tailwind.config.ts
Normal file
38
tailwind.config.ts
Normal 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
21
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Reference in a new issue