Skip to content
Open
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
9 changes: 3 additions & 6 deletions apps/web/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,9 @@ export async function middleware(request: NextRequest) {
secret: process.env.NEXTAUTH_SECRET,
});

// Unauthenticated visitors at root get redirected to the marketing site
if (!token && request.nextUrl.pathname === "/") {
const wwwUrl = process.env.NEXT_PUBLIC_WWW_URL || "https://openvpm.com";
return NextResponse.redirect(wwwUrl);
}

// Unauthenticated visitors go to the demo login, which offers one-click
// demo access. (Previously the root path bounced to the marketing site,
// which dead-ended anyone who came straight to demo.openvpm.com to try it.)
if (!token) {
const loginUrl = new URL("/login", request.url);
return NextResponse.redirect(loginUrl);
Expand Down
91 changes: 91 additions & 0 deletions apps/www/app/api/feedback/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { NextResponse } from "next/server";

const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export async function POST(request: Request) {
try {
const body = await request.json();
const message = body.message?.trim();
const name = body.name?.trim() || undefined;
const email = body.email?.trim() || undefined;
const context = body.context?.trim() || undefined; // e.g. which page/feature

if (!message || message.length < 3) {
return NextResponse.json(
{ error: "Please enter a bit more detail." },
{ status: 400 }
);
}
if (message.length > 5000) {
return NextResponse.json(
{ error: "That's a lot — please keep it under 5000 characters." },
{ status: 400 }
);
}
if (email && !EMAIL_REGEX.test(email)) {
return NextResponse.json(
{ error: "That email doesn't look right." },
{ status: 400 }
);
}

const webhookUrl = process.env.SLACK_WEBHOOK_URL;
if (!webhookUrl) {
console.error("Missing SLACK_WEBHOOK_URL");
return NextResponse.json(
{ error: "Feedback is temporarily unavailable." },
{ status: 503 }
);
}

const fields: { type: "mrkdwn"; text: string }[] = [
{ type: "mrkdwn", text: `*Feedback:*\n${message}` },
];
if (name) fields.push({ type: "mrkdwn", text: `*Name:*\n${name}` });
if (email) fields.push({ type: "mrkdwn", text: `*Email:*\n${email}` });
if (context) fields.push({ type: "mrkdwn", text: `*Context:*\n${context}` });

const slackPayload = {
text: `New OpenVPM feedback`,
blocks: [
{
type: "header",
text: { type: "plain_text", text: "💬 New OpenVPM feedback" },
},
{ type: "section", fields },
{
type: "context",
elements: [
{
type: "mrkdwn",
text: `Source: openvpm.com/feedback · ${new Date().toISOString()}`,
},
],
},
],
};

const res = await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(slackPayload),
});

if (!res.ok) {
const text = await res.text();
console.error("Slack webhook error:", res.status, text);
return NextResponse.json(
{ error: "Something went wrong. Please try again." },
{ status: 500 }
);
}

return NextResponse.json({ success: true });
} catch (error) {
console.error("Feedback error:", error);
return NextResponse.json(
{ error: "Something went wrong. Please try again." },
{ status: 500 }
);
}
}
67 changes: 67 additions & 0 deletions apps/www/app/feedback/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { Metadata } from "next";
import { Github, MessageSquare } from "lucide-react";
import { FeedbackForm } from "@/components/feedback-form";

export const metadata: Metadata = {
title: "Feedback",
description:
"Share feedback on OpenVPM — what's working, what's not, and what you'd love to see. Every note helps.",
};

export default function FeedbackPage() {
return (
<div className="py-16 sm:py-24">
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-10">
<div className="inline-flex items-center justify-center w-12 h-12 rounded-2xl bg-teal-50 text-teal-600 mb-5">
<MessageSquare className="w-6 h-6" />
</div>
<h1 className="text-4xl sm:text-5xl font-bold font-heading text-gray-900 tracking-tight mb-4">
Tell us what you think
</h1>
<p className="text-lg text-gray-600">
OpenVPM is built in the open, with the veterinary community. If you
tried the demo or are running it yourself, we&apos;d love your notes —
what worked, what didn&apos;t, what&apos;s missing. We read every one.
</p>
</div>

<div className="rounded-2xl border border-gray-100 bg-white p-6 sm:p-8 shadow-sm">
<FeedbackForm />
</div>

{/* Developers: point to the real OSS channels rather than a form. */}
<div className="mt-8 rounded-2xl border border-gray-100 bg-gray-50/60 p-6">
<h2 className="flex items-center gap-2 text-base font-semibold font-heading text-gray-900 mb-2">
<Github className="w-4 h-4" />
Building on OpenVPM?
</h2>
<p className="text-sm text-gray-600">
Bug reports, feature requests, and contributions are best filed on
GitHub, where the whole community can see and weigh in:
</p>
<div className="mt-4 flex flex-wrap gap-3">
<a
href="https://github.com/evangauer/openvpm/issues"
target="_blank"
rel="noopener noreferrer"
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"
>
<Github className="w-4 h-4" />
Open an issue
</a>
<a
href="https://github.com/evangauer/openvpm/discussions"
target="_blank"
rel="noopener noreferrer"
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"
>
<MessageSquare className="w-4 h-4" />
Start a discussion
</a>
</div>
</div>
</div>
</div>
);
}
1 change: 1 addition & 0 deletions apps/www/app/sitemap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ export default function sitemap(): MetadataRoute.Sitemap {
{ url: `${baseUrl}/why`, lastModified: now, changeFrequency: "monthly", priority: 0.7 },
{ url: `${baseUrl}/install`, lastModified: now, changeFrequency: "weekly", priority: 0.9 },
{ url: `${baseUrl}/updates`, lastModified: now, changeFrequency: "weekly", priority: 0.6 },
{ url: `${baseUrl}/feedback`, lastModified: now, changeFrequency: "monthly", priority: 0.5 },
];
}
110 changes: 110 additions & 0 deletions apps/www/components/feedback-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"use client";

import { useState } from "react";
import { Send, CheckCircle2, Loader2 } from "lucide-react";

type Step = "idle" | "submitting" | "success" | "error";

export function FeedbackForm() {
const [step, setStep] = useState<Step>("idle");
const [message, setMessage] = useState("");
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [errorMsg, setErrorMsg] = useState("");

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (message.trim().length < 3) return;

setStep("submitting");
setErrorMsg("");

try {
const res = await fetch("/api/feedback", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message: message.trim(),
name: name.trim() || undefined,
email: email.trim() || undefined,
}),
});
const data = await res.json();
if (!res.ok) {
setStep("error");
setErrorMsg(data.error || "Something went wrong. Please try again.");
return;
}
setStep("success");
} catch {
setStep("error");
setErrorMsg("Something went wrong. Please try again.");
}
};

if (step === "success") {
return (
<div className="flex items-center justify-center gap-3 rounded-xl border border-teal-100 bg-teal-50/50 py-6">
<CheckCircle2 className="w-5 h-5 text-teal-600 shrink-0" />
<span className="text-teal-700 font-medium">
Thank you — every note helps us make this better.
</span>
</div>
);
}

return (
<form onSubmit={handleSubmit} className="space-y-3">
<textarea
required
rows={5}
placeholder="What's working, what's not, what you'd love to see…"
value={message}
onChange={(e) => setMessage(e.target.value)}
disabled={step === "submitting"}
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"
/>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<input
type="text"
placeholder="Your name (optional)"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={step === "submitting"}
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"
/>
<input
type="email"
placeholder="Email (optional, for a reply)"
value={email}
onChange={(e) => setEmail(e.target.value)}
disabled={step === "submitting"}
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"
/>
</div>
<button
type="submit"
disabled={step === "submitting" || message.trim().length < 3}
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"
>
{step === "submitting" ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Sending…
</>
) : (
<>
Send feedback
<Send className="w-4 h-4" />
</>
)}
</button>
{step === "error" && (
<p className="text-sm text-red-600 text-center">{errorMsg}</p>
)}
<p className="text-xs text-gray-400 text-center pt-1">
No account needed. Email is optional and only used to follow up.
</p>
</form>
);
}
3 changes: 3 additions & 0 deletions apps/www/components/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ export function MarketingFooter() {
<Link href="/updates" className="text-sm text-gray-500 hover:text-teal-600 transition-colors">
Updates
</Link>
<Link href="/feedback" className="text-sm text-gray-500 hover:text-teal-600 transition-colors">
Feedback
</Link>
<span className="text-sm text-gray-500">
Pricing <span className="text-teal-600 font-medium">(free)</span>
</span>
Expand Down
1 change: 1 addition & 0 deletions apps/www/components/nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const navLinks = [
{ label: "Install", href: "/install" },
{ label: "Why Open Source", href: "/why" },
{ label: "Updates", href: "/updates" },
{ label: "Feedback", href: "/feedback" },
];

export function MarketingNav() {
Expand Down
Loading
Loading