Skip to content
Merged
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
403 changes: 279 additions & 124 deletions README.md

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions docs/database-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ Apply the pipeline migrations in filename order:
7. `infra/d1/pipeline/migrations/0007_snapshot_derivatives.sql`
8. `infra/d1/pipeline/migrations/0008_release_builder_contract.sql`
9. `infra/d1/pipeline/migrations/0009_entity_discovery_registry.sql`
10. `infra/d1/pipeline/migrations/0010_materialization_batches.sql`
11. `infra/d1/pipeline/migrations/0011_materialization_release_requests.sql`
12. `infra/d1/pipeline/migrations/0012_localization_pipeline.sql`
13. `infra/d1/pipeline/migrations/0013_institution_target_coverage.sql`
14. `infra/d1/pipeline/migrations/0014_entity_candidate_materialization.sql`

### Domain records

Expand Down Expand Up @@ -141,6 +146,12 @@ Apply the catalog migrations in filename order:
2. `infra/d1/catalog/migrations/0002_programs_scholarships.sql`
3. `infra/d1/catalog/migrations/0003_search_views.sql`
4. `infra/d1/catalog/migrations/0004_atomic_release_cutover.sql`
5. `infra/d1/catalog/migrations/0005_public_projection_hardening.sql`
6. `infra/d1/catalog/migrations/0006_field_meta_projection.sql`
7. `infra/d1/catalog/migrations/0007_deadline_grace_publication.sql`
8. `infra/d1/catalog/migrations/0008_identity_only_program_visibility.sql`
9. `infra/d1/catalog/migrations/0009_release_compatibility_artifacts.sql`
10. `infra/d1/catalog/migrations/0010_release_retention.sql`

Every catalog domain row includes `release_id`. A release is an immutable public snapshot. Building a new release does not affect readers because all `current_*` views start from the singleton `release_pointer` and require its target release to be `active`.

Expand Down
65 changes: 65 additions & 0 deletions docs/plans/2026-08-07-site-experience-readme-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Site experience and README design

Date: 2026-08-07
Status: approved for implementation

## Goal

Move the public experience from a collection of catalogue cards toward a credible,
decision-oriented international education atlas. The homepage should answer four
questions within the first two sections:

1. What can I explore here?
2. How broad is the public catalogue?
3. Why should I trust the records?
4. What should I do next?

The README should offer the same clarity to engineers and contributors: product
purpose, current public scope, evidence model, architecture, quality gates, local
workflow, deployment model and honest expansion roadmap.

## Design language

Keep the existing "Eastern academic atlas" system: warm paper, deep ink, vermilion
and jade accents, editorial typography, cartographic rules and restrained motion.
The experience should feel like a modern university yearbook and field guide, not a
generic SaaS dashboard. New sections use square editorial panels, fine rules and
large tabular figures rather than excessive pills or decorative gradients.

## Homepage information architecture

1. **Hero** — preserve the primary university/program actions and source-first
promise.
2. **Catalogue ledger** — show runtime-derived public counts, the latest official
source check and a direct link to the data policy.
3. **Applicant pathway** — four linked steps: discover universities, compare
programs, verify evidence, then use the official application route.
4. **Curated institutions** — retain featured university cards.
5. **Field index** — retain all 17 applicant-oriented fields and live program counts.
6. **City constellation** — retain the compliant coordinate index.
7. **Application guides** — retain the first three guide entries.

All new user-facing copy is available for the six launch locales. Counts come from
the active catalogue repository, never from hard-coded marketing text.

## README structure

- Branded project header and live links
- Honest public-release metrics
- Product capabilities and user journey
- Source and field-state trust model
- End-to-end architecture diagram
- Technology choices and responsibilities
- Local setup and verification commands
- Repository map and public API surface
- Release, rollback, privacy and security boundaries
- Measured 257 → 500 → 1000+ roadmap

## Constraints and acceptance criteria

- No new runtime dependency.
- No invented coverage, freshness or accuracy claim.
- Preserve server rendering and progressive enhancement.
- New links are locale-aware and keyboard accessible.
- Layout remains usable at 320 px and honours reduced motion.
- Lint, typecheck, unit/data validation, production build and browser smoke tests pass.
9 changes: 8 additions & 1 deletion src/app/[locale]/favorites/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,12 @@ import { getTodayDate } from '@/lib/data/freshness'
import { getCatalogData } from '@/lib/data/load'
import { pageMetadata, requireLocale } from '@/lib/site'

export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { const locale = requireLocale((await params).locale) || 'en'; const m = getMessages(locale); return pageMetadata(locale, m.favorites.title, m.favorites.intro, 'favorites') }
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
const locale = requireLocale((await params).locale) || 'en'
const messages = getMessages(locale)
return {
...pageMetadata(locale, messages.favorites.title, messages.favorites.intro, 'favorites'),
robots: { index: false, follow: true },
}
}
export default async function FavoritesPage({ params }: { params: Promise<{ locale: string }> }) { const locale = requireLocale((await params).locale); if (!locale) notFound(); const messages = getMessages(locale); const data = await getCatalogData(); return <><PageHero variant="compact" eyebrow={messages.common.compare} title={messages.favorites.title} description={messages.favorites.intro} /><section className="atlas-container atlas-section"><FavoritesView programs={data.programs} universities={data.universities} cycles={data.admissionCycles} locale={locale} messages={messages} today={getTodayDate()} /></section></> }
2 changes: 1 addition & 1 deletion src/app/[locale]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export default async function LocaleLayout({ children, params }: Readonly<{ chil
return <html lang={locale} dir={localeDirection(locale)}>
<body>
<AppHeader locale={locale} />
<main id="main-content" className="atlas-main">{children}</main>
<main id="main-content" className="atlas-main" tabIndex={-1}>{children}</main>
<SiteFooter
locale={locale}
homeHref={`/${locale}`}
Expand Down
58 changes: 58 additions & 0 deletions src/app/[locale]/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { ImageResponse } from 'next/og'

import { isPublicLocale } from '@/i18n/config'
import { getMessages } from '@/i18n/messages'

export const alt = 'Study in China Atlas — source-led university and program discovery'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'

export default async function OpenGraphImage({ params }: { params: Promise<{ locale: string }> }) {
const value = (await params).locale
const locale = isPublicLocale(value) ? value : 'en'
const messages = getMessages(locale)

return new ImageResponse(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
position: 'relative',
overflow: 'hidden',
background: '#f5efe2',
color: '#10283d',
padding: '72px 80px',
fontFamily: 'Georgia, Times New Roman, serif',
}}
>
<div style={{ position: 'absolute', inset: 0, display: 'flex', opacity: 0.18, backgroundImage: 'linear-gradient(90deg, #b8a789 1px, transparent 1px), linear-gradient(#b8a789 1px, transparent 1px)', backgroundSize: '72px 72px' }} />
<div style={{ position: 'absolute', top: 66, right: 84, width: 222, height: 222, display: 'flex', borderRadius: '999px', background: '#c7442d', opacity: 0.94 }} />
<div style={{ position: 'absolute', right: 0, bottom: 0, width: 460, height: 190, display: 'flex', borderTop: '2px dashed rgba(16,40,61,.32)', borderRadius: '50%', transform: 'rotate(-12deg)' }} />

<div style={{ width: '100%', display: 'flex', flexDirection: 'column', justifyContent: 'space-between', position: 'relative' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 22 }}>
<div style={{ width: 72, height: 72, display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#10283d', color: '#f5efe2', fontSize: 42, fontWeight: 700 }}>中</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
<div style={{ fontFamily: 'Arial, sans-serif', fontSize: 25, fontWeight: 800, letterSpacing: '-0.02em' }}>{messages.brand}</div>
<div style={{ fontFamily: 'Arial, sans-serif', fontSize: 15, letterSpacing: '0.16em', textTransform: 'uppercase', color: '#62717c' }}>{messages.shell.brandTagline}</div>
</div>
</div>

<div style={{ maxWidth: 850, display: 'flex', flexDirection: 'column', gap: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, fontFamily: 'Arial, sans-serif', color: '#9d3324', fontSize: 17, fontWeight: 800, letterSpacing: '0.13em', textTransform: 'uppercase' }}>
<span style={{ width: 42, height: 2, display: 'flex', background: '#9d3324' }} />
{messages.home.eyebrow}
</div>
<div style={{ fontSize: 66, fontWeight: 700, letterSpacing: '-0.045em', lineHeight: 1.02 }}>{messages.home.title}</div>
</div>

<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', fontFamily: 'Arial, sans-serif', fontSize: 17, color: '#536570' }}>
<span>{messages.common.officialSource} · {messages.common.lastVerified}</span>
<span style={{ color: '#10283d', fontWeight: 800 }}>studyinchina.vercel.app</span>
</div>
</div>
</div>,
size,
)
}
43 changes: 42 additions & 1 deletion src/app/[locale]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { notFound } from 'next/navigation'
import { Badge, Card, LinkButton, PageHero, SectionHeading } from '@/components/ui'
import { CityConstellation } from '@/components/features/CityConstellation'
import { UniversityCard } from '@/components/features/RecordCards'
import { getHomeExperienceCopy } from '@/i18n/home-experience'
import { getMessages } from '@/i18n/messages'
import { localize } from '@/lib/data/format'
import { classifyProgramField, programFieldTaxonomy } from '@/lib/data/fields'
Expand All @@ -17,18 +18,58 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s

export default async function HomePage({ params }: { params: Promise<{ locale: string }> }) {
const locale = requireLocale((await params).locale); if (!locale) notFound()
const messages = getMessages(locale); const data = await getCatalogData()
const messages = getMessages(locale); const experience = getHomeExperienceCopy(locale); const data = await getCatalogData()
const featured = data.universities.filter((item) => item.featured).slice(0, 6)
const fieldsByUniversity = Object.fromEntries(data.universities.map((university) => [university.id, [...new Set(data.programs.filter((program) => program.universityId === university.id).map(classifyProgramField))]]))
const fields = programFieldTaxonomy(locale)
const fieldCounts = Object.fromEntries(fields.map(({ key }) => [key, data.programs.filter((program) => classifyProgramField(program) === key).length]))
const officialSourceCount = data.sources.filter((source) => source.official).length
const latestSourceCheck = data.sources.reduce<string | null>((latest, source) => !latest || source.accessedAt > latest ? source.accessedAt : latest, null)
const sourceCheckLabel = latestSourceCheck
? new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeZone: 'UTC' }).format(new Date(`${latestSourceCheck}T00:00:00Z`))
: messages.common.unknown
const pathwayHrefs = ['universities', 'programs', 'data-policy', 'guides'] as const

return <>
<PageHero variant="feature" eyebrow={messages.home.eyebrow} title={messages.home.title} description={messages.home.intro}
actions={<><LinkButton href={`/${locale}/universities`} size="large" iconEnd="→">{messages.home.findUniversity}</LinkButton><LinkButton href={`/${locale}/programs`} variant="ghost" size="large">{messages.home.explorePrograms}</LinkButton></>}
meta={<><span>{data.universities.length} {messages.nav.universities}</span><span>{data.programs.length} {messages.nav.programs}</span><span>{data.cities.length} {messages.nav.cities}</span><span>{data.scholarships.length} {messages.nav.scholarships}</span></>}
aside={<div className="atlas-stack"><Badge tone="jade" dot>{messages.common.officialSource}</Badge><h2>{messages.home.sourceHeading}</h2><p>{messages.common.authoritativeNotice}</p><Link className="text-link" href={`/${locale}/data-policy`}>{messages.footer.dataPolicy} →</Link></div>} />

<section className="atlas-home-ledger" aria-labelledby="catalog-ledger-title">
<div className="atlas-container atlas-home-ledger__grid">
<div className="atlas-home-ledger__intro">
<div className="atlas-kicker">{experience.catalogEyebrow}</div>
<h2 id="catalog-ledger-title">{experience.catalogTitle}</h2>
<p>{experience.catalogIntro}</p>
<div className="atlas-home-ledger__freshness">
<span><i aria-hidden="true" />{experience.latestSourceCheck}: <time dateTime={latestSourceCheck || undefined}>{sourceCheckLabel}</time></span>
<Link href={`/${locale}/data-policy`}>{experience.policyLink} →</Link>
</div>
</div>
<dl className="atlas-home-ledger__metrics">
<div><dt>{messages.nav.universities}</dt><dd>{data.universities.length.toLocaleString(locale)}</dd></div>
<div><dt>{messages.nav.programs}</dt><dd>{data.programs.length.toLocaleString(locale)}</dd></div>
<div><dt>{messages.nav.scholarships}</dt><dd>{data.scholarships.length.toLocaleString(locale)}</dd></div>
<div><dt>{experience.officialSources}</dt><dd>{officialSourceCount.toLocaleString(locale)}</dd></div>
</dl>
<p className="atlas-home-ledger__principle"><span aria-hidden="true">※</span>{experience.noGuessing}</p>
</div>
</section>

<section className="atlas-container atlas-section atlas-home-pathway" aria-labelledby="applicant-pathway-title">
<SectionHeading eyebrow={experience.pathwayEyebrow} title={<span id="applicant-pathway-title">{experience.pathwayTitle}</span>} description={experience.pathwayIntro} />
<ol className="atlas-home-pathway__steps">
{experience.steps.map((step, index) => <li key={step.title}>
<Link href={`/${locale}/${pathwayHrefs[index]}`}>
<span className="atlas-home-pathway__number" aria-hidden="true">0{index + 1}</span>
<div><h3>{step.title}</h3><p>{step.description}</p></div>
<span className="atlas-home-pathway__arrow" aria-hidden="true">↗</span>
</Link>
</li>)}
</ol>
</section>

<section className="atlas-container atlas-section">
<SectionHeading eyebrow="01" title={messages.home.featured} description={messages.home.featuredIntro} action={<LinkButton href={`/${locale}/universities`} variant="quiet">{messages.common.explore} →</LinkButton>} />
<div className="content-grid">{featured.map((university) => <UniversityCard key={university.id} university={university} city={data.cities.find((city) => city.id === university.cityId)} fields={fieldsByUniversity[university.id] || []} locale={locale} messages={messages} />)}</div>
Expand Down
Loading
Loading