Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
0ed3a09
chore(cms): open content sandbox D
miloshroma Sep 4, 2026
83055fc
feat(cms): add a product carousel, a sidebar section and a topic route
miloshroma Sep 7, 2026
d248627
fix(cms): render talk bodies, and let the new blocks honour their sec…
miloshroma Sep 7, 2026
802636d
fix(cms): give the talk and topic routes their site chrome
miloshroma Sep 7, 2026
f0da510
fix(cms): send a Buy click to hosted checkout, not to the cart page
miloshroma Sep 7, 2026
caf92b6
feat(cms): make a talk page machine-readable
miloshroma Sep 7, 2026
98919ea
fix(cms): make the seek-to-quote control actually seek
miloshroma Sep 7, 2026
bbbbd08
fix(cms): register the two new collections with the SEO plugin
miloshroma Sep 7, 2026
9055068
refactor(cms): give the two new collections a Content tab beside SEO
miloshroma Sep 7, 2026
ea37320
feat(cms): pick a store product from a list instead of typing its handle
miloshroma Sep 7, 2026
278f756
refactor(cms): put the demo's own pages on the design system
miloshroma Sep 9, 2026
2d744c9
fix(cms): let click-to-edit work on a talk and a topic
miloshroma Sep 14, 2026
4efde7a
fix(cms): make the talk schema read like a CMS, not a database
chenaski Sep 14, 2026
7936763
feat(cms): rebuild the design system and the sections it was drawn for
chenaski Sep 14, 2026
e2891a1
fix(cms): make the two-weight, one-family rule true across the site
chenaski Sep 14, 2026
d36cc94
feat(cms): extract the concept's shared primitives
chenaski Sep 15, 2026
1e2525e
refactor(cms): put the store blocks on the shared card
chenaski Sep 15, 2026
2a21a39
feat(cms): render real talks in the design's card
chenaski Sep 15, 2026
9dc8d6e
feat(cms): make the guidance rail filter real teachings
chenaski Sep 15, 2026
66648e9
feat(cms): migrate the schema the rebuilt blocks need
chenaski Sep 15, 2026
b2d4536
fix(cms): stop painting every section heading green
chenaski Sep 15, 2026
d6fd306
fix(cms): give a card's width to its track, not to the card
chenaski Sep 17, 2026
2295dd0
feat(cms): index the archive for semantic search, and give it a page
chenaski Sep 17, 2026
5964661
fix(cms): drop the cover well when a card has no picture
chenaski Sep 17, 2026
61d8967
feat(cms): give the store carousel its own card
chenaski Sep 18, 2026
a8ca117
fix(cms): let the browser's own smooth scroll page the rail
chenaski Sep 18, 2026
bdb1157
fix(cms): bleed the store card's cover to its edge, and give it a boo…
chenaski Sep 18, 2026
4cb601a
fix(cms): ease the store cover off a true book ratio, back to rail scale
chenaski Sep 18, 2026
6a24634
feat(cms): clicking a store card starts checkout, not a trip to the p…
chenaski Sep 18, 2026
7574793
Drop the header's dead cart button, widen the gate's crawlable excerpt
chenaski Sep 18, 2026
d17e18a
Spread the guidance rail's filter chips across talks, not one talk's …
chenaski Sep 18, 2026
01607ec
Frame the store card's cover instead of cropping it
chenaski Sep 18, 2026
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: 8 additions & 1 deletion apps/cms/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,14 @@ yarn-debug.log*
yarn-error.log*

# local env files
.env*.local
#
# Ignore every .env variant and re-admit the two that are meant to be read. `.env*.local` alone let
# `.env.preview` through, and that file carries DATABASE_URL, PAYLOAD_SECRET, OPENAI_API_KEY,
# GOOGLE_CLIENT_SECRET, MAIL_PASSWORD and a Vercel REST token into a PUBLIC repo on one `git add -A`.
# Untracked is a state; ignored is a guarantee.
.env*
!.env.example
!test.env

# vercel
.vercel
Expand Down
66 changes: 66 additions & 0 deletions apps/cms/SANDBOX.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Client content sandbox D

This branch exists to hold open a preview deployment of `apps/cms` that a prospect
can log into and edit while they evaluate the CMS. Their content is a large archive
of talks, articles and lessons that currently lives in Magento, where every item is
modelled as a catalogue product.

**Do not merge this PR, and do not push unrelated code here.** The branch is a
demo surface with a date on it, not a feature in progress.

This repo is public, so the branch does not say which prospect it belongs to, and
neither does any file on it. The mapping, the login we hand out and the seeded
content all live in the deal record instead.

## What this branch adds

Unlike a stock content sandbox, this one carries code — the point being demonstrated
is that the archive collapses into one collection rather than thirty attribute sets:

- `Talk` and `Topic` collections. `Talk` holds a `requiredTier` field, which is
editorial metadata about what an item needs, never a record of who paid
- three page-builder blocks (`TalkGrid`, `TopicChips`, `ShopifyProduct`) on top of
the twelve already in Ideal CMS
- a talk route with a "view as" tier switch, so the gated and ungated states can be
shown on a call without building sign-in
- metadata parity on the two hand-written routes and talk-level structured data — an
`Article` carrying its audio as an `AudioObject` and Google's paywalled-content
pattern, so a gated item is indexable and marked as metered rather than truncated
- a server-rendered Shopify product card, reading one product handle through the
Storefront API

The seeded documents are the prospect's own material and are loaded over the API by
a script kept in the deal record. **No client content is committed to this repo.**

## How it works

- the open PR keeps a Vercel preview deployment alive on this branch
- the Neon integration gives that deployment its own database branch, copied from
the parent when the branch was cut, so what they edit stays off the shared demo
- live preview resolves against the deployment's own origin (`generatePreviewPath`
returns a relative path), so editing works on the preview domain unconfigured
- migrations auto-apply during `next build`, so pushing the branch is the whole
deploy story

## What does not work here, by design

- **Scheduled publishing stores a schedule and never fires.** Vercel crons only run
on production deployments, so `/api/scheduled-publish/run` is never called. The UI
works; nothing executes it
- **Neon branches the database, not the Blob store.** This deployment shares its
media library with the public production demo, so an image deleted here is deleted
there

## Retiring it

Three steps, because closing the PR does none of them — a previously closed sandbox
PR was still serving its deployment three weeks after its branch was deleted:

1. close the PR and delete the branch
2. delete the preview deployment — `vercel remove <deployment-url> --yes`, targeting
the immutable deployment URL, never the project name
3. delete the Neon branch for this git branch

Then request the branch alias and expect a 404. A 200 means the copy is still
reachable by anyone holding the project-wide protection-bypass secret. Nothing on
`main` is affected at any point.
9 changes: 9 additions & 0 deletions apps/cms/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ const nextConfig = {
hostname: "payload-cms-ideal-cms.vercel.app",
protocol: "https",
},
/*
* Every product image the storefront API returns is served from this host. Without it the
* commerce blocks have to pass `unoptimized`, which ships the original full-size asset and
* drops the whole image pipeline on the one part of the page that is all photography.
*/
{
hostname: "cdn.shopify.com",
protocol: "https",
},
],
},
};
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/cms/public/design/background-01-plus.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/cms/public/design/course-01.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/cms/public/design/course-02.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/cms/public/design/course-03.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/cms/public/design/course-04.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/cms/public/design/course-05.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/cms/public/design/course-06.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/cms/public/design/pink-flower.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export async function FeaturedPost({ post, readMoreLabel, locale, className }: F
</div>

<div className="flex flex-col gap-[18px]">
<div className="flex flex-wrap items-center gap-3 font-mono text-[0.72rem] uppercase tracking-[0.08em] text-muted-foreground">
<div className="flex flex-wrap items-center gap-3 text-[0.72rem] uppercase tracking-[0.1em] text-muted-foreground">
{category && (
<>
<span className="whitespace-nowrap text-primary">{category.title}</span>
Expand All @@ -77,7 +77,7 @@ export async function FeaturedPost({ post, readMoreLabel, locale, className }: F
<div className="mt-1.5 flex items-center gap-[18px]">
{author && <AuthorAvatar author={author} size="md" />}
<div>
{author && <div className="text-[0.95rem] font-semibold">{author.name}</div>}
{author && <div className="text-[0.95rem] font-medium">{author.name}</div>}
{publishedDate && (
<time
dateTime={post.publishedAt ?? undefined}
Expand All @@ -91,7 +91,7 @@ export async function FeaturedPost({ post, readMoreLabel, locale, className }: F
)}

{readMoreLabel && (
<span className="mt-2 inline-flex items-center gap-1.5 font-semibold transition-colors hover:text-primary motion-reduce:transition-none">
<span className="mt-2 inline-flex items-center gap-1.5 font-medium transition-colors hover:text-primary motion-reduce:transition-none">
{readMoreLabel} <span aria-hidden>→</span>
</span>
)}
Expand Down
146 changes: 146 additions & 0 deletions apps/cms/src/app/(frontend)/[locale]/browse-topics/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* The topic page. A topic is a collection item, so it renders from this route and needs no Page
* document - one Page per topic would make the editor maintain a document whose only content is a
* query, and it would drift the moment an item was retagged.
*
* This is the landing page the topic chips point at, from the homepage and from every item page,
* so the route existing is what keeps those links alive. The path is /browse-topics/[slug], which
* is the segment the client's live site already uses.
*
* It is a LISTING, not a reading page, so it sits in the same measure every other listing sits in:
* max-w-containerMaxW with px-containerBase, which is the column the header logo aligns to. The
* first version borrowed the blog article's 720px reading column, and the content jumped about
* 165px sideways when a visitor clicked from /browse-topics into a topic.
*
* Route placement note: this sits under [locale] because proxy.ts rewrites every top-level path
* except api|admin|_next|_vercel into the locale catch-all. A route at /browse-topics/[slug]
* outside [locale] would build, appear in the route table, and 404 in production.
*
* No generateStaticParams, matching the sibling /talks/[slug] route: both render on demand and
* cache at the DAL, so a newly published item or topic is reachable without a rebuild.
*/

import type { Metadata } from "next";
import { notFound } from "next/navigation";

import { BreadcrumbsJsonLd } from "@/components/seo/components";
import { DisplayHeading } from "@/components/DisplayHeading";
import { getPayloadClient, getTalks } from "@/dal";
import { getSiteSettings } from "@/dal/getSiteSettings";
// Deep import: the "@/dal" barrel does not re-export this one yet. Switch to the barrel once it
// does, so application code keeps a single DAL entry point.
import { getTopicBySlug } from "@/dal/getTopicBySlug";
import type { Locale } from "@/lib/types";
import { generateMeta } from "@/lib/utils/generateMeta";
import { buildUrl } from "@/lib/utils/path/buildUrl";
import type { Footer as FooterType, Header as HeaderType } from "@/payload-types";
import { Footer } from "@/collections/Footer/Component";
import { Header } from "@/collections/Header/Component";
// The same card the TalkGrid block renders. Sharing it is what keeps the tier label, the lock and
// the teaser cut identical on two pages that are walked one after the other.
import { TalkList } from "@/blocks/TalkGrid/ui";

interface PageProps {
params: Promise<{ locale: Locale; slug: string }>;
}

/** A topic page lists its whole topic, so the listing default of 6 would silently truncate it. */
const MAX_ITEMS_LISTED = 100;

/**
* Same shared helper as the Page catch-all and the blog route, so a topic page ships the canonical,
* robots, og:* and twitter:* set a Page ships. The route used to return a title and a description
* and nothing else, which left the SEO tab's stored meta image and robots choice unread.
*
* The topic-level fallback (meta.description -> the topic's own description) is resolved into the
* doc rather than lost, exactly as the sibling talk route does it; generateMeta continues from
* there into the site-level default.
*/
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { locale, slug } = await params;
const payload = await getPayloadClient();
const topic = await getTopicBySlug(payload, slug, locale);
if (!topic) return {};

return generateMeta({
collection: "topic",
doc: {
...topic,
meta: {
...topic.meta,
description: topic.meta?.description ?? topic.description ?? undefined,
},
},
locale,
});
}

export default async function TopicPage({ params }: PageProps) {
const { locale, slug } = await params;
const payload = await getPayloadClient();

const topic = await getTopicBySlug(payload, slug, locale);
if (!topic) notFound();

const siteSettings = await getSiteSettings({ locale });

// The same DAL the TalkGrid block uses, filtered by this topic's slug - one query shape for
// "items carrying a topic", so a change to the listing select or sort reaches both.
const { docs } = await getTalks(payload, {
limit: MAX_ITEMS_LISTED,
locale,
topicSlug: topic.slug,
});

return (
<div className="flex min-h-screen flex-col">
<Header data={siteSettings.blog.header as HeaderType} />
<main className="grow">
{/* Breadcrumbs only, on purpose. The one listing schema this repo owns, createBlogSchema,
is typed to Post and resolves every URL through the blog base path, so it cannot
describe a topic; an ItemList written here would be new markup invented for one page
rather than a shared component, so it waits for a reason to exist. */}
<BreadcrumbsJsonLd
items={[
{
label: topic.title,
url: buildUrl({ collection: "topic", locale, slug: topic.slug }),
},
]}
locale={locale}
/>

{/* The band and its bottom border are the separator the header needs. Without one the
header floated over the page with nothing under it. Padding matches PostHero. */}
<header className="border-b border-border bg-surface-muted pb-[clamp(28px,4vw,44px)] pt-[clamp(40px,6vw,72px)]">
<div className="mx-auto w-full max-w-containerMaxW px-containerBase">
<div className="flex max-w-[720px] flex-col gap-5">
<p className="text-eyebrow text-muted-foreground">Topic</p>
<DisplayHeading as="h1" size="display-2" text={topic.title} />
{topic.description && (
<p className="text-lead text-muted-foreground">{topic.description}</p>
)}
</div>
</div>
</header>

<div className="mx-auto w-full max-w-containerMaxW px-containerBase py-sectionBase">
<div>
<h2 className="text-h-card mb-12">
{docs.length === 1 ? "1 item" : `${docs.length} items`} on this topic
</h2>

{docs.length > 0 ? (
<TalkList talks={docs} />
) : (
<p className="text-body-lg text-muted-foreground">
Nothing is published under this topic yet.
</p>
)}
</div>
</div>
</main>
<Footer data={siteSettings.blog.footer as FooterType} />
</div>
);
}
38 changes: 14 additions & 24 deletions apps/cms/src/app/(frontend)/[locale]/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Viewport } from "next";
import { Archivo, IBM_Plex_Mono, Newsreader } from "next/font/google";
import { Poppins } from "next/font/google";
import { getMessages } from "next-intl/server";
import { draftMode } from "next/headers";
import React from "react";
Expand All @@ -12,33 +12,27 @@ import type { Locale } from "@/lib/types";
import { LivePreviewListener } from "@/components/LivePreviewListener";
import { VisualEditingEditRouter } from "@/components/VisualEditingEditRouter";

const newsreader = Newsreader({
/**
* One family for the whole site, which is the design system's own decision - display and body
* differ by size and tracking, never by face.
*
* 400 and 500 are the only weights, and only those two are loaded. The public components that
* still reached for a 600 were moved to 500 rather than kept alive by shipping a third weight -
* there is no bold in this design, so a face that can render one is a face that will.
*/
const poppins = Poppins({
display: "swap",
style: ["normal", "italic"],
subsets: ["latin"],
variable: "--font-newsreader",
weight: ["400", "500", "600"],
});

const archivo = Archivo({
display: "swap",
subsets: ["latin"],
variable: "--font-archivo",
weight: ["400", "500", "600"],
});

const ibmPlexMono = IBM_Plex_Mono({
display: "swap",
subsets: ["latin"],
variable: "--font-ibm-plex-mono",
variable: "--font-poppins",
weight: ["400", "500"],
});

export const viewport: Viewport = {
initialScale: 1,
themeColor: [
{ color: "#eef2f3", media: "(prefers-color-scheme: light)" },
{ color: "#08100f", media: "(prefers-color-scheme: dark)" },
{ color: "#ffffff", media: "(prefers-color-scheme: light)" },
{ color: "#0b0d0c", media: "(prefers-color-scheme: dark)" },
],
width: "device-width",
};
Expand All @@ -54,11 +48,7 @@ export default async function RootLayout({ children, params }: Props) {
const messages = await getMessages();

return (
<html
lang={locale}
data-theme="light"
className={`${newsreader.variable} ${archivo.variable} ${ibmPlexMono.variable}`}
>
<html lang={locale} data-theme="light" className={poppins.variable}>
<head />
<body>
<AnalyticsProviderClient measurementId={process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID!}>
Expand Down
2 changes: 1 addition & 1 deletion apps/cms/src/app/(frontend)/[locale]/not-found.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export default async function NotFound() {
<main className="flex flex-1 flex-col">
<section className="flex flex-1 items-center justify-center py-12 px-4 sm:py-16 sm:px-6 md:py-20 md:px-8 lg:py-24">
<div className="mx-auto max-w-7xl text-center">
<h1 className="text-4xl md:text-6xl font-bold mb-6">
<h1 className="text-4xl md:text-6xl font-medium mb-6">
{settings.title || "404 - Page not found"}
</h1>
<p className="text-lg md:text-xl text-gray-600 dark:text-gray-400 mb-8 max-w-2xl mx-auto">
Expand Down
Loading