diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index fa23eb93..0a50912a 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -15,7 +15,7 @@ jobs: name: Playwright smoke tests runs-on: ubuntu-latest env: - NEXTAUTH_SECRET: playwright-placeholder-secret-that-is-long-enough + NEXTAUTH_SECRET: test-nextauth-secret-for-playwright-tests NEXTAUTH_URL: http://127.0.0.1:3000 NEXT_PUBLIC_APP_URL: http://127.0.0.1:3000 GITHUB_ID: playwright-github-id @@ -24,6 +24,7 @@ jobs: NEXT_PUBLIC_SUPABASE_ANON_KEY: placeholder-anon-key SUPABASE_SERVICE_ROLE_KEY: placeholder-service-role-key PLAYWRIGHT_SERVER_MODE: start + CI: true steps: - uses: actions/checkout@v4 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 73f47470..e8ce209d 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -15,6 +15,7 @@ Everything you need to run DevTrack locally from scratch in under 10 minutes. You also need free accounts on: - [Supabase](https://supabase.com) — for the database - GitHub — for OAuth (you already have this) +- [Resend](https://resend.com) — for the contact form backend --- @@ -78,6 +79,11 @@ NEXTAUTH_SECRET=generate_with_openssl_rand_base64_32 # GitHub OAuth GITHUB_ID=Ov23... GITHUB_SECRET=your_github_client_secret + +# Contact form email delivery +RESEND_API_KEY=re_xxx... +RESEND_FROM_EMAIL="DevTrack " +CONTACT_TO_EMAIL=you@example.com ``` Generate `NEXTAUTH_SECRET`: diff --git a/e2e/dashboard-widgets.spec.js b/e2e/dashboard-widgets.spec.js index 9fcd243e..45f12805 100644 --- a/e2e/dashboard-widgets.spec.js +++ b/e2e/dashboard-widgets.spec.js @@ -2,10 +2,14 @@ import { expect, test } from "@playwright/test"; import { encode } from "next-auth/jwt"; test.beforeEach(async ({ page }) => { + const authSecret = + process.env.NEXTAUTH_SECRET || + "test-nextauth-secret-for-playwright-tests"; + // Create a valid NextAuth JWT and set it as the session cookie so // dashboard pages render as an authenticated user in Playwright. const token = await encode({ - secret: process.env.NEXTAUTH_SECRET || "playwright-placeholder-secret-that-is-long-enough", + secret: authSecret, token: { name: "Playwright User", email: "playwright@example.com", @@ -113,19 +117,15 @@ test.beforeEach(async ({ page }) => { }); }); - await page.route("**/api/goals/sync**", async (route) => { - await route.fulfill({ - contentType: "application/json", - body: JSON.stringify({ ok: true }), - }); - }); - - await page.route("**/api/goals/sync", async (route) => { - await route.fulfill({ - contentType: "application/json", - body: JSON.stringify({ updated: 1, commitCount: 4 }), - }); + await page.route("**/api/goals/sync**", async (route) => { + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + ok: true, + last_synced_at: new Date().toISOString(), + }), }); +}); await page.route("**/api/ai-insights**", async (route) => { await route.fulfill({ diff --git a/e2e/notifications.spec.js b/e2e/notifications.spec.js index f76df029..b1df3212 100644 --- a/e2e/notifications.spec.js +++ b/e2e/notifications.spec.js @@ -1,8 +1,10 @@ import { expect, test } from "@playwright/test"; import { encode } from "next-auth/jwt"; -const authSecret = "playwright-placeholder-secret-that-is-long-enough"; - +const authSecret = + process.env.NEXTAUTH_SECRET || + "test-nextauth-secret-for-playwright-tests"; + /** Returns a properly-shaped mock response for each metric endpoint. */ function mockMetricResponse(url) { if (url.includes("/api/metrics/prs")) diff --git a/e2e/settings.spec.js b/e2e/settings.spec.js index 67284cde..cad0c590 100644 --- a/e2e/settings.spec.js +++ b/e2e/settings.spec.js @@ -1,8 +1,10 @@ import { expect, test } from "@playwright/test"; import { encode } from "next-auth/jwt"; -const authSecret = "playwright-placeholder-secret-that-is-long-enough"; - +const authSecret = + process.env.NEXTAUTH_SECRET || + "test-nextauth-secret-for-playwright-tests"; + test.beforeEach(async ({ page }) => { const sessionToken = await encode({ secret: authSecret, diff --git a/e2e/theme.spec.js b/e2e/theme.spec.js index 39c0923e..cae3450b 100644 --- a/e2e/theme.spec.js +++ b/e2e/theme.spec.js @@ -2,10 +2,12 @@ import { expect, test } from "@playwright/test"; import { encode } from "next-auth/jwt"; test.beforeEach(async ({ page }) => { + const authSecret = + process.env.NEXTAUTH_SECRET || + "test-nextauth-secret-for-playwright-tests"; + const token = await encode({ - secret: - process.env.NEXTAUTH_SECRET || - "playwright-placeholder-secret-that-is-long-enough", + secret: authSecret, token: { name: "Playwright User", email: "playwright@example.com", diff --git a/playwright.config.mjs b/playwright.config.mjs index e9387459..5440f966 100644 --- a/playwright.config.mjs +++ b/playwright.config.mjs @@ -28,7 +28,7 @@ export default defineConfig({ reuseExistingServer: !process.env.CI, timeout: 120_000, env: { - NEXTAUTH_SECRET: "playwright-placeholder-secret-that-is-long-enough", + NEXTAUTH_SECRET: "test-nextauth-secret-for-playwright-tests", NEXTAUTH_URL: baseURL, NEXT_PUBLIC_APP_URL: baseURL, GITHUB_ID: "playwright-github-id", diff --git a/src/app/api/contact/route.ts b/src/app/api/contact/route.ts new file mode 100644 index 00000000..2133c685 --- /dev/null +++ b/src/app/api/contact/route.ts @@ -0,0 +1,102 @@ +import { NextRequest, NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; + +type ContactPayload = { + name?: unknown; + email?: unknown; + message?: unknown; +}; + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +export async function POST(request: NextRequest) { + let payload: ContactPayload; + + try { + payload = (await request.json()) as ContactPayload; + } catch { + return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); + } + + const name = typeof payload.name === "string" ? payload.name.trim() : ""; + const email = typeof payload.email === "string" ? payload.email.trim() : ""; + const message = typeof payload.message === "string" ? payload.message.trim() : ""; + + if (!name || !email || !message) { + return NextResponse.json({ error: "Name, email, and message are required." }, { status: 400 }); + } + + const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailPattern.test(email)) { + return NextResponse.json({ error: "Please enter a valid email address." }, { status: 400 }); + } + + const apiKey = process.env.RESEND_API_KEY; + const fromEmail = process.env.RESEND_FROM_EMAIL; + const toEmail = process.env.CONTACT_TO_EMAIL; + + if (!apiKey || !fromEmail || !toEmail) { + return NextResponse.json( + { + error: + "Contact delivery is not configured. Set RESEND_API_KEY, RESEND_FROM_EMAIL, and CONTACT_TO_EMAIL.", + }, + { status: 503 } + ); + } + + const subject = `DevTrack contact message from ${name}`; + const safeName = escapeHtml(name); + const safeEmail = escapeHtml(email); + const safeMessage = escapeHtml(message).replaceAll("\n", "
"); + + const html = ` +
+

New DevTrack contact message

+

Name: ${safeName}

+

Email: ${safeEmail}

+

Message:

+
+ ${safeMessage} +
+
+ `; + + const text = `Name: ${name}\nEmail: ${email}\n\nMessage:\n${message}`; + + const response = await fetch("https://api.resend.com/emails", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + from: fromEmail, + to: toEmail, + subject, + html, + text, + reply_to: email, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("Contact email send failed:", response.status, errorText); + + return NextResponse.json( + { error: "Failed to send your message. Please try again later." }, + { status: 502 } + ); + } + + return NextResponse.json({ success: true }); +} \ No newline at end of file diff --git a/src/app/contact/page.tsx b/src/app/contact/page.tsx new file mode 100644 index 00000000..7ed023bb --- /dev/null +++ b/src/app/contact/page.tsx @@ -0,0 +1,61 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import ContactForm from "@/components/ContactForm"; + +export const metadata: Metadata = { + title: "Contact | DevTrack", + description: "Send feedback, questions, or support requests to the DevTrack team.", +}; + +export default function ContactPage() { + return ( +
+
+ +
+
+

+ Contact DevTrack +

+

+ Reach out with feedback, questions, or support needs. +

+

+ Use this form for product feedback, bug reports, or anything else you want to share with the DevTrack team. +

+ +
+
+

+ Response focus +

+

+ Messages are reviewed with product and support in mind, so you can keep everything in one place. +

+
+
+

+ Prefer an issue? +

+

+ You can also open an issue on GitHub if the request is better suited to public tracking. +

+ + View GitHub issues + +
+
+
+ +
+ +
+
+
+ ); +} diff --git a/src/components/ContactForm.tsx b/src/components/ContactForm.tsx new file mode 100644 index 00000000..7a7bf295 --- /dev/null +++ b/src/components/ContactForm.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { useId, useState } from "react"; + +type FormValues = { + name: string; + email: string; + message: string; +}; + +type FieldErrors = Partial>; + +type SubmissionState = "idle" | "submitting" | "success" | "error"; + +const initialValues: FormValues = { + name: "", + email: "", + message: "", +}; + +function validate(values: FormValues): FieldErrors { + const nextErrors: FieldErrors = {}; + const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + + if (!values.name.trim()) { + nextErrors.name = "Name is required."; + } + + if (!values.email.trim()) { + nextErrors.email = "Email is required."; + } else if (!emailPattern.test(values.email.trim())) { + nextErrors.email = "Enter a valid email address."; + } + + if (!values.message.trim()) { + nextErrors.message = "Message is required."; + } + + return nextErrors; +} + +export default function ContactForm() { + const nameId = useId(); + const emailId = useId(); + const messageId = useId(); + const statusId = useId(); + + const [values, setValues] = useState(initialValues); + const [errors, setErrors] = useState({}); + const [status, setStatus] = useState("idle"); + const [feedback, setFeedback] = useState(""); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + + const nextErrors = validate(values); + if (Object.keys(nextErrors).length > 0) { + setErrors(nextErrors); + setStatus("error"); + setFeedback("Please fix the highlighted fields and try again."); + return; + } + + setErrors({}); + setStatus("submitting"); + setFeedback(""); + + try { + const response = await fetch("/api/contact", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(values), + }); + + const data = (await response.json().catch(() => ({}))) as { error?: string }; + + if (!response.ok) { + throw new Error(data.error || "We could not submit your message right now."); + } + + setStatus("success"); + setFeedback("Thanks. Your message has been received."); + setValues(initialValues); + } catch (error) { + setStatus("error"); + setFeedback( + error instanceof Error ? error.message : "We could not submit your message right now. Please try again." + ); + } + } + + function handleChange(field: keyof FormValues, value: string) { + setValues((current) => ({ ...current, [field]: value })); + + if (errors[field]) { + setErrors((current) => { + const nextErrors = { ...current }; + delete nextErrors[field]; + return nextErrors; + }); + } + + if (status !== "idle") { + setStatus("idle"); + setFeedback(""); + } + } + + const isSubmitting = status === "submitting"; + const statusTone = status === "error" ? "destructive" : status === "success" ? "success" : ""; + + return ( +
+
+

+ Contact form +

+

+ Send a message +

+

+ Share feedback, ask a question, or report something that needs attention. +

+
+ +
+ {feedback || "All fields are required before sending."} +
+ +
+
+ + handleChange("name", event.target.value)} + aria-invalid={Boolean(errors.name)} + aria-describedby={errors.name ? `${nameId}-error` : undefined} + className="w-full rounded-2xl border border-white/10 bg-[#0f172a] px-4 py-3 text-[#f8fafc] transition-colors placeholder:text-[#64748b] hover:border-[#2563eb]/50 focus:border-[#60a5fa] focus:bg-[#111b2f] focus:outline-none" + placeholder="Your name" + /> + {errors.name ? ( +

+ {errors.name} +

+ ) : null} +
+ +
+ + handleChange("email", event.target.value)} + aria-invalid={Boolean(errors.email)} + aria-describedby={errors.email ? `${emailId}-error` : undefined} + className="w-full rounded-2xl border border-white/10 bg-[#0f172a] px-4 py-3 text-[#f8fafc] transition-colors placeholder:text-[#64748b] hover:border-[#2563eb]/50 focus:border-[#60a5fa] focus:bg-[#111b2f] focus:outline-none" + placeholder="you@example.com" + /> + {errors.email ? ( +

+ {errors.email} +

+ ) : null} +
+ +
+ +