diff --git a/apps/web/content/blog/vegan-iq-data-pipeline.mdx b/apps/web/content/blog/vegan-iq-data-pipeline.mdx index 9bbdfef..f81f93a 100644 --- a/apps/web/content/blog/vegan-iq-data-pipeline.mdx +++ b/apps/web/content/blog/vegan-iq-data-pipeline.mdx @@ -2,7 +2,7 @@ title: "Building a Data-Driven Trivia Engine: No LLMs, Just Verified Facts" date: "2026-04-09" excerpt: "How we built Vegan IQ's question pipeline: deterministic generation from USDA, Oxford, and FAOSTAT data, quality filtering from 2,461 to 1,066 questions, and a greedy anti-repetition algorithm that makes every quiz feel fresh." -draft: true +draft: false --- Vegan IQ ships with 1,066 trivia questions. None of them were written by an LLM. Every question traces back to a specific data source: USDA FoodData Central, Poore & Nemecek (Science, 2018), NIH, or FAOSTAT. The pipeline that produces them is a deterministic Python script that generates questions from structured data, filters them for quality, and outputs a single JSON file that the Flutter app bundles as an asset. diff --git a/apps/web/src/actions/submit-contact.ts b/apps/web/src/actions/submit-contact.ts index bf9cf07..7df6237 100644 --- a/apps/web/src/actions/submit-contact.ts +++ b/apps/web/src/actions/submit-contact.ts @@ -23,10 +23,18 @@ export async function submitContactForm(prevState: any, formData: FormData) { return { success: false, errors: validated.error.flatten().fieldErrors }; } - // 2. Turnstile Verification - const isHuman = await verifyTurnstile(validated.data.token); - if (!isHuman) { - return { success: false, message: "Security check failed. Please refresh." }; + // 2. Turnstile Verification. Distinguish "you failed the check" from "we cannot run the + // check": telling a real visitor to refresh, when the server is missing its key, sends them + // round a loop that cannot succeed. + const check = await verifyTurnstile(validated.data.token); + if (!check.ok) { + return { + success: false, + message: + check.reason === 'rejected' + ? "Security check failed. Please refresh." + : "We could not run the security check just now. Please email sales@such.software.", + }; } // 3. Send email via Resend diff --git a/apps/web/src/app/about/page.tsx b/apps/web/src/app/about/page.tsx index 0a986fc..57ebc26 100644 --- a/apps/web/src/app/about/page.tsx +++ b/apps/web/src/app/about/page.tsx @@ -6,18 +6,14 @@ import { ScrollReveal } from "@/components/scroll-reveal"; import { FaMapMarkerAlt, FaPhone, FaBuilding, FaExternalLinkAlt } from "react-icons/fa"; import { FaXTwitter, FaTelegram, FaDiscord, FaBluesky, FaTiktok, FaYoutube, FaInstagram, FaLinkedinIn, FaFacebookF, FaPinterestP } from "react-icons/fa6"; import type { Metadata } from "next"; +import { pageMetadata } from "@/lib/page-metadata"; export const metadata: Metadata = { - title: "About: A Kennett Square, PA Software Studio | Such Software", - description: "Such Software is a Kennett Square, PA studio founded by John Winter Murphy, building accessible web apps, crypto and payment integrations, and games, with front-ends engineered to meet WCAG 2.2 AA.", - alternates: { - canonical: "/about", - }, - openGraph: { + ...pageMetadata({ title: "About: A Kennett Square, PA Software Studio | Such Software", description: "Such Software is a Kennett Square, PA studio founded by John Winter Murphy, building accessible web apps, crypto and payment integrations, and games, with front-ends engineered to meet WCAG 2.2 AA.", - type: "website", - }, + path: "/about", + }), }; export default function AboutPage() { diff --git a/apps/web/src/app/blog/[slug]/page.tsx b/apps/web/src/app/blog/[slug]/page.tsx index 88a7695..abd787e 100644 --- a/apps/web/src/app/blog/[slug]/page.tsx +++ b/apps/web/src/app/blog/[slug]/page.tsx @@ -14,6 +14,7 @@ import { notFound } from "next/navigation"; import { MDXRemote } from "next-mdx-remote/rsc"; import remarkGfm from "remark-gfm"; import { getAllSlugs, getPost } from "@/lib/blog"; +import { pageMetadata } from "@/lib/page-metadata"; import { JsonLd, articleLd, breadcrumbLd } from "@/components/seo/json-ld"; type Props = { params: Promise<{ slug: string }> }; @@ -26,23 +27,16 @@ export async function generateMetadata({ params }: Props): Promise { const { slug } = await params; const post = getPost(slug); if (!post) return {}; - return { + return pageMetadata({ title: `${post.title} | Such Software Blog`, description: post.excerpt, - alternates: { canonical: `/blog/${slug}` }, - openGraph: { - title: post.title, - description: post.excerpt, - type: "article", - url: `/blog/${slug}`, - siteName: "Such Software", - }, - twitter: { - card: "summary", - title: post.title, - description: post.excerpt, - }, - }; + path: `/blog/${slug}`, + type: "article", + // The tab wants the site name; a shared link already shows the domain, so the post's own + // title is the whole card. + socialTitle: post.title, + publishedTime: post.date || undefined, + }); } export default async function BlogPost({ params }: Props) { diff --git a/apps/web/src/app/blog/page.tsx b/apps/web/src/app/blog/page.tsx index 3100cc6..e6477c1 100644 --- a/apps/web/src/app/blog/page.tsx +++ b/apps/web/src/app/blog/page.tsx @@ -3,17 +3,15 @@ import { MobileNav } from "@/components/layout/mobile-nav"; import { Card } from "@repo/ui/components/card"; import Link from "next/link"; import type { Metadata } from "next"; +import { pageMetadata } from "@/lib/page-metadata"; import { getAllPosts } from "@/lib/blog"; export const metadata: Metadata = { - title: "Blog: Build Notes on Crypto, Commerce & Apps | Such Software", - description: "Technical write-ups from Such Software on provably-fair games, Monero tooling, custom e-commerce, and the apps we ship.", - alternates: { canonical: "/blog" }, - openGraph: { + ...pageMetadata({ title: "Blog: Build Notes on Crypto, Commerce & Apps | Such Software", description: "Technical write-ups from Such Software on provably-fair games, Monero tooling, custom e-commerce, and the apps we ship.", - type: "website", - }, + path: "/blog", + }), }; export default function BlogPage() { diff --git a/apps/web/src/app/evaluetron/privacy/page.tsx b/apps/web/src/app/evaluetron/privacy/page.tsx index b3b84ad..a481729 100644 --- a/apps/web/src/app/evaluetron/privacy/page.tsx +++ b/apps/web/src/app/evaluetron/privacy/page.tsx @@ -1,18 +1,14 @@ import { Header } from "@/components/layout/header"; import { MobileNav } from "@/components/layout/mobile-nav"; import type { Metadata } from "next"; +import { pageMetadata } from "@/lib/page-metadata"; export const metadata: Metadata = { - title: "Evaluetron Privacy Policy | Such Software", - description: - "What Evaluetron stores, for how long, and who else sees it, including which prompts leave for upstream model providers.", - alternates: { canonical: "/evaluetron/privacy" }, - openGraph: { + ...pageMetadata({ title: "Evaluetron Privacy Policy | Such Software", - description: - "What Evaluetron stores, for how long, and who else sees it, including which prompts leave for upstream model providers.", - type: "website", - }, + description: "What Evaluetron stores, for how long, and who else sees it, including which prompts leave for upstream model providers.", + path: "/evaluetron/privacy", + }), }; export default function EvaluetronPrivacyPage() { diff --git a/apps/web/src/app/evaluetron/terms/page.tsx b/apps/web/src/app/evaluetron/terms/page.tsx index ffc19b2..d1270a7 100644 --- a/apps/web/src/app/evaluetron/terms/page.tsx +++ b/apps/web/src/app/evaluetron/terms/page.tsx @@ -1,18 +1,14 @@ import { Header } from "@/components/layout/header"; import { MobileNav } from "@/components/layout/mobile-nav"; import type { Metadata } from "next"; +import { pageMetadata } from "@/lib/page-metadata"; export const metadata: Metadata = { - title: "Evaluetron Terms of Service | Such Software", - description: - "The terms governing Evaluetron, the generation service reached on the web and through chat platforms including Discord and Telegram.", - alternates: { canonical: "/evaluetron/terms" }, - openGraph: { + ...pageMetadata({ title: "Evaluetron Terms of Service | Such Software", - description: - "The terms governing Evaluetron, the generation service reached on the web and through chat platforms including Discord and Telegram.", - type: "website", - }, + description: "The terms governing Evaluetron, the generation service reached on the web and through chat platforms including Discord and Telegram.", + path: "/evaluetron/terms", + }), }; export default function EvaluetronTermsPage() { diff --git a/apps/web/src/app/privacy/page.tsx b/apps/web/src/app/privacy/page.tsx index e6e5fe2..a132563 100644 --- a/apps/web/src/app/privacy/page.tsx +++ b/apps/web/src/app/privacy/page.tsx @@ -2,16 +2,14 @@ import { Header } from "@/components/layout/header"; import { MobileNav } from "@/components/layout/mobile-nav"; import Link from "next/link"; import type { Metadata } from "next"; +import { pageMetadata } from "@/lib/page-metadata"; export const metadata: Metadata = { - title: "Privacy Policy | Such Software", - description: "The umbrella privacy policy for the such.software website and Such Software LLC. Individual apps and games may have their own privacy policies.", - alternates: { canonical: "/privacy" }, - openGraph: { + ...pageMetadata({ title: "Privacy Policy | Such Software", description: "The umbrella privacy policy for the such.software website and Such Software LLC. Individual apps and games may have their own privacy policies.", - type: "website", - }, + path: "/privacy", + }), }; export default function PrivacyPage() { diff --git a/apps/web/src/app/products/bloomword/page.tsx b/apps/web/src/app/products/bloomword/page.tsx index 865a615..5ceb277 100644 --- a/apps/web/src/app/products/bloomword/page.tsx +++ b/apps/web/src/app/products/bloomword/page.tsx @@ -6,12 +6,18 @@ import Image from "next/image"; import { JsonLd, breadcrumbLd, faqLd } from "@/components/seo/json-ld"; import { StoreButtons } from "@/components/store-buttons"; import type { Metadata } from "next"; +import { pageMetadata } from "@/lib/page-metadata"; export const metadata: Metadata = { - title: "Bloomword: Daily Word Game with a Living Tree | Such Software", - description: - "Bloomword is a daily word game where everyone gets the same letters and every word you find grows a living 3D typographic tree. Five biomes, streaks, and a bloom to share. Free on iOS and Android.", - alternates: { canonical: "/products/bloomword" }, + ...pageMetadata({ + title: "Bloomword: Daily Word Game with a Living Tree | Such Software", + description: + "Bloomword is a daily word game where everyone gets the same letters and every word you find grows a living 3D typographic tree. Five biomes, streaks, and a bloom to share. Free on iOS and Android.", + path: "/products/bloomword", + socialTitle: "Bloomword: Find the Day's Words, Grow a Living Tree", + socialDescription: + "A daily word game where every word you find grows a biome-specific, living 3D typographic tree. Free on iOS and Android from Such Software.", + }), keywords: [ "daily word game", "word puzzle game", @@ -21,21 +27,29 @@ export const metadata: Metadata = { "wordle alternative", "bloomword", ], - openGraph: { - title: "Bloomword: Find the Day's Words, Grow a Living Tree", - description: - "A daily word game where every word you find grows a biome-specific, living 3D typographic tree. Free on iOS and Android from Such Software.", - type: "website", - url: "/products/bloomword", - }, }; const faqs = [ - { q: "What is Bloomword?", a: "A daily word game. Everyone gets the same set of letters each day and races to find words before the sunset, and every valid word you find grows a living 3D tree made of typography. It is designed as a calm daily habit: find your words, watch your tree bloom, keep your streak." }, - { q: "How does the tree work?", a: "Each word you find sprouts new growth on a 3D typographic tree rendered by our in-house typograph-engine. The tree's look changes with the day's biome, so your finished board is both a score and a little piece of generative art you can share." }, - { q: "Is it the same puzzle for everyone?", a: "Yes. Like the best daily games, the letters are shared worldwide each day, so leaderboards and streaks are apples-to-apples. Results are server-replayed for fairness." }, - { q: "Is it free?", a: "Yes, free to play, supported by light ads and optional in-app purchases. A privacy-conscious build is a priority." }, - { q: "What platforms is it on?", a: "Built in Godot for iOS and Android, and those are the only places you can play it. Download it free on the App Store or Google Play; learn more at bloomword.earth." }, + { + q: "What is Bloomword?", + a: "A daily word game. Everyone gets the same set of letters each day and races to find words before the sunset, and every valid word you find grows a living 3D tree made of typography. It is designed as a calm daily habit: find your words, watch your tree bloom, keep your streak.", + }, + { + q: "How does the tree work?", + a: "Each word you find sprouts new growth on a 3D typographic tree rendered by our in-house typograph-engine. The tree's look changes with the day's biome, so your finished board is both a score and a little piece of generative art you can share.", + }, + { + q: "Is it the same puzzle for everyone?", + a: "Yes. Like the best daily games, the letters are shared worldwide each day, so leaderboards and streaks are apples-to-apples. Results are server-replayed for fairness.", + }, + { + q: "Is it free?", + a: "Yes, free to play, supported by light ads and optional in-app purchases. A privacy-conscious build is a priority.", + }, + { + q: "What platforms is it on?", + a: "Built in Godot for iOS and Android, and those are the only places you can play it. Download it free on the App Store or Google Play; learn more at bloomword.earth.", + }, ]; const jsonLd = { @@ -53,32 +67,67 @@ const jsonLd = { }; const features = [ - { title: "One shared daily puzzle", body: "Everyone gets the same letters each day and races the sunset. Server-replayed results keep streaks and leaderboards honest." }, - { title: "Words become a living tree", body: "Every word you find grows a 3D typographic tree, rendered in real time by our in-house typograph-engine. Your finished board is a shareable piece of generative art." }, - { title: "Five biomes", body: "Forest, desert, mushroom, kelp, and prairie; each biome gives the day's tree its own palette and character, so no two blooms look alike." }, - { title: "Built for the daily habit", body: "Streaks and biome collections reward showing up. It is a calm, one-a-day ritual, not an attention trap." }, - { title: "Play with anyone", body: "A shared daily seed and Nakama-backed leaderboards mean you can compare with friends or the whole world on the exact same board." }, - { title: "Free and privacy-conscious", body: "Free to play with light ads and optional purchases, built to respect your data." }, + { + title: "One shared daily puzzle", + body: "Everyone gets the same letters each day and races the sunset. Server-replayed results keep streaks and leaderboards honest.", + }, + { + title: "Words become a living tree", + body: "Every word you find grows a 3D typographic tree, rendered in real time by our in-house typograph-engine. Your finished board is a shareable piece of generative art.", + }, + { + title: "Five biomes", + body: "Forest, desert, mushroom, kelp, and prairie; each biome gives the day's tree its own palette and character, so no two blooms look alike.", + }, + { + title: "Built for the daily habit", + body: "Streaks and biome collections reward showing up. It is a calm, one-a-day ritual, not an attention trap.", + }, + { + title: "Play with anyone", + body: "A shared daily seed and Nakama-backed leaderboards mean you can compare with friends or the whole world on the exact same board.", + }, + { + title: "Free and privacy-conscious", + body: "Free to play with light ads and optional purchases, built to respect your data.", + }, ]; export default function BloomwordPage() { return (
- -