diff --git a/apps/web/app/components/SoapNoteDisplay.tsx b/apps/web/components/SoapNoteDisplay.tsx similarity index 100% rename from apps/web/app/components/SoapNoteDisplay.tsx rename to apps/web/components/SoapNoteDisplay.tsx diff --git a/apps/web/app/components/SoapNoteEditor.tsx b/apps/web/components/SoapNoteEditor.tsx similarity index 100% rename from apps/web/app/components/SoapNoteEditor.tsx rename to apps/web/components/SoapNoteEditor.tsx diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts index e875974..98f1b1e 100644 --- a/apps/web/middleware.ts +++ b/apps/web/middleware.ts @@ -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); diff --git a/apps/www/app/api/feedback/route.ts b/apps/www/app/api/feedback/route.ts new file mode 100644 index 0000000..52f156c --- /dev/null +++ b/apps/www/app/api/feedback/route.ts @@ -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 } + ); + } +} diff --git a/apps/www/app/feedback/page.tsx b/apps/www/app/feedback/page.tsx new file mode 100644 index 0000000..d9da122 --- /dev/null +++ b/apps/www/app/feedback/page.tsx @@ -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 ( +
+
+
+
+ +
+

+ Tell us what you think +

+

+ OpenVPM is built in the open, with the veterinary community. If you + tried the demo or are running it yourself, we'd love your notes — + what worked, what didn't, what's missing. We read every one. +

+
+ +
+ +
+ + {/* Developers: point to the real OSS channels rather than a form. */} +
+

+ + Building on OpenVPM? +

+

+ Bug reports, feature requests, and contributions are best filed on + GitHub, where the whole community can see and weigh in: +

+
+ + + Open an issue + + + + Start a discussion + +
+
+
+
+ ); +} diff --git a/apps/www/app/sitemap.ts b/apps/www/app/sitemap.ts index 3c4621e..e8cb92b 100644 --- a/apps/www/app/sitemap.ts +++ b/apps/www/app/sitemap.ts @@ -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 }, ]; } diff --git a/apps/www/components/feedback-form.tsx b/apps/www/components/feedback-form.tsx new file mode 100644 index 0000000..aa588a9 --- /dev/null +++ b/apps/www/components/feedback-form.tsx @@ -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("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 ( +
+ + + Thank you — every note helps us make this better. + +
+ ); + } + + return ( +
+