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
75 changes: 75 additions & 0 deletions apps/www/app/api/subscribe/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { NextResponse } from "next/server";

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

// Content/newsletter subscribe. Captures to Slack today (same channel as the
// waitlist) so no signup is lost; point this at a real ESP list when one is
// wired (see the co-founder memo / growth playbook).
export async function POST(request: Request) {
try {
const body = await request.json();
const email = body.email?.trim();
const source = body.source?.trim() || "site";

if (!email || !EMAIL_REGEX.test(email)) {
return NextResponse.json(
{ error: "Please enter a valid email address." },
{ status: 400 }
);
}

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

const slackPayload = {
text: `New OpenVPM subscriber — ${email}`,
blocks: [
{
type: "header",
text: { type: "plain_text", text: "📰 New OpenVPM subscriber" },
},
{
type: "section",
fields: [
{ type: "mrkdwn", text: `*Email:*\n${email}` },
{ type: "mrkdwn", text: `*Source:*\n${source}` },
],
},
{
type: "context",
elements: [
{ type: "mrkdwn", text: `openvpm.com · ${new Date().toISOString()}` },
],
},
],
};

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

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

return NextResponse.json({ success: true });
} catch (error) {
console.error("Subscribe error:", error);
return NextResponse.json(
{ error: "Something went wrong. Please try again." },
{ status: 500 }
);
}
}
82 changes: 82 additions & 0 deletions apps/www/app/blog/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeft, Github } from "lucide-react";
import { posts, getPost } from "@/lib/posts";
import { PostContent } from "@/components/blog/post-content";
import { SubscribeForm } from "@/components/subscribe-form";

export function generateStaticParams() {
return posts.map((p) => ({ slug: p.slug }));
}

export function generateMetadata({ params }: { params: { slug: string } }): Metadata {
const post = getPost(params.slug);
if (!post) return { title: "Not found" };
return {
title: post.title,
description: post.excerpt,
openGraph: { title: post.title, description: post.excerpt, type: "article" },
};
}

export default function PostPage({ params }: { params: { slug: string } }) {
const post = getPost(params.slug);
if (!post) notFound();

return (
<article className="py-16 sm:py-24">
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8">
<Link
href="/blog"
className="inline-flex items-center gap-1.5 text-sm text-teal-600 hover:text-teal-700 mb-8"
>
<ArrowLeft className="w-4 h-4" />
All writing
</Link>

<div className="flex items-center gap-3 text-sm text-gray-400 mb-4">
<time>{post.date}</time>
<span>·</span>
<span>{post.author}</span>
<span>·</span>
<span>{post.readingMinutes} min read</span>
</div>
<h1 className="text-3xl sm:text-4xl font-bold font-heading text-gray-900 tracking-tight leading-tight mb-8">
{post.title}
</h1>

<PostContent blocks={post.content} />

{/* Conversion: every post ends with a way to engage. */}
<div className="mt-12 rounded-2xl border border-gray-100 bg-gray-50/60 p-6 sm:p-8">
<h2 className="text-lg font-semibold font-heading text-gray-900 mb-2">
We&apos;re building this in the open
</h2>
<p className="text-sm text-gray-600 mb-5">
OpenVPM is free and MIT licensed. Try the live demo, star the repo, or
subscribe and tell us where we&apos;re wrong — the harder the feedback, the better.
</p>
<div className="flex flex-wrap gap-3 mb-6">
<a
href="https://demo.openvpm.com/login"
className="inline-flex items-center gap-2 rounded-lg bg-teal-600 px-4 py-2 text-sm font-semibold text-white hover:bg-teal-700 transition-colors"
>
Try the live demo
</a>
<a
href="https://github.com/evangauer/openvpm"
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" />
Star on GitHub
</a>
</div>
<SubscribeForm source={`post:${post.slug}`} />
</div>
</div>
</article>
);
}
71 changes: 71 additions & 0 deletions apps/www/app/blog/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { posts } from "@/lib/posts";
import { SubscribeForm } from "@/components/subscribe-form";

export const metadata: Metadata = {
title: "Writing",
description:
"Notes on building open, API-first veterinary software — owning your data, open APIs, and AI agents in the clinic.",
};

export default function BlogIndexPage() {
return (
<div className="py-16 sm:py-24">
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="mb-12">
<h1 className="text-4xl sm:text-5xl font-bold font-heading text-gray-900 tracking-tight mb-4">
Writing
</h1>
<p className="text-lg text-gray-600 max-w-2xl">
Notes on building veterinary software in the open — owning your data,
open APIs, and putting AI agents to work in the clinic. We publish our
thinking here and we want yours back.
</p>
</div>

<div className="space-y-8">
{posts.map((post) => (
<article
key={post.slug}
className="group rounded-2xl border border-gray-100 bg-white p-6 sm:p-8 hover:border-teal-200 hover:shadow-lg hover:shadow-teal-50 transition-all"
>
<div className="flex items-center gap-3 text-sm text-gray-400 mb-3">
<time>{post.date}</time>
<span>·</span>
<span>{post.readingMinutes} min read</span>
</div>
<h2 className="text-2xl font-bold font-heading text-gray-900 tracking-tight mb-2">
<Link href={`/blog/${post.slug}`} className="hover:text-teal-700">
{post.title}
</Link>
</h2>
<p className="text-gray-600 leading-relaxed mb-4">{post.excerpt}</p>
<Link
href={`/blog/${post.slug}`}
className="inline-flex items-center gap-1.5 text-sm font-medium text-teal-600 group-hover:text-teal-700"
>
Read
<ArrowRight className="w-4 h-4" />
</Link>
</article>
))}
</div>

<div className="mt-14 rounded-2xl border border-teal-100 bg-teal-50/40 p-8 text-center">
<h2 className="text-xl font-semibold font-heading text-gray-900 mb-2">
Get new writing by email
</h2>
<p className="text-gray-600 mb-6 max-w-md mx-auto">
No spam — just our notes on open veterinary software as we publish them.
Reply with your thoughts; we read every one.
</p>
<div className="max-w-sm mx-auto">
<SubscribeForm source="blog" />
</div>
</div>
</div>
</div>
);
}
8 changes: 8 additions & 0 deletions apps/www/app/sitemap.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { MetadataRoute } from "next";
import { posts } from "@/lib/posts";

const baseUrl = "https://openvpm.com";

Expand All @@ -10,5 +11,12 @@ 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}/blog`, lastModified: now, changeFrequency: "weekly", priority: 0.7 },
...posts.map((p) => ({
url: `${baseUrl}/blog/${p.slug}`,
lastModified: now,
changeFrequency: "monthly" as const,
priority: 0.6,
})),
];
}
47 changes: 47 additions & 0 deletions apps/www/components/blog/post-content.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { Block } from "@/lib/posts";

export function PostContent({ blocks }: { blocks: Block[] }) {
return (
<div className="space-y-5">
{blocks.map((block, i) => {
switch (block.type) {
case "h2":
return (
<h2
key={i}
className="text-xl sm:text-2xl font-bold font-heading text-gray-900 tracking-tight pt-2"
>
{block.text}
</h2>
);
case "quote":
return (
<blockquote
key={i}
className="border-l-4 border-teal-300 pl-5 py-1 text-lg font-heading text-gray-700 italic"
>
{block.text}
</blockquote>
);
case "ul":
return (
<ul key={i} className="space-y-2 pl-1">
{block.items.map((item, j) => (
<li key={j} className="flex items-start gap-2 text-gray-600 leading-relaxed">
<span className="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-teal-500" />
<span>{item}</span>
</li>
))}
</ul>
);
default:
return (
<p key={i} className="text-gray-600 leading-relaxed text-[17px]">
{block.text}
</p>
);
}
})}
</div>
);
}
3 changes: 3 additions & 0 deletions apps/www/components/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ export function MarketingFooter() {
<Link href="/why" className="text-sm text-gray-500 hover:text-teal-600 transition-colors">
Why Open Source
</Link>
<Link href="/blog" className="text-sm text-gray-500 hover:text-teal-600 transition-colors">
Writing
</Link>
<Link href="/updates" className="text-sm text-gray-500 hover:text-teal-600 transition-colors">
Updates
</Link>
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 @@ -29,6 +29,7 @@ const navLinks = [
{ label: "Features", href: "/features" },
{ label: "Install", href: "/install" },
{ label: "Why Open Source", href: "/why" },
{ label: "Writing", href: "/blog" },
{ label: "Updates", href: "/updates" },
];

Expand Down
Loading
Loading