Skip to content

Commit 6686395

Browse files
evangauerclaude
andcommitted
Add a feedback page to the marketing site
Captures evaluator/user feedback on OUR property (the marketing site), not the open-source app — so self-hosters never get a phone-home widget and feedback is never a blocker to using the tool. A /feedback page posts to the same Slack channel as the waitlist (message required; name/email optional), and points developers to GitHub Issues/Discussions for bug reports and contributions. Wired into nav, footer, and sitemap. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 01e1d7a commit 6686395

6 files changed

Lines changed: 273 additions & 0 deletions

File tree

apps/www/app/api/feedback/route.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { NextResponse } from "next/server";
2+
3+
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
4+
5+
export async function POST(request: Request) {
6+
try {
7+
const body = await request.json();
8+
const message = body.message?.trim();
9+
const name = body.name?.trim() || undefined;
10+
const email = body.email?.trim() || undefined;
11+
const context = body.context?.trim() || undefined; // e.g. which page/feature
12+
13+
if (!message || message.length < 3) {
14+
return NextResponse.json(
15+
{ error: "Please enter a bit more detail." },
16+
{ status: 400 }
17+
);
18+
}
19+
if (message.length > 5000) {
20+
return NextResponse.json(
21+
{ error: "That's a lot — please keep it under 5000 characters." },
22+
{ status: 400 }
23+
);
24+
}
25+
if (email && !EMAIL_REGEX.test(email)) {
26+
return NextResponse.json(
27+
{ error: "That email doesn't look right." },
28+
{ status: 400 }
29+
);
30+
}
31+
32+
const webhookUrl = process.env.SLACK_WEBHOOK_URL;
33+
if (!webhookUrl) {
34+
console.error("Missing SLACK_WEBHOOK_URL");
35+
return NextResponse.json(
36+
{ error: "Feedback is temporarily unavailable." },
37+
{ status: 503 }
38+
);
39+
}
40+
41+
const fields: { type: "mrkdwn"; text: string }[] = [
42+
{ type: "mrkdwn", text: `*Feedback:*\n${message}` },
43+
];
44+
if (name) fields.push({ type: "mrkdwn", text: `*Name:*\n${name}` });
45+
if (email) fields.push({ type: "mrkdwn", text: `*Email:*\n${email}` });
46+
if (context) fields.push({ type: "mrkdwn", text: `*Context:*\n${context}` });
47+
48+
const slackPayload = {
49+
text: `New OpenVPM feedback`,
50+
blocks: [
51+
{
52+
type: "header",
53+
text: { type: "plain_text", text: "💬 New OpenVPM feedback" },
54+
},
55+
{ type: "section", fields },
56+
{
57+
type: "context",
58+
elements: [
59+
{
60+
type: "mrkdwn",
61+
text: `Source: openvpm.com/feedback · ${new Date().toISOString()}`,
62+
},
63+
],
64+
},
65+
],
66+
};
67+
68+
const res = await fetch(webhookUrl, {
69+
method: "POST",
70+
headers: { "Content-Type": "application/json" },
71+
body: JSON.stringify(slackPayload),
72+
});
73+
74+
if (!res.ok) {
75+
const text = await res.text();
76+
console.error("Slack webhook error:", res.status, text);
77+
return NextResponse.json(
78+
{ error: "Something went wrong. Please try again." },
79+
{ status: 500 }
80+
);
81+
}
82+
83+
return NextResponse.json({ success: true });
84+
} catch (error) {
85+
console.error("Feedback error:", error);
86+
return NextResponse.json(
87+
{ error: "Something went wrong. Please try again." },
88+
{ status: 500 }
89+
);
90+
}
91+
}

apps/www/app/feedback/page.tsx

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import type { Metadata } from "next";
2+
import { Github, MessageSquare } from "lucide-react";
3+
import { FeedbackForm } from "@/components/feedback-form";
4+
5+
export const metadata: Metadata = {
6+
title: "Feedback",
7+
description:
8+
"Share feedback on OpenVPM — what's working, what's not, and what you'd love to see. Every note helps.",
9+
};
10+
11+
export default function FeedbackPage() {
12+
return (
13+
<div className="py-16 sm:py-24">
14+
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8">
15+
<div className="text-center mb-10">
16+
<div className="inline-flex items-center justify-center w-12 h-12 rounded-2xl bg-teal-50 text-teal-600 mb-5">
17+
<MessageSquare className="w-6 h-6" />
18+
</div>
19+
<h1 className="text-4xl sm:text-5xl font-bold font-heading text-gray-900 tracking-tight mb-4">
20+
Tell us what you think
21+
</h1>
22+
<p className="text-lg text-gray-600">
23+
OpenVPM is built in the open, with the veterinary community. If you
24+
tried the demo or are running it yourself, we&apos;d love your notes —
25+
what worked, what didn&apos;t, what&apos;s missing. We read every one.
26+
</p>
27+
</div>
28+
29+
<div className="rounded-2xl border border-gray-100 bg-white p-6 sm:p-8 shadow-sm">
30+
<FeedbackForm />
31+
</div>
32+
33+
{/* Developers: point to the real OSS channels rather than a form. */}
34+
<div className="mt-8 rounded-2xl border border-gray-100 bg-gray-50/60 p-6">
35+
<h2 className="flex items-center gap-2 text-base font-semibold font-heading text-gray-900 mb-2">
36+
<Github className="w-4 h-4" />
37+
Building on OpenVPM?
38+
</h2>
39+
<p className="text-sm text-gray-600">
40+
Bug reports, feature requests, and contributions are best filed on
41+
GitHub, where the whole community can see and weigh in:
42+
</p>
43+
<div className="mt-4 flex flex-wrap gap-3">
44+
<a
45+
href="https://github.com/evangauer/openvpm/issues"
46+
target="_blank"
47+
rel="noopener noreferrer"
48+
className="inline-flex items-center gap-2 rounded-lg border-2 border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-700 hover:border-teal-200 hover:text-teal-600 transition-colors"
49+
>
50+
<Github className="w-4 h-4" />
51+
Open an issue
52+
</a>
53+
<a
54+
href="https://github.com/evangauer/openvpm/discussions"
55+
target="_blank"
56+
rel="noopener noreferrer"
57+
className="inline-flex items-center gap-2 rounded-lg border-2 border-gray-200 bg-white px-4 py-2 text-sm font-semibold text-gray-700 hover:border-teal-200 hover:text-teal-600 transition-colors"
58+
>
59+
<MessageSquare className="w-4 h-4" />
60+
Start a discussion
61+
</a>
62+
</div>
63+
</div>
64+
</div>
65+
</div>
66+
);
67+
}

apps/www/app/sitemap.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,6 @@ export default function sitemap(): MetadataRoute.Sitemap {
1010
{ url: `${baseUrl}/why`, lastModified: now, changeFrequency: "monthly", priority: 0.7 },
1111
{ url: `${baseUrl}/install`, lastModified: now, changeFrequency: "weekly", priority: 0.9 },
1212
{ url: `${baseUrl}/updates`, lastModified: now, changeFrequency: "weekly", priority: 0.6 },
13+
{ url: `${baseUrl}/feedback`, lastModified: now, changeFrequency: "monthly", priority: 0.5 },
1314
];
1415
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import { Send, CheckCircle2, Loader2 } from "lucide-react";
5+
6+
type Step = "idle" | "submitting" | "success" | "error";
7+
8+
export function FeedbackForm() {
9+
const [step, setStep] = useState<Step>("idle");
10+
const [message, setMessage] = useState("");
11+
const [name, setName] = useState("");
12+
const [email, setEmail] = useState("");
13+
const [errorMsg, setErrorMsg] = useState("");
14+
15+
const handleSubmit = async (e: React.FormEvent) => {
16+
e.preventDefault();
17+
if (message.trim().length < 3) return;
18+
19+
setStep("submitting");
20+
setErrorMsg("");
21+
22+
try {
23+
const res = await fetch("/api/feedback", {
24+
method: "POST",
25+
headers: { "Content-Type": "application/json" },
26+
body: JSON.stringify({
27+
message: message.trim(),
28+
name: name.trim() || undefined,
29+
email: email.trim() || undefined,
30+
}),
31+
});
32+
const data = await res.json();
33+
if (!res.ok) {
34+
setStep("error");
35+
setErrorMsg(data.error || "Something went wrong. Please try again.");
36+
return;
37+
}
38+
setStep("success");
39+
} catch {
40+
setStep("error");
41+
setErrorMsg("Something went wrong. Please try again.");
42+
}
43+
};
44+
45+
if (step === "success") {
46+
return (
47+
<div className="flex items-center justify-center gap-3 rounded-xl border border-teal-100 bg-teal-50/50 py-6">
48+
<CheckCircle2 className="w-5 h-5 text-teal-600 shrink-0" />
49+
<span className="text-teal-700 font-medium">
50+
Thank you — every note helps us make this better.
51+
</span>
52+
</div>
53+
);
54+
}
55+
56+
return (
57+
<form onSubmit={handleSubmit} className="space-y-3">
58+
<textarea
59+
required
60+
rows={5}
61+
placeholder="What's working, what's not, what you'd love to see…"
62+
value={message}
63+
onChange={(e) => setMessage(e.target.value)}
64+
disabled={step === "submitting"}
65+
className="w-full rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent disabled:opacity-60 resize-none"
66+
/>
67+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
68+
<input
69+
type="text"
70+
placeholder="Your name (optional)"
71+
value={name}
72+
onChange={(e) => setName(e.target.value)}
73+
disabled={step === "submitting"}
74+
className="w-full rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent disabled:opacity-60"
75+
/>
76+
<input
77+
type="email"
78+
placeholder="Email (optional, for a reply)"
79+
value={email}
80+
onChange={(e) => setEmail(e.target.value)}
81+
disabled={step === "submitting"}
82+
className="w-full rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm text-gray-900 placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent disabled:opacity-60"
83+
/>
84+
</div>
85+
<button
86+
type="submit"
87+
disabled={step === "submitting" || message.trim().length < 3}
88+
className="w-full inline-flex items-center justify-center gap-2 rounded-xl bg-teal-600 px-6 py-3 text-sm font-semibold text-white hover:bg-teal-700 transition-colors disabled:opacity-60"
89+
>
90+
{step === "submitting" ? (
91+
<>
92+
<Loader2 className="w-4 h-4 animate-spin" />
93+
Sending…
94+
</>
95+
) : (
96+
<>
97+
Send feedback
98+
<Send className="w-4 h-4" />
99+
</>
100+
)}
101+
</button>
102+
{step === "error" && (
103+
<p className="text-sm text-red-600 text-center">{errorMsg}</p>
104+
)}
105+
<p className="text-xs text-gray-400 text-center pt-1">
106+
No account needed. Email is optional and only used to follow up.
107+
</p>
108+
</form>
109+
);
110+
}

apps/www/components/footer.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ export function MarketingFooter() {
4545
<Link href="/updates" className="text-sm text-gray-500 hover:text-teal-600 transition-colors">
4646
Updates
4747
</Link>
48+
<Link href="/feedback" className="text-sm text-gray-500 hover:text-teal-600 transition-colors">
49+
Feedback
50+
</Link>
4851
<span className="text-sm text-gray-500">
4952
Pricing <span className="text-teal-600 font-medium">(free)</span>
5053
</span>

apps/www/components/nav.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ const navLinks = [
3030
{ label: "Install", href: "/install" },
3131
{ label: "Why Open Source", href: "/why" },
3232
{ label: "Updates", href: "/updates" },
33+
{ label: "Feedback", href: "/feedback" },
3334
];
3435

3536
export function MarketingNav() {

0 commit comments

Comments
 (0)