From d9f9b08e52e14aca966dbc127df88077183d62fd Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Wed, 11 Nov 2026 16:15:39 +0530 Subject: [PATCH 01/28] docs(B3-A): lock all-vertical onboarding, navigation and public discovery contract --- docs/B3_UNIFIED_EXPERIENCE.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 docs/B3_UNIFIED_EXPERIENCE.md diff --git a/docs/B3_UNIFIED_EXPERIENCE.md b/docs/B3_UNIFIED_EXPERIENCE.md new file mode 100644 index 0000000..94f5ce5 --- /dev/null +++ b/docs/B3_UNIFIED_EXPERIENCE.md @@ -0,0 +1,19 @@ +# B3: Unified Club Experience, multi-vertical onboarding and public discovery + +Owner-approved on 11 November 2026. All verticals launch together (GYM, YOGA, DANCE, SPORTS, OTHER). New clubs must finish guided setup before operational access. Public profiles are an early-release opt-in capability, subject to platform moderation. Preserve all existing URL/API contracts and previous locked commercial/privacy decisions. + +## Contract +- One Better Auth user, many club-scoped roles. Personal records are completely independent. +- Existing /clubs/[clubId] private routes remain the canonical paths, with a role-aware responsive Luxe layout; /home chooses club context. +- /clubs/new creates an IN_PROGRESS club. The owner is directed to /clubs/[clubId]/setup; the club stays ACTIVE as a tenant lifecycle status, distinct from setup readiness. Existing clubs migrate to LEGACY_ACTIVE so B1/B2 remain operable. +- Setup requires vertical, operating mode, country, currency, timezone, and at least one non-archived plan. Logo, images, public address, and public listing are optional. Owner-only progression is persisted and server-validated. A club can be operated when setup is READY or LEGACY_ACTIVE. +- Protect operational route handlers and pages; reject bypass attempts from NEW clubs (fail-closed). Setup plan creation is a narrowly scoped authenticated exception that calls the existing plan service. +- /discover/clubs and /c/[slug] expose approved public data only. A club must explicitly submit a profile; platform admin must approve. Published content remains unchanged while an edit is pending. Hidden/unlisted profiles are excluded from directory indexing. +- Public discovery does NOT join clubs, reveal member records, or process membership payments. Seven-day single-use /join#invite remains canonical. +- New schema is additive; club statuses, roles, B1 roster/plans/renewals, B2 attendance and age/region/auth feature gates are preserved. +- No new plan quota numbers, sponsored seats, clinical features, class-pack engine, marketplace or platform checkout. + +## Security & release +Tenant-scoped writes: verified session, exact-origin CSRF, per-action fail-closed Redis limits, Zod parse, bound payloads, audit logging. Public reads use whitelisted projections and bounded paging. Publishing requires PLATFORM_ADMIN, no implied membership rights. Inactive/suspended clubs are not visible. Do not publish minors or Personal data. + +Release gates: immutable migrations on native PG17, fresh/legacy/noop tests, unit and isolation tests, Next 16 standalone startup smoke on ARM64, 360px/390px mobile and tablet check, keyboard/a11y, QR scanner regression and production-side restore rehearsal. ClubKeep's PostgreSQL, systemd, Redis and secrets remain separate from MenuMinute. Public listing can be disabled independently from private club operations. From 022097123198580b001896e1de3d0562e29f5b1f Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Wed, 11 Nov 2026 16:15:47 +0530 Subject: [PATCH 02/28] feat(B3-B): additive club vertical, setup progress and public revision schema --- .../migration.sql | 43 +++++++++++ prisma/schema.prisma | 71 +++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 prisma/migrations/20261111120000_b3_unified_experience/migration.sql diff --git a/prisma/migrations/20261111120000_b3_unified_experience/migration.sql b/prisma/migrations/20261111120000_b3_unified_experience/migration.sql new file mode 100644 index 0000000..34e8d15 --- /dev/null +++ b/prisma/migrations/20261111120000_b3_unified_experience/migration.sql @@ -0,0 +1,43 @@ +-- B3: safe additive club setup and moderated public listings +CREATE TYPE "ClubVertical" AS ENUM ('GYM','YOGA','DANCE','SPORTS','OTHER'); +CREATE TYPE "ClubSetupStatus" AS ENUM ('IN_PROGRESS','READY','LEGACY_ACTIVE'); +CREATE TYPE "ClubPublicationStatus" AS ENUM ('HIDDEN','PENDING','UNLISTED','LISTED','SUSPENDED'); +CREATE TYPE "ClubPublicReviewStatus" AS ENUM ('PENDING','APPROVED','REJECTED'); +ALTER TABLE "Club" ADD COLUMN "vertical" "ClubVertical" NOT NULL DEFAULT 'OTHER'; +ALTER TABLE "Club" ADD COLUMN "setupStatus" "ClubSetupStatus" NOT NULL DEFAULT 'IN_PROGRESS'; +ALTER TABLE "Club" ADD COLUMN "setupCompletedAt" TIMESTAMP(3); +-- Pre-B3 clubs retain all B1/B2 access, with incomplete setup flagged for later completion. +UPDATE "Club" SET "setupStatus"='LEGACY_ACTIVE'; +CREATE TABLE "ClubSetupProgress" ( + "clubId" TEXT NOT NULL PRIMARY KEY REFERENCES "Club"("id") ON DELETE RESTRICT ON UPDATE CASCADE, + "currentStep" INTEGER NOT NULL DEFAULT 0, + "version" INTEGER NOT NULL DEFAULT 0, + "countryCode" VARCHAR(2), + "currencyCode" VARCHAR(3), + "operatingMode" VARCHAR(16), + "city" VARCHAR(100), + "address" VARCHAR(240), + "contactEmail" VARCHAR(254), + "logoUrl" VARCHAR(512), + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE "ClubPublicProfile" ( + "clubId" TEXT NOT NULL PRIMARY KEY REFERENCES "Club"("id") ON DELETE RESTRICT ON UPDATE CASCADE, + "draft" JSONB NOT NULL DEFAULT '{}'::jsonb, + "published" JSONB, + "status" "ClubPublicationStatus" NOT NULL DEFAULT 'HIDDEN', + "publishedAt" TIMESTAMP(3), + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE "ClubPublicRevision" ( + "id" TEXT NOT NULL PRIMARY KEY, + "clubId" TEXT NOT NULL REFERENCES "Club"("id") ON DELETE RESTRICT ON UPDATE CASCADE, + "content" JSONB NOT NULL, + "status" "ClubPublicReviewStatus" NOT NULL DEFAULT 'PENDING', + "submittedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "reviewedAt" TIMESTAMP(3), + "reviewerId" TEXT +); +CREATE INDEX "ClubPublicProfile_status_publishedAt_idx" ON "ClubPublicProfile"("status","publishedAt"); +CREATE INDEX "ClubPublicRevision_clubId_status_submittedAt_idx" ON "ClubPublicRevision"("clubId","status","submittedAt"); +CREATE INDEX "ClubPublicRevision_status_submittedAt_idx" ON "ClubPublicRevision"("status","submittedAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4785b5d..cc082ad 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -174,6 +174,12 @@ model Club { memberSequence Int @default(0) // IANA zone for the club's calendar-day attendance boundary; UTC is safe until configured. timeZone String @default("UTC") + vertical ClubVertical @default(OTHER) + setupStatus ClubSetupStatus @default(IN_PROGRESS) + setupCompletedAt DateTime? + setupProgress ClubSetupProgress? + publicProfile ClubPublicProfile? + publicRevisions ClubPublicRevision[] memberships ClubMembership[] invites ClubInvite[] @@ -408,3 +414,68 @@ model ClubAttendance { @@index([clubId, localDate, checkedInAt(sort: Desc)]) @@index([clubId, memberRecordId, checkedInAt(sort: Desc)]) } + + +enum ClubVertical { + GYM + YOGA + DANCE + SPORTS + OTHER +} +enum ClubSetupStatus { + IN_PROGRESS + READY + LEGACY_ACTIVE +} +enum ClubPublicationStatus { + HIDDEN + PENDING + UNLISTED + LISTED + SUSPENDED +} +enum ClubPublicReviewStatus { + PENDING + APPROVED + REJECTED +} + +model ClubSetupProgress { + clubId String @id + currentStep Int @default(0) + version Int @default(0) + countryCode String? @db.VarChar(2) + currencyCode String? @db.VarChar(3) + operatingMode String? @db.VarChar(16) + city String? @db.VarChar(100) + address String? @db.VarChar(240) + contactEmail String? @db.VarChar(254) + logoUrl String? @db.VarChar(512) + updatedAt DateTime @updatedAt + club Club @relation(fields: [clubId], references: [id], onDelete: Restrict) +} + +model ClubPublicProfile { + clubId String @id + draft Json @default("{}") + published Json? + status ClubPublicationStatus @default(HIDDEN) + publishedAt DateTime? + updatedAt DateTime @updatedAt + club Club @relation(fields: [clubId], references: [id], onDelete: Restrict) + @@index([status, publishedAt]) +} + +model ClubPublicRevision { + id String @id @default(cuid()) + clubId String + content Json + status ClubPublicReviewStatus @default(PENDING) + submittedAt DateTime @default(now()) + reviewedAt DateTime? + reviewerId String? + club Club @relation(fields: [clubId], references: [id], onDelete: Restrict) + @@index([clubId, status, submittedAt]) + @@index([status, submittedAt]) +} From 19255529be79985d645f3bde51fc874b6999dcf6 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Wed, 11 Nov 2026 16:18:19 +0530 Subject: [PATCH 03/28] feat(B3-C): persisted setup services, readiness guards and existing-plan reuse --- src/lib/club-onboarding.ts | 4 +- src/lib/club-setup.ts | 89 ++++++++++++++++++++++++++++++++++++++ src/lib/member-service.ts | 4 +- src/lib/plan-service.ts | 7 ++- 4 files changed, 98 insertions(+), 6 deletions(-) create mode 100644 src/lib/club-setup.ts diff --git a/src/lib/club-onboarding.ts b/src/lib/club-onboarding.ts index 7da875e..a352add 100644 --- a/src/lib/club-onboarding.ts +++ b/src/lib/club-onboarding.ts @@ -42,8 +42,8 @@ async function requireLiveClub( roles: ("OWNER" | "MANAGER" | "STAFF")[] ) { await requireClubRole(tx, userId, clubId, roles); - const club = await tx.club.findUnique({ where: { id: clubId }, select: { status: true } }); - if (club?.status !== "ACTIVE") throw new AccessDeniedError(); + const club = await tx.club.findUnique({ where: { id: clubId }, select: { status: true, setupStatus: true } }); + if (club?.status !== "ACTIVE" || club.setupStatus === "IN_PROGRESS") throw new AccessDeniedError(); } export async function createClub(db: PrismaClient, actorId: string, name: string) { diff --git a/src/lib/club-setup.ts b/src/lib/club-setup.ts new file mode 100644 index 0000000..a46f80e --- /dev/null +++ b/src/lib/club-setup.ts @@ -0,0 +1,89 @@ +import { z } from "zod"; +import type { PrismaClient } from "@/generated/prisma/client"; +import { requireClubRole, AccessDeniedError } from "@/lib/club-access"; +import { OnboardingError } from "@/lib/club-onboarding"; + +export const VERTICALS = ["GYM", "YOGA", "DANCE", "SPORTS", "OTHER"] as const; +const detailsInput = z.object({ + countryCode: z.string().regex(/^[A-Z]{2}$/), + currencyCode: z.string().regex(/^[A-Z]{3}$/), + operatingMode: z.enum(["PHYSICAL", "HYBRID", "ONLINE"]), + timeZone: z.string().min(3).max(80), + city: z.string().trim().max(100).optional().default(""), + address: z.string().trim().max(240).optional().default(""), + contactEmail: z.union([z.email().max(254), z.literal("")]).optional().default(""), +}).strict(); +const identityInput = z.object({ logoUrl: z.union([z.url().max(512).refine(v => v.startsWith("https://"), "HTTPS required"), z.literal("")]).optional().default("") }).strict(); +export const setupStepInput = z.discriminatedUnion("step", [ + z.object({step:z.literal("type"),vertical:z.enum(VERTICALS)}).strict(), + z.object({step:z.literal("details"),data:detailsInput}).strict(), + z.object({step:z.literal("identity"),data:identityInput}).strict(), +]); +export type SetupStepInput = z.infer; + +function validZone(zone: string): boolean { + try { new Intl.DateTimeFormat("en", {timeZone:zone}); return true; } catch { return false; } +} +async function requireOwner(db: PrismaClient, actorId:string, clubId:string) { + await requireClubRole(db, actorId, clubId, ["OWNER"]); + const club = await db.club.findFirst({where:{id:clubId,status:"ACTIVE"},select:{id:true,setupStatus:true}}); + if(!club) throw new AccessDeniedError(); + return club; +} +export async function loadSetup(db: PrismaClient, actorId:string,clubId:string) { + const club=await requireOwner(db,actorId,clubId); + const [record,plans]=await Promise.all([ + db.club.findUniqueOrThrow({where:{id:clubId},select:{name:true,vertical:true,timeZone:true,setupStatus:true,setupProgress:true}}), + db.clubPlan.findMany({where:{clubId,archivedAt:null},select:{id:true,name:true,currency:true},take:20}) + ]); + return {club:{id:clubId,...record},plans,alreadyReady:club.setupStatus!=="IN_PROGRESS"}; +} +export async function saveSetup(db:PrismaClient,actorId:string,clubId:string,input:SetupStepInput){ + await requireOwner(db,actorId,clubId); + return db.$transaction(async tx=>{ + const club=await tx.club.findUniqueOrThrow({where:{id:clubId},select:{setupStatus:true}}); + if(club.setupStatus!=="IN_PROGRESS") throw new OnboardingError(409,"Club setup is already completed"); + if(input.step==="type") { + await tx.club.update({where:{id:clubId},data:{vertical:input.vertical}}); + } else if(input.step==="details") { + if(!validZone(input.data.timeZone)) throw new OnboardingError(400,"Choose a valid IANA timezone"); + await tx.club.update({where:{id:clubId},data:{timeZone:input.data.timeZone}}); + } + const data=input.step==="details"?{ + countryCode:input.data.countryCode,currencyCode:input.data.currencyCode, + operatingMode:input.data.operatingMode,city:input.data.city||null, + address:input.data.address||null,contactEmail:input.data.contactEmail||null + }:input.step==="identity"?{logoUrl:input.data.logoUrl||null}:{}; + const currentStep=input.step==="type"?1:input.step==="details"?2:3; + return tx.clubSetupProgress.upsert({ + where:{clubId},create:{clubId,currentStep,...data}, + update:{...data,currentStep,version:{increment:1}} + }); + }); +} +export async function completeSetup(db:PrismaClient,actorId:string,clubId:string){ + await requireOwner(db,actorId,clubId); + return db.$transaction(async tx=>{ + // Update acquires a PostgreSQL row lock, serializing simultaneous completion. + await tx.club.update({where:{id:clubId},data:{updatedAt:new Date()}}); + const club=await tx.club.findUniqueOrThrow({ + where:{id:clubId},select:{status:true,setupStatus:true,vertical:true,timeZone:true,setupProgress:true} + }); + if(club.status!=="ACTIVE") throw new AccessDeniedError(); + if(club.setupStatus==="READY") return {ready:true}; + if(club.setupStatus==="LEGACY_ACTIVE") throw new OnboardingError(409,"Existing club is already operational"); + const progress=club.setupProgress; + if(!progress?.countryCode||!progress.currencyCode||!progress.operatingMode||!validZone(club.timeZone)) + throw new OnboardingError(409,"Complete club details before launching"); + const plan=await tx.clubPlan.findFirst({where:{clubId,archivedAt:null,currency:progress.currencyCode},select:{id:true}}); + if(!plan) throw new OnboardingError(409,"Create at least one membership plan in your chosen currency"); + await tx.club.update({where:{id:clubId},data:{setupStatus:"READY",setupCompletedAt:new Date()}}); + return {ready:true}; + }); +} +export async function requireSetupPlanPermission(db:PrismaClient,actorId:string,clubId:string,currency:string){ + const club=await requireOwner(db,actorId,clubId); + if(club.setupStatus!=="IN_PROGRESS") throw new OnboardingError(409,"Use the membership plan studio"); + const p=await db.clubSetupProgress.findUnique({where:{clubId}}); + if(!p?.currencyCode||currency!==p.currencyCode)throw new OnboardingError(409,"Choose club currency first; plan currency must match"); +} diff --git a/src/lib/member-service.ts b/src/lib/member-service.ts index b526934..3841d45 100644 --- a/src/lib/member-service.ts +++ b/src/lib/member-service.ts @@ -14,9 +14,9 @@ export async function requireClubActive( const context = await requireClubRole(tx, actorId, clubId, roles); const club = await tx.club.findFirst({ where: { id: clubId, status: "ACTIVE" }, - select: { id: true, businessTier: true }, + select: { id: true, businessTier: true, setupStatus: true }, }); - if (!club) throw new AccessDeniedError(); + if (!club || club.setupStatus === "IN_PROGRESS") throw new AccessDeniedError(); return { ...context, businessTier: club.businessTier }; } diff --git a/src/lib/plan-service.ts b/src/lib/plan-service.ts index 5219ff9..9d54404 100644 --- a/src/lib/plan-service.ts +++ b/src/lib/plan-service.ts @@ -1,6 +1,7 @@ import type { PrismaClient, PlanDurationUnit } from "@/generated/prisma/client"; import { OnboardingError } from "@/lib/club-onboarding"; import { requireClubActive } from "@/lib/member-service"; +import { requireSetupPlanPermission } from "@/lib/club-setup"; export type PlanInput = { name: string; @@ -15,10 +16,12 @@ export async function createPlan( db: PrismaClient, actorId: string, clubId: string, - input: PlanInput + input: PlanInput, + forSetup = false ) { + if (forSetup) await requireSetupPlanPermission(db, actorId, clubId, input.currency); return db.$transaction(async (tx) => { - await requireClubActive(tx, actorId, clubId, ["OWNER", "MANAGER"]); + if (!forSetup) await requireClubActive(tx, actorId, clubId, ["OWNER", "MANAGER"]); const plan = await tx.clubPlan.create({ data: { clubId, ...input }, select: { id: true, name: true }, From 54d9c7775a324674bdeb83146a52a44ac2dc0186 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Wed, 11 Nov 2026 16:19:38 +0530 Subject: [PATCH 04/28] feat(B3-D): guarded setup endpoints and required redirect after club creation --- .../clubs/[clubId]/setup/complete/route.ts | 16 ++++++++++ .../api/clubs/[clubId]/setup/plan/route.ts | 18 ++++++++++++ src/app/api/clubs/[clubId]/setup/route.ts | 29 +++++++++++++++++++ src/app/clubs/[clubId]/page.tsx | 8 +++-- src/components/clubs/create-club-form.tsx | 2 +- 5 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 src/app/api/clubs/[clubId]/setup/complete/route.ts create mode 100644 src/app/api/clubs/[clubId]/setup/plan/route.ts create mode 100644 src/app/api/clubs/[clubId]/setup/route.ts diff --git a/src/app/api/clubs/[clubId]/setup/complete/route.ts b/src/app/api/clubs/[clubId]/setup/complete/route.ts new file mode 100644 index 0000000..7a7c77e --- /dev/null +++ b/src/app/api/clubs/[clubId]/setup/complete/route.ts @@ -0,0 +1,16 @@ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { completeSetup } from "@/lib/club-setup"; +import { guardOnboardingPost, jsonPrivate, onboardingFailure, readOnboardingBody } from "@/lib/onboarding-http"; +import { idInput } from "@/lib/validations/onboarding"; +export async function POST(request:NextRequest,{params}:{params:Promise<{clubId:string}>}){ + try{ + const {clubId}=await params; + if(!idInput.safeParse(clubId).success)return jsonPrivate({error:"Not found"},404); + const guard=await guardOnboardingPost(request,"b3-complete",10,true); + if(!guard.ok)return guard.response; + const body=await readOnboardingBody(request); + if(!body||typeof body!=="object"||Object.keys(body).length!==0)return jsonPrivate({error:"Invalid request"},400); + return jsonPrivate(await completeSetup(prisma,guard.userId!,clubId)); + }catch(error){return onboardingFailure(error);} +} diff --git a/src/app/api/clubs/[clubId]/setup/plan/route.ts b/src/app/api/clubs/[clubId]/setup/plan/route.ts new file mode 100644 index 0000000..a1dacce --- /dev/null +++ b/src/app/api/clubs/[clubId]/setup/plan/route.ts @@ -0,0 +1,18 @@ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { createPlan } from "@/lib/plan-service"; +import { guardOnboardingPost, jsonPrivate, onboardingFailure, readOnboardingBody } from "@/lib/onboarding-http"; +import { idInput } from "@/lib/validations/onboarding"; +import { planInput } from "@/lib/validations/business"; +export async function POST(request:NextRequest,{params}:{params:Promise<{clubId:string}>}){ + try{ + const {clubId}=await params; + if(!idInput.safeParse(clubId).success)return jsonPrivate({error:"Not found"},404); + const guard=await guardOnboardingPost(request,"b3-first-plan",15,true); + if(!guard.ok)return guard.response; + const input=planInput.safeParse(await readOnboardingBody(request)); + if(!input.success)return jsonPrivate({error:"Check membership plan fields"},400); + const plan=await createPlan(prisma,guard.userId!,clubId,input.data,true); + return jsonPrivate({plan},201); + }catch(error){return onboardingFailure(error);} +} diff --git a/src/app/api/clubs/[clubId]/setup/route.ts b/src/app/api/clubs/[clubId]/setup/route.ts new file mode 100644 index 0000000..b05494c --- /dev/null +++ b/src/app/api/clubs/[clubId]/setup/route.ts @@ -0,0 +1,29 @@ +import { NextRequest } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { loadSetup, saveSetup, setupStepInput } from "@/lib/club-setup"; +import { guardOnboardingPost, jsonPrivate, onboardingFailure, readOnboardingBody } from "@/lib/onboarding-http"; +import { idInput } from "@/lib/validations/onboarding"; + +type Context={params:Promise<{clubId:string}>}; +export async function GET(request:NextRequest,{params}:Context){ + try{ + const {clubId}=await params; + if(!idInput.safeParse(clubId).success)return jsonPrivate({error:"Not found"},404); + const session=await auth.api.getSession({headers:request.headers}); + if(!session?.user?.emailVerified)return jsonPrivate({error:"Sign in to continue"},401); + return jsonPrivate(await loadSetup(prisma,session.user.id,clubId)); + }catch(error){return onboardingFailure(error);} +} +export async function PATCH(request:NextRequest,{params}:Context){ + try{ + const {clubId}=await params; + if(!idInput.safeParse(clubId).success)return jsonPrivate({error:"Not found"},404); + const guard=await guardOnboardingPost(request,"b3-setup",60,true); + if(!guard.ok)return guard.response; + const input=setupStepInput.safeParse(await readOnboardingBody(request)); + if(!input.success)return jsonPrivate({error:"Check the setup fields"},400); + const progress=await saveSetup(prisma,guard.userId!,clubId,input.data); + return jsonPrivate({ok:true,version:progress.version}); + }catch(error){return onboardingFailure(error);} +} diff --git a/src/app/clubs/[clubId]/page.tsx b/src/app/clubs/[clubId]/page.tsx index 57814dd..c06ec57 100644 --- a/src/app/clubs/[clubId]/page.tsx +++ b/src/app/clubs/[clubId]/page.tsx @@ -1,6 +1,6 @@ import { Suspense } from "react"; import Link from "next/link"; -import { notFound } from "next/navigation"; +import { notFound, redirect } from "next/navigation"; import type { Metadata } from "next"; import { ShieldCheck, Users, CalendarDays, Layers3, ArrowRight, ScanLine } from "lucide-react"; import { requireSession } from "@/lib/required-session"; @@ -32,8 +32,12 @@ async function ClubContent({ params }: { params: Promise<{ clubId: string }> }) } const club = await prisma.club.findFirst({ where: { id: clubId, status: "ACTIVE" }, - select: { id: true, name: true }, + select: { id: true, name: true, setupStatus: true }, }); + if (club?.setupStatus === "IN_PROGRESS") { + if (role === "OWNER") redirect(`/clubs/${clubId}/setup/type`); + notFound(); + } if (!club) notFound(); const canInvite = role === "OWNER" || role === "MANAGER"; const canOperate = role !== "MEMBER"; diff --git a/src/components/clubs/create-club-form.tsx b/src/components/clubs/create-club-form.tsx index 2e3a325..e013328 100644 --- a/src/components/clubs/create-club-form.tsx +++ b/src/components/clubs/create-club-form.tsx @@ -27,7 +27,7 @@ export function CreateClubForm() { setMessage(payload.error || "Could not create club"); return; } - router.replace(`/clubs/${encodeURIComponent(payload.club.id)}`); + router.replace(`/clubs/${encodeURIComponent(payload.club.id)}/setup/type`); router.refresh(); } catch { setMessage("Couldn't create your club. Please try again."); From eff9e9f66013c02b4a1e598a70d21bd4c858b6cd Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Wed, 11 Nov 2026 16:22:13 +0530 Subject: [PATCH 05/28] feat(B3-E): Luxe role-aware navigation and five-step guided owner setup --- src/app/clubs/[clubId]/setup/[step]/page.tsx | 17 +++ src/app/clubs/[clubId]/setup/page.tsx | 5 + src/components/clubs/business-shell.tsx | 143 +++++++++---------- src/components/clubs/setup-wizard.tsx | 103 +++++++++++++ src/components/clubs/theme-toggle.tsx | 13 ++ 5 files changed, 203 insertions(+), 78 deletions(-) create mode 100644 src/app/clubs/[clubId]/setup/[step]/page.tsx create mode 100644 src/app/clubs/[clubId]/setup/page.tsx create mode 100644 src/components/clubs/setup-wizard.tsx create mode 100644 src/components/clubs/theme-toggle.tsx diff --git a/src/app/clubs/[clubId]/setup/[step]/page.tsx b/src/app/clubs/[clubId]/setup/[step]/page.tsx new file mode 100644 index 0000000..b24de0a --- /dev/null +++ b/src/app/clubs/[clubId]/setup/[step]/page.tsx @@ -0,0 +1,17 @@ +import { redirect,notFound } from "next/navigation"; +import type { Metadata } from "next"; +import { requireSession } from "@/lib/required-session"; +import { prisma } from "@/lib/prisma"; +import { loadSetup } from "@/lib/club-setup"; +import { SetupWizard } from "@/components/clubs/setup-wizard"; +export const metadata:Metadata={title:"Finish setting up your club · ClubKeep",robots:{index:false}}; +const steps=["type","details","identity","membership","review"] as const; +export default async function SetupStep({params}:{params:Promise<{clubId:string;step:string}>}){ + const {clubId,step}=await params; + if(!steps.some(s=>s===step))notFound(); + const session=await requireSession(); + let initial; + try{initial=await loadSetup(prisma,session.user.id,clubId);}catch{notFound();} + if(initial.alreadyReady)redirect("/clubs/"+encodeURIComponent(clubId)); + return ; +} diff --git a/src/app/clubs/[clubId]/setup/page.tsx b/src/app/clubs/[clubId]/setup/page.tsx new file mode 100644 index 0000000..aa67557 --- /dev/null +++ b/src/app/clubs/[clubId]/setup/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; +export default async function SetupHome({params}:{params:Promise<{clubId:string}>}){ + const {clubId}=await params; + redirect("/clubs/"+encodeURIComponent(clubId)+"/setup/type"); +} diff --git a/src/components/clubs/business-shell.tsx b/src/components/clubs/business-shell.tsx index 852794f..c2cc898 100644 --- a/src/components/clubs/business-shell.tsx +++ b/src/components/clubs/business-shell.tsx @@ -1,86 +1,73 @@ import Link from "next/link"; -import { - ArrowLeft, - CalendarDays, - LayoutDashboard, - Layers3, - ScanLine, - UsersRound, -} from "lucide-react"; +import { House, ScanLine, UsersRound, Layers3, CalendarClock, Settings2, Globe2, ArrowLeft, Building2, Menu, Fingerprint } from "lucide-react"; +import { requireSession } from "@/lib/required-session"; +import { prisma } from "@/lib/prisma"; +import { requireActiveClubMembership } from "@/lib/club-access"; +import { ClubThemeToggle } from "@/components/clubs/theme-toggle"; -export function BusinessShell({ - clubId, - clubName, - active, - eyebrow, - title, - description, - children, -}: { - clubId: string; - clubName: string; - active: "members" | "plans" | "renewals" | "overview" | "attendance"; - eyebrow: string; - title: string; - description: string; - children: React.ReactNode; -}) { - const sections = [ - { key: "overview", label: "Overview", url: `/clubs/${clubId}`, icon: LayoutDashboard }, - { key: "attendance", label: "Check-in", url: `/clubs/${clubId}/attendance`, icon: ScanLine }, - { key: "members", label: "Members", url: `/clubs/${clubId}/members`, icon: UsersRound }, - { key: "plans", label: "Membership Plans", url: `/clubs/${clubId}/plans`, icon: Layers3 }, - { key: "renewals", label: "Renewals", url: `/clubs/${clubId}/renewals`, icon: CalendarDays }, - ] as const; +type Active = "members" | "plans" | "renewals" | "overview" | "attendance"; +export async function BusinessShell({ + clubId,clubName,active,eyebrow,title,description,children, +}:{ + clubId:string;clubName:string;active:Active;eyebrow:string;title:string;description:string;children:React.ReactNode; +}){ + const session=await requireSession(); + const context=await requireActiveClubMembership(prisma,session.user.id,clubId); + const isOwner=context.role==="OWNER"; + const nav=[ + {key:"overview",label:"Overview",href:"",icon:House}, + {key:"attendance",label:"Check-in",href:"/attendance",icon:ScanLine}, + {key:"members",label:"Members",href:"/members",icon:UsersRound}, + {key:"plans",label:"Membership Plans",href:"/plans",icon:Layers3}, + {key:"renewals",label:"Renewals",href:"/renewals",icon:CalendarClock}, + ...(isOwner?[{key:"profile",label:"Public Profile",href:"/settings/public-profile",icon:Globe2}, + {key:"settings",label:"Club Settings",href:"/settings",icon:Settings2}]:[]), + ]; + const base="/clubs/"+encodeURIComponent(clubId); return ( -
-
-
- - - CK - - - ClubKeep / Business - +
+
+
- -
- -
-
+ + ); } diff --git a/src/components/clubs/setup-wizard.tsx b/src/components/clubs/setup-wizard.tsx new file mode 100644 index 0000000..ca74c2b --- /dev/null +++ b/src/components/clubs/setup-wizard.tsx @@ -0,0 +1,103 @@ +"use client"; +import { useState, type FormEvent } from "react"; +import { useRouter } from "next/navigation"; +import { ArrowLeft, ArrowRight, CheckCircle2, Building2, Dumbbell, Flower2, Music2, Trophy, Layers3 } from "lucide-react"; + +const steps=["type","details","identity","membership","review"] as const; +type Step=typeof steps[number]; +type Initial={club:{id:string;name:string;vertical:string;timeZone:string;setupStatus:string;setupProgress:{ + countryCode:string|null;currencyCode:string|null;operatingMode:string|null;city:string|null;address:string|null;contactEmail:string|null;logoUrl:string|null; +}|null};plans:{id:string;name:string;currency:string}[]}; +const categories=[ + {value:"GYM",title:"Gym & Fitness",icon:Dumbbell,description:"Gyms, fitness studios and strength training"}, + {value:"YOGA",title:"Yoga & Wellness",icon:Flower2,description:"Yoga studios and wellness communities"}, + {value:"DANCE",title:"Dance Academy",icon:Music2,description:"Dance schools and movement academies"}, + {value:"SPORTS",title:"Sports Academy",icon:Trophy,description:"Sports clubs and athlete training"}, + {value:"OTHER",title:"General Club",icon:Building2,description:"Flexible clubs and community spaces"}, +] as const; +export function SetupWizard({clubId,initial,initialStep}:{clubId:string;initial:Initial;initialStep:Step}){ + const router=useRouter(); + const stepIndex=steps.indexOf(initialStep); + const current=initial.club.setupProgress; + const [vertical,setVertical]=useState(initial.club.vertical); + const [country,setCountry]=useState(current?.countryCode||""); + const [currency,setCurrency]=useState(current?.currencyCode||""); + const [zone,setZone]=useState(initial.club.timeZone==="UTC"?"":initial.club.timeZone); + const [mode,setMode]=useState(current?.operatingMode||"PHYSICAL"); + const [city,setCity]=useState(current?.city||""); + const [address,setAddress]=useState(current?.address||""); + const [email,setEmail]=useState(current?.contactEmail||""); + const [logo,setLogo]=useState(current?.logoUrl||""); + const [name,setName]=useState(""); + const [duration,setDuration]=useState("MONTHS"); + const [count,setCount]=useState("1"); + const [price,setPrice]=useState("0"); + const [busy,setBusy]=useState(false); + const [error,setError]=useState(""); + const [plans,setPlans]=useState(initial.plans); + const base="/clubs/"+encodeURIComponent(clubId); + function go(next:Step){router.push(base+"/setup/"+next);router.refresh();} + async function post(url:string,body:unknown,method="POST"){ + const response=await fetch(url,{method,credentials:"same-origin",cache:"no-store",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)}); + const data=await response.json(); + if(!response.ok)throw new Error(data.error||"Unable to save, please try again"); + return data; + } + async function onNext(event:FormEvent){ + event.preventDefault();if(busy)return;setBusy(true);setError(""); + try{ + if(initialStep==="type") await post(base.replace("/clubs/","/api/clubs/")+"/setup",{step:"type",vertical},"PATCH"); + if(initialStep==="details") await post("/api"+base+"/setup",{step:"details",data:{countryCode:country.trim().toUpperCase(),currencyCode:currency.trim().toUpperCase(),operatingMode:mode,timeZone:zone.trim(),city,address,contactEmail:email}},"PATCH"); + if(initialStep==="identity")await post("/api"+base+"/setup",{step:"identity",data:{logoUrl:logo}},"PATCH"); + if(initialStep==="membership"&&plans.length===0){ + if(!name.trim())throw new Error("Give your first plan a name"); + const minor=Math.round(Number(price)*100); + if(!Number.isSafeInteger(minor)||minor<0)throw new Error("Enter a valid nonnegative price"); + const result=await post("/api"+base+"/setup/plan",{name:name.trim(),description:"",durationUnit:duration,durationCount:Number(count),priceMinor:minor,currency:currency||current?.currencyCode||""}); + setPlans([...plans,{...result.plan,currency:currency||current?.currencyCode||""}]); + } + if(initialStep==="review"){await post("/api"+base+"/setup/complete",{});router.replace(base);router.refresh();return;} + go(steps[stepIndex+1]); + }catch(e){setError(e instanceof Error?e.message:"Unable to continue");}finally{setBusy(false);} + } + const btn="min-h-12 rounded-xl border border-[#f6dc9b]/25 bg-[#1b130d] px-4 text-sm text-[#ffe4c0] outline-offset-4 focus-visible:outline-2 focus-visible:outline-orange-400"; + const field="min-h-12 w-full rounded-xl border border-[#f6dc9b]/25 bg-[#0d0907] px-4 text-[#fff0dd] outline-offset-2 focus-visible:outline-2 focus-visible:outline-orange-400"; + const labels=["Your club","Club details","Make it yours","Membership plans","Ready to launch"]; + return
+
+
ClubKeep / BusinessStep {stepIndex+1} of 5
+
{steps.map((s,i)=>)}
+

{initial.club.name} · Required setup

+

{labels[stepIndex]}

+

Finish these essentials to open your club workspace. Your progress is saved after each step.

+
+ {initialStep==="type"&&
What kind of club are you running?
{categories.map(c=>)}
} + {initialStep==="details"&&
+ + + + + + + +
} + {initialStep==="identity"&&

ClubKeep will use a tasteful initials logo until you add your own. Public listings are private until you request review.

} + {initialStep==="membership"&&
+ {plans.length>0?

Membership plan ready

{plans.map(p=>p.name).join(", ")}

:<> +

Create one plan to launch. You can add more later, including free memberships.

+ + + + + } +
} + {initialStep==="review"&&

Almost there.

Your club is created, but operations remain locked until you launch. You can publish a public profile separately after launch.

Club: {initial.club.name}

Type: {initial.club.vertical}

Membership plans: {plans.length}

} + {error&&

{error}

} +
+ + +
+
+
+
; +} diff --git a/src/components/clubs/theme-toggle.tsx b/src/components/clubs/theme-toggle.tsx new file mode 100644 index 0000000..a1225e1 --- /dev/null +++ b/src/components/clubs/theme-toggle.tsx @@ -0,0 +1,13 @@ +"use client"; +import { useEffect,useState } from "react"; +import { Moon,Sun } from "lucide-react"; +export function ClubThemeToggle(){ + const [dark,setDark]=useState(true); + useEffect(()=>{setDark(document.documentElement.classList.contains("dark"));},[]); + return ; +} From e938c6af57f26af966210cbaf11ec43d2edd094b Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Wed, 11 Nov 2026 16:24:04 +0530 Subject: [PATCH 06/28] feat(B3-F): tenant-scoped public profile drafts, review snapshots and admin moderation APIs --- .../[revisionId]/[decision]/route.ts | 15 ++++ .../clubs/[clubId]/public-profile/route.ts | 28 +++++++ .../[clubId]/public-profile/submit/route.ts | 13 +++ .../[clubId]/public-profile/unlist/route.ts | 13 +++ src/lib/public-directory.ts | 81 +++++++++++++++++++ 5 files changed, 150 insertions(+) create mode 100644 src/app/api/admin/directory/[revisionId]/[decision]/route.ts create mode 100644 src/app/api/clubs/[clubId]/public-profile/route.ts create mode 100644 src/app/api/clubs/[clubId]/public-profile/submit/route.ts create mode 100644 src/app/api/clubs/[clubId]/public-profile/unlist/route.ts create mode 100644 src/lib/public-directory.ts diff --git a/src/app/api/admin/directory/[revisionId]/[decision]/route.ts b/src/app/api/admin/directory/[revisionId]/[decision]/route.ts new file mode 100644 index 0000000..a24d910 --- /dev/null +++ b/src/app/api/admin/directory/[revisionId]/[decision]/route.ts @@ -0,0 +1,15 @@ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { reviewProfile } from "@/lib/public-directory"; +import { guardOnboardingPost,jsonPrivate,onboardingFailure,readOnboardingBody } from "@/lib/onboarding-http"; +import { idInput } from "@/lib/validations/onboarding"; +export async function POST(request:NextRequest,{params}:{params:Promise<{revisionId:string;decision:string}>}){ + try{ + const {revisionId,decision}=await params; + if(!idInput.safeParse(revisionId).success||!["approve","reject"].includes(decision))return jsonPrivate({error:"Not found"},404); + const guard=await guardOnboardingPost(request,"b3-profile-review",20,true);if(!guard.ok)return guard.response; + const body=await readOnboardingBody(request); + if(!body||typeof body!=="object"||Object.keys(body).length!==0)return jsonPrivate({error:"Invalid request"},400); + return jsonPrivate(await reviewProfile(prisma,guard.userId!,revisionId,decision==="approve")); + }catch(error){return onboardingFailure(error);} +} diff --git a/src/app/api/clubs/[clubId]/public-profile/route.ts b/src/app/api/clubs/[clubId]/public-profile/route.ts new file mode 100644 index 0000000..bb2caca --- /dev/null +++ b/src/app/api/clubs/[clubId]/public-profile/route.ts @@ -0,0 +1,28 @@ +import { NextRequest } from "next/server"; +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { readOwnerProfile,saveOwnerProfile,publicDraftInput } from "@/lib/public-directory"; +import { guardOnboardingPost,jsonPrivate,onboardingFailure,readOnboardingBody } from "@/lib/onboarding-http"; +import { idInput } from "@/lib/validations/onboarding"; +type Context={params:Promise<{clubId:string}>}; +export async function GET(request:NextRequest,{params}:Context){ + try{ + const {clubId}=await params; + if(!idInput.safeParse(clubId).success)return jsonPrivate({error:"Not found"},404); + const session=await auth.api.getSession({headers:request.headers}); + if(!session?.user?.emailVerified)return jsonPrivate({error:"Sign in to continue"},401); + return jsonPrivate(await readOwnerProfile(prisma,session.user.id,clubId)); + }catch(error){return onboardingFailure(error);} +} +export async function PATCH(request:NextRequest,{params}:Context){ + try{ + const {clubId}=await params; + if(!idInput.safeParse(clubId).success)return jsonPrivate({error:"Not found"},404); + const guard=await guardOnboardingPost(request,"b3-profile-edit",50,true); + if(!guard.ok)return guard.response; + const data=publicDraftInput.safeParse(await readOnboardingBody(request)); + if(!data.success)return jsonPrivate({error:"Check your public profile"},400); + await saveOwnerProfile(prisma,guard.userId!,clubId,data.data); + return jsonPrivate({ok:true}); + }catch(error){return onboardingFailure(error);} +} diff --git a/src/app/api/clubs/[clubId]/public-profile/submit/route.ts b/src/app/api/clubs/[clubId]/public-profile/submit/route.ts new file mode 100644 index 0000000..7364f9b --- /dev/null +++ b/src/app/api/clubs/[clubId]/public-profile/submit/route.ts @@ -0,0 +1,13 @@ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { submitProfile } from "@/lib/public-directory"; +import { guardOnboardingPost,jsonPrivate,onboardingFailure,readOnboardingBody } from "@/lib/onboarding-http"; +import { idInput } from "@/lib/validations/onboarding"; +export async function POST(request:NextRequest,{params}:{params:Promise<{clubId:string}>}){ + try{ + const {clubId}=await params;if(!idInput.safeParse(clubId).success)return jsonPrivate({error:"Not found"},404); + const guard=await guardOnboardingPost(request,"b3-profile-submit",10,true);if(!guard.ok)return guard.response; + const body=await readOnboardingBody(request);if(!body||typeof body!=="object"||Object.keys(body).length!==0)return jsonPrivate({error:"Invalid request"},400); + return jsonPrivate(await submitProfile(prisma,guard.userId!,clubId),201); + }catch(error){return onboardingFailure(error);} +} diff --git a/src/app/api/clubs/[clubId]/public-profile/unlist/route.ts b/src/app/api/clubs/[clubId]/public-profile/unlist/route.ts new file mode 100644 index 0000000..3b9bc05 --- /dev/null +++ b/src/app/api/clubs/[clubId]/public-profile/unlist/route.ts @@ -0,0 +1,13 @@ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { hideOwnerProfile } from "@/lib/public-directory"; +import { guardOnboardingPost,jsonPrivate,onboardingFailure,readOnboardingBody } from "@/lib/onboarding-http"; +import { idInput } from "@/lib/validations/onboarding"; +export async function POST(request:NextRequest,{params}:{params:Promise<{clubId:string}>}){ + try{ + const {clubId}=await params;if(!idInput.safeParse(clubId).success)return jsonPrivate({error:"Not found"},404); + const guard=await guardOnboardingPost(request,"b3-profile-hide",10,true);if(!guard.ok)return guard.response; + const body=await readOnboardingBody(request);if(!body||typeof body!=="object"||Object.keys(body).length!==0)return jsonPrivate({error:"Invalid request"},400); + await hideOwnerProfile(prisma,guard.userId!,clubId);return jsonPrivate({ok:true}); + }catch(error){return onboardingFailure(error);} +} diff --git a/src/lib/public-directory.ts b/src/lib/public-directory.ts new file mode 100644 index 0000000..12fe58e --- /dev/null +++ b/src/lib/public-directory.ts @@ -0,0 +1,81 @@ +import { z } from "zod"; +import type { PrismaClient } from "@/generated/prisma/client"; +import { requireClubRole, AccessDeniedError } from "@/lib/club-access"; +import { OnboardingError } from "@/lib/club-onboarding"; + +const httpsUrl=z.union([z.literal(""),z.url().max(512).refine(value=>value.startsWith("https://"))]); +export const publicDraftInput=z.object({ + tagline:z.string().trim().max(140).default(""), + description:z.string().trim().max(1800).default(""), + city:z.string().trim().max(100).default(""), + website:httpsUrl.default(""), + contactEmail:z.union([z.literal(""),z.email().max(254)]).default(""), + listed:z.boolean().default(true), +}).strict(); +export type PublicDraft=z.infer; +export function validatedPublic(value:unknown):PublicDraft|null{ + const result=publicDraftInput.safeParse(value); + return result.success?result.data:null; +} +async function owner(db:PrismaClient,actorId:string,clubId:string){ + await requireClubRole(db,actorId,clubId,["OWNER"]); + const club=await db.club.findFirst({where:{id:clubId,status:"ACTIVE",setupStatus:{in:["READY","LEGACY_ACTIVE"]}},select:{id:true}}); + if(!club)throw new AccessDeniedError(); +} +export async function readOwnerProfile(db:PrismaClient,actorId:string,clubId:string){ + await owner(db,actorId,clubId); + const [club,profile]=await Promise.all([ + db.club.findUniqueOrThrow({where:{id:clubId},select:{id:true,name:true,vertical:true,slug:true}}), + db.clubPublicProfile.findUnique({where:{clubId},select:{draft:true,status:true,publishedAt:true}}), + ]); + return {club,profile:{draft:validatedPublic(profile?.draft)||publicDraftInput.parse({}),status:profile?.status||"HIDDEN",publishedAt:profile?.publishedAt||null}}; +} +export async function saveOwnerProfile(db:PrismaClient,actorId:string,clubId:string,data:PublicDraft){ + await owner(db,actorId,clubId); + await db.clubPublicProfile.upsert({ + where:{clubId},create:{clubId,draft:data},update:{draft:data} + }); +} +export async function submitProfile(db:PrismaClient,actorId:string,clubId:string){ + await owner(db,actorId,clubId); + return db.$transaction(async tx=>{ + const profile=await tx.clubPublicProfile.findUnique({where:{clubId}}); + if(!profile||profile.status==="SUSPENDED")throw new OnboardingError(409,"Profile unavailable for publication"); + const data=validatedPublic(profile.draft); + if(!data?.tagline||!data.description||!data.city)throw new OnboardingError(409,"Add a tagline, description and city before submitting"); + const already=await tx.clubPublicRevision.findFirst({where:{clubId,status:"PENDING"},select:{id:true}}); + if(already)throw new OnboardingError(409,"A profile revision is already awaiting review"); + const revision=await tx.clubPublicRevision.create({data:{clubId,content:data},select:{id:true}}); + if(!profile.published)await tx.clubPublicProfile.update({where:{clubId},data:{status:"PENDING"}}); + return {revisionId:revision.id}; + }); +} +export async function hideOwnerProfile(db:PrismaClient,actorId:string,clubId:string){ + await owner(db,actorId,clubId); + await db.$transaction(async tx=>{ + await tx.clubPublicProfile.updateMany({where:{clubId,status:{not:"SUSPENDED"}},data:{status:"HIDDEN"}}); + await tx.clubPublicRevision.updateMany({where:{clubId,status:"PENDING"},data:{status:"REJECTED",reviewedAt:new Date()}}); + }); +} +export async function platformAdmin(db:PrismaClient,actorId:string){ + const admin=await db.user.findUnique({where:{id:actorId},select:{platformRole:true}}); + if(admin?.platformRole!=="PLATFORM_ADMIN")throw new AccessDeniedError(); +} +export async function reviewProfile(db:PrismaClient,actorId:string,revisionId:string,approve:boolean){ + await platformAdmin(db,actorId); + return db.$transaction(async tx=>{ + const revision=await tx.clubPublicRevision.findUnique({where:{id:revisionId},include:{club:{select:{id:true,status:true,setupStatus:true}}}}); + if(!revision||revision.status!=="PENDING")throw new OnboardingError(409,"Revision already reviewed"); + if(revision.club.status!=="ACTIVE"||revision.club.setupStatus==="IN_PROGRESS")throw new OnboardingError(409,"Club unavailable"); + const changed=await tx.clubPublicRevision.updateMany({where:{id:revisionId,status:"PENDING"},data:{status:approve?"APPROVED":"REJECTED",reviewerId:actorId,reviewedAt:new Date()}}); + if(changed.count!==1)throw new OnboardingError(409,"Revision already reviewed"); + const data=validatedPublic(revision.content); + if(approve){ + if(!data)throw new OnboardingError(409,"Invalid profile content"); + await tx.clubPublicProfile.update({where:{clubId:revision.clubId},data:{published:data,status:data.listed?"LISTED":"UNLISTED",publishedAt:new Date()}}); + }else{ + await tx.clubPublicProfile.updateMany({where:{clubId:revision.clubId,published:null},data:{status:"HIDDEN"}}); + } + return {ok:true}; + }); +} From d4dee3cbfacbf25fb28b071a3dc7db46d3204c86 Mon Sep 17 00:00:00 2001 From: "Sameer .I. Siddiqui" Date: Wed, 11 Nov 2026 16:26:46 +0530 Subject: [PATCH 07/28] feat(B3-G): public club discovery, canonical profiles and owner publication workspace --- src/app/c/[slug]/page.tsx | 32 +++++++++++ src/app/clubs/[clubId]/settings/page.tsx | 28 ++++++++++ .../[clubId]/settings/public-profile/page.tsx | 15 ++++++ src/app/discover/clubs/page.tsx | 33 ++++++++++++ .../clubs/public-profile-editor.tsx | 54 +++++++++++++++++++ 5 files changed, 162 insertions(+) create mode 100644 src/app/c/[slug]/page.tsx create mode 100644 src/app/clubs/[clubId]/settings/page.tsx create mode 100644 src/app/clubs/[clubId]/settings/public-profile/page.tsx create mode 100644 src/app/discover/clubs/page.tsx create mode 100644 src/components/clubs/public-profile-editor.tsx diff --git a/src/app/c/[slug]/page.tsx b/src/app/c/[slug]/page.tsx new file mode 100644 index 0000000..188a3b2 --- /dev/null +++ b/src/app/c/[slug]/page.tsx @@ -0,0 +1,32 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import type { Metadata } from "next"; +import { prisma } from "@/lib/prisma"; +import { validatedPublic } from "@/lib/public-directory"; +import { Globe2, MapPin, Mail, ArrowLeft, ShieldCheck } from "lucide-react"; +async function getClub(slug:string){ + return prisma.club.findUnique({where:{slug},select:{name:true,slug:true,vertical:true,status:true,setupStatus:true,publicProfile:{select:{status:true,published:true}}}}); +} +export async function generateMetadata({params}:{params:Promise<{slug:string}>}):Promise{ + const {slug}=await params;const club=await getClub(slug); + if(!club||club.status!=="ACTIVE"||club.setupStatus==="IN_PROGRESS"||!["LISTED","UNLISTED"].includes(club.publicProfile?.status||""))return {title:"Club not found",robots:{index:false}}; + const p=validatedPublic(club.publicProfile?.published); + return {title:club.name+" · ClubKeep",description:p?.tagline||"Discover this club on ClubKeep",robots:{index:club.publicProfile?.status==="LISTED",follow:club.publicProfile?.status==="LISTED"}}; +} +export default async function ClubPublic({params}:{params:Promise<{slug:string}>}){ + const {slug}=await params;const club=await getClub(slug); + if(!club||club.status!=="ACTIVE"||club.setupStatus==="IN_PROGRESS"||!["LISTED","UNLISTED"].includes(club.publicProfile?.status||""))notFound(); + const p=validatedPublic(club.publicProfile?.published);if(!p)notFound(); + return
+
Explore clubsClubKeep
+
+

{club.vertical.toLowerCase().replace("_"," ")}

{club.name}

{p.tagline}

{p.description}

+
{p.city} + {p.website&&Website} + {p.contactEmail&&Email club} +
+
+

This is a club-owned public profile, not a verified business identity or membership application. Club membership requires an independent invitation and verified account.

+
Powered by ClubKeep · BabaBuilds
+
; +} diff --git a/src/app/clubs/[clubId]/settings/page.tsx b/src/app/clubs/[clubId]/settings/page.tsx new file mode 100644 index 0000000..a6027f6 --- /dev/null +++ b/src/app/clubs/[clubId]/settings/page.tsx @@ -0,0 +1,28 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import type { Metadata } from "next"; +import { requireSession } from "@/lib/required-session"; +import { requireClubRole } from "@/lib/club-access"; +import { prisma } from "@/lib/prisma"; +import { BusinessShell } from "@/components/clubs/business-shell"; +export const metadata:Metadata={title:"Club settings · ClubKeep",robots:{index:false}}; +export default async function ClubSettings({params}:{params:Promise<{clubId:string}>}){ + const {clubId}=await params;const session=await requireSession(); + try{await requireClubRole(prisma,session.user.id,clubId,["OWNER"]);}catch{notFound();} + const club=await prisma.club.findFirst({where:{id:clubId,status:"ACTIVE",setupStatus:{in:["READY","LEGACY_ACTIVE"]}},select:{id:true,name:true,slug:true,vertical:true,timeZone:true,setupProgress:true}}); + if(!club)notFound(); + return +
+

Club identity

+
Name
{club.name}
+
Club type
{club.vertical.toLowerCase()}
+
Timezone
{club.timeZone}
+
Country / Currency
{club.setupProgress?.countryCode??"Not configured"} / {club.setupProgress?.currencyCode??"Not configured"}
+
+

Public presence

Your listing is private unless you submit it and the platform approves it.

+ Manage public profile +
+
+

Membership pricing, existing attendance timezone data and historical subscriptions are never silently rewritten by a club identity change.

+
; +} diff --git a/src/app/clubs/[clubId]/settings/public-profile/page.tsx b/src/app/clubs/[clubId]/settings/public-profile/page.tsx new file mode 100644 index 0000000..7c88408 --- /dev/null +++ b/src/app/clubs/[clubId]/settings/public-profile/page.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { requireSession } from "@/lib/required-session"; +import { prisma } from "@/lib/prisma"; +import { readOwnerProfile } from "@/lib/public-directory"; +import { BusinessShell } from "@/components/clubs/business-shell"; +import { PublicProfileEditor } from "@/components/clubs/public-profile-editor"; +export const metadata:Metadata={title:"Public club profile · ClubKeep",robots:{index:false}}; +export default async function OwnerPublicProfile({params}:{params:Promise<{clubId:string}>}){ + const {clubId}=await params;const session=await requireSession(); + let data;try{data=await readOwnerProfile(prisma,session.user.id,clubId);}catch{notFound();} + return + + ; +} diff --git a/src/app/discover/clubs/page.tsx b/src/app/discover/clubs/page.tsx new file mode 100644 index 0000000..1486e7e --- /dev/null +++ b/src/app/discover/clubs/page.tsx @@ -0,0 +1,33 @@ +import Link from "next/link"; +import type { Metadata } from "next"; +import { prisma } from "@/lib/prisma"; +import { validatedPublic } from "@/lib/public-directory"; +import { Dumbbell, Flower2, Music2, Trophy, Building2, Search, MapPin, ArrowRight } from "lucide-react"; +export const metadata:Metadata={title:"Discover clubs · ClubKeep",description:"Discover gyms, yoga and dance studios, sports academies and local clubs."}; +type Query={q?:string;type?:string;cursor?:string}; +const icons={GYM:Dumbbell,YOGA:Flower2,DANCE:Music2,SPORTS:Trophy,OTHER:Building2} as const; +const categories=["GYM","YOGA","DANCE","SPORTS","OTHER"] as const; +export default async function Discover({searchParams}:{searchParams:Promise}){ + const input=await searchParams; + const q=(input.q||"").trim().slice(0,80); + const type=categories.find(x=>x===input.type); + const cursor=input.cursor&&/^[a-zA-Z0-9_-]{10,128}$/.test(input.cursor)?input.cursor:undefined; + const base={status:"LISTED" as const,club:{status:"ACTIVE" as const,setupStatus:{in:["READY","LEGACY_ACTIVE"] as const},...(q?{name:{contains:q,mode:"insensitive" as const}}:{}),...(type?{vertical:type}:{})}}; + const items=await prisma.clubPublicProfile.findMany({where:base,orderBy:[{publishedAt:"desc"},{clubId:"desc"}],take:13,...(cursor?{cursor:{clubId:cursor},skip:1}:{}),select:{clubId:true,published:true,publishedAt:true,club:{select:{name:true,slug:true,vertical:true}}}}); + const more=items.length>12;const list=items.slice(0,12);const next=list[list.length-1]?.clubId; + return
+
ClubKeepDiscoverSign in
+

Find your people

Every community starts somewhere.

Explore gyms, yoga studios, dance academies, sports clubs and more. Only owner-submitted and approved profiles appear here.

+
+ + + +
+ {list.length?
{list.map(item=>{const data=validatedPublic(item.published);const Icon=icons[item.club.vertical];return +
+

{item.club.vertical.toLowerCase()}

{item.club.name}

{data?.tagline||"A place to belong."}

{data?.city||"Location not published"}

+ })}
:

No clubs found

Try another name or category. New clubs appear only after approval.

} + {more&&next&&Explore more clubs } +
ClubKeep by BabaBuilds · Discover a community, keep your membership private.
+
; +} diff --git a/src/components/clubs/public-profile-editor.tsx b/src/components/clubs/public-profile-editor.tsx new file mode 100644 index 0000000..74d3cae --- /dev/null +++ b/src/components/clubs/public-profile-editor.tsx @@ -0,0 +1,54 @@ +"use client"; +import { useState,type FormEvent } from "react"; +import { useRouter } from "next/navigation"; +import { Globe2, ShieldCheck, EyeOff, Send, Save } from "lucide-react"; +type Draft={tagline:string;description:string;city:string;website:string;contactEmail:string;listed:boolean}; +export function PublicProfileEditor({clubId,slug,initial,status}:{clubId:string;slug:string;initial:Draft;status:string}){ + const router=useRouter();const [draft,setDraft]=useState(initial);const [busy,setBusy]=useState(false);const [error,setError]=useState("");const [note,setNote]=useState(""); + const base="/api/clubs/"+encodeURIComponent(clubId)+"/public-profile"; + const field="mt-2 w-full min-h-12 rounded-xl border border-[#f6dc9b]/20 bg-[#110b08] px-4 py-3 text-[#fff2e3] focus-visible:outline-2 focus-visible:outline-[#f26b00]"; + async function mutation(url:string,body:unknown,method:string){ + const res=await fetch(url,{method,headers:{"Content-Type":"application/json"},credentials:"same-origin",cache:"no-store",body:JSON.stringify(body)}); + const data=await res.json();if(!res.ok)throw new Error(data.error||"Unable to save");return data; + } + async function save(event?:FormEvent){ + event?.preventDefault();if(busy)return;setBusy(true);setError("");setNote(""); + try{await mutation(base,draft,"PATCH");setNote("Draft saved. Publication requires a separate review.");router.refresh();} + catch(e){setError(e instanceof Error?e.message:"Save failed");}finally{setBusy(false);} + } + async function action(which:"submit"|"unlist"){ + if(busy)return;setBusy(true);setError("");setNote(""); + try{ + if(which==="submit")await mutation(base,draft,"PATCH"); + await mutation(base+"/"+which,{},"POST"); + setNote(which==="submit"?"Sent for review. Your previously approved profile stays unchanged.":"Public profile hidden."); + router.refresh(); + }catch(e){setError(e instanceof Error?e.message:"Action failed");}finally{setBusy(false);} + } + const update=(key:keyof Draft,value:string|boolean)=>setDraft(old=>({...old,[key]:value})); + return
+
+

Public directory

Tell your club's story

Only what you explicitly submit and the platform approves becomes public.

+ +