From d1d56ef74485538952efc6f174c8702547df0421 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sat, 5 Sep 2026 23:40:46 -0400 Subject: [PATCH] fix(web): fail closed on Turnstile, keep the social card, derive the sitemap Five things that were each one line of wrong and are cheap to hold shut. Turnstile failed OPEN. `lib/turnstile.ts` returned true whenever TURNSTILE_SECRET_KEY was unset, and logged the visitor's token while doing it. Lose that variable in a deploy and the contact form quietly stops checking anything, the only trace a warning nobody reads. Missing configuration is now a refusal; the dev bypass survives as an explicit TURNSTILE_DEV_BYPASS=1 that a production build ignores, rather than as a side effect of an empty variable. The caller now distinguishes "you failed the check" from "we cannot run the check", because telling a real visitor to refresh, when the server has no key, sends them round a loop that cannot succeed. The social card was being dropped by the pages that cared enough to write their own. Next merges metadata shallowly, so a page exporting `openGraph: { title, description }` replaces the parent's whole block, image included. Seventeen pages did that; five had an opengraph-image.tsx to fall back on and twelve shared as a bare grey rectangle. `pageMetadata()` takes the parts that differ and fills in the parts that must not go missing. Built output goes from 23/51 pages carrying og:image and 12/51 carrying twitter:image to 51/51 on both. Canonical was already 51/51 and stays there. The sitemap was a hand-kept list and had drifted nine routes behind the site, among them /products/{vegan-iq,suchoice,bauhaus-echo,such-moon-launch}/privacy, which Apple and Google fetch during store review. It now walks the route tree, so a page is advertised because it exists. Dynamic segments still come from their data (blog posts, service pillars), and /contact is excluded WITH its reason: it redirects, and a sitemap should name the destination rather than the door. Result: 37 static routes, up from 28, nothing removed. `vegan-iq-data-pipeline.mdx` was `draft: true`, which kept the most substantial post on the site out of the index, the sitemap and generateStaticParams. And /contact was unreachable on a first visit. It redirects to /#contact, but the splash owns the first screen and entering it pins scroll to 0, so every inbound contact link played the intro and dropped the visitor at the top of the homepage with the hash discarded. A fragment that names something real on the page now skips the splash and is honoured once, on arrival. Verified: pnpm build green, lint clean (8 pre-existing no-img-element warnings, none from this change), og/twitter/canonical counted in the built HTML. --- .../content/blog/vegan-iq-data-pipeline.mdx | 2 +- apps/web/src/actions/submit-contact.ts | 16 +- apps/web/src/app/about/page.tsx | 12 +- apps/web/src/app/blog/[slug]/page.tsx | 24 +-- apps/web/src/app/blog/page.tsx | 10 +- apps/web/src/app/evaluetron/privacy/page.tsx | 14 +- apps/web/src/app/evaluetron/terms/page.tsx | 14 +- apps/web/src/app/privacy/page.tsx | 10 +- apps/web/src/app/products/bloomword/page.tsx | 191 +++++++++++++----- apps/web/src/app/products/meatspace/page.tsx | 171 ++++++++++++---- .../app/products/mushroom-capital/page.tsx | 167 +++++++++++---- apps/web/src/app/products/page.tsx | 10 +- apps/web/src/app/sitemap.ts | 87 +++++--- apps/web/src/app/support/page.tsx | 10 +- apps/web/src/app/terms/page.tsx | 10 +- .../web/src/components/landing/home-shell.tsx | 21 +- apps/web/src/lib/page-metadata.ts | 78 +++++++ apps/web/src/lib/routes.ts | 53 +++++ apps/web/src/lib/turnstile.ts | 78 +++++-- 19 files changed, 719 insertions(+), 259 deletions(-) create mode 100644 apps/web/src/lib/page-metadata.ts create mode 100644 apps/web/src/lib/routes.ts 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 (
- -