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
88 changes: 88 additions & 0 deletions apps/caramel-app/e2e/seo-a11y.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ test.describe('SEO & Accessibility Basics', () => {
// seeded app (e2e-pr/local) and the deployed dev site (e2e-push). Only the
// codecademy.com assertion depends on a specific catalog row, so only it is
// DATABASE_URL-gated; everything else holds on any non-empty catalog.
// Distinct hrefs matching `re` (group 1) in raw HTML, in document order.
function hrefsOf(html: string, re: RegExp): string[] {
return Array.from(new Set(Array.from(html.matchAll(re)).map(m => m[1]!)))
}

function extractJsonLd(html: string): Array<Record<string, unknown>> {
const scriptRe =
/<script type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/g
Expand Down Expand Up @@ -183,6 +188,8 @@ test.describe('Coupon pages — crawler-visible SEO', () => {
.map(loc => /\/coupons\/([^/?#]+)$/.exec(loc)?.[1])
.filter((slug): slug is string => Boolean(slug))
.map(slug => decodeURIComponent(slug))
// /coupons/stores is the A–Z directory index, not a store page.
.filter(slug => slug !== 'stores')
// The catalog is never legitimately empty in either context.
expect(storeSlugs.length).toBeGreaterThan(0)

Expand All @@ -203,6 +210,87 @@ test.describe('Coupon pages — crawler-visible SEO', () => {
const origin = (baseURL ?? '').replace(/\/+$/, '')
expect(await res.text()).toContain(`<loc>${origin}/support</loc>`)
})

// A–Z store directory (2026-09-12): before it, 8 of ~4,262 store pages
// had any internal inbound link — the rest were sitemap-only orphans.
// These walk the crawl chain the way a crawler does, from the RAW HTML:
// directory index → a letter page → a store page → its neighbours. URLs
// are discovered from the served HTML, never assumed, so the tests hold
// against the 5-store hermetic seed and the real catalog alike.
const letterHrefRe = /href="(\/coupons\/stores\/(?:[a-z]|0-9))"/g
const storeHrefRe = /href="(\/coupons\/(?!stores(?:\/|"|$))[^"/?#]+)"/g

test('/coupons/stores is served with a self canonical, index+follow, and at least one letter link', async ({
page,
}) => {
const res = await page.request.get('/coupons/stores')
expect(res.status()).toBe(200)
const html = await res.text()

expect(html).toMatch(
/<link rel="canonical" href="[^"]*\/coupons\/stores"\/?>/,
)
expect(html).not.toMatch(/name="robots"[^>]*noindex/)
expect((html.match(/<h1/g) ?? []).length).toBe(1)
expect(hrefsOf(html, letterHrefRe).length).toBeGreaterThan(0)
})

test('a letter page found from the directory index is served with a self canonical and lists store links', async ({
page,
}) => {
const index = await page.request.get('/coupons/stores')
const letterPath = hrefsOf(await index.text(), letterHrefRe)[0]
expect(letterPath).toBeTruthy()

const res = await page.request.get(letterPath!)
expect(res.status()).toBe(200)
const html = await res.text()

expect(html).toMatch(
new RegExp(
`<link rel="canonical" href="[^"]*${letterPath!.replace(/[/-]/g, '\\$&')}"/?>`,
),
)
expect(html).not.toMatch(/name="robots"[^>]*noindex/)
const storeHrefs = hrefsOf(html, storeHrefRe)
expect(storeHrefs.length).toBeGreaterThan(0)
// Every listed store is a canonical base (the same rule the sitemap
// obeys) and shows a real code count.
for (const href of storeHrefs) {
const slug = decodeURIComponent(href.slice('/coupons/'.length))
expect(resolveStoreDomain(slug), href).toBe(slug)
}
expect(html).toMatch(/ · \d[\d,]* codes?</)
})

test('a store page reached from a letter page carries a "More stores" section with at least one neighbour link', async ({
page,
}) => {
const index = await page.request.get('/coupons/stores')
const letterPath = hrefsOf(await index.text(), letterHrefRe)[0]!
const letter = await page.request.get(letterPath)
const storePath = hrefsOf(await letter.text(), storeHrefRe)[0]
expect(storePath).toBeTruthy()

const res = await page.request.get(storePath!)
expect(res.status()).toBe(200)
const html = await res.text()

expect(html).toContain('More stores')
// At least one OTHER store linked from this page (the neighbour chain),
// and the way back into the directory letter this store lives on.
const others = hrefsOf(html, storeHrefRe).filter(h => h !== storePath)
expect(others.length).toBeGreaterThan(0)
expect(hrefsOf(html, letterHrefRe)).toContain(letterPath)
})

test('a letter with no stores is a 404, not an empty page', async ({
page,
}) => {
// Not a directory letter at all — always 404 regardless of catalog.
const res = await page.request.get('/coupons/stores/zz')
expect(res.status()).toBe(404)
})
})

test.describe('Responsive - Mobile Viewport', () => {
Expand Down
5 changes: 5 additions & 0 deletions apps/caramel-app/src/app/(marketing)/coupons/[store]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import CouponsSection from '@/components/coupons/coupons-section'
import PopularStores from '@/components/coupons/popular-stores'
import StoreFavoriteStar from '@/components/coupons/store-favorite-star'
import StoreNeighbours from '@/components/coupons/store-neighbours'
import { attachSignals } from '@/lib/couponSignals'
import { listStoreCoupons } from '@/lib/couponsRepo'
import { BASE_URL } from '@/lib/env.client'
Expand Down Expand Up @@ -259,6 +260,10 @@ export default async function StoreCouponsPage({
</p>
</section>
<PopularStores currentSite={base} />
{/* Alphabetical neighbours + this store's directory letter page:
the crawl chain that reaches every store page (PopularStores
links the same 4 stores site-wide; this links the nearest). */}
<StoreNeighbours base={base} />
<script
type="application/ld+json"
suppressHydrationWarning
Expand Down
4 changes: 4 additions & 0 deletions apps/caramel-app/src/app/(marketing)/coupons/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import CouponsSection from '@/components/coupons/coupons-section'
import PopularStores from '@/components/coupons/popular-stores'
import StoreLetterStrip from '@/components/coupons/store-letter-strip'
import { attachSignals } from '@/lib/couponSignals'
import { listCoupons } from '@/lib/couponsRepo'
import { BASE_URL } from '@/lib/env.client'
Expand Down Expand Up @@ -86,6 +87,9 @@ export default async function CouponsPage() {
heroSubtitle="Browse verified coupon codes, promo codes, and offers for your favorite stores."
/>
<PopularStores />
{/* Server-rendered letter strip into the A–Z directory — the hub's
crawl path to every store page, not just the 4 popular ones. */}
<StoreLetterStrip />
<script
type="application/ld+json"
suppressHydrationWarning
Expand Down
222 changes: 222 additions & 0 deletions apps/caramel-app/src/app/(marketing)/coupons/stores/[letter]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import StoreLetterStrip from '@/components/coupons/store-letter-strip'
import { BASE_URL } from '@/lib/env.client'
import { jsonLdString } from '@/lib/jsonLd'
import type { DirectoryLetter, DirectoryPage } from '@/lib/seo/storeDirectory'
import {
bucketStoresByLetter,
directoryLetterLabel,
directoryPath,
paginateDirectoryBucket,
parseDirectoryLetter,
parseDirectoryPage,
} from '@/lib/seo/storeDirectory'
import { getStoreDirectoryEntries } from '@/lib/seo/storeDirectoryCache'
import type { Metadata } from 'next'
import { notFound } from 'next/navigation'
import { cache } from 'react'

// One letter of the A–Z store directory: a server-rendered list of
// `<a href="/coupons/<base>">` for every indexable store whose canonical base
// starts with that letter, with its live coupon count. This is the crawl path
// into the store pages (see src/lib/seo/storeDirectory.ts for why).
//
// Reads the catalog → per-request, never prerendered (same constraint as
// /coupons and sitemap.ts: the production image builds against an
// unreachable placeholder DATABASE_URL).
export const dynamic = 'force-dynamic'

const baseUrl = BASE_URL.replace(/\/+$/, '')

type LetterParams = { letter: string }
type LetterSearchParams = Record<string, string | string[] | undefined>

type ResolvedLetterPage = {
letter: DirectoryLetter
page: DirectoryPage
}

// cache(): generateMetadata and the body both need the resolved page, and
// React request-level caching makes that ONE lookup (the underlying catalog
// read is itself cached in storeDirectoryCache). Any invalid input — a param
// that is not a directory letter, a `?page=` that is not a positive integer,
// a page past the end, or a letter with no stores — is `notFound()`: a letter
// with zero stores must be a 404, never an empty page.
const resolveLetterPage = cache(
async (
rawLetter: string,
rawPage: string | string[] | undefined,
): Promise<ResolvedLetterPage> => {
const letter = parseDirectoryLetter(rawLetter)
const pageNumber = parseDirectoryPage(rawPage)
if (!letter || pageNumber === null) notFound()

const buckets = bucketStoresByLetter(await getStoreDirectoryEntries())
const bucket = buckets.find(b => b.letter === letter)
const page = bucket
? paginateDirectoryBucket(bucket.stores, pageNumber)
: null
if (!page) notFound()

return { letter, page }
},
)

async function resolveInput(input: {
params: Promise<LetterParams> | LetterParams
searchParams?: Promise<LetterSearchParams> | LetterSearchParams
}): Promise<ResolvedLetterPage> {
const { letter } = await Promise.resolve(input.params)
const search = (await Promise.resolve(input.searchParams)) ?? {}
return resolveLetterPage(
typeof letter === 'string' ? letter : '',
search.page,
)
}

export async function generateMetadata(input: {
params: Promise<LetterParams> | LetterParams
searchParams?: Promise<LetterSearchParams> | LetterSearchParams
}): Promise<Metadata> {
const { letter, page } = await resolveInput(input)
const label = directoryLetterLabel(letter)
const title = `Stores starting with ${label} — coupon codes | Caramel`
const description = `Browse every store starting with ${label} that Caramel has live coupon codes for, with the number of codes available at each one right now.`
// Canonical is ALWAYS the un-paged letter URL: a split letter's later
// pages are continuation, not distinct answers, so they point at page 1
// and stay out of the index (still followed — their store links are the
// whole point). Page 1 is index, follow.
const canonical = `${baseUrl}${directoryPath(letter)}`
const banner = `${baseUrl}/caramel_banner.png`

return {
title,
description,
alternates: { canonical },
robots:
page.page > 1
? { index: false, follow: true }
: { index: true, follow: true },
openGraph: {
type: 'website',
url: canonical,
title,
description,
locale: 'en_US',
siteName: 'Caramel',
images: [{ url: banner, width: 1200, height: 630 }],
},
twitter: {
card: 'summary_large_image',
site: '@CaramelOfficial',
title,
description,
images: [banner],
},
}
}

export default async function StoreDirectoryLetterPage(input: {
params: Promise<LetterParams> | LetterParams
searchParams?: Promise<LetterSearchParams> | LetterSearchParams
}) {
const { letter, page } = await resolveInput(input)
const label = directoryLetterLabel(letter)
const total = page.stores.length
const breadcrumbData = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: baseUrl },
{
'@type': 'ListItem',
position: 2,
name: 'Coupons',
item: `${baseUrl}/coupons`,
},
{
'@type': 'ListItem',
position: 3,
name: 'All stores A–Z',
item: `${baseUrl}${directoryPath()}`,
},
{ '@type': 'ListItem', position: 4, name: `Stores: ${label}` },
],
}

return (
<main className="relative min-h-screen px-6 pt-32 dark:bg-darkBg lg:px-8">
<header className="mx-auto max-w-4xl pb-10">
<h1 className="mb-4 text-4xl font-extrabold tracking-tight text-gray-900 dark:text-white md:text-3xl">
Stores starting with {label}
</h1>
<p className="leading-relaxed text-gray-600 dark:text-gray-400">
{page.pageCount > 1
? `Page ${page.page} of ${page.pageCount}. `
: ''}
{`${total.toLocaleString('en-US')} ${total === 1 ? 'store' : 'stores'} with live coupon codes${page.pageCount > 1 ? ' on this page' : ''}. Each link opens that store's current codes.`}
</p>
</header>
<StoreLetterStrip current={letter} />
{/* Deliberately lean markup: this list can hold up to
DIRECTORY_PAGE_SIZE stores and every byte here ships twice (HTML
+ the RSC payload), so styling lives on the <ul> via child
selectors and each item is a bare <li><a>base</a> · N codes</li>.
Plain <a>, not next/link — see store-letter-strip.tsx. */}
<section
aria-labelledby="stores-heading"
className="mx-auto max-w-4xl pb-24"
>
<h2
id="stores-heading"
className="mb-4 text-2xl font-bold tracking-tight text-gray-900 dark:text-white"
>
{label} stores with coupon codes
</h2>
<ul className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm text-gray-600 dark:text-gray-400 sm:grid-cols-1 [&>li>a]:font-semibold [&>li>a]:text-gray-900 [&>li>a]:underline-offset-2 hover:[&>li>a]:text-caramel hover:[&>li>a]:underline dark:[&>li>a]:text-white">
{page.stores.map(store => (
<li key={store.base}>
<a
href={`/coupons/${encodeURIComponent(store.base)}`}
>
{store.base}
</a>
{` · ${store.couponCount.toLocaleString('en-US')} ${store.couponCount === 1 ? 'code' : 'codes'}`}
</li>
))}
</ul>
{page.pageCount > 1 ? (
<nav
aria-label="Pagination"
className="mt-8 flex items-center gap-4 text-sm font-semibold text-caramel"
>
{page.page > 1 ? (
<a
href={directoryPath(letter, page.page - 1)}
rel="prev"
className="underline-offset-2 hover:underline"
>
← Previous page
</a>
) : null}
{page.page < page.pageCount ? (
<a
href={directoryPath(letter, page.page + 1)}
rel="next"
className="underline-offset-2 hover:underline"
>
Next page →
</a>
) : null}
</nav>
) : null}
</section>
<script
type="application/ld+json"
suppressHydrationWarning
dangerouslySetInnerHTML={{
__html: jsonLdString(breadcrumbData),
}}
/>
</main>
)
}
Loading
Loading