Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e0a10aa
feat(tools): AI chatbot diagnostic + embeddable widget
harshit-epyc Aug 14, 2026
070a0ec
docs: how to run the chatbot tool locally
harshit-epyc Aug 14, 2026
ca26cc7
feat(embed): host-safe origin binding, split dev/live quotas, owner r…
harshit-epyc Aug 17, 2026
1d3e9a5
Merge remote-tracking branch 'origin/main' into feat/ai-chatbot-tool
harshit-epyc Aug 17, 2026
8991b2b
feat(tools): optional code reveal for environments with no email prov…
harshit-epyc Aug 17, 2026
562d88d
fix(tools): fail closed without a salt, claim turns atomically, bound…
harshit-epyc Aug 17, 2026
265e2f2
Merge pull request #109 from teamEPYC/feat/ai-chatbot-tool
harshit-epyc Aug 17, 2026
c7f2a85
feat(cms): provider abstraction, Payload provider, and a parity checker
keshav-epyc Aug 23, 2026
ed0236b
fix(cms): point the parity script at its actual filename
keshav-epyc Aug 23, 2026
d08cfef
fix(cms): rebuild taxonomy shape, blank alt, and ordering ties
keshav-epyc Aug 23, 2026
08464e0
feat(cms): read the plugin-seo meta group
keshav-epyc Aug 23, 2026
e35a503
docs(cms): handoff brief for the Payload cutover
keshav-epyc Aug 27, 2026
ed2a719
Merge pull request #110 from teamEPYC/feat/payload-cms-migration
keshav-epyc Aug 27, 2026
d8dce46
chore(cms): switch staging to Payload
keshav-epyc Aug 27, 2026
270d555
Merge pull request #111 from teamEPYC/feat/payload-staging-cutover
keshav-epyc Aug 27, 2026
5f10fc8
chore(cms): switch production to Payload
keshav-epyc Aug 27, 2026
a5cf60e
Merge pull request #112 from teamEPYC/feat/payload-production-cutover
keshav-epyc Aug 27, 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
24 changes: 19 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,16 +1,30 @@
# Strapi v5 server base URL (no trailing slash and no /admin path)
# Provider switch retained during the rollback window. Unknown values use Strapi.
CMS_PROVIDER=strapi

# Only this exact pair permits draft reads. All other values are published-only.
CMS_MODE=published
DEPLOYMENT_ROLE=production

# Payload API base URL and server-only read credentials.
PAYLOAD_URL=https://cms.epyc.in
PAYLOAD_READ_TOKEN=
PAYLOAD_PREVIEW_TOKEN=

# HMAC secret shared with Payload's revalidation webhook. The webhook sends a
# hex SHA-256 signature in the x-epyc-signature header.
CMS_REVALIDATION_SECRET=

# Strapi v5 server base URL retained for migration and rollback.
STRAPI_URL=https://your-strapi-server.com

# Read-only API token from Strapi Admin → Settings → API Tokens
# Leave blank if the collections are publicly readable
STRAPI_API_TOKEN=

# Set to "true" to fetch unpublished drafts (?status=draft) with caching off.
# Use only on preview/staging — the token above must have draft read access.
# Legacy fallback. Drafts still require DEPLOYMENT_ROLE=preview.
STRAPI_PREVIEW=

# Public base URL of the R2 bucket (custom domain) that serves Strapi media.
# The image loader prepends this to Strapi's bare /uploads/* paths.
# Public base URL of the R2 media bucket.
NEXT_PUBLIC_MEDIA_BASE_URL=https://media.epyc.in

# Contact form submissions are stored in Cloudflare D1, reached through the `DB`
Expand Down
12 changes: 10 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ name: CI

on:
pull_request:
branches: [develop, master, production]
# `main` is the default branch and `production` is the release branch.
# These were previously [develop, master, production] — branch names this
# repo no longer uses — so CI never ran on a normal pull request.
branches: [main, production]
workflow_dispatch:

jobs:
Expand All @@ -19,14 +22,19 @@ jobs:

- uses: actions/setup-node@v4
with:
node-version: 20
# 22, not 20: lib/tools/counters.test.ts runs its SQL against the
# built-in `node:sqlite`, which does not exist before Node 22.
node-version: 22
cache: pnpm

- run: pnpm install --frozen-lockfile

- name: Typecheck
run: pnpm exec tsc --noEmit

- name: Test
run: pnpm test

- name: Typecheck contact-webhook worker
# The consumer Worker is excluded from the root tsconfig (different
# libs), so it has its own project — check it explicitly.
Expand Down
17 changes: 16 additions & 1 deletion .github/workflows/deploy-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ jobs:
STRAPI_URL: ${{ secrets.PRODUCTION_STRAPI_URL }}
STRAPI_API_TOKEN: ${{ secrets.PRODUCTION_STRAPI_API_TOKEN }}
STRAPI_PREVIEW: ${{ secrets.PRODUCTION_STRAPI_PREVIEW }}
# Published-only reads. robots.ts and the root layout's metadata are
# evaluated at build time, so these must be present here as well as in
# the Worker's vars.
CMS_PROVIDER: payload
CMS_MODE: published
DEPLOYMENT_ROLE: production
PAYLOAD_URL: https://epyc-payload-cms.epyc.workers.dev
PAYLOAD_READ_TOKEN: ${{ secrets.PRODUCTION_PAYLOAD_READ_TOKEN }}
CMS_REVALIDATION_SECRET: ${{ secrets.CMS_REVALIDATION_SECRET }}

- name: Upload secrets to Cloudflare Workers (production)
env:
Expand All @@ -53,15 +62,21 @@ jobs:
STRAPI_URL: ${{ secrets.PRODUCTION_STRAPI_URL }}
STRAPI_API_TOKEN: ${{ secrets.PRODUCTION_STRAPI_API_TOKEN }}
STRAPI_PREVIEW: ${{ secrets.PRODUCTION_STRAPI_PREVIEW }}
PAYLOAD_READ_TOKEN: ${{ secrets.PRODUCTION_PAYLOAD_READ_TOKEN }}
CMS_REVALIDATION_SECRET: ${{ secrets.CMS_REVALIDATION_SECRET }}
run: |
jq -n \
--arg STRAPI_URL "$STRAPI_URL" \
--arg STRAPI_API_TOKEN "$STRAPI_API_TOKEN" \
--arg STRAPI_PREVIEW "$STRAPI_PREVIEW" \
--arg PAYLOAD_READ_TOKEN "$PAYLOAD_READ_TOKEN" \
--arg CMS_REVALIDATION_SECRET "$CMS_REVALIDATION_SECRET" \
'{
STRAPI_URL: $STRAPI_URL,
STRAPI_API_TOKEN: $STRAPI_API_TOKEN,
STRAPI_PREVIEW: $STRAPI_PREVIEW
STRAPI_PREVIEW: $STRAPI_PREVIEW,
PAYLOAD_READ_TOKEN: $PAYLOAD_READ_TOKEN,
CMS_REVALIDATION_SECRET: $CMS_REVALIDATION_SECRET
}' \
| pnpm exec wrangler secret bulk --env production

Expand Down
17 changes: 16 additions & 1 deletion .github/workflows/deploy-staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ jobs:
STRAPI_URL: ${{ secrets.STAGING_STRAPI_URL }}
STRAPI_API_TOKEN: ${{ secrets.STAGING_STRAPI_API_TOKEN }}
STRAPI_PREVIEW: ${{ secrets.STAGING_STRAPI_PREVIEW }}
# Staging is the content-preview site: draft reads, noindex, no sitemap.
# robots.ts and the root layout's metadata are evaluated at build time,
# so these must be present here as well as in the Worker's vars.
CMS_PROVIDER: payload
CMS_MODE: draft
DEPLOYMENT_ROLE: preview
PAYLOAD_URL: https://epyc-payload-cms.epyc.workers.dev
PAYLOAD_PREVIEW_TOKEN: ${{ secrets.STAGING_PAYLOAD_PREVIEW_TOKEN }}
CMS_REVALIDATION_SECRET: ${{ secrets.CMS_REVALIDATION_SECRET }}

- name: Upload secrets to Cloudflare Workers (staging)
env:
Expand All @@ -53,15 +62,21 @@ jobs:
STRAPI_URL: ${{ secrets.STAGING_STRAPI_URL }}
STRAPI_API_TOKEN: ${{ secrets.STAGING_STRAPI_API_TOKEN }}
STRAPI_PREVIEW: ${{ secrets.STAGING_STRAPI_PREVIEW }}
PAYLOAD_PREVIEW_TOKEN: ${{ secrets.STAGING_PAYLOAD_PREVIEW_TOKEN }}
CMS_REVALIDATION_SECRET: ${{ secrets.CMS_REVALIDATION_SECRET }}
run: |
jq -n \
--arg STRAPI_URL "$STRAPI_URL" \
--arg STRAPI_API_TOKEN "$STRAPI_API_TOKEN" \
--arg STRAPI_PREVIEW "$STRAPI_PREVIEW" \
--arg PAYLOAD_PREVIEW_TOKEN "$PAYLOAD_PREVIEW_TOKEN" \
--arg CMS_REVALIDATION_SECRET "$CMS_REVALIDATION_SECRET" \
'{
STRAPI_URL: $STRAPI_URL,
STRAPI_API_TOKEN: $STRAPI_API_TOKEN,
STRAPI_PREVIEW: $STRAPI_PREVIEW
STRAPI_PREVIEW: $STRAPI_PREVIEW,
PAYLOAD_PREVIEW_TOKEN: $PAYLOAD_PREVIEW_TOKEN,
CMS_REVALIDATION_SECRET: $CMS_REVALIDATION_SECRET
}' \
| pnpm exec wrangler secret bulk --env staging

Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,9 @@ graphify-out/
/.design-sync/.cache/
/.design-sync/learnings/
/.design-sync/node_modules

# Local-only embed widget test page — never ship this
public/embed-test.html

# Parity and migration reports — generated, may contain CMS content
artifacts/
36 changes: 6 additions & 30 deletions app/(my-app)/blog/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { fetchStrapi } from '@/lib/strapi/client'
import type { StrapiList, StrapiBlog } from '@/lib/strapi/types'
import { getCMS } from '@/lib/cms'
import { BlogPost } from '@/components/sections/blog-post'
import { CTAFooter } from '@/components/sections/cta-footer'
import { normalise } from '@/lib/blogs/normalise'
Expand Down Expand Up @@ -40,17 +39,7 @@ function rewriteMediaUrls(html: string): string {

export async function generateMetadata({ params }: { params: Params }): Promise<Metadata> {
const { slug } = await params
const { data } = await fetchStrapi<StrapiList<StrapiBlog>>('/blogs', {
'filters[slug][$eq]': slug,
'fields[0]': 'title',
'fields[1]': 'metaTitle',
'fields[2]': 'metaDescription',
'fields[3]': 'content',
'fields[4]': 'coverImageAlt',
'populate[coverImage][fields]': 'url,width,height,alternativeText',
'pagination[limit]': '1',
})
const blog = data[0]
const blog = await getCMS().getBlogBySlug(slug)
if (!blog) return {}

const ogImage = blog.coverImage
Expand All @@ -73,26 +62,13 @@ export async function generateMetadata({ params }: { params: Params }): Promise<
export default async function BlogDetailPage({ params }: { params: Params }) {
const { slug } = await params

const [{ data }, { data: relatedData }] = await Promise.all([
fetchStrapi<StrapiList<StrapiBlog>>('/blogs', {
'filters[slug][$eq]': slug,
'populate[coverImage][fields]': 'url,width,height,alternativeText,formats',
'populate[author][fields]': 'name,slug',
'pagination[limit]': '1',
}),
fetchStrapi<StrapiList<StrapiBlog>>('/blogs', {
'filters[slug][$ne]': slug,
'populate[coverImage][fields]': 'url,width,height,alternativeText,formats',
'populate[author][fields]': 'name,slug',
'sort': 'publishedDate:desc',
'pagination[limit]': '3',
}),
const [blog, relatedData] = await Promise.all([
getCMS().getBlogBySlug(slug),
getCMS().listBlogs({ excludeSlug: slug, limit: 3 }),
])

const blog = data[0]
if (!blog) notFound()

const relatedBlogs = relatedData.filter((b) => b.slug).map((b) => normalise(b))
const relatedBlogs = relatedData.map((b) => normalise(b))

const jsonLd = {
'@context': 'https://schema.org',
Expand Down
12 changes: 3 additions & 9 deletions app/(my-app)/blog/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { Metadata } from 'next'
import { fetchStrapi } from '@/lib/strapi/client'
import type { StrapiList, StrapiBlog } from '@/lib/strapi/types'
import { getCMS } from '@/lib/cms'
import { BlogIndex } from '@/components/sections/blog-index'
import { CTAFooter } from '@/components/sections/cta-footer'
import { normalise } from '@/lib/blogs/normalise'
Expand All @@ -19,13 +18,8 @@ export const metadata: Metadata = {
export const revalidate = 60

export default async function BlogsPage() {
const { data } = await fetchStrapi<StrapiList<StrapiBlog>>('/blogs', {
'populate[coverImage][fields]': 'url,width,height,alternativeText,formats',
'populate[author][fields]': 'name,slug',
'sort': 'publishedDate:desc',
'pagination[limit]': '100',
})
const blogs = data.filter((b) => b.slug).map((b) => normalise(b))
const data = await getCMS().listBlogs({ limit: 100 })
const blogs = data.map((b) => normalise(b))

const itemListJsonLd = {
'@context': 'https://schema.org',
Expand Down
32 changes: 6 additions & 26 deletions app/(my-app)/gallery/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { fetchStrapi } from '@/lib/strapi/client'
import type { StrapiList, StrapiGalleryItem } from '@/lib/strapi/types'
import { getCMS } from '@/lib/cms'
import { normaliseGallery } from '@/lib/gallery/normalise'
import { GalleryDetail } from '@/components/sections/gallery-detail'
import { CTAFooter } from '@/components/sections/cta-footer'
Expand All @@ -11,22 +10,13 @@ export const revalidate = 60
const GALLERY_TITLE = 'Gallery'
const GALLERY_DESCRIPTION = 'Stills, motion clips, and prototypes from the EPYC studio.'

const POPULATE_PARAMS = {
'populate[image][fields]': 'url,width,height,alternativeText',
}

export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>
}): Promise<Metadata> {
const { slug } = await params
const { data } = await fetchStrapi<StrapiList<StrapiGalleryItem>>('/gallery-items', {
'filters[slug][$eq]': slug,
...POPULATE_PARAMS,
'pagination[limit]': '1',
})
const raw = data[0]
const raw = await getCMS().getGalleryItemBySlug(slug)
if (!raw) return { title: GALLERY_TITLE }

const item = normaliseGallery(raw)
Expand Down Expand Up @@ -60,24 +50,14 @@ export default async function GalleryItemPage({
}) {
const { slug } = await params

const [{ data }, { data: allData }] = await Promise.all([
fetchStrapi<StrapiList<StrapiGalleryItem>>('/gallery-items', {
'filters[slug][$eq]': slug,
...POPULATE_PARAMS,
'pagination[limit]': '1',
}),
fetchStrapi<StrapiList<StrapiGalleryItem>>('/gallery-items', {
'filters[slug][$ne]': slug,
...POPULATE_PARAMS,
'pagination[limit]': '3',
}),
const [raw, allData] = await Promise.all([
getCMS().getGalleryItemBySlug(slug),
getCMS().listGalleryItems({ excludeSlug: slug, limit: 3 }),
])

const raw = data[0]
if (!raw) notFound()

const item = normaliseGallery(raw)
const related = allData.filter((r) => r.slug).map((r) => normaliseGallery(r))
const related = allData.map((r) => normaliseGallery(r))

return (
<>
Expand Down
10 changes: 3 additions & 7 deletions app/(my-app)/gallery/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { Metadata } from 'next'
import { fetchStrapi } from '@/lib/strapi/client'
import type { StrapiList, StrapiGalleryItem } from '@/lib/strapi/types'
import { getCMS } from '@/lib/cms'
import { normaliseGallery } from '@/lib/gallery/normalise'
import { GalleryIndex } from '@/components/sections/gallery-index'
import { FAQs } from '@/components/sections/faqs'
Expand All @@ -19,11 +18,8 @@ export const metadata: Metadata = {
export const revalidate = 60

export default async function GalleryPage() {
const { data } = await fetchStrapi<StrapiList<StrapiGalleryItem>>('/gallery-items', {
'populate[image][fields]': 'url,width,height,alternativeText',
'pagination[limit]': '500',
})
const items = data.filter((item) => item.slug).map((item) => normaliseGallery(item))
const data = await getCMS().listGalleryItems({ limit: 500 })
const items = data.map((item) => normaliseGallery(item))

return (
<>
Expand Down
5 changes: 4 additions & 1 deletion app/(my-app)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
import localFont from 'next/font/local'
import Script from 'next/script'
import './globals.css'
import { FloatingMenuButton } from '@/components/ui/floating-menu'

Check warning on line 6 in app/(my-app)/layout.tsx

View workflow job for this annotation

GitHub Actions / check

'FloatingMenuButton' is defined but never used
import { site } from '@/data/site'
import { isPreviewDeployment } from '@/lib/cms/config'

const inter = Inter({
subsets: ['latin'],
Expand Down Expand Up @@ -89,7 +90,9 @@
twitter: {
card: 'summary_large_image',
},
robots: { index: true, follow: true, 'max-image-preview': 'large' },
robots: isPreviewDeployment()
? { index: false, follow: false }
: { index: true, follow: true, 'max-image-preview': 'large' },
}

export default function RootLayout({
Expand Down
13 changes: 3 additions & 10 deletions app/(my-app)/projects/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { Metadata } from 'next'
import { fetchStrapi } from '@/lib/strapi/client'
import type { StrapiList, StrapiProject } from '@/lib/strapi/types'
import { getCMS } from '@/lib/cms'
import { ProjectsIndex } from '@/components/sections/projects-index'
import { CTAFooter } from '@/components/sections/cta-footer'
import { normaliseProject } from '@/lib/projects/normalise'
Expand All @@ -18,14 +17,8 @@ export const metadata: Metadata = {
export const revalidate = 60

export default async function ProjectsPage() {
const { data } = await fetchStrapi<StrapiList<StrapiProject>>('/projects', {
'populate[thumbnail][fields]': 'url,width,height,alternativeText,formats',
'populate[industry][fields]': 'title,slug',
'populate[platform][fields]': 'title,slug',
'sort': 'featured:desc,publishedAt:desc',
'pagination[limit]': '200',
})
const projects = data.filter((p) => p.slug).map((p) => normaliseProject(p))
const data = await getCMS().listProjects({ limit: 200 })
const projects = data.map((p) => normaliseProject(p))

return (
<>
Expand Down
16 changes: 5 additions & 11 deletions app/(my-app)/sitemap.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,17 @@
import type { MetadataRoute } from "next";
import { site } from "@/data/site";
import { fetchStrapi } from "@/lib/strapi/client";

type SlugEntry = { slug: string; publishedAt: string };
type StrapiSlugList = { data: SlugEntry[]; meta: unknown };
import { getCMS } from "@/lib/cms";
import { isPreviewDeployment } from "@/lib/cms/config";

export const revalidate = 60;

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
if (isPreviewDeployment()) return [];
const url = (path: string) => ({ url: `${site.url}${path}` });

const blogs = await fetchStrapi<StrapiSlugList>("/blogs", {
"fields[0]": "slug",
"fields[1]": "publishedAt",
"pagination[limit]": "1000",
"sort": "publishedDate:desc",
});
const blogs = await getCMS().listBlogSlugsForSitemap();

const blogEntries = blogs.data.map(({ slug, publishedAt }) => ({
const blogEntries = blogs.map(({ slug, publishedAt }) => ({
url: `${site.url}/blog/${slug}`,
lastModified: new Date(publishedAt),
}));
Expand Down
Loading
Loading