diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3785a7e..d5f03f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -204,6 +204,8 @@ jobs: run: node --test tests/integration/business-schema.test.mjs - name: Validate B2 attendance constraints and tenant isolation on PostgreSQL 17 run: node --test tests/integration/attendance-schema.test.mjs + - name: Validate B3 schema constraints and public revision safety on PostgreSQL 17 + run: node --test tests/integration/b3-schema.test.mjs - name: One-time platform administrator provisioning on isolated PostgreSQL 17 run: node --test tests/integration/platform-bootstrap.test.mjs - name: Legacy unversioned Better Auth baseline and populated upgrade 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. 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..64ad4a1 --- /dev/null +++ b/prisma/migrations/20261111120000_b3_unified_experience/migration.sql @@ -0,0 +1,49 @@ +-- 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, + "typeConfirmed" BOOLEAN NOT NULL DEFAULT false, + "detailsConfirmed" BOOLEAN NOT NULL DEFAULT false, + "identityConfirmed" BOOLEAN NOT NULL DEFAULT false, + "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 +); +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, + "city" VARCHAR(100), + "status" "ClubPublicationStatus" NOT NULL DEFAULT 'HIDDEN', + "publishedAt" TIMESTAMP(3), + "updatedAt" TIMESTAMP(3) NOT NULL +); +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 "ClubPublicProfile_status_city_publishedAt_idx" ON "ClubPublicProfile"("status","city","publishedAt"); +CREATE INDEX "ClubPublicRevision_clubId_status_submittedAt_idx" ON "ClubPublicRevision"("clubId","status","submittedAt"); +CREATE INDEX "ClubPublicRevision_status_submittedAt_idx" ON "ClubPublicRevision"("status","submittedAt"); +CREATE UNIQUE INDEX "ClubPublicRevision_one_pending_per_club" ON "ClubPublicRevision" ("clubId") WHERE "status"='PENDING'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4785b5d..a17c5d3 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,73 @@ 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) + typeConfirmed Boolean @default(false) + detailsConfirmed Boolean @default(false) + identityConfirmed Boolean @default(false) + 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? + city String? @db.VarChar(100) + status ClubPublicationStatus @default(HIDDEN) + publishedAt DateTime? + updatedAt DateTime @updatedAt + club Club @relation(fields: [clubId], references: [id], onDelete: Restrict) + @@index([status, publishedAt]) + @@index([status, city, 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]) +} diff --git a/src/app/admin/directory/page.tsx b/src/app/admin/directory/page.tsx new file mode 100644 index 0000000..2e3888f --- /dev/null +++ b/src/app/admin/directory/page.tsx @@ -0,0 +1,80 @@ +import { Suspense } from "react"; +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { requireSession } from "@/lib/required-session"; +import { prisma } from "@/lib/prisma"; +import { platformAdmin, validatedPublic } from "@/lib/public-directory"; +import { DirectoryReview } from "@/components/admin/directory-review"; +import { DirectoryVisibility } from "@/components/admin/directory-visibility"; +export const metadata: Metadata = { + title: "Directory moderation · ClubKeep Admin", + robots: { index: false }, +}; +export default function DirectoryAdmin() { + return ( + Loading ClubKeep… + } + > + + + ); +} + +async function DirectoryAdminContent() { + const session = await requireSession(); + try { + await platformAdmin(prisma, session.user.id); + } catch { + notFound(); + } + const visible = await prisma.clubPublicProfile.findMany({ + where: { status: { in: ["LISTED", "UNLISTED", "SUSPENDED"] } }, + select: { clubId: true, status: true, club: { select: { name: true } } }, + take: 50, + orderBy: { updatedAt: "desc" }, + }); + const revisions = await prisma.clubPublicRevision.findMany({ + where: { status: "PENDING" }, + orderBy: { submittedAt: "asc" }, + take: 50, + select: { + id: true, + clubId: true, + content: true, + submittedAt: true, + club: { select: { name: true } }, + }, + }); + return ( +
+
+ + ← ClubKeep + +

+ Platform administration · Restricted +

+

Public directory moderation

+

+ Approve only business-owned content suitable for publication. Approval is not a + verification badge. +

+ ({ + id: x.id, + clubId: x.clubId, + clubName: x.club.name, + content: validatedPublic(x.content), + submittedAt: x.submittedAt.toISOString(), + }))} + /> + ({ clubId: x.clubId, status: x.status, name: x.club.name }))} + /> +
+
+ ); +} diff --git a/src/app/api/admin/club-profile/[clubId]/[action]/route.ts b/src/app/api/admin/club-profile/[clubId]/[action]/route.ts new file mode 100644 index 0000000..9a90486 --- /dev/null +++ b/src/app/api/admin/club-profile/[clubId]/[action]/route.ts @@ -0,0 +1,36 @@ +import { NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { moderateProfileVisibility } 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; action: string }> } +) { + try { + const { clubId, action } = await params; + if (!idInput.safeParse(clubId).success || !["suspend", "reinstate"].includes(action)) + return jsonPrivate({ error: "Not found" }, 404); + const guard = await guardOnboardingPost(request, "b3-profile-moderation", 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 moderateProfileVisibility( + prisma, + guard.userId!, + clubId, + action as "suspend" | "reinstate" + ) + ); + } catch (error) { + return onboardingFailure(error); + } +} 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..e2aa2fd --- /dev/null +++ b/src/app/api/admin/directory/[revisionId]/[decision]/route.ts @@ -0,0 +1,30 @@ +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..8470271 --- /dev/null +++ b/src/app/api/clubs/[clubId]/public-profile/route.ts @@ -0,0 +1,37 @@ +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..e8d8199 --- /dev/null +++ b/src/app/api/clubs/[clubId]/public-profile/submit/route.ts @@ -0,0 +1,27 @@ +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..00c6d18 --- /dev/null +++ b/src/app/api/clubs/[clubId]/public-profile/unlist/route.ts @@ -0,0 +1,28 @@ +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/app/api/clubs/[clubId]/settings/route.ts b/src/app/api/clubs/[clubId]/settings/route.ts new file mode 100644 index 0000000..9cf93bb --- /dev/null +++ b/src/app/api/clubs/[clubId]/settings/route.ts @@ -0,0 +1,54 @@ +import { NextRequest } from "next/server"; +import { z } from "zod"; +import { prisma } from "@/lib/prisma"; +import { requireClubRole, AccessDeniedError } from "@/lib/club-access"; +import { + guardOnboardingPost, + jsonPrivate, + onboardingFailure, + readOnboardingBody, +} from "@/lib/onboarding-http"; +import { idInput } from "@/lib/validations/onboarding"; +import { VERTICALS } from "@/lib/club-setup"; + +const input = z + .object({ + vertical: z.enum(VERTICALS), + countryCode: z.string().regex(/^[A-Z]{2}$/), + city: z.string().trim().max(100), + }) + .strict(); +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ clubId: string }> } +) { + try { + const { clubId } = await params; + if (!idInput.safeParse(clubId).success) return jsonPrivate({ error: "Club not found" }, 404); + const guard = await guardOnboardingPost(request, "b3-settings", 20, true); + if (!guard.ok) return guard.response; + const parsed = input.safeParse(await readOnboardingBody(request)); + if (!parsed.success) return jsonPrivate({ error: "Check club type, country and city" }, 400); + await prisma.$transaction(async (tx) => { + await requireClubRole(tx, guard.userId!, clubId, ["OWNER"]); + const club = await tx.club.findFirst({ + where: { id: clubId, status: "ACTIVE", setupStatus: { in: ["READY", "LEGACY_ACTIVE"] } }, + select: { id: true }, + }); + if (!club) throw new AccessDeniedError(); + await tx.club.update({ where: { id: clubId }, data: { vertical: parsed.data.vertical } }); + await tx.clubSetupProgress.upsert({ + where: { clubId }, + create: { clubId, countryCode: parsed.data.countryCode, city: parsed.data.city || null }, + update: { + countryCode: parsed.data.countryCode, + city: parsed.data.city || null, + version: { increment: 1 }, + }, + }); + }); + return jsonPrivate({ ok: true }); + } catch (error) { + return onboardingFailure(error); + } +} 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..ef45da4 --- /dev/null +++ b/src/app/api/clubs/[clubId]/setup/complete/route.ts @@ -0,0 +1,27 @@ +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..92135f6 --- /dev/null +++ b/src/app/api/clubs/[clubId]/setup/plan/route.ts @@ -0,0 +1,28 @@ +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..d0d8860 --- /dev/null +++ b/src/app/api/clubs/[clubId]/setup/route.ts @@ -0,0 +1,38 @@ +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/c/[slug]/page.tsx b/src/app/c/[slug]/page.tsx new file mode 100644 index 0000000..5e30175 --- /dev/null +++ b/src/app/c/[slug]/page.tsx @@ -0,0 +1,132 @@ +import { Suspense } from "react"; +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 function ClubPublic({ params }: { params: Promise<{ slug: string }> }) { + return ( + Loading ClubKeep… + } + > + + + ); +} + +async function ClubPublicContent({ 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 clubs + + ClubKeep +
+
+
+
+

+ {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]/page.tsx b/src/app/clubs/[clubId]/page.tsx index 57814dd..bc316ee 100644 --- a/src/app/clubs/[clubId]/page.tsx +++ b/src/app/clubs/[clubId]/page.tsx @@ -1,20 +1,19 @@ 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 { ScanLine, IdCard, ShieldCheck } from "lucide-react"; import { requireSession } from "@/lib/required-session"; import { prisma } from "@/lib/prisma"; import { requireActiveClubMembership } from "@/lib/club-access"; import { ClubManager } from "@/components/clubs/club-manager"; - +import { BusinessShell } from "@/components/clubs/business-shell"; export const metadata: Metadata = { title: "Your club · ClubKeep", robots: { index: false } }; - export default function ClubPage({ params }: { params: Promise<{ clubId: string }> }) { return ( Opening your club… +
Opening your club…
} > @@ -22,161 +21,122 @@ export default function ClubPage({ params }: { params: Promise<{ clubId: string ); } async function ClubContent({ params }: { params: Promise<{ clubId: string }> }) { - const session = await requireSession(); const { clubId } = await params; - let role: "OWNER" | "MANAGER" | "STAFF" | "MEMBER"; + const session = await requireSession(); + let context; try { - role = (await requireActiveClubMembership(prisma, session.user.id, clubId)).role; + context = await requireActiveClubMembership(prisma, session.user.id, clubId); } catch { notFound(); } const club = await prisma.club.findFirst({ where: { id: clubId, status: "ACTIVE" }, - select: { id: true, name: true }, + select: { id: true, name: true, setupStatus: true, vertical: true }, }); if (!club) notFound(); - const canInvite = role === "OWNER" || role === "MANAGER"; - const canOperate = role !== "MEMBER"; - const members = canInvite - ? await prisma.clubMembership.findMany({ - where: { clubId, status: "ACTIVE" }, - select: { id: true, role: true, joinedAt: true, user: { select: { name: true } } }, - orderBy: { joinedAt: "desc" }, - take: 100, - }) - : []; - const invites = canInvite - ? await prisma.clubInvite.findMany({ - where: { clubId }, - select: { - id: true, - expiresAt: true, - claimedAt: true, - revokedAt: true, - claimedBy: { select: { name: true } }, - }, - orderBy: { createdAt: "desc" }, - take: 50, - }) - : []; - return ( -
-
-
- - - CK - - ClubKeep - - - Back to your space + if (club.setupStatus === "IN_PROGRESS") { + if (context.role === "OWNER") redirect("/clubs/" + encodeURIComponent(clubId) + "/setup/type"); + notFound(); + } + if (context.role === "MEMBER") + return ( +
+
+ + ← My clubs -
-

- ClubKeep Business · {role.toLowerCase()} -

-

{club.name}

-

- Your private club space. Access is isolated from your Personal account and other clubs. -

- {canOperate && ( - - )} - {canInvite ? ( - ({ - id: invite.id, - expiresAt: invite.expiresAt.toISOString(), - claimedAt: invite.claimedAt?.toISOString() ?? null, - revokedAt: invite.revokedAt?.toISOString() ?? null, - claimedBy: invite.claimedBy?.name ?? null, - }))} - initialMembers={members.map((member) => ({ - id: member.id, - name: member.user.name, - role: member.role, - joinedAt: member.joinedAt.toISOString(), - }))} - /> - ) : role === "STAFF" ? ( -
-
- ) : ( -
-
- )} -
-
+ + + + ); + const canInvite = context.role === "OWNER" || context.role === "MANAGER"; + const [members, invites] = canInvite + ? await Promise.all([ + prisma.clubMembership.findMany({ + where: { clubId, status: "ACTIVE" }, + select: { id: true, role: true, joinedAt: true, user: { select: { name: true } } }, + take: 100, + orderBy: { joinedAt: "desc" }, + }), + prisma.clubInvite.findMany({ + where: { clubId }, + select: { + id: true, + expiresAt: true, + claimedAt: true, + revokedAt: true, + claimedBy: { select: { name: true } }, + }, + take: 50, + orderBy: { createdAt: "desc" }, + }), + ]) + : [[], []]; + return ( + + {club.setupStatus === "LEGACY_ACTIVE" && context.role === "OWNER" && ( +

+ + This club predates guided setup. Existing operations remain available; review its settings + before public publication. +

+ )} + {context.role === "STAFF" ? ( +
+ +

Your reception desk is ready

+

+ Welcome members and keep attendance accurate. Owner settings remain protected. +

+ + Open check-in + +
+ ) : ( + ({ + id: x.id, + expiresAt: x.expiresAt.toISOString(), + claimedAt: x.claimedAt?.toISOString() ?? null, + revokedAt: x.revokedAt?.toISOString() ?? null, + claimedBy: x.claimedBy?.name ?? null, + }))} + initialMembers={members.map((x) => ({ + id: x.id, + name: x.user.name, + role: x.role, + joinedAt: x.joinedAt.toISOString(), + }))} + /> + )} +
); } diff --git a/src/app/clubs/[clubId]/settings/page.tsx b/src/app/clubs/[clubId]/settings/page.tsx new file mode 100644 index 0000000..27b8ce7 --- /dev/null +++ b/src/app/clubs/[clubId]/settings/page.tsx @@ -0,0 +1,80 @@ +import { Suspense } from "react"; +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"; +import { ClubSettingsEditor } from "@/components/clubs/club-settings-editor"; +export const metadata: Metadata = { title: "Club settings · ClubKeep", robots: { index: false } }; +export default function ClubSettings({ params }: { params: Promise<{ clubId: string }> }) { + return ( + Loading ClubKeep… + } + > + + + ); +} + +async function ClubSettingsContent({ 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 ( + +
+ +
+

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..649ca5a --- /dev/null +++ b/src/app/clubs/[clubId]/settings/public-profile/page.tsx @@ -0,0 +1,51 @@ +import { Suspense } from "react"; +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 function OwnerPublicProfile({ params }: { params: Promise<{ clubId: string }> }) { + return ( + Loading ClubKeep… + } + > + + + ); +} + +async function OwnerPublicProfileContent({ 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/clubs/[clubId]/setup/[step]/page.tsx b/src/app/clubs/[clubId]/setup/[step]/page.tsx new file mode 100644 index 0000000..d5abf2e --- /dev/null +++ b/src/app/clubs/[clubId]/setup/[step]/page.tsx @@ -0,0 +1,43 @@ +import { Suspense } from "react"; +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 function SetupStep({ + params, +}: { + params: Promise<{ clubId: string; step: string }>; +}) { + return ( + Loading ClubKeep… + } + > + + + ); +} + +async function SetupStepContent({ 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..bbeabf5 --- /dev/null +++ b/src/app/clubs/[clubId]/setup/page.tsx @@ -0,0 +1,18 @@ +import { Suspense } from "react"; +import { redirect } from "next/navigation"; +export default function SetupHome({ params }: { params: Promise<{ clubId: string }> }) { + return ( + Loading ClubKeep… + } + > + + + ); +} + +async function SetupHomeContent({ params }: { params: Promise<{ clubId: string }> }) { + const { clubId } = await params; + return redirect("/clubs/" + encodeURIComponent(clubId) + "/setup/type"); +} diff --git a/src/app/discover/clubs/page.tsx b/src/app/discover/clubs/page.tsx new file mode 100644 index 0000000..7e485a7 --- /dev/null +++ b/src/app/discover/clubs/page.tsx @@ -0,0 +1,217 @@ +import { Suspense } from "react"; +import Link from "next/link"; +import type { Metadata } from "next"; +import { prisma } from "@/lib/prisma"; +import type { Prisma } from "@/generated/prisma/client"; +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; city?: 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 function Discover({ searchParams }: { searchParams: Promise }) { + return ( + Loading ClubKeep… + } + > + + + ); +} + +async function DiscoverContent({ searchParams }: { searchParams: Promise }) { + const input = await searchParams; + const q = (input.q || "").trim().slice(0, 80); + const city = (input.city || "").trim().slice(0, 100); + 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: Prisma.ClubPublicProfileWhereInput = { + status: "LISTED", + ...(city ? { city: { contains: city, mode: "insensitive" } } : {}), + club: { + status: "ACTIVE", + setupStatus: { in: ["READY", "LEGACY_ACTIVE"] }, + ...(q ? { name: { contains: q, mode: "insensitive" } } : {}), + ...(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 ( +
+
+
+ + ClubKeepDiscover + + + Sign 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/app/home/page.tsx b/src/app/home/page.tsx index 3ac9aa9..1cdcb8a 100644 --- a/src/app/home/page.tsx +++ b/src/app/home/page.tsx @@ -111,6 +111,18 @@ async function ClubKeepHomeContent() { )} +
+

Discover nearby communities

+

+ Explore owner-approved public gyms, yoga studios, dance schools and sports academies. +

+ + Explore clubs → + +
(null); + const [error, setError] = useState(""); + async function decide(id: string, decision: "approve" | "reject") { + if (busy) return; + setBusy(id); + setError(""); + try { + const res = await fetch("/api/admin/directory/" + encodeURIComponent(id) + "/" + decision, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: "{}", + }); + const payload = await res.json(); + if (!res.ok) throw new Error(payload.error || "Review unsuccessful"); + router.refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : "Review failed"); + } finally { + setBusy(null); + } + } + return ( +
+ {error && ( +

+ {error} +

+ )} + {items.length === 0 ? ( +
+ No public listing submissions awaiting review. +
+ ) : ( + items.map((item) => ( +
+
+

{item.clubName}

+ + {new Date(item.submittedAt).toLocaleDateString()} + +
+

+ {item.content?.tagline || "Invalid content"} +

+

+ {item.content?.description} +

+

+ City: {item.content?.city} ·{" "} + {item.content?.listed ? "Directory listing" : "Unlisted profile"} +

+
+ + +
+
+ )) + )} +
+ ); +} diff --git a/src/components/admin/directory-visibility.tsx b/src/components/admin/directory-visibility.tsx new file mode 100644 index 0000000..3902064 --- /dev/null +++ b/src/components/admin/directory-visibility.tsx @@ -0,0 +1,68 @@ +"use client"; +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { EyeOff, RotateCcw } from "lucide-react"; +type Entry = { clubId: string; name: string; status: string }; +export function DirectoryVisibility({ items }: { items: Entry[] }) { + const router = useRouter(); + const [busy, setBusy] = useState(null), + [error, setError] = useState(""); + async function act(clubId: string, action: "suspend" | "reinstate") { + if (busy) return; + setBusy(clubId); + setError(""); + try { + const res = await fetch( + "/api/admin/club-profile/" + encodeURIComponent(clubId) + "/" + action, + { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: "{}", + } + ); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Moderation failed"); + router.refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : "Moderation failed"); + } finally { + setBusy(null); + } + } + return ( +
+

Published and suspended profiles

+

+ Suspend an unsafe listing immediately; reinstate only previously approved content. +

+ {error && ( +

+ {error} +

+ )} +
    + {items.map((x) => ( +
  • + + {x.name} + {x.status.toLowerCase()} + + +
  • + ))} +
+
+ ); +} diff --git a/src/components/clubs/business-shell.tsx b/src/components/clubs/business-shell.tsx index 852794f..186e683 100644 --- a/src/components/clubs/business-shell.tsx +++ b/src/components/clubs/business-shell.tsx @@ -1,14 +1,24 @@ import Link from "next/link"; import { - ArrowLeft, - CalendarDays, - LayoutDashboard, - Layers3, + 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({ +type Active = "members" | "plans" | "renewals" | "overview" | "attendance"; +export async function BusinessShell({ clubId, clubName, active, @@ -19,68 +29,153 @@ export function BusinessShell({ }: { clubId: string; clubName: string; - active: "members" | "plans" | "renewals" | "overview" | "attendance"; + active: Active; 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; + 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 ( -
-
-
- - +
+
+
- -
+
+ -
-
+ + ); } diff --git a/src/components/clubs/club-settings-editor.tsx b/src/components/clubs/club-settings-editor.tsx new file mode 100644 index 0000000..0ef5d13 --- /dev/null +++ b/src/components/clubs/club-settings-editor.tsx @@ -0,0 +1,113 @@ +"use client"; +import { useState, type FormEvent } from "react"; +import { useRouter } from "next/navigation"; +import { Save, Building2 } from "lucide-react"; +const names = { + GYM: "Gym & Fitness", + YOGA: "Yoga & Wellness", + DANCE: "Dance Academy", + SPORTS: "Sports Academy", + OTHER: "General Club", +} as const; +type ClubType = keyof typeof names; +export function ClubSettingsEditor({ + clubId, + initial, +}: { + clubId: string; + initial: { vertical: ClubType; countryCode: string; city: string }; +}) { + const router = useRouter(); + const [vertical, setVertical] = useState(initial.vertical); + const [country, setCountry] = useState(initial.countryCode); + const [city, setCity] = useState(initial.city); + const [busy, setBusy] = useState(false), + [message, setMessage] = useState(""); + const field = + "mt-2 min-h-12 w-full rounded-xl border border-[#f6dc9b]/25 bg-[#0b0705] px-4 text-[#fff0df] outline-offset-2 focus-visible:outline-2 focus-visible:outline-orange-400"; + async function submit(e: FormEvent) { + e.preventDefault(); + if (busy) return; + setBusy(true); + setMessage(""); + try { + const res = await fetch("/api/clubs/" + encodeURIComponent(clubId) + "/settings", { + method: "PATCH", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + vertical, + countryCode: country.trim().toUpperCase(), + city: city.trim(), + }), + }); + const json = await res.json(); + if (!res.ok) throw new Error(json.error || "Unable to save"); + setMessage("Club settings saved."); + router.refresh(); + } catch (err) { + setMessage(err instanceof Error ? err.message : "Unable to save"); + } finally { + setBusy(false); + } + } + return ( +
+ +

Club category and location

+

+ Adjust your club type without rewriting historical attendance, plans or membership records. +

+
+ + + +
+ {message && ( +

+ {message} +

+ )} + + + ); +} 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."); diff --git a/src/components/clubs/public-profile-editor.tsx b/src/components/clubs/public-profile-editor.tsx new file mode 100644 index 0000000..ef1b0b3 --- /dev/null +++ b/src/components/clubs/public-profile-editor.tsx @@ -0,0 +1,218 @@ +"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. +

+
+ +