Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
35 changes: 35 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: CI

on:
pull_request:
branches: [main]
push:
branches: [main]

jobs:
validate:
runs-on: ubuntu-latest
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/convertneo?schema=public

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

- name: Install dependencies
run: npm ci

- name: Generate Prisma client
run: npm run db:generate

- name: Lint
run: npm run lint

- name: Build
run: npm run build
40 changes: 40 additions & 0 deletions .github/workflows/vercel-db-deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Vercel Production DB Deploy

on:
workflow_dispatch:
push:
branches:
- main

concurrency:
group: vercel-production-db-deploy
cancel-in-progress: false

jobs:
migrate-production:
name: Run Prisma migrate deploy
runs-on: ubuntu-latest
environment: production

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- name: Install dependencies
run: npm ci

- name: Generate Prisma client
run: npm run db:generate
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}

- name: Apply production migrations
run: npm run db:deploy
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,7 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts

# prisma sqlite
/dev.db
/dev.db-journal
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@ npm install
Create a `.env.local` file in the project root:

```env
# PostgreSQL connection string (local dev example)
DATABASE_URL=postgresql://USER:PASSWORD@localhost:5432/convertneo?schema=public

# Resend API key — required for the /contact form to send emails
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxx

Expand All @@ -128,6 +131,22 @@ npm run build
npm start
```

### Database Commands (Prisma)

```bash
# Generate Prisma client
npm run db:generate

# Create/apply local migrations
npm run db:migrate -- --name your_migration_name

# Open DB GUI
npm run db:studio

# Apply migrations in production
npm run db:deploy
```

### Lint

```bash
Expand Down Expand Up @@ -169,6 +188,13 @@ npx vercel

Add `RESEND_API_KEY` and `CONTACT_EMAIL` as environment variables in your Vercel project settings.

For contact persistence, also add `DATABASE_URL` in production.

Recommended production deployment order:
1. Set `DATABASE_URL`, `RESEND_API_KEY`, and `CONTACT_EMAIL` in platform secrets.
2. Run `npm run db:deploy` during deploy/startup.
3. Start the app.

For other platforms (Netlify, Railway, Docker, etc.) follow the standard Next.js deployment guide: [nextjs.org/docs/deployment](https://nextjs.org/docs/app/building-your-application/deploying).

---
Expand Down
151 changes: 136 additions & 15 deletions app/api/contact/route.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,143 @@
import { NextResponse } from 'next/server'
import { Resend } from 'resend'
import { NextResponse } from "next/server";
import { Resend } from "resend";

const resend = new Resend(process.env.RESEND_API_KEY)
import { saveContactMessage } from "@/lib/server/contact-message-store";
import { getClientIp, rateLimitByKey } from "@/lib/server/rate-limit";
import { contactSchema } from "@/lib/validations/contact";

const CONTACT_RATE_LIMIT = {
limit: 5,
windowMs: 10 * 60 * 1000,
};

export async function POST(req: Request) {
const { name, email, message } = await req.json()
const ip = getClientIp(req);
const rateLimit = rateLimitByKey(`contact:${ip}`, CONTACT_RATE_LIMIT);

if (!rateLimit.allowed) {
const retryAfterSec = Math.max(
1,
Math.ceil((rateLimit.resetAt - Date.now()) / 1000),
);

return NextResponse.json(
{
error: "Too many requests. Please wait before sending another message.",
},
{
status: 429,
headers: {
"Retry-After": String(retryAfterSec),
"X-RateLimit-Remaining": String(rateLimit.remaining),
},
},
);
}

let payload: unknown;
try {
payload = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}

const parsed = contactSchema.safeParse(payload);

if (!parsed.success) {
return NextResponse.json(
{
error: "Invalid request body",
fieldErrors: parsed.error.flatten().fieldErrors,
},
{ status: 400 },
);
}

const { name, email, message } = parsed.data;

if (!name || !email || !message) {
return NextResponse.json({ error: 'Missing fields' }, { status: 400 })
try {
await saveContactMessage({
name,
email,
message,
ipAddress: ip,
userAgent: req.headers.get("user-agent") || undefined,
});
} catch {
return NextResponse.json({ error: "Failed to save message" }, { status: 500 });
}

const { error } = await resend.emails.send({
from: 'Convert-neo <onboarding@resend.dev>', // use your domain once verified
to: process.env.CONTACT_EMAIL!,
subject: `New message from ${name}`,
text: `Name: ${name}\nEmail: ${email}\n\nMessage:\n${message}`,
})
const contactEmail = process.env.CONTACT_EMAIL;
const resendApiKey = process.env.RESEND_API_KEY;

if (!contactEmail || !resendApiKey) {
return NextResponse.json(
{
success: true,
message: "Message saved",
warning: "Message stored, but email notifications are not configured.",
rateLimit: {
remaining: rateLimit.remaining,
},
},
{
status: 201,
headers: {
"X-RateLimit-Remaining": String(rateLimit.remaining),
},
},
);
}

let emailFailed = false;

try {
const resend = new Resend(resendApiKey);

const { error } = await resend.emails.send({
from: "Convert-neo <onboarding@resend.dev>",
to: contactEmail,
subject: `New message from ${name}`,
text: `Name: ${name}\nEmail: ${email}\n\nMessage:\n${message}`,
});

emailFailed = Boolean(error);
} catch {
emailFailed = true;
}

if (emailFailed) {
return NextResponse.json(
{
success: true,
message: "Message saved",
warning: "Message stored, but email delivery failed.",
rateLimit: {
remaining: rateLimit.remaining,
},
},
{
status: 201,
headers: {
"X-RateLimit-Remaining": String(rateLimit.remaining),
},
},
);
}

if (error) return NextResponse.json({ error }, { status: 500 })
return NextResponse.json({ success: true })
}
return NextResponse.json(
{
success: true,
message: "Message received",
rateLimit: {
remaining: rateLimit.remaining,
},
},
{
status: 201,
headers: {
"X-RateLimit-Remaining": String(rateLimit.remaining),
},
},
);
}
39 changes: 23 additions & 16 deletions app/contact/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
Expand All @@ -13,15 +12,7 @@ import Link from "next/link";
import { ArrowLeft } from 'lucide-react'
import { toast } from 'sonner'
import { MotionEffect } from '@/components/ui/motion-highlight'

/* ---------- Zod schema ---------- */
const schema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Please enter a valid email'),
message: z.string().min(10, 'Message must be at least 10 characters'),
})

type FormData = z.infer<typeof schema>
import { contactSchema, type ContactFormData } from '@/lib/validations/contact'

/* ---------- Page ---------- */
export default function ContactPage() {
Expand All @@ -32,23 +23,39 @@ export default function ContactPage() {
handleSubmit,
reset,
formState: { errors },
} = useForm<FormData>({ resolver: zodResolver(schema) })
} = useForm<ContactFormData>({ resolver: zodResolver(contactSchema) })

async function onSubmit(data: FormData) {
async function onSubmit(data: ContactFormData) {
setStatus('loading')
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!res.ok) throw new Error()

const payload = (await res.json().catch(() => null)) as
| { error?: string; warning?: string }
| null

if (!res.ok) {
throw new Error(payload?.error ?? 'Failed to send message')
}

setStatus('idle')
reset()
toast.success('Message sent!', { description: "We'll be in touch shortly." })
} catch {
if (payload?.warning) {
toast.success('Message saved!', { description: payload.warning })
} else {
toast.success('Message sent!', { description: "We'll be in touch shortly." })
}
} catch (error) {
setStatus('idle')
toast.error('Failed to send message', { description: 'Something went wrong. Please try again.' })
const description =
error instanceof Error
? error.message
: 'Something went wrong. Please try again.'
toast.error('Failed to send message', { description })
}
}

Expand Down
4 changes: 2 additions & 2 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import Footer from "@/components/layout/footer";
import { ThemeProvider } from "@/components/layout/theme-provider";
// import ScrollToTop from "@/components/scroll-to-top";
import ThemeToggle from "@/components/layout/theme-toggle";
import { LogoImage } from "@/components/logo-image";
import { Logo } from "@/components/logo";
// import Demo from"@/components/layout/demo";
import BottomDock from "@/components/layout/bottom-dock";

Expand Down Expand Up @@ -40,7 +40,7 @@ export default function RootLayout({
>
<ThemeProvider>
<div className="fixed left-8 top-4 z-50">
<LogoImage />
<Logo className="h-3 w-auto" />
</div>

<ThemeToggle />
Expand Down
Loading
Loading