A cinematic personal website and digital sheet-music store for pianist and composer Reza Ohadi. Built to feel like a quiet concert hall: deep matte black, warm ivory, piano-inspired motion, and a secure storefront for selling original scores as PDFs.
It runs out of the box. With no credentials configured the site boots in demo mode — full design, placeholder catalogue, working previews — so you can see everything immediately. Add Supabase + Stripe + Resend to go live.
| Concern | Choice |
|---|---|
| Framework | Next.js 15 (App Router) + TypeScript |
| Styling | Tailwind CSS (custom black-&-white design system) |
| Animation | Framer Motion (reduced-motion aware) |
| Database / Auth / Storage | Supabase |
| Payments | Stripe Checkout + webhooks |
| Resend (transactional) | |
| Hosting | Vercel-ready |
# 1. Install dependencies
npm install
# 2. (Optional) generate the placeholder art again
npm run gen:placeholders
# 3. Start the dev server
npm run dev # http://localhost:3000
That’s it for demo mode. To enable real data and payments:
cp .env.example .env.local
# fill in the values (see below), then restart `npm run dev`
Other scripts:
npm run build # production build
npm start # run the production build
npm run lint # eslint
npm run typecheck # tsc --noEmit
Copy .env.example → .env.local. Nothing is required for demo mode; add
groups as you enable features.
| Variable | Required for | Notes |
|---|---|---|
NEXT_PUBLIC_SITE_URL |
SEO, emails, Stripe URLs | e.g. https://rezaohadi.com (no trailing slash) |
NEXT_PUBLIC_SUPABASE_URL |
Supabase | Project URL |
NEXT_PUBLIC_SUPABASE_ANON_KEY |
Supabase | Public anon key |
SUPABASE_SERVICE_ROLE_KEY |
Supabase (server) | Secret. Webhooks, downloads, admin writes |
SUPABASE_PUBLIC_BUCKET |
Supabase | Default public-assets |
SUPABASE_SCORES_BUCKET |
Supabase | Default scores (private) |
STRIPE_SECRET_KEY |
Payments (server) | Secret |
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY |
Payments | Public key |
STRIPE_WEBHOOK_SECRET |
Payments | From stripe listen / dashboard |
RESEND_API_KEY |
Purchase + contact emails | |
EMAIL_FROM |
Verified sender, e.g. Reza Ohadi <store@domain.com> |
|
CONTACT_TO_EMAIL |
Where the contact form is delivered | |
ADMIN_EMAILS |
Admin | Comma-separated allowlist for /admin |
DOWNLOAD_LINK_TTL_SECONDS |
Downloads | Signed-URL lifetime (default 300) |
A feature is considered “configured” only when its keys are present; otherwise the app degrades gracefully (placeholder data, disabled checkout, logged emails).
supabase/schema.sql — tables, RLS, triggerssupabase/storage.sql — buckets + storage policiessupabase/seed.sql — demo catalogue/contentanon key + service_role key
into .env.local.ADMIN_EMAILS, orupdate public.profiles set role = 'admin' where email = 'you@example.com';/admin.storage.sql creates them, but to do it by hand:
| Bucket | Public? | Holds |
|---|---|---|
public-assets |
Public | cover art, preview pages, gallery, portraits |
scores |
Private | original PDFs — only ever delivered via signed URLs |
The PDFs live in the private scores bucket. They are never exposed
directly; /api/download verifies the buyer’s token and mints a short-lived
signed URL server-side.
.env.local.Guest checkout is enabled (Stripe collects the buyer’s email); customer accounts can be layered on later via Supabase Auth.
Fulfillment (recording the purchase + emailing the download link) happens in the webhook and is double-checked on the success page, so it’s reliable either way.
Local development
stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# copy the printed "whsec_..." into STRIPE_WEBHOOK_SECRET
Production — Developers → Webhooks → Add endpoint:
https://YOUR_DOMAIN/api/webhooks/stripecheckout.session.completedcheckout.session.async_payment_succeededSTRIPE_WEBHOOK_SECRET.Everything is managed from /admin (no code or redeploy needed):
scores bucket (delivered only after purchase)site_content table (key bio)..env.local in
Project → Settings → Environment Variables (set NEXT_PUBLIC_SITE_URL to
your real domain).https://YOUR_DOMAIN/api/webhooks/stripe (step 5).All placeholders are clearly labelled ([PLACEHOLDER …] in text, /placeholders/*.svg
for images). Nothing is hardcoded into the UI — it all flows through the data
layer (src/lib/data) and is editable via the admin dashboard or database.
| Placeholder | Replace via |
|---|---|
| Hero / artist photo | Admin → Content → Biography images, or site_content.hero.image |
| Biography portrait / wide / signature | Admin → Content → Biography |
| Biography text, timeline, quotes | Admin → Content (text) / site_content JSON (arrays) |
| Sheet-music covers, previews, PDFs | Admin → Products |
| Audio previews, YouTube/Instagram links | Admin → Products / Content |
| Gallery photos | Admin → Content → Gallery |
| Media (videos/reels) | site_content.media JSON (see src/lib/data/placeholder.ts shape) |
| Social links & contact email | Admin → Content → Social |
| Concert dates | site_content.home.performances JSON |
Placeholder art is generated into public/placeholders/ by
scripts/gen-placeholders.mjs. It runs
automatically on npm install (a postinstall hook) and on Vercel builds, so
the files are not committed — re-run manually with npm run gen:placeholders.
The owner’s original profile.jpg is kept at the repo root for reference (low-res).
src/
├── app/
│ ├── (site)/ # public site (shares nav/footer + page transitions)
│ │ ├── page.tsx # Home
│ │ ├── store/ # catalogue + [slug] product detail
│ │ ├── biography, gallery, media, contact, library/
│ │ └── checkout/success/
│ ├── admin/ # protected dashboard (login + (dashboard) group)
│ ├── api/ # checkout, webhooks/stripe, download
│ ├── sitemap.ts, robots.ts, opengraph-image.tsx
│ └── layout.tsx, globals.css
├── components/ # ui, motion, layout, home, store, media, gallery, admin
├── lib/
│ ├── data/ # data-access layer (+ placeholder fallback)
│ ├── supabase/ # browser / server / admin / public clients + storage
│ ├── stripe/ # client + fulfillment
│ ├── email/ # Resend wrapper + templates
│ ├── auth.ts, env.ts, types.ts, utils.ts, validation.ts
│ └── ...
├── config/site.ts # brand + navigation
└── middleware.ts # session refresh + /admin gate
supabase/ # schema.sql, storage.sql, seed.sql
published = true; admins manage all.
Private pdf_path points into the scores bucket.download_token, status).
No public read — server-only via service role; admins read in the dashboard.social, hero, home, bio, media),
deep-merged over built-in defaults.auth.users → role (admin gate). Auto-created on signup.RLS is enabled on every table; public.is_admin() powers admin policies.
download_token; each request mints a
fresh, expiring signed URL (DOWNLOAD_LINK_TTL_SECONDS)./admin is gated by middleware and re-checked in the layout and every
server action. The service_role key is server-only.To go fully live you’ll need to supply:
EMAIL_FROM).NEXT_PUBLIC_SITE_URL set to the production domain.Until then, the site happily runs in demo mode with the bundled placeholders. ```