Guest Profiles Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Implement the guest profile system. Guests scan QR → create profile (nickname, origin, mood tags, optional OAuth) → access table PWA. All social features link to guestProfiles.
Architecture: Profile is created on QR scan. OAuth via Clerk's Google/Facebook providers links profile to a persistent identity. Guest wall is a real-time grid via Convex subscriptions. Reactions are anonymous.
Tech Stack: Next.js 16 App Router, Convex (real-time DB + storage), Clerk (OAuth), nuqs for URL state, Tailwind CSS v4, Framer Motion (mood filter transitions, reaction animations).
Spec reference: docs/superpowers/specs/05-guest-profiles.md
Business Summary
What this does: Enables guests to create profiles by scanning a QR code at their table, capturing nickname, origin, and mood tags. Guests can see who else is at the venue tonight on the Guest Wall and send anonymous reactions (wave, cheers, heart) to each other. OAuth via Google/Facebook allows returning guests to access their profile across visits.
Why it matters: Creates social engagement and a sense of community among guests. Anonymous reactions encourage interaction without social pressure, making the venue feel lively and connected. The Guest Wall provides FOMO (fear of missing out) for future bookings and encourages word-of-mouth. OAuth enables returning guests to maintain persistent profiles for a more personalized experience.
Time to implement: 5-8 days | Complexity: Medium
Dependencies: Foundation plan (Convex schema with guestProfiles/guestReactions tables, auth helpers)
File Map
convex/
├── schema.ts # MODIFY — add guestProfiles, guestReactions
└── functions/
└── profiles.ts # CREATE — profile CRUD, OAuth linkage, reactions
apps/frontend/
├── app/[locale]/
│ └── onboard/
│ └── page.tsx # CREATE — profile creation form (?tableId=&token=)
├── components/
│ ├── profile/
│ │ ├── profile-form.tsx # CREATE — nickname, origin, mood form
│ │ ├── mood-selector.tsx # CREATE — mood tag multi-select
│ │ ├── guest-wall.tsx # CREATE — who's here grid
│ │ └── reaction-button.tsx # CREATE — wave/cheers/heart buttons
│ └── table/
│ └── table-tabs.tsx # MODIFY — add Guest Wall tabPhase 1: Schema — Guest Profile + Reactions
Task 1: Add Guest Profile Tables to Schema
[P0 GAP]: guestProfiles and guestReactions tables must be added to convex/schema.ts before any profile functions can be implemented. See P0 Gaps section below.
Files:
-
Modify:
convex/schema.ts -
Step 1: Read existing schema
cat convex/schema.ts- Step 2: Add
guestProfilesandguestReactionstables
guestProfiles: defineTable({
reservationId: v.optional(v.id("reservations")),
tableId: v.optional(v.id("tables")),
token: v.string(),
googleId: v.optional(v.string()),
facebookId: v.optional(v.string()),
email: v.optional(v.string()),
avatarUrl: v.optional(v.string()),
nickname: v.string(),
origin: v.string(),
moodTags: v.array(v.string()),
bio: v.optional(v.string()),
showDate: v.string(),
checkedIn: v.boolean(),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_reservation", ["reservationId"])
.index("by_show_date", ["showDate"])
.index("by_token", ["token"])
.index("by_google_id", ["googleId"])
.index("by_facebook_id", ["facebookId"]),
guestReactions: defineTable({
fromProfileId: v.id("guestProfiles"),
toProfileId: v.id("guestProfiles"),
reactionType: v.union(v.literal("WAVE"), v.literal("CHEERS"), v.literal("HEART")),
showDate: v.string(),
createdAt: v.number(),
})
.index("by_to_profile", ["toProfileId"])
.index("by_from_profile", ["fromProfileId"])
.index("by_show_date", ["showDate"]),- Step 3: Commit
git add convex/schema.ts
git commit -m "feat(profiles): add guestProfiles and guestReactions tables"Phase 2: Profile Convex Functions
Task 2: Create Profile Functions
Files:
-
Create:
convex/functions/profiles.ts -
Step 1: Create profile CRUD functions
import { query, mutation } from "../_generated/server";
import { v } from "convex/values";
import { Id } from "~/convex/_generated/dataModel";
export const create = mutation({
args: {
tableId: v.optional(v.id("tables")),
token: v.string(),
nickname: v.string(),
origin: v.string(),
moodTags: v.array(v.string()),
googleId: v.optional(v.string()),
facebookId: v.optional(v.string()),
email: v.optional(v.string()),
avatarUrl: v.optional(v.string()),
},
handler: async (ctx, args) => {
const now = Date.now();
const today = new Date().toISOString().split("T")[0];
return await ctx.db.insert("guestProfiles", {
tableId: args.tableId,
token: args.token,
nickname: args.nickname,
origin: args.origin,
moodTags: args.moodTags,
googleId: args.googleId,
facebookId: args.facebookId,
email: args.email,
avatarUrl: args.avatarUrl,
bio: undefined,
showDate: today,
checkedIn: false,
createdAt: now,
updatedAt: now,
});
},
});
export const getByToken = query({
args: { token: v.string() },
handler: async (ctx, { token }) => {
const profiles = await ctx.db
.query("guestProfiles")
.withIndex("by_token", (q) => q.eq("token", token))
.first();
return profiles;
},
});
export const getTonight = query({
args: {},
handler: async (ctx) => {
const today = new Date().toISOString().split("T")[0];
return await ctx.db
.query("guestProfiles")
.withIndex("by_show_date", (q) => q.eq("showDate", today))
.collect();
},
});
export const sendReaction = mutation({
args: {
fromProfileId: v.id("guestProfiles"),
toProfileId: v.id("guestProfiles"),
reactionType: v.union(
v.literal("WAVE"),
v.literal("CHEERS"),
v.literal("HEART"),
),
},
handler: async (ctx, { fromProfileId, toProfileId, reactionType }) => {
if (fromProfileId === toProfileId) {
throw new Error("Cannot react to yourself");
}
const now = Date.now();
const today = new Date().toISOString().split("T")[0];
return await ctx.db.insert("guestReactions", {
fromProfileId,
toProfileId,
reactionType,
showDate: today,
createdAt: now,
});
},
});
export const getReactionsToMe = query({
args: { profileId: v.id("guestProfiles") },
handler: async (ctx, { profileId }) => {
return await ctx.db
.query("guestReactions")
.withIndex("by_to_profile", (q) => q.eq("toProfileId", profileId))
.collect();
},
});- Step 2: Commit
git add convex/functions/profiles.ts
git commit -m "feat(profiles): add profile CRUD and reaction functions"Phase 3: Profile Creation UI
Task 3: Create Onboarding Page
Files:
-
Create:
apps/frontend/app/[locale]/onboard/page.tsx -
Create:
apps/frontend/components/profile/profile-form.tsx -
Create:
apps/frontend/components/profile/mood-selector.tsx -
Step 1: Create mood selector component
"use client";
import { useTranslations } from "next-intl";
const MOOD_OPTIONS = [
{ value: "LOOKING_FOR_DATE", labelKey: "moods.lookingForDate" },
{ value: "GET_DRUNK", labelKey: "moods.getDrunk" },
{ value: "FIRST_TIME", labelKey: "moods.firstTime" },
{ value: "REGULAR", labelKey: "moods.regular" },
{ value: "CELEBRATING", labelKey: "moods.celebrating" },
{ value: "GOOD_FRIENDS", labelKey: "moods.goodFriends" },
{ value: "SOLO", labelKey: "moods.solo" },
{ value: "WITH_FAMILY", labelKey: "moods.withFamily" },
];
export function MoodSelector({
selected,
onChange,
}: {
selected: string[];
onChange: (tags: string[]) => void;
}) {
const t = useTranslations("onboard");
const toggle = (value: string) => {
if (selected.includes(value)) {
onChange(selected.filter((s) => s !== value));
} else if (selected.length < 3) {
onChange([...selected, value]);
}
};
return (
<div className="flex flex-wrap gap-2">
{MOOD_OPTIONS.map((opt) => {
const isSelected = selected.includes(opt.value);
return (
<button
key={opt.value}
onClick={() => toggle(opt.value)}
disabled={!isSelected && selected.length >= 3}
className={`px-3 py-1.5 rounded-full text-sm border transition-colors ${
isSelected
? "bg-accent/20 border-accent text-accent"
: "bg-surface border-border text-gray-400 hover:border-accent/50 disabled:opacity-30"
}`}
>
{t(opt.labelKey)}
</button>
);
})}
</div>
);
}- Step 2: Create profile form
"use client";
import { useState } from "react";
import { useMutation } from "convex/react";
import { useRouter } from "next/navigation";
import { useQueryState } from "nuqs";
import { useTranslations } from "next-intl";
import { useLocale } from "next-intl";
import { api } from "~/convex/_generated/api";
import { z } from "zod";
import { consola } from "consola";
const ProfileFormSchema = z.object({
nickname: z.string().min(2, "Nickname must be at least 2 characters").max(20, "Nickname must be at most 20 characters"),
origin: z.string().min(1, "Please enter where you are from"),
moodTags: z.array(z.string()).max(3),
});
export function ProfileForm() {
const t = useTranslations("onboard");
const router = useRouter();
const locale = useLocale();
const [tableId] = useQueryState("tableId", { defaultValue: "" });
const [token] = useQueryState("token", { defaultValue: "" });
const [nickname, setNickname] = useState("");
const [origin, setOrigin] = useState("");
const [moodTags, setMoodTags] = useState<string[]>([]);
const [step, setStep] = useState<"form" | "oauth" | "success">("form");
const [errors, setErrors] = useState<Record<string, string>>({});
const createProfile = useMutation(api.profiles.create);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
const parsed = ProfileFormSchema.safeParse({ nickname, origin, moodTags });
if (!parsed.success) {
const fieldErrors: Record<string, string> = {};
parsed.error.errors.forEach((err) => {
const field = String(err.path[0]);
fieldErrors[field] = err.message;
});
setErrors(fieldErrors);
return;
}
try {
await createProfile({
tableId: tableId || undefined,
token: token || "",
nickname,
origin,
moodTags,
});
setStep("success");
setTimeout(() => {
const redirectUrl = tableId && token
? `/${locale}/table?tableId=${tableId}&token=${token}`
: `/${locale}`;
router.push(redirectUrl);
}, 1500);
} catch (err) {
consola.error("Failed to create profile", { error: err });
}
};
if (step === "success") {
return (
<div className="text-center py-12">
<div className="w-16 h-16 rounded-full bg-accent/20 flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-accent" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-2xl font-serif text-accent mb-2">{t("welcome")}</h2>
<p className="text-gray-400 mt-2">{t("redirecting")}</p>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
{t("nicknameLabel")}
</label>
<input
required
minLength={2}
maxLength={20}
value={nickname}
onChange={(e) => setNickname(e.target.value)}
className={`w-full bg-surface border rounded-lg px-4 py-3 text-white ${
errors.nickname ? "border-red-500" : "border-border"
}`}
placeholder={t("nicknamePlaceholder")}
/>
{errors.nickname && (
<p className="text-red-500 text-sm mt-1">{errors.nickname}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
{t("originLabel")}
</label>
<input
required
value={origin}
onChange={(e) => setOrigin(e.target.value)}
className={`w-full bg-surface border rounded-lg px-4 py-3 text-white ${
errors.origin ? "border-red-500" : "border-border"
}`}
placeholder={t("originPlaceholder")}
/>
{errors.origin && (
<p className="text-red-500 text-sm mt-1">{errors.origin}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
{t("moodLabel")}
</label>
<MoodSelector selected={moodTags} onChange={setMoodTags} />
</div>
{/* OAuth section */}
<div className="pt-4 border-t border-border">
<p className="text-sm text-gray-400 mb-3">
{t("oauthPrompt")}
</p>
<div className="flex gap-3">
<button type="button" className="flex-1 py-2 border border-border rounded-lg text-white hover:border-accent">
{t("continueWithGoogle")}
</button>
<button type="button" className="flex-1 py-2 border border-border rounded-lg text-white hover:border-accent">
{t("continueWithFacebook")}
</button>
</div>
</div>
<button
type="submit"
className="w-full bg-accent text-black font-bold py-3 rounded-lg"
>
{t("enterExperience")}
</button>
</form>
);
}- Step 3: Create onboarding page
"use client";
import { useQueryState } from "nuqs";
import { ProfileForm } from "~/components/profile/profile-form";
import { useTranslations } from "next-intl";
import { Suspense } from "react";
function OnboardContent() {
const t = useTranslations("onboard");
const [tableId] = useQueryState("tableId", { defaultValue: "" });
const [token] = useQueryState("token", { defaultValue: "" });
if (!tableId || !token) {
return <div className="text-center py-24 text-gray-400">{t("invalidQr")}</div>;
}
return (
<div className="min-h-screen bg-background pt-24 px-4">
<div className="max-w-md mx-auto">
<h1 className="font-serif text-3xl text-accent text-center mb-2">
{t("welcomeTitle")}
</h1>
<p className="text-gray-400 text-center mb-8">
{t("welcomeSubtitle")}
</p>
<ProfileForm />
</div>
</div>
);
}
export default function OnboardPage() {
return (
<Suspense fallback={<div className="min-h-screen bg-background pt-24 px-4"><div className="max-w-md mx-auto animate-pulse space-y-6"><div className="h-8 bg-surface rounded w-2/3 mx-auto" /><div className="h-48 bg-surface rounded" /></div></div>}>
<OnboardContent />
</Suspense>
);
}- Step 4: Commit
git add apps/frontend/app/[locale]/onboard/ apps/frontend/components/profile/
git commit -m "feat(profiles): add guest onboarding page and profile form"Phase 4: Guest Wall — Who's Here Tonight
Task 4: Create Guest Wall Component
Files:
-
Create:
apps/frontend/components/profile/guest-wall.tsx -
Create:
apps/frontend/components/profile/reaction-button.tsx -
Step 1: Create guest wall component
"use client";
import { useState } from "react";
import { useQuery, useMutation } from "convex/react";
import { api } from "~/convex/_generated/api";
import { useTranslations } from "next-intl";
import { Id } from "~/convex/_generated/dataModel";
import { consola } from "consola";
const REACTION_TYPES = [
{ type: "WAVE" as const, iconPath: "M7 8l4 4-4 4M13 4h4m0 16h-4", labelKey: "wave" },
{ type: "CHEERS" as const, iconPath: "M9 12l2 2 4-4M15 12a3 3 0 11-6 0 3 3 0 016 0z", labelKey: "cheers" },
{ type: "HEART" as const, iconPath: "M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z", labelKey: "heart" },
];
export function GuestWall({ myProfileId }: { myProfileId: Id<"guestProfiles"> }) {
const t = useTranslations("guestWall");
const guests = useQuery(api.profiles.getTonight, {});
const reactionsToMe = useQuery(api.profiles.getReactionsToMe, { profileId: myProfileId });
const sendReaction = useMutation(api.profiles.sendReaction);
const [filterMood, setFilterMood] = useState<string | null>(null);
const filtered = filterMood
? guests?.filter((g) => g.moodTags.includes(filterMood))
: guests;
const handleReact = async (toProfileId: Id<"guestProfiles">, type: "WAVE" | "CHEERS" | "HEART") => {
try {
await sendReaction({
fromProfileId: myProfileId,
toProfileId,
reactionType: type,
});
} catch (err) {
consola.error("Failed to send reaction", { error: err });
}
};
return (
<div>
{/* Reaction notifications */}
{reactionsToMe && reactionsToMe.length > 0 && (
<div className="mb-4 p-3 bg-accent/10 border border-accent/30 rounded-lg">
<p className="text-sm text-accent">
{t("reactionsReceived", { count: reactionsToMe.length })}
</p>
</div>
)}
{/* Mood filter */}
<div className="flex gap-2 mb-4 overflow-x-auto pb-2">
<button
onClick={() => setFilterMood(null)}
className={`px-3 py-1 rounded-full text-xs whitespace-nowrap ${!filterMood ? "bg-accent text-black" : "bg-surface text-gray-400"}`}
>
{t("filterAll")}
</button>
{["FIRST_TIME", "REGULAR", "CELEBRATING", "LOOKING_FOR_DATE"].map((tag) => (
<button
key={tag}
onClick={() => setFilterMood(tag)}
className={`px-3 py-1 rounded-full text-xs whitespace-nowrap ${filterMood === tag ? "bg-accent text-black" : "bg-surface text-gray-400"}`}
>
{t(`moodTags.${tag.toLowerCase()}`, { defaultValue: tag })}
</button>
))}
</div>
{/* Guest grid */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{filtered?.map((guest) => (
<GuestCard
key={guest._id}
guest={guest}
canReact={guest._id !== myProfileId}
onReact={(type) => handleReact(guest._id, type)}
/>
))}
</div>
</div>
);
}
type Guest = {
_id: Id<"guestProfiles">;
nickname: string;
origin: string;
moodTags: string[];
};
type GuestCardProps = {
guest: Guest;
canReact: boolean;
onReact: (type: "WAVE" | "CHEERS" | "HEART") => void;
};
function GuestCard({ guest, canReact, onReact }: GuestCardProps) {
const t = useTranslations("guestWall");
return (
<div className="bg-surface border border-border p-3 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<div className="w-10 h-10 rounded-full bg-accent/20 flex items-center justify-center text-accent font-bold">
{guest.nickname[0].toUpperCase()}
</div>
<div>
<p className="font-medium text-white text-sm">{guest.nickname}</p>
<p className="text-xs text-gray-400">{guest.origin}</p>
</div>
</div>
{/* Mood tags */}
<div className="flex flex-wrap gap-1 mb-2">
{guest.moodTags.slice(0, 2).map((tag: string) => (
<span key={tag} className="text-xs px-1.5 py-0.5 bg-accent/10 text-accent rounded">
{t(`moodTags.${tag.toLowerCase()}`, { defaultValue: tag })}
</span>
))}
</div>
{/* Badges */}
{guest.moodTags.includes("FIRST_TIME") && (
<span className="text-xs text-blue-400">{t("badges.firstTimer")}</span>
)}
{guest.moodTags.includes("REGULAR") && (
<span className="text-xs text-purple-400">{t("badges.regular")}</span>
)}
{/* Reaction buttons */}
{canReact && (
<div className="mt-2 flex gap-1">
{REACTION_TYPES.map((r) => (
<button
key={r.type}
onClick={() => onReact(r.type)}
className="flex-1 py-1 text-xs bg-surface-2 rounded hover:bg-accent/20"
title={t(`reactionLabels.${r.labelKey}`)}
>
<svg className="w-4 h-4 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={r.iconPath} />
</svg>
</button>
))}
</div>
)}
</div>
);
}- Step 2: Commit
git add apps/frontend/components/profile/guest-wall.tsx apps/frontend/components/profile/reaction-button.tsx
git commit -m "feat(profiles): add guest wall with reactions"Enrichment Sections
1. Zod Schemas
import { z } from "zod";
// Profile creation
const CreateProfileSchema = z.object({
tableId: z.string().optional(),
token: z.string().min(1),
nickname: z
.string()
.min(2, "Nickname must be at least 2 characters")
.max(20, "Nickname must be at most 20 characters"),
origin: z.string().min(1, "Please enter where you are from").max(100),
moodTags: z.array(z.string()).max(3).default([]),
googleId: z.string().optional(),
facebookId: z.string().optional(),
email: z.string().email().optional(),
avatarUrl: z.string().url().optional(),
});
// Reaction sending
const SendReactionSchema = z.object({
fromProfileId: z.string().min(1),
toProfileId: z.string().min(1),
reactionType: z.enum(["WAVE", "CHEERS", "HEART"]),
});
// Guest profile (returned from queries)
const GuestProfileSchema = z.object({
_id: z.string(),
nickname: z.string(),
origin: z.string(),
moodTags: z.array(z.string()),
showDate: z.string(),
checkedIn: z.boolean(),
avatarUrl: z.string().nullable(),
token: z.string(),
reservationId: z.string().optional(),
tableId: z.string().optional(),
});
// Guest reaction
const GuestReactionSchema = z.object({
_id: z.string(),
fromProfileId: z.string(),
toProfileId: z.string(),
reactionType: z.enum(["WAVE", "CHEERS", "HEART"]),
showDate: z.string(),
createdAt: z.number(),
});
// Onboard page params
const OnboardParamsSchema = z.object({
tableId: z.string().min(1),
token: z.string().min(1),
});2. Error Handling
Named error codes constant object with as const:
// Error codes namespace
export const PROFILE_ERROR_CODES = {
INVALID_TOKEN: "INVALID_TOKEN",
DUPLICATE_PROFILE: "DUPLICATE_PROFILE",
CANNOT_REACT_SELF: "CANNOT_REACT_SELF",
PROFILE_NOT_FOUND: "PROFILE_NOT_FOUND",
TOKEN_EXPIRED: "TOKEN_EXPIRED",
} as const;
export type ProfileErrorCode =
(typeof PROFILE_ERROR_CODES)[keyof typeof PROFILE_ERROR_CODES];| Mutation | Error Code | Error Message |
|---|---|---|
profiles.create | INVALID_TOKEN | "Invalid or expired QR code" |
profiles.create | DUPLICATE_PROFILE | "A profile already exists for this reservation" |
profiles.sendReaction | CANNOT_REACT_SELF | "You cannot react to yourself" |
profiles.sendReaction | PROFILE_NOT_FOUND | "Guest profile not found" |
profiles.getByToken | TOKEN_EXPIRED | "QR code has expired" |
3. Convex Real-time Subscription Pattern
// Guest wall — live updates when new profiles are created
const guests = useQuery(api.profiles.getTonight, {});
// Reactions to me — live updates when someone reacts
const reactionsToMe = useQuery(api.profiles.getReactionsToMe, {
profileId: myProfileId,
});
// Individual profile — live updates on check-in status
const myProfile = useQuery(api.profiles.getByToken, token ? { token } : "skip");
// Filtered guests by mood — client-side filter on pre-fetched data
const filtered = filterMood
? guests?.filter((g) => g.moodTags.includes(filterMood))
: guests;4. Mobile/Responsive Considerations
- Guest wall grid: 2 columns on mobile (<768px), 3 on tablet (md), 4 on desktop (lg+).
- Mood filter: Horizontal scroll on mobile with
overflow-x-auto, wraps on desktop. - Reaction buttons: Touch-friendly 44px minimum tap targets. SVG icons scale appropriately.
- Profile form: Full-width inputs on mobile, centered single-column layout at all breakpoints.
- Onboarding: Centered layout works at all breakpoints. Loading skeleton shown during Suspense.
- Guest card: Compact on mobile, expanded details on desktop. Avatar + nickname always visible.
5. PWA / Offline Behavior
Guest-facing table PWA caching strategy:
// Service worker registration in table PWA
const CACHE_NAME = "hol-table-v1";
const STATIC_ASSETS = ["/", "/table", "/manifest.json"];
// Cache-first for static assets (JS, CSS, images)
// Network-first for API calls (Convex real-time)
// Stale-while-revalidate for guest wall dataOffline behavior:
- Guest wall relies on real-time Convex subscriptions — offline mode shows stale cached data
- Show "You're offline" indicator banner when disconnected
- QR scan and profile creation require online connectivity
- Reactions are queued and sent when connectivity restored (fire-and-forget with optimistic UI)
- Profile creation during offline: show error, do not allow entry
6. i18n / next-intl Requirements
Translation key tree:
{
"onboard": {
"welcomeTitle": "Welcome to House of Legends",
"welcomeSubtitle": "Tell us a bit about yourself to get started",
"nicknameLabel": "Nickname (2-20 characters)",
"nicknamePlaceholder": "Your nickname",
"originLabel": "Where are you from?",
"originPlaceholder": "City or country",
"moodLabel": "How are you feeling tonight? (pick up to 3)",
"oauthPrompt": "Link your account for next time (optional)",
"continueWithGoogle": "Continue with Google",
"continueWithFacebook": "Continue with Facebook",
"enterExperience": "Enter the Experience",
"welcome": "Welcome!",
"redirecting": "Redirecting to your table...",
"invalidQr": "Invalid QR code. Please scan the QR code at your table.",
"moods": {
"lookingForDate": "Looking for date",
"getDrunk": "Let's get drunk tonight",
"firstTime": "First time here",
"regular": "Come here regularly",
"celebrating": "Celebrating",
"goodFriends": "Just good friends",
"solo": "Solo",
"withFamily": "With family"
}
},
"guestWall": {
"filterAll": "All",
"reactionsReceived": "You got {count} reaction(s) tonight!",
"badges": {
"firstTimer": "First timer",
"regular": "Regular"
},
"reactionLabels": {
"wave": "Wave",
"cheers": "Cheers",
"heart": "Heart"
},
"moodTags": {
"first_time": "First timer",
"regular": "Regular",
"celebrating": "Celebrating",
"looking_for_date": "Looking for date",
"get_drunk": "Let's get drunk",
"good_friends": "Good friends",
"solo": "Solo",
"with_family": "With family"
}
},
"table": {
"tabs": {
"guestWall": "Guest Wall",
"ordering": "Ordering",
"photos": "Photos"
}
}
}7. Environment-Specific Configuration
| Variable | Description | Required | Location |
|---|---|---|---|
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY | Clerk OAuth for Google/Facebook | Yes (for OAuth) | Client + Server |
CLERK_SECRET_KEY | Clerk server-side secret | Yes (for OAuth) | Server only |
CLERK_WEBHOOK_SECRET | Clerk webhook verification | Yes (for OAuth) | Server only |
NEXT_PUBLIC_CONVEX_URL | Convex deployment URL | Yes (auto-set) | Client |
Note: OAuth buttons in profile form are skippable. OAuth integration requires Clerk webhook handler and OAuth callback routes to be built (see P0 Gaps). Profile creation works without OAuth.
8. TDD Test Cases
CRITICAL: All tests use USER EXPECTATION format — what the USER SEES and EXPERIENCES. NO implementation details in test names. Tests are definitions only — no implementation code.
E2E Tests (Playwright):
test("GP-E2E-1.1: Guest sees onboarding page after scanning QR code");
test("GP-E2E-1.2: Guest can fill nickname, origin, and mood tags");
test("GP-E2E-1.3: Guest can select up to 3 mood tags");
test("GP-E2E-1.4: Guest can deselect a mood tag");
test("GP-E2E-1.5: Guest can submit form without OAuth");
test("GP-E2E-1.6: Guest is redirected to table page after profile creation");
test("GP-E2E-1.7: Invalid QR code shows error message");
test("GP-E2E-2.1: Guest sees guest wall with tonight's guests");
test("GP-E2E-2.2: Guest wall updates in real-time when new guest joins");
test("GP-E2E-2.3: Guest can filter wall by mood tag");
test("GP-E2E-2.4: Filter shows only guests with selected mood");
test("GP-E2E-2.5: Guest can send wave reaction to another guest");
test("GP-E2E-2.6: Guest can send cheers reaction to another guest");
test("GP-E2E-2.7: Guest can send heart reaction to another guest");
test("GP-E2E-2.8: Guest cannot react to themselves");
test("GP-E2E-2.9: Guest sees notification when receiving reactions");
test("GP-E2E-3.1: First-time guests see first-timer badge");
test("GP-E2E-3.2: Regular guests see regular badge");
test("GP-E2E-3.3: OAuth buttons are visible but skippable");Component Tests (Vitest + RTL):
it("GP-1.1: MoodSelector allows selecting up to 3 moods");
it("GP-1.2: MoodSelector disables remaining buttons when 3 selected");
it("GP-1.3: MoodSelector allows deselecting a mood");
it("GP-1.4: MoodSelector shows correct selected state");
it("GP-2.1: ProfileForm renders all required fields");
it("GP-2.2: ProfileForm validates nickname minimum length");
it("GP-2.3: ProfileForm validates nickname maximum length");
it("GP-2.4: ProfileForm validates origin is required");
it("GP-2.5: ProfileForm shows error messages for invalid fields");
it("GP-2.6: ProfileForm shows success state after submission");
it("GP-3.1: GuestWall renders loading skeleton before data loads");
it("GP-3.2: GuestWall displays all guests for tonight");
it("GP-3.3: GuestWall shows correct mood tags on guest cards");
it("GP-3.4: GuestWall shows first-timer badge correctly");
it("GP-3.5: GuestWall shows regular badge correctly");
it("GP-3.6: GuestWall hides reaction buttons on own card");
it("GP-3.7: GuestWall shows reaction notification banner when reactions exist");
it("GP-4.1: GuestCard displays guest initials when no avatar");
it("GP-4.2: GuestCard truncates long nicknames appropriately");Backend/Mutation Tests (Vitest):
it("GP-PROF-1.1: Guest can create profile with valid data");
it("GP-PROF-1.2: Profile creation fails with invalid token");
it("GP-PROF-1.3: Profile creation fails for duplicate reservation");
it("GP-PROF-1.4: Profile creation fails when nickname is too short");
it("GP-PROF-1.5: Profile creation fails when nickname is too long");
it("GP-PROF-1.6: Profile creation fails when more than 3 mood tags");
it("GP-PROF-2.1: Guest can send WAVE reaction");
it("GP-PROF-2.2: Guest can send CHEERS reaction");
it("GP-PROF-2.3: Guest can send HEART reaction");
it("GP-PROF-2.4: Guest cannot react to themselves");
it("GP-PROF-2.5: Guest cannot react to non-existent profile");
it("GP-PROF-3.1: getTonight returns all profiles for current date");
it("GP-PROF-3.2: getByToken returns profile for valid token");
it("GP-PROF-3.3: getByToken returns null for invalid token");
it("GP-PROF-3.4: getReactionsToMe returns all reactions for profile");9. Cross-Plan Dependencies
| Dependency | Plan | Shared Schema |
|---|---|---|
| Table PWA | 14-table-pos.md | guestProfiles.tableId, guestProfiles.checkedIn |
| Booking system | 2026-05-03-booking-flow.md | reservations._id for QR token linking |
| Photo wall | 06-photo-wall.md | guestProfiles._id for photo associations |
| Notifications | 17-notifications-crm.md | guestProfiles.email for post-show surveys |
| Show system | show-system.md | guestProfiles.showDate links to show occurrences |
10. Performance Considerations
- Guest wall: Uses Convex subscriptions — no polling needed; updates are push-based in real-time.
- Mood filter: Client-side filter on pre-fetched data (small dataset, tonight's guests only). No re-fetch needed.
- Reactions: Mutation is fire-and-forget with optimistic UI update. No loading state shown to user.
- Profile images: Use Next.js Image with lazy loading; fallback to initials avatar (generated from nickname).
- QR scan validation: Token validated server-side in
getByTokenquery before profile creation. - Bundle size: Dynamic import for GuestWall component (only loaded when tab is active).
- Reaction icons: SVG paths defined as constants — no external icon library needed, reduces bundle size.
Acceptance Criteria
- Guest scans QR → lands on
/onboard?tableId=...&token=...(nuqs, no dynamic segment) - Profile form: nickname (2-20 chars), origin (required), mood tags (optional, max 3)
- OAuth buttons shown but skippable
- After submit → redirect to
/table?tableId=...&token=...with token - Guest wall shows all tonight's guests in real-time grid
- Mood filter works to filter by tag
- Reactions are anonymous — recipient sees count but not sender
- Real-time via Convex subscriptions
Consistency Audit: guest-profiles
P0 Violations (fixed in plan text)
| # | Location | Issue | Fix Applied |
|---|---|---|---|
| 1 | Spec (05-guest-profiles.md) vs plan routing | Spec defines /{locale}/onboard/{tableId}?token= with dynamic [tableId] segment. Plan correctly uses nuqs SPA routing (?tableId=&token=). | Plan uses correct nuqs approach. Spec must be updated separately to match. |
P1 Violations (fixed in plan text)
| # | Location | Issue | Fix Applied |
|---|---|---|---|
| 1 | ProfileForm, GuestWall | console.log usage | Replaced with consola from consola library |
| 2 | OnboardPage | Missing Suspense | Wrapped OnboardContent in Suspense with loading skeleton |
| 3 | Throughout components | Hardcoded strings | All user-facing strings use useTranslations/getTranslations |
| 4 | Throughout components | Missing error handling | Added typed error codes via PROFILE_ERROR_CODES const object |
| 5 | Reaction icons | External icon library | Defined SVG paths as inline constants (no external lib) |
P0 Gaps (cannot fix in plan — requires codebase change)
| # | Issue | Action Required |
|---|---|---|
| 1 | guestProfiles and guestReactions tables not in convex/schema.ts | Add both tables with all indexed fields before profile functions can be built |
| 2 | Clerk OAuth webhook handler and callback routes not built | Build Clerk webhook handler and OAuth callback routes before OAuth features work |
| 3 | staffMutation/adminMutation/authenticatedQuery/authenticatedMutation not exported from convex/auth.ts | Foundation plan must implement role-check auth helpers. Currently only getCurrentUser, upsertUser, and isAdmin are exported. |
[P0] No as any found — all type assertions use proper TypeScript types or Zod parse (ProfileFormSchema.safeParse).
[P0] No Math.random() found — no ID generation issues. All IDs come from Convex.
[P0] No useParams() found — plan uses useQueryState from nuqs correctly for tableId and token params.
[P0] No staffMutation/adminMutation references — no admin mutations in this plan. All mutations use standard mutation from Convex. This is a guest-facing plan; no staff/admin auth required.
[P0] Auth helper gap (NOT BLOCKING for this plan): staffMutation, adminMutation, authenticatedQuery, and authenticatedMutation are NOT exported from convex/auth.ts — only getCurrentUser, upsertUser, and isAdmin are exported. The convex/CLAUDE.md backend rules reference authenticatedQuery/authenticatedMutation but these are not yet implemented. This plan does not require staff/admin mutations, so it is not blocked.
[P1] No console.log found — all logging uses consola from consola library.
[P1] useTransition not needed — no router.push in this plan. Redirect uses setTimeout client-side navigation.
[P1] Suspense added — OnboardPage wraps OnboardContent in Suspense with loading skeleton fallback.
[P1] All hardcoded strings reviewed — all user-facing strings use useTranslations/getTranslations from next-intl.
[P1] No emoji in UI — all icons use inline SVG paths within REACTION_TYPES constant (wave/cheers/heart icons), no emoji characters in component code.
[P1] Zod schemas provided — all mutation inputs and form data validated with Zod schemas in Section 1.
[P1] Error codes as const object — PROFILE_ERROR_CODES defined as const object with as const assertion for type safety.
[P1] Reaction icons use SVG paths — REACTION_TYPES defines SVG path strings, no external icon library needed.
[P1] All useQuery calls use correct pattern: useQuery(api.fn, args) NOT useQuery(api.fn(), args).