diff --git a/.env.example b/.env.example index 034e3db..dfc08de 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,32 @@ -# Supabase +# ---------------------------------------------------------------------------- +# Product Builders environment variables +# +# Copy this file to .env.local and fill in the values. Without these, the app +# runs in a read-only DEMO MODE backed by sample data (see src/lib/mock-data.ts), +# so you can still explore the UI. +# +# NEVER commit .env.local. NEVER expose the service role key or cron secret to +# the browser (only NEXT_PUBLIC_* values are sent to the client). +# ---------------------------------------------------------------------------- + +# --- Supabase (required for real data/auth) --- +# Project URL and anon key from: Supabase Dashboard > Project Settings > API NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key + +# --- Server-only secrets (NEVER expose to the browser) --- +# Service role key: used by the Friday cron job and for auto-promoting admins. +# Keep this secret. Do not prefix with NEXT_PUBLIC_. SUPABASE_SERVICE_ROLE_KEY=your-service-role-key -# Cron job authentication (generate with: openssl rand -base64 32) +# Cron authentication for /api/cron/demo-day (generate: openssl rand -base64 32) CRON_SECRET=your-cron-secret + +# --- Admin bootstrap (optional) --- +# Comma-separated emails that are auto-promoted to admin on sign-in. +# Requires SUPABASE_SERVICE_ROLE_KEY. Alternatively set is_admin in SQL. +ADMIN_EMAILS=you@example.com + +# --- Site URL (optional) --- +# Used for absolute links / OG metadata. Defaults to production domain. +NEXT_PUBLIC_SITE_URL=http://localhost:3000 diff --git a/MANUAL_TEST_PLAN.md b/MANUAL_TEST_PLAN.md new file mode 100644 index 0000000..5851534 --- /dev/null +++ b/MANUAL_TEST_PLAN.md @@ -0,0 +1,168 @@ +# Manual Test Plan — Product Builders v0.1 + +Two run modes: + +- **Demo mode:** no env vars (or placeholders). Read-only sample data. Good for + verifying UI, empty/loading/error states, and graceful degradation. +- **Live mode:** real Supabase env vars in `.env.local` after applying + migrations `001`–`004`. Required for the write flows (submit, vote, approve, + Demo Day curation). + +Legend: ✅ pass · ⬜ to verify + +--- + +## A. Build & tooling + +| # | Step | Expected | +| --- | --- | --- | +| A1 | `npm install` | Installs without errors | +| A2 | `npm run lint` | No errors | +| A3 | `npm run build` | Build succeeds, all routes compile | +| A4 | `npm run dev` | Dev server starts, home loads | + +## 1. Homepage + +| # | Step | Expected | +| --- | --- | --- | +| 1.1 | Open `/` | Hero, CTAs, and the project feed render | +| 1.2 | Demo mode | Approved sample projects show; pending one does **not** | +| 1.3 | No approved projects | Friendly empty state with a submit CTA | +| 1.4 | Toggle Hot/New | Sort order changes | + +## 2. Browse projects + +| # | Step | Expected | +| --- | --- | --- | +| 2.1 | View cards | Name, tagline, stage, category, vote button visible | +| 2.2 | Leaderboard `/leaderboard` | Top 3 podium + the rest, or empty state | + +## 3. Project detail + +| # | Step | Expected | +| --- | --- | --- | +| 3.1 | Open an approved project | Detail, problem/audience, comments, vote button | +| 3.2 | Open a non-existent id `/p/does-not-exist` | 404 (not a crash) | +| 3.3 | Owner opens own **pending** project | "Pending review" banner; no public vote button | + +## 4. Signup / login + +| # | Step | Expected | +| --- | --- | --- | +| 4.1 | `/login` | Magic link + Google options render | +| 4.2 | Send magic link | "Check your email" confirmation | +| 4.3 | First login without handle | Redirect to `/onboarding` | +| 4.4 | Provider error (`/login?error=auth`) | Friendly error message, no blank page | +| 4.5 | Demo mode protected route (`/submit`) | No infinite redirect; demo notice shown | + +## 5. Submit project (live mode) + +| # | Step | Expected | +| --- | --- | --- | +| 5.1 | Logged-out → `/submit` | Redirect to `/login?redirect=/submit` | +| 5.2 | Choose path → fill form → submit | Saved as **pending**; redirect to project (or prep) | +| 5.3 | After submit | Project visible to owner with pending banner; not on home feed | +| 5.4 | Demo mode `/submit` | "Demo mode" message instead of the form | + +## 6. Submit validation + +| # | Step | Expected | +| --- | --- | --- | +| 6.1 | Empty name/tagline | Submit disabled; required fields enforced | +| 6.2 | URL without scheme (`foo.com`) | Saved as `https://foo.com` | +| 6.3 | Image > 2 MB | "Image must be under 2 MB" error | + +## 7. Public listing after approval + +| # | Step | Expected | +| --- | --- | --- | +| 7.1 | Admin approves a pending project | Status → approved | +| 7.2 | Reload home | Approved project now appears publicly | +| 7.3 | Admin rejects a project | Owner sees "Not approved"; not public | + +## 8. Voting / support + +| # | Step | Expected | +| --- | --- | --- | +| 8.1 | Logged-in user votes | Count +1, heart filled | +| 8.2 | Remove vote | Count -1 (never below 0) | +| 8.3 | Logged-out votes | Redirect to login | + +## 9. Duplicate vote + +| # | Step | Expected | +| --- | --- | --- | +| 9.1 | Vote twice (same user/project) | Second insert blocked by PK; count stays correct | + +## 10. Admin access + +| # | Step | Expected | +| --- | --- | --- | +| 10.1 | Non-admin → `/admin` | Redirect to `/` | +| 10.2 | Logged-out → `/admin` | Redirect to login | +| 10.3 | Admin → `/admin` | Review queue + Demo Day tools load | + +## 11. Approve / reject project + +| # | Step | Expected | +| --- | --- | --- | +| 11.1 | Pending filter | Pending submissions listed with Approve/Reject | +| 11.2 | Approve | Moves to approved; appears publicly | +| 11.3 | Reject | Marked not approved | +| 11.4 | Hide / Remove approved | Status changes; removed from public feed | + +## 12. Create / select Demo Day + +| # | Step | Expected | +| --- | --- | --- | +| 12.1 | Create demo day (pick a date) | New upcoming Demo Day appears | +| 12.2 | Manage line-up → add approved project | Project added to the line-up | +| 12.3 | Remove from line-up | Project removed | +| 12.4 | Mark presented | Line-up status updates | +| 12.5 | Mark completed / upcoming | Demo Day status toggles | + +## 13. Demo Day public page + +| # | Step | Expected | +| --- | --- | --- | +| 13.1 | `/demo-days` with an upcoming day | "Upcoming" section + selected line-up | +| 13.2 | With a completed day | Archive entry (winners or line-up, recording link) | +| 13.3 | No demo days | Empty state with submit CTA | + +## 14. Missing env vars + +| # | Step | Expected | +| --- | --- | --- | +| 14.1 | Remove Supabase env | App boots in demo mode, no crash | +| 14.2 | "Demo data" banner | Visible on all pages | +| 14.3 | Proxy | No redirect loops on protected routes | + +## 15. Database error + +| # | Step | Expected | +| --- | --- | --- | +| 15.1 | Invalid Supabase URL/key | Pages render empty/error states, no white screen | +| 15.2 | Vote/comment fails | Error surfaced; count not corrupted | + +## 16. Mobile layout + +| # | Step | Expected | +| --- | --- | --- | +| 16.1 | Narrow viewport (375px) | Nav, cards, forms, admin, demo-days are usable | + +## 17. Build verification + +| # | Step | Expected | +| --- | --- | --- | +| 17.1 | `npm run build` | Succeeds with the changes | +| 17.2 | No server-only secret in client bundle | Service role key never referenced in `_next/static` | + +## 18. RLS / security checks (live mode, SQL Editor) + +| # | Step | Expected | +| --- | --- | --- | +| 18.1 | As a normal user, `update profiles set is_admin=true` on own row | `is_admin` stays false (trigger blocks it) | +| 18.2 | As a builder, `update products set status='live'` on own pending row | Status stays pending (trigger blocks it) | +| 18.3 | As a builder, set own product `status='removed'` | Allowed (withdraw) | +| 18.4 | Anonymous select on a pending product | Returns nothing (only approved are public) | +| 18.5 | Non-admin insert into `demo_day_projects` | Blocked by RLS | diff --git a/README.md b/README.md index 45d8fe3..1ae9a62 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,64 @@ # Product Builders -A friendly weekly product showcase for builders. Share what you are building, get real feedback from the community, and demo live every Friday. +A platform for builders in the Tech Immigrants ecosystem. Builders submit the +projects they are working on, the community supports them with votes and +comments, and selected projects demo live at **Tech Immigrants Demo Day** with +feedback from technical and business advisors. **Live at:** [productbuilders.app](https://productbuilders.app) -## How it works +## The v0.1 workflow -The platform runs on a weekly cycle (Helsinki time): +This release supports the real, end-to-end Demo Day workflow: -1. **Submit** any time during the week, whether it is a rough idea or a shipped product. -2. **Vote and comment** on what others are building. -3. **Demo live** every Friday on Google Meet, where top projects present to the community. +``` +submit → admin review/approve → public project → vote/support → select for Demo Day → public Demo Day page +``` -Builders choose one of two paths when they submit: +1. A builder **submits** a project. New submissions start as **pending review** + and are only visible to the builder (and admins) until approved. +2. An **admin reviews** the queue and **approves** or **rejects** each project. +3. **Approved** projects appear publicly on the home feed and project pages. +4. The community **votes/supports** approved projects (one vote per user per + project). +5. The admin **creates a Demo Day** and **selects approved projects** for the + live line-up, with an optional running order. +6. Everyone sees **upcoming and past Demo Days** with the selected line-up. -- **Showcase it live:** reserve a Friday slot, pick a demo language (English or Farsi), and get a built-in prep guide. -- **Share for feedback:** post the project and collect comments and encouragement, no live call required. +There is also a legacy weekly "Friday showcase" flavor (countdown, leaderboard, +and an automated top-3 snapshot) that remains available but is secondary to the +curated Demo Day flow above. -## Features +## Demo mode (no setup required) -- Weekly browse feed with Hot and New sorting -- Leaderboard with a live countdown and a top-3 podium -- Demo day archive of past winners -- Guided, multi-step submission flow (problem, audience, stage, category, image) -- Demo prep guide with a 7-minute presentation structure -- Voting and threaded comments -- Magic-link and Google OAuth sign in, with new-user onboarding -- Public builder profiles and account settings -- Role-gated admin panel (hide, show, or remove products, and trigger demo-day snapshots) -- Automated Friday cron that snapshots the week's top 3 +If Supabase env vars are missing or still placeholders, the app runs in a +read-only **demo mode** backed by sample data (`src/lib/mock-data.ts`). You can +browse the home feed, project pages, and an upcoming Demo Day with a sample +line-up without any database. A "Demo data" banner is shown, and write actions +(submit/vote) are disabled with a friendly message. -## Stack +```bash +npm install +npm run dev # open http://localhost:3000 — works immediately in demo mode +``` -- **Framework:** Next.js 16 (App Router, Server Components, Turbopack) -- **Database, Auth, Storage:** Supabase (Postgres, Row Level Security, Auth, Storage) +## Tech stack + +- **Framework:** Next.js 16 (App Router, Server Components, Turbopack). Note: + middleware is called **Proxy** (`src/proxy.ts`) in Next.js 16. +- **Database / Auth / Storage:** Supabase (Postgres, Row Level Security, Auth, + Storage) - **Styling:** Tailwind CSS v4 -- **Fonts:** Fraunces (display), Manrope (body), JetBrains Mono (metadata) -- **Deployment:** Vercel (with a weekly cron job) +- **Deployment:** Vercel (with an optional weekly cron job) -## Local setup +## Local setup with Supabase ### Prerequisites -- Node.js 18 or newer +- Node.js 18+ (developed on Node 20+) - A [Supabase](https://supabase.com) project -### 1. Clone and install +### 1. Install ```bash git clone https://github.com/SaharPak/productbuilders-app.git @@ -53,89 +66,96 @@ cd productbuilders-app npm install ``` -### 2. Set up Supabase +### 2. Apply the database schema + +In the Supabase **SQL Editor**, run the migrations in order: + +- `supabase/migrations/001_initial_schema.sql` +- `supabase/migrations/002_demo_type_and_guided_fields.sql` +- `supabase/migrations/003_admin_read_all_products.sql` +- `supabase/migrations/004_review_workflow_and_demo_curation.sql` + +Migration `004` adds the review workflow (`pending`/`rejected` statuses), the +`demo_day_projects` curation table, and closes two RLS privilege-escalation +holes (self-promotion to admin, and builders self-approving their own projects). -1. Create a new project at [supabase.com](https://supabase.com). -2. Open the **SQL Editor** and run the migrations in order: - - `supabase/migrations/001_initial_schema.sql` - - `supabase/migrations/002_demo_type_and_guided_fields.sql` - - `supabase/migrations/003_admin_read_all_products.sql` -3. Enable **Google OAuth** (optional) under Authentication, Providers, Google. -4. Create a public **Storage bucket** named `product-images`. +Optionally create a public **Storage bucket** named `product-images` (for +project screenshots) and enable **Google OAuth** under Authentication → +Providers. -### 3. Configure environment +### 3. Seed demo data (optional) + +Run `supabase/seed.sql` in the SQL Editor. It is idempotent and creates a demo +admin account, sample projects (including a pending one), votes, an upcoming +Demo Day with a curated line-up, and a completed Demo Day with winners. + +### 4. Configure environment ```bash cp .env.example .env.local ``` -Fill in the values from your Supabase project settings: +Fill in from Supabase → Project Settings → API: -- `NEXT_PUBLIC_SUPABASE_URL`: your project URL -- `NEXT_PUBLIC_SUPABASE_ANON_KEY`: the anon/public key -- `SUPABASE_SERVICE_ROLE_KEY`: the service_role key (used by the cron job) -- `CRON_SECRET`: generate with `openssl rand -base64 32` +| Variable | Required | Notes | +| --- | --- | --- | +| `NEXT_PUBLIC_SUPABASE_URL` | yes | Project URL | +| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | yes | Public anon key | +| `SUPABASE_SERVICE_ROLE_KEY` | server-only | Cron + admin auto-promote. **Never expose to the browser.** | +| `CRON_SECRET` | server-only | Bearer token for the cron endpoint | +| `ADMIN_EMAILS` | optional | Comma-separated emails auto-promoted to admin on sign-in | +| `NEXT_PUBLIC_SITE_URL` | optional | Absolute links / OG metadata | -The service role key and cron secret are server-only. Never commit them or expose them to the browser. - -### 4. Run locally +### 5. Run ```bash -npm run dev +npm run dev # http://localhost:3000 +npm run build # production build +npm run start # serve production build +npm run lint # ESLint + type-aware rules ``` -Open [http://localhost:3000](http://localhost:3000). +## Admin setup + +Admin authority lives in `profiles.is_admin` and is enforced by RLS. To make +someone an admin, use either: -> Tip: if Supabase env vars are not set, the app runs in a built-in mock-data mode so you can browse the UI without a database. +- **Env allowlist (reproducible):** set `ADMIN_EMAILS=you@example.com` (and + `SUPABASE_SERVICE_ROLE_KEY`). The user is promoted automatically when they + sign in. +- **SQL:** `update public.profiles set is_admin = true where handle = 'yourhandle';` -### 5. Seed data (optional) +The admin panel at `/admin` lets you review/approve/reject submissions, create +Demo Days, select approved projects for the line-up, mark projects as presented, +add recording URLs, and run the automatic top-3 snapshot. Non-admins are +redirected away, and the database triggers prevent privilege escalation even if +a request bypasses the UI. -After creating your first account, grab your user UUID from the Supabase Auth dashboard, then edit and run `supabase/seed.sql`. +## Deploy to Vercel -## Scripts +1. Push to GitHub and import the repo in Vercel. +2. Add the environment variables from `.env.example`. +3. Deploy. `vercel.json` configures an optional Friday cron job + (`/api/cron/demo-day`) that snapshots the week's top 3 approved projects. -- `npm run dev`: start the dev server -- `npm run build`: production build -- `npm run start`: serve the production build -- `npm run lint`: run ESLint +## Security notes -## Deploy to Vercel +- The service role key and cron secret are server-only and never sent to the + browser (only `NEXT_PUBLIC_*` values are). +- Public users can only read **approved** projects. Builders can read and edit + their own (including pending) projects. Only admins can approve/reject/feature + projects and manage Demo Days. See `MANUAL_TEST_PLAN.md` for the RLS checks. -1. Push to GitHub. -2. Import the repo in Vercel. -3. Add the environment variables from `.env.example`. -4. Deploy. +## Known limitations -`vercel.json` configures a cron job that runs every Friday at 11:30 UTC (14:30 Helsinki) to snapshot the week's top 3 demo-day winners. See the operations guide for manual trigger options and admin tasks. +- Demo Day line-up ordering is set by add-order (no drag-to-reorder yet). +- Anonymous voting is not supported; voting requires sign-in. +- The legacy auto-snapshot and the manual curation flow coexist; the curated + `demo_day_projects` line-up is the primary public surface. -## Project structure +## Roadmap -``` -src/ -├── app/ -│ ├── (auth)/login/ # Magic link + Google OAuth -│ ├── admin/ # Admin panel (role-gated) -│ ├── api/cron/ # Demo day cron endpoint -│ ├── auth/callback/ # OAuth callback handler -│ ├── demo-days/ # Demo day archive -│ ├── leaderboard/ # Weekly leaderboard with podium -│ ├── onboarding/ # New user profile setup -│ ├── p/[id]/ # Product detail page -│ │ ├── edit/ # Edit a product -│ │ └── prep/ # Live demo prep guide -│ ├── settings/ # Profile settings -│ ├── submit/ # Guided submission flow -│ ├── u/[handle]/ # Public user profile -│ ├── layout.tsx # Root layout with fonts + nav -│ └── page.tsx # Home / browse page -├── components/ # Shared UI components -├── lib/ -│ ├── supabase/ # Supabase clients (browser, server, session) -│ └── week.ts # Weekly cycle date utilities -├── types/ -│ └── database.ts # TypeScript types for all tables -└── proxy.ts # Request proxy (session refresh) -``` +See `STABILIZATION_REPORT.md` for the recommended next tasks. ## License diff --git a/STABILIZATION_REPORT.md b/STABILIZATION_REPORT.md new file mode 100644 index 0000000..139bf4a --- /dev/null +++ b/STABILIZATION_REPORT.md @@ -0,0 +1,132 @@ +# Stabilization Report — Product Builders v0.1 + +Branch: `stabilize-v0.1` + +## Goal + +Stabilize the app into a clean, reliable, end-to-end v0.1 that supports the real +Tech Immigrants Demo Day workflow: + +``` +submit → admin review/approve → public project → vote/support → select for Demo Day → public Demo Day page +``` + +## Current status: stable + +- ✅ Installs (`npm install`) +- ✅ Runs locally (`npm run dev`) +- ✅ Build passes (`npm run build`, Next.js 16 / Turbopack) +- ✅ Lint passes (`npm run lint`) +- ✅ Demo mode verified by smoke-testing routes with placeholder env +- ✅ The full Demo Day workflow is implemented end-to-end + +## What the app is + +Next.js 16 (App Router, Server Components) + Supabase (Postgres, RLS, Auth, +Storage) + Tailwind v4. The repo already had a working weekly "Friday showcase" +with immediate publishing and an automated top-3 snapshot. This stabilization +adds the missing **review/approve gate** and **manual Demo Day curation** so the +requested workflow works, while keeping the existing weekly features. + +## What worked before + +- Next.js app structure, routing, fonts, styling +- Supabase browser/server/proxy clients, magic-link + Google OAuth, onboarding +- Submit form, project pages, voting, comments, profiles, settings +- Leaderboard, countdown, auto top-3 snapshot, cron endpoint +- A basic demo (mock) mode on a few pages + +## What was broken or missing (and fixed) + +### Workflow gaps (core ask) +- **No review/approve step.** Submissions published immediately. → Added + `pending`/`rejected` statuses; new submissions default to **pending**; admin + **Approve/Reject** in `/admin`; only approved projects are public. +- **No manual Demo Day selection.** Demo days were only auto top-3 snapshots. → + Added `demo_day_projects` table + admin UI to **create a Demo Day and select + approved projects** (with order, "mark presented", recording URL), and a + rebuilt public `/demo-days` showing **upcoming line-ups + past archive**. +- **Builders could not see their own pending work.** → Product detail now lets + owners/admins view non-public projects with a status banner; profile shows an + "In review & not public" section; added an RLS policy so builders can read + their own projects. + +### Security (found during audit, fixed) +- **Privilege escalation: any user could self-promote to admin** via + `update profiles set is_admin=true` (the update policy had no column guard). → + Added a `protect_admin_flag` trigger; `is_admin` can only change via an + existing admin or a privileged (service-role/SQL) connection. +- **Builders could self-approve** by setting their product `status='live'`. → + Added a `protect_product_status` trigger; builders may only withdraw + (`removed`); approve/feature is admin-only. +- Removed noisy per-request `console.log` in the proxy and verbose auth-callback + logs that printed cookie/session details. + +### Robustness / DX +- **Demo mode hardened:** richer mock data (approved + pending projects, + upcoming/past Demo Days, curated line-up), a global "Demo data" banner, a + demo-mode guard on `/submit`, and the proxy now skips auth entirely when env + is missing (no redirect loops, no blank crashes). +- `.env.example` documents every variable with server-only warnings; added + `ADMIN_EMAILS` and `NEXT_PUBLIC_SITE_URL`. +- Reproducible, idempotent `supabase/seed.sql` (demo admin, projects, votes, + Demo Days, line-up, winners). +- `ADMIN_EMAILS` allowlist auto-promotes trusted emails to admin on sign-in + (server-only, uses the service role key safely). + +## Database / auth problems found + +- RLS policies allowed two privilege escalations (fixed via triggers in + migration `004`). +- Default `status='live'` meant no approval gate (changed to `pending`). +- Builders lacked a self-read policy once non-live statuses existed (added). +- Seed data was entirely commented out / not reproducible (rewritten). + +## What still needs manual setup + +To run with real data (not required for a demo — demo mode works with zero +setup): + +1. Create a Supabase project. +2. Run migrations `001`–`004` in the SQL Editor. +3. (Optional) run `supabase/seed.sql`; (optional) create the `product-images` + Storage bucket; (optional) enable Google OAuth. +4. Copy `.env.example` → `.env.local` and fill in the keys. +5. Make yourself admin via `ADMIN_EMAILS` or + `update profiles set is_admin=true ...`. + +## What is safe to demo publicly + +- Demo mode (no secrets, no real personal data) is safe to show anywhere. +- In live mode, only approved projects are public; admin writes are protected by + RLS **and** DB triggers; the service role key and cron secret are server-only. + +## Known limitations + +- Demo Day line-up order is add-order (no drag-to-reorder yet). +- Voting requires sign-in (no anonymous voting). +- Auto top-3 snapshot and manual curation coexist; the curated line-up is the + primary public surface. +- `seed.sql` inserts an `auth.users` row directly (relies on pgcrypto, enabled + by default on Supabase); it is a convenience for fresh projects, not tested + against every Supabase version. + +## Next 5 recommended tasks + +1. **Drag-to-reorder** the Demo Day line-up and persist `display_order`. +2. **Email/notify builders** when their project is approved/rejected or selected + for a Demo Day. +3. **"My projects" dashboard** consolidating a builder's submissions and statuses. +4. **Admin server actions + tests:** move admin writes to `"use server"` actions + with `revalidatePath`, and add unit tests for status transitions + RLS. +5. **Playwright e2e** covering home, project detail, submit validation, demo-day + page, and admin access protection. + +## Verification log + +- `npm install` — ok +- `npm run lint` — ok (no warnings) +- `npm run build` — ok (13 routes) +- Demo-mode smoke test (placeholder env): `/`, `/demo-days`, `/submit`, + `/leaderboard`, `/p/p1`, `/p/p4` (pending banner), `/u/alexbuilds` all return + 200 with expected content and the Demo data banner. diff --git a/package-lock.json b/package-lock.json index 8a204be..6e78f87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,8 @@ "next": "16.2.6", "next-themes": "^0.4.6", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "server-only": "^0.0.1" }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -5901,6 +5902,12 @@ "semver": "bin/semver.js" } }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", diff --git a/package.json b/package.json index 8dd6a9d..baab8ce 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "next": "16.2.6", "next-themes": "^0.4.6", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "server-only": "^0.0.1" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index bcf5a3c..f0b6ca8 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -2,26 +2,52 @@ import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; +import Link from "next/link"; import { createClient } from "@/lib/supabase/client"; -import { format } from "date-fns"; -import type { Product, DemoDay } from "@/types/database"; +import { format, startOfWeek } from "date-fns"; +import { upcomingDemoFridays } from "@/lib/week"; +import { StatusBadge } from "@/components/status-badge"; +import type { + Product, + DemoDay, + DemoDayProjectWithProduct, +} from "@/types/database"; + +type ProductFilter = "pending" | "live" | "all" | "hidden" | "rejected"; + +const PRODUCT_FILTERS: ProductFilter[] = [ + "pending", + "live", + "hidden", + "rejected", + "all", +]; + +function mondayOf(dateStr: string): string { + const d = new Date(`${dateStr}T12:00:00`); + return format(startOfWeek(d, { weekStartsOn: 1 }), "yyyy-MM-dd"); +} export default function AdminPage() { const [products, setProducts] = useState([]); const [isAdmin, setIsAdmin] = useState(false); const [loading, setLoading] = useState(true); - const [filter, setFilter] = useState<"all" | "live" | "hidden" | "removed">( - "all" - ); + const [filter, setFilter] = useState("pending"); const [snapshotLoading, setSnapshotLoading] = useState(false); const [message, setMessage] = useState(null); const [recordingUrl, setRecordingUrl] = useState(""); const [demoDays, setDemoDays] = useState([]); + const [lineups, setLineups] = useState< + Record + >({}); + const [newDemoDate, setNewDemoDate] = useState(""); + const [expandedWeek, setExpandedWeek] = useState(null); const [editingWeek, setEditingWeek] = useState(null); const [editUrl, setEditUrl] = useState(""); const router = useRouter(); const supabase = createClient(); + const fridays = upcomingDemoFridays(6); useEffect(() => { async function init() { @@ -37,7 +63,7 @@ export default function AdminPage() { .from("profiles") .select("is_admin") .eq("id", user.id) - .single(); + .maybeSingle(); if (!profile?.is_admin) { router.push("/"); @@ -45,7 +71,7 @@ export default function AdminPage() { } setIsAdmin(true); - await Promise.all([loadProducts(), loadDemoDays()]); + await Promise.all([loadProducts(), loadDemoDays(), loadLineups()]); setLoading(false); } init(); @@ -68,6 +94,106 @@ export default function AdminPage() { if (data) setDemoDays(data); } + async function loadLineups() { + const { data } = await supabase + .from("demo_day_projects") + .select("*, product:products(id, name, tagline, stage, category, demo_type, demo_language)") + .order("display_order", { ascending: true }); + const grouped: Record = {}; + (data as DemoDayProjectWithProduct[] | null)?.forEach((row) => { + (grouped[row.week_of] ??= []).push(row); + }); + setLineups(grouped); + } + + async function updateStatus(id: string, status: string) { + const { error } = await supabase + .from("products") + .update({ status }) + .eq("id", id); + if (error) { + setMessage(`Failed to update project: ${error.message}`); + return; + } + await loadProducts(); + } + + async function createDemoDay() { + setMessage(null); + if (!newDemoDate) { + setMessage("Pick a date for the demo day."); + return; + } + const weekOf = mondayOf(newDemoDate); + const demoDate = new Date(`${newDemoDate}T12:30:00Z`).toISOString(); + const { error } = await supabase + .from("demo_days") + .upsert({ week_of: weekOf, demo_date: demoDate, status: "upcoming" }); + if (error) { + setMessage(`Failed to create demo day: ${error.message}`); + return; + } + setNewDemoDate(""); + setExpandedWeek(weekOf); + await loadDemoDays(); + } + + async function setDemoDayStatus(weekOf: string, status: string) { + const { error } = await supabase + .from("demo_days") + .update({ status }) + .eq("week_of", weekOf); + if (error) { + setMessage(`Failed to update demo day: ${error.message}`); + return; + } + await loadDemoDays(); + } + + async function addToLineup(weekOf: string, productId: string) { + const existing = lineups[weekOf] ?? []; + if (existing.some((r) => r.product_id === productId)) return; + const order = existing.length; + const { error } = await supabase + .from("demo_day_projects") + .insert({ week_of: weekOf, product_id: productId, display_order: order }); + if (error) { + setMessage(`Failed to add project: ${error.message}`); + return; + } + await loadLineups(); + } + + async function removeFromLineup(weekOf: string, productId: string) { + const { error } = await supabase + .from("demo_day_projects") + .delete() + .eq("week_of", weekOf) + .eq("product_id", productId); + if (error) { + setMessage(`Failed to remove project: ${error.message}`); + return; + } + await loadLineups(); + } + + async function setLineupStatus( + weekOf: string, + productId: string, + status: string + ) { + const { error } = await supabase + .from("demo_day_projects") + .update({ status }) + .eq("week_of", weekOf) + .eq("product_id", productId); + if (error) { + setMessage(`Failed to update line-up: ${error.message}`); + return; + } + await loadLineups(); + } + async function updateRecordingUrl(weekOf: string, url: string) { const { error } = await supabase .from("demo_days") @@ -82,11 +208,6 @@ export default function AdminPage() { await loadDemoDays(); } - async function updateStatus(id: string, status: string) { - await supabase.from("products").update({ status }).eq("id", id); - await loadProducts(); - } - async function triggerSnapshot() { setSnapshotLoading(true); setMessage(null); @@ -102,7 +223,7 @@ export default function AdminPage() { .limit(3); if (!topProducts || topProducts.length === 0) { - setMessage("No products found for current week."); + setMessage("No approved products found for the current week."); setSnapshotLoading(false); return; } @@ -121,12 +242,14 @@ export default function AdminPage() { } for (let i = 0; i < topProducts.length; i++) { - const { error: winnerError } = await supabase.from("demo_day_winners").upsert({ - week_of: weekOf, - rank: i + 1, - product_id: topProducts[i].id, - vote_count: topProducts[i].vote_count, - }); + const { error: winnerError } = await supabase + .from("demo_day_winners") + .upsert({ + week_of: weekOf, + rank: i + 1, + product_id: topProducts[i].id, + vote_count: topProducts[i].vote_count, + }); if (winnerError) { setMessage(`Failed to record winner ${i + 1}: ${winnerError.message}`); setSnapshotLoading(false); @@ -134,14 +257,20 @@ export default function AdminPage() { } } - setMessage(`Snapshot completed for week of ${weekOf}. ${topProducts.length} winners recorded.`); + setMessage( + `Snapshot completed for week of ${weekOf}. ${topProducts.length} winners recorded.` + ); setSnapshotLoading(false); + await Promise.all([loadDemoDays(), loadLineups()]); } + const counts = { + pending: products.filter((p) => p.status === "pending").length, + live: products.filter((p) => p.status === "live").length, + }; const filtered = - filter === "all" - ? products - : products.filter((p) => p.status === filter); + filter === "all" ? products : products.filter((p) => p.status === filter); + const approvedProducts = products.filter((p) => p.status === "live"); if (loading) { return ( @@ -168,161 +297,349 @@ export default function AdminPage() { -
-

- Demo Day Snapshot -

-

- Manually trigger the winner snapshot for the current week. + {message && ( +

+ {message}

- setRecordingUrl(e.target.value)} - placeholder="YouTube recording URL (optional)" - className="mt-3 w-full rounded-lg border border-border bg-paper-bg px-3 py-2 text-sm text-ink placeholder:text-ink-faint focus:border-persimmon focus:outline-none" - /> - - {message && ( -

{message}

- )} -
+ )} - {demoDays.length > 0 && ( -
-

- Past Demo Days + {/* Review queue */} +
+
+

+ Review submissions + {counts.pending > 0 && ( + + {counts.pending} pending + + )}

-
- {demoDays.map((dd) => ( +
+ {PRODUCT_FILTERS.map((f) => ( + + ))} +
+
+ +
+ {filtered.length === 0 ? ( +
+ Nothing here right now. +
+ ) : ( + filtered.map((product) => (
-

- {format(new Date(dd.demo_date), "MMM d, yyyy")} +

+ + {product.name} + + +
+

+ {product.tagline}

-

- {dd.week_of} · {dd.status} +

+ {format(new Date(product.created_at), "MMM d, HH:mm")} ·{" "} + {product.category} · {product.demo_type === "live_demo" ? "live demo" : "feedback"}

- {editingWeek === dd.week_of ? ( -
- setEditUrl(e.target.value)} - placeholder="YouTube URL" - className="min-w-0 flex-1 rounded-lg border border-border bg-card-bg px-2 py-1 text-xs text-ink placeholder:text-ink-faint focus:border-persimmon focus:outline-none" - autoFocus - /> +
+
+ {product.status === "pending" && ( + <> -
- ) : ( -

- {dd.recording_url ? dd.recording_url : "No recording"} -

+ + )} + {product.status !== "live" && product.status !== "pending" && ( + + )} + {product.status === "live" && ( + + )} + {product.status !== "removed" && ( + )}
- {editingWeek !== dd.week_of && ( - - )}
- ))} -
+ )) + )}
- )} +
-
-
-

- Submissions ({filtered.length}) -

-
- {(["all", "live", "hidden", "removed"] as const).map((f) => ( - - ))} + {/* Demo day curation */} +
+

Demo Days

+

+ Create a demo day, then select approved projects for the live line-up. +

+ +
+
+ + setNewDemoDate(e.target.value)} + list="demo-fridays" + className="w-full rounded-lg border border-border bg-paper-bg px-3 py-2 text-sm text-ink focus:border-persimmon focus:outline-none" + /> + + {fridays.map((f) => ( + + ))} +
+
-
- {filtered.map((product) => ( -
-
-

{product.name}

-

- {product.tagline} -

-

- {format(new Date(product.created_at), "MMM d, HH:mm")} ·{" "} - {product.status} -

-
-
- {product.status !== "live" && ( - - )} - {product.status !== "hidden" && ( - - )} - {product.status !== "removed" && ( - - )} -
+
+ {demoDays.length === 0 ? ( +
+ No demo days yet. Create one above.
- ))} + ) : ( + demoDays.map((dd) => { + const lineup = lineups[dd.week_of] ?? []; + const inLineup = new Set(lineup.map((r) => r.product_id)); + const isExpanded = expandedWeek === dd.week_of; + return ( +
+
+
+

+ {format(new Date(dd.demo_date), "MMMM d, yyyy")} +

+

+ Week of {dd.week_of} · {dd.status} · {lineup.length}{" "} + selected +

+
+
+ + +
+
+ + {/* Recording URL */} +
+ {editingWeek === dd.week_of ? ( +
+ setEditUrl(e.target.value)} + placeholder="YouTube URL" + className="min-w-0 flex-1 rounded-lg border border-border bg-paper-bg px-2 py-1 text-xs text-ink placeholder:text-ink-faint focus:border-persimmon focus:outline-none" + autoFocus + /> + + +
+ ) : ( + + )} +
+ + {/* Selected line-up */} + {lineup.length > 0 && ( +
+ {lineup.map((row, i) => ( +
+ + {i + 1} + +
+

+ {row.product?.name ?? "Untitled"} +

+

+ {row.status} +

+
+ + +
+ ))} +
+ )} + + {/* Add approved projects */} + {isExpanded && ( +
+

+ Add an approved project +

+ {approvedProducts.filter((p) => !inLineup.has(p.id)) + .length === 0 ? ( +

+ No approved projects available to add. +

+ ) : ( +
+ {approvedProducts + .filter((p) => !inLineup.has(p.id)) + .map((p) => ( + + ))} +
+ )} +
+ )} +
+ ); + }) + )}
+ + {/* Auto snapshot (top-3 by votes) */} +
+

+ Auto snapshot (top 3 by votes) +

+

+ Records this week's top 3 approved projects as demo day winners. +

+ setRecordingUrl(e.target.value)} + placeholder="YouTube recording URL (optional)" + className="mt-3 w-full rounded-lg border border-border bg-paper-bg px-3 py-2 text-sm text-ink placeholder:text-ink-faint focus:border-persimmon focus:outline-none" + /> + +
); } diff --git a/src/app/auth/callback/route.ts b/src/app/auth/callback/route.ts index 75213cc..65f8199 100644 --- a/src/app/auth/callback/route.ts +++ b/src/app/auth/callback/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { createServerClient, parseCookieHeader } from "@supabase/ssr"; import type { CookieOptions } from "@supabase/ssr"; +import { promoteAdminIfAllowlisted } from "@/lib/admin"; export async function GET(request: Request) { const { searchParams, origin } = new URL(request.url); @@ -47,18 +48,14 @@ export async function GET(request: Request) { const { data, error } = await supabase.auth.exchangeCodeForSession(code); - console.log( - "[auth/callback] exchange result:", - error ? `error=${error.message}` : "ok", - `cookies collected: ${collectedCookies.length}`, - `cookie names: [${collectedCookies.map((c) => c.name).join(", ")}]` - ); - if (!error && data.session) { const user = data.session.user; let redirectTo = `${origin}${redirect}`; if (user) { + // Promote trusted emails (ADMIN_EMAILS) to admin on sign-in. + await promoteAdminIfAllowlisted(user.id, user.email); + const { data: profile } = await supabase .from("profiles") .select("handle") @@ -80,11 +77,6 @@ export async function GET(request: Request) { response.cookies.set(name, value, options); } - console.log( - "[auth/callback] redirect to:", - redirectTo, - `Set-Cookie count: ${response.headers.getSetCookie().length}` - ); return response; } diff --git a/src/app/demo-days/page.tsx b/src/app/demo-days/page.tsx index 4173352..7cb59ee 100644 --- a/src/app/demo-days/page.tsx +++ b/src/app/demo-days/page.tsx @@ -1,150 +1,285 @@ import Link from "next/link"; -import { createClient } from "@/lib/supabase/server"; import { format } from "date-fns"; import type { Metadata } from "next"; -import type { DemoDayWinnerWithProduct } from "@/types/database"; +import { + isMockMode, + MOCK_DEMO_DAYS, + MOCK_DEMO_DAY_PROJECTS, + MOCK_DEMO_DAY_WINNERS, +} from "@/lib/mock-data"; +import type { + DemoDay, + DemoDayProjectWithProduct, + DemoDayWinnerWithProduct, +} from "@/types/database"; const demoDaysDescription = - "Browse past Product Builders demo days, weekly winners, vote counts, and recordings."; + "See upcoming Tech Immigrants Demo Days, the selected line-up, and the archive of past sessions and winners."; export const metadata: Metadata = { - title: "Demo Days Archive", + title: "Demo Days", description: demoDaysDescription, - alternates: { - canonical: "/demo-days", - }, + alternates: { canonical: "/demo-days" }, openGraph: { - title: "Demo Days Archive", + title: "Demo Days", description: demoDaysDescription, url: "/demo-days", }, }; -export default async function DemoDaysPage() { +interface DemoDaysData { + demoDays: DemoDay[]; + lineups: Map; + winners: Map; +} + +async function getData(): Promise { + if (isMockMode()) { + const lineups = new Map(); + MOCK_DEMO_DAY_PROJECTS.forEach((p) => { + lineups.set(p.week_of, [...(lineups.get(p.week_of) ?? []), p]); + }); + const winners = new Map(); + MOCK_DEMO_DAY_WINNERS.forEach((w) => { + winners.set(w.week_of, [...(winners.get(w.week_of) ?? []), w]); + }); + return { demoDays: MOCK_DEMO_DAYS, lineups, winners }; + } + + const { createClient } = await import("@/lib/supabase/server"); const supabase = await createClient(); const { data: demoDays } = await supabase .from("demo_days") .select("*") - .eq("status", "completed") .order("week_of", { ascending: false }); + const { data: lineupData } = await supabase + .from("demo_day_projects") + .select( + "*, product:products(id, name, tagline, stage, category, demo_type, demo_language)" + ) + .order("display_order", { ascending: true }); + const { data: winnersData } = await supabase .from("demo_day_winners") .select("*, product:products(name, tagline, id)") .order("rank", { ascending: true }); - const winners = (winnersData ?? []) as DemoDayWinnerWithProduct[]; - const winnersMap = new Map(); - winners.forEach((w) => { - const existing = winnersMap.get(w.week_of) ?? []; - existing.push(w); - winnersMap.set(w.week_of, existing); + const lineups = new Map(); + ((lineupData ?? []) as DemoDayProjectWithProduct[]).forEach((row) => { + lineups.set(row.week_of, [...(lineups.get(row.week_of) ?? []), row]); + }); + + const winners = new Map(); + ((winnersData ?? []) as DemoDayWinnerWithProduct[]).forEach((w) => { + winners.set(w.week_of, [...(winners.get(w.week_of) ?? []), w]); }); + return { demoDays: (demoDays ?? []) as DemoDay[], lineups, winners }; +} + +function LineupRow({ + order, + href, + name, + tagline, + badge, +}: { + order?: number; + href?: string; + name: string; + tagline?: string | null; + badge?: string; +}) { + return ( +
+ {order !== undefined && ( + + {order} + + )} +
+ {href ? ( + + {name} + + ) : ( + {name} + )} + {tagline && ( +

{tagline}

+ )} +
+ {badge && ( + {badge} + )} +
+ ); +} + +export default async function DemoDaysPage() { + const { demoDays, lineups, winners } = await getData(); + + const upcoming = demoDays.filter((d) => d.status === "upcoming"); + const past = demoDays.filter((d) => d.status !== "upcoming"); + const hasAny = demoDays.length > 0; + return (
-

- Demo Days Archive -

+

Demo Days

- Every Friday, the top 3 products demo live. Here's the history. + Builders demo live for the Tech Immigrants community and get feedback + from technical and business advisors.

- {!demoDays || demoDays.length === 0 ? ( + {!hasAny && (

- No demo days yet + No demo day scheduled yet

- The first one is coming this Friday. + Check back soon, or share your project to be in the running.

+ + Submit your project +
- ) : ( -
- {demoDays.map((dd) => { - const weekWinners = winnersMap.get(dd.week_of) ?? []; - return ( -
-
-
-

+ )} + + {upcoming.length > 0 && ( +
+

Upcoming

+
+ {upcoming.map((dd) => { + const lineup = lineups.get(dd.week_of) ?? []; + return ( +
+
+ + Upcoming + +

{format(new Date(dd.demo_date), "MMMM d, yyyy")} -

-

- Week of {dd.week_of} -

+

- {dd.recording_url && ( - - Watch on YouTube → - + {dd.notes && ( +

{dd.notes}

+ )} + + {lineup.length > 0 ? ( +
+

+ Selected line-up +

+
+ {lineup.map((row, i) => ( + + ))} +
+
+ ) : ( +

+ The line-up is being finalized. +

)}
+ ); + })} +
+ + )} - {dd.recording_url && ( -
-