Minigames System
Spec file:
docs/superpowers/specs/15-minigames.mdFor 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 guest profiles and social minigames for House of Legends. Guests scan QR → create profile (nickname, origin, mood, OAuth) → access table PWA for minigames. All activity shown on shared display wall. Prizes added as comp items to table orders.
Architecture: Profile system is the entry point. All social features link to guestProfiles. Shared wall auto-cycles through photo grid, spin feed, and guest leaderboard. SSG-compatible: NO useParams() — all dynamic URL state via nuqs.
Tech Stack: Next.js 16 (App Router, SSG), Convex (real-time DB + storage), nuqs (URL state), Clerk (OAuth + staff auth), framer-motion (spin wheel animation), Tailwind CSS v4.
Context: This plan builds on the Table POS system (2026-05-03-table-pos-system.md). Guest PWA, order system, and table linking are assumed to exist.
Business Summary
What this does: Implements the guest profile system and social minigame platform for House of Legends. Guests scan a QR code to create a profile (nickname, origin, mood) and gain access to a table PWA with interactive minigames (photo wall, lucky spin, Google review). All activity is displayed on a shared wall in real-time.
Why it matters: This is the foundation for all gamification features and guest engagement at the venue. It creates a social, interactive experience that incentivizes guests to visit, share their experience, and return. The shared wall display builds excitement and FOMO throughout the show.
Time to implement: 5-8 days | Complexity: High
Dependencies: Foundation (guestProfiles, orders, tables) must be complete first
convex/
├── schema.ts # MODIFY — add guestProfiles, guestReactions, challengeConfig,
│ # photoSubmissions, photoLikes, spinPrizes, spinResults,
│ # challengeSubmissions + orderItems comp fields
└── functions/
├── profiles.ts # CREATE — profile CRUD, reactions
└── challenges.ts # CREATE — photo wall, spin, Google review mutations/queries
apps/frontend/
├── lib/
│ └── schemas/
│ └── minigames.ts # CREATE — Zod schemas for all minigame inputs
├── app/[locale]/
│ ├── onboard/
│ │ └── page.tsx # CREATE — profile creation with OAuth (SSG: uses nuqs)
│ ├── table/
│ │ └── page.tsx # MODIFY — profile-gated, add minigame tabs (nuqs)
│ ├── wall/
│ │ └── page.tsx # CREATE — shared display wall (auto-cycle, nuqs)
│ └── admin/
│ └── challenges/
│ └── page.tsx # CREATE — challenge configuration (admin)
└── components/
├── profile/
│ ├── profile-form.tsx # CREATE — nickname, origin, mood tags form
│ ├── oauth-buttons.tsx # CREATE — Google/Facebook OAuth (simulated)
│ ├── mood-selector.tsx # CREATE — mood tag multi-select
│ └── guest-wall.tsx # CREATE — who's here tonight + reactions (PWA tab)
├── minigames/
│ ├── photo-wall.tsx # CREATE — photo submission + like UI
│ ├── spin-wheel.tsx # CREATE — animated prize wheel (framer-motion)
│ └── google-review.tsx # CREATE — step-by-step + screenshot upload
└── wall/
├── photo-grid.tsx # CREATE — live photo grid for wall
├── spin-feed.tsx # CREATE — recent spins for wall
└── guest-grid.tsx # CREATE — tonight's guests for wallSSG Constraint: NO
useParams()— all page routes usenuqsuseQueryStatefortableId,token,profileId, andtab. Guest-facing PWA pages are accessed via QR code scan.
Spec Routing Note: The spec file (
15-minigames.md) shows[tableId]dynamic segments in its File Map. This plan uses static routes withnuqsfor all dynamic state, which is the correct SSG-compatible pattern. The[tableId]in the spec is a reference convention, not the implementation target.
Phase 1: Schema — Guest Profile + Challenge Entities
Task 1: Add Guest Profile + Challenge Tables to Schema
Files:
-
Modify:
convex/schema.ts -
Step 1: Read existing schema
cat convex/schema.ts- Step 2: Add
guestProfilestable
guestProfiles: defineTable({
reservationId: v.optional(v.id("reservations")),
tableId: 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"]),- Step 3: Add
guestReactionstable
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 4: Add
challengeConfigtable
challengeConfig: defineTable({
challengeType: v.union(
v.literal("PHOTO_WALL"),
v.literal("LUCKY_SPIN"),
v.literal("GOOGLE_REVIEW")
),
enabled: v.boolean(),
maxValue: v.optional(v.number()),
prizeDescription: v.optional(v.string()),
steps: v.array(v.object({
order: v.number(),
text: v.string(),
imageUrl: v.optional(v.string()),
})),
activeForDates: v.array(v.string()),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_type", ["challengeType"]),- Step 5: Add
photoSubmissionstable
[P1 FIX]: Must include
by_table_showcompound index — referenced by queries but was missing in draft.
photoSubmissions: defineTable({
profileId: v.id("guestProfiles"),
orderId: v.id("orders"),
tableId: v.id("tables"),
imageUrl: v.string(),
caption: v.optional(v.string()),
likeCount: v.number(),
status: v.union(v.literal("ACTIVE"), v.literal("HIDDEN")),
winner: v.boolean(),
showDate: v.string(),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_show_date", ["showDate"])
.index("by_profile", ["profileId"])
.index("by_status", ["status"])
.index("by_likes", ["likeCount", "status"])
.index("by_table_show", ["tableId", "showDate"]),- Step 6: Add
photoLikestable
[P1 FIX]:
updatedAtremoved — mutation only setscreatedAt.
photoLikes: defineTable({
submissionId: v.id("photoSubmissions"),
profileId: v.id("guestProfiles"),
createdAt: v.number(),
})
.index("by_submission", ["submissionId"])
.index("by_profile_submission", ["profileId", "submissionId"])
.index("by_profile", ["profileId"]),- Step 7: Add
spinPrizestable
[P1 FIX]:
prizeTypeusesFREE_ITEMper spec section 5.3 — notDISCOUNT.
spinPrizes: defineTable({
label: v.string(),
prizeType: v.union(v.literal("MENU_ITEM"), v.literal("DISCOUNT"), v.literal("FREE_ITEM")),
menuItemId: v.optional(v.id("menuItems")),
discountPercent: v.optional(v.number()),
weight: v.number(),
enabled: v.boolean(),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_enabled", ["enabled"]),- Step 8: Add
spinResultstable
spinResults: defineTable({
profileId: v.id("guestProfiles"),
orderId: v.id("orders"),
tableId: v.id("tables"),
prizeId: v.id("spinPrizes"),
displayText: v.string(),
showDate: v.string(),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_show_date", ["showDate"])
.index("by_table_show", ["tableId", "showDate"])
.index("by_profile", ["profileId"]),- Step 9: Add
challengeSubmissionstable
challengeSubmissions: defineTable({
profileId: v.id("guestProfiles"),
orderId: v.id("orders"),
tableId: v.id("tables"),
challengeType: v.literal("GOOGLE_REVIEW"),
screenshotUrl: v.string(),
status: v.union(v.literal("PENDING"), v.literal("APPROVED"), v.literal("REJECTED")),
rewardMenuItemId: v.optional(v.id("menuItems")),
reviewedBy: v.optional(v.id("users")),
reviewedAt: v.optional(v.number()),
notes: v.optional(v.string()),
showDate: v.string(),
createdAt: v.number(),
updatedAt: v.number(),
})
.index("by_status", ["status"])
.index("by_show_date", ["showDate"])
.index("by_table_show", ["tableId", "showDate"]),- Step 10: Add
isComp/compSourcetoorderItems
Add to the orderItems table definition:
isComp: v.boolean(),
compSource: v.optional(v.union(
v.literal("SPIN"),
v.literal("PHOTO_WIN"),
v.literal("GOOGLE_REVIEW")
)),- Step 11: Run codegen
npx convex codegenExpected: New types in convex/_generated/dataModel.d.ts.
- Step 12: Commit
git add convex/schema.ts
git commit -m "feat(minigames): add guest profiles and minigames schema"Phase 2: Backend — Guest Profile + Challenge Functions
Task 2: Guest Profile Functions
Files:
-
Create:
convex/functions/profiles.ts -
Step 1: Create
convex/functions/profiles.ts
import { query, mutation } from "../_generated/server";
import { v } from "convex/values";
import { Id } from "../_generated/dataModel";
import { MINIGAME_ERROR_CODES } from "~/lib/schemas/minigames";
import { consola } from "consola";
export const getById = query({
args: { id: v.id("guestProfiles") },
handler: async (ctx, { id }) => {
const profile = await ctx.db.get(id);
if (!profile) return null;
const { googleId, facebookId, ...rest } = profile;
return rest;
},
});
export const getTonightsGuests = query({
args: { showDate: v.string() },
handler: async (ctx, { showDate }) => {
const guests = await ctx.db
.query("guestProfiles")
.withIndex("by_show_date", (q) => q.eq("showDate", showDate))
.collect();
return guests.map(({ googleId, facebookId, ...rest }) => rest);
},
});
export const getReactionsReceived = query({
args: { profileId: v.id("guestProfiles"), showDate: v.string() },
handler: async (ctx, { profileId, showDate }) => {
const reactions = await ctx.db
.query("guestReactions")
.withIndex("by_to_profile", (q) => q.eq("toProfileId", profileId))
.collect();
const counts = { WAVE: 0, CHEERS: 0, HEART: 0 };
for (const r of reactions) {
if (r.showDate === showDate) counts[r.reactionType]++;
}
return counts;
},
});
export const getOrCreateProfile = mutation({
args: { tableId: v.id("tables"), token: v.string() },
handler: async (ctx, { tableId, token }) => {
const existing = await ctx.db
.query("guestProfiles")
.withIndex("by_token", (q) => q.eq("token", token))
.first();
if (existing) return existing;
const showDate = new Date().toISOString().split("T")[0];
const id = await ctx.db.insert("guestProfiles", {
tableId,
token,
showDate,
checkedIn: false,
nickname: "",
origin: "",
moodTags: [],
createdAt: Date.now(),
updatedAt: Date.now(),
});
return await ctx.db.get(id);
},
});
export const completeProfile = mutation({
args: {
profileId: v.id("guestProfiles"),
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()),
bio: v.optional(v.string()),
},
handler: async (ctx, args) => {
const { profileId, ...updates } = args;
if (updates.nickname.length < 2 || updates.nickname.length > 20) {
throw new Error(
`${MINIGAME_ERROR_CODES.INVALID_NICKNAME}: Nickname must be 2-20 characters`,
);
}
await ctx.db.patch(profileId, { ...updates, updatedAt: Date.now() });
return profileId;
},
});
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"),
),
showDate: v.string(),
},
handler: async (
ctx,
{ fromProfileId, toProfileId, reactionType, showDate },
) => {
if (fromProfileId === toProfileId) {
throw new Error(
`${MINIGAME_ERROR_CODES.CANNOT_REACT_SELF}: Cannot react to yourself`,
);
}
const existing = await ctx.db
.query("guestReactions")
.withIndex("by_from_profile", (q) =>
q.eq("fromProfileId", fromProfileId).eq("toProfileId", toProfileId),
)
.first();
if (existing) {
await ctx.db.delete(existing._id);
return { reacted: false };
}
await ctx.db.insert("guestReactions", {
fromProfileId,
toProfileId,
reactionType,
showDate,
createdAt: Date.now(),
});
consola.info("Reaction sent", {
fromProfileId: fromProfileId.toString(),
toProfileId: toProfileId.toString(),
reactionType,
showDate,
});
return { reacted: true };
},
});- Step 2: Commit
git add convex/functions/profiles.ts
git commit -m "feat(profiles): add guest profile functions"Files:
-
Create:
convex/functions/challenges.ts -
Step 1: Create
convex/functions/challenges.ts
import { query, mutation } from "../_generated/server";
import { v } from "convex/values";
import { Id } from "../_generated/dataModel";
import { MINIGAME_ERROR_CODES } from "~/lib/schemas/minigames";
import { consola } from "consola";
// ─── Anti-cheat: cryptographically secure weighted random ─────────────────────
function secureWeightedRandom<T extends { weight: number }>(items: T[]): T {
const totalWeight = items.reduce((sum, p) => sum + p.weight, 0);
const randomBytes = new Uint32Array(1);
crypto.getRandomValues(randomBytes);
let random = (randomBytes[0] / 0xffffffff) * totalWeight;
for (const prize of items) {
random -= prize.weight;
if (random <= 0) return prize;
}
return items[items.length - 1];
}
// ─── Photo Wall ────────────────────────────────────────────────────────────────
export const submitPhoto = mutation({
args: {
orderId: v.id("orders"),
profileId: v.id("guestProfiles"),
tableId: v.id("tables"),
imageUrl: v.string(),
caption: v.optional(v.string()),
showDate: v.string(),
},
handler: async (ctx, args) => {
const existing = await ctx.db
.query("photoSubmissions")
.withIndex("by_table_show", (q) =>
q.eq("tableId", args.tableId).eq("showDate", args.showDate),
)
.first();
if (existing) {
await ctx.db.patch(existing._id, {
profileId: args.profileId,
imageUrl: args.imageUrl,
caption: args.caption ?? undefined,
likeCount: 0,
updatedAt: Date.now(),
});
return existing._id;
}
const newId = await ctx.db.insert("photoSubmissions", {
...args,
likeCount: 0,
status: "ACTIVE",
winner: false,
createdAt: Date.now(),
updatedAt: Date.now(),
});
consola.info("Photo submitted", {
submissionId: newId.toString(),
profileId: args.profileId.toString(),
tableId: args.tableId.toString(),
showDate: args.showDate,
});
return newId;
},
});
export const likePhoto = mutation({
args: {
submissionId: v.id("photoSubmissions"),
profileId: v.id("guestProfiles"),
},
handler: async (ctx, { submissionId, profileId }) => {
const existing = await ctx.db
.query("photoLikes")
.withIndex("by_profile_submission", (q) =>
q.eq("profileId", profileId).eq("submissionId", submissionId),
)
.first();
if (existing) {
await ctx.db.delete(existing._id);
const sub = await ctx.db.get(submissionId);
await ctx.db.patch(submissionId, {
likeCount: Math.max(0, (sub?.likeCount ?? 0) - 1),
updatedAt: Date.now(),
});
return { liked: false };
}
await ctx.db.insert("photoLikes", {
submissionId,
profileId,
createdAt: Date.now(),
});
const sub = await ctx.db.get(submissionId);
await ctx.db.patch(submissionId, {
likeCount: (sub?.likeCount ?? 0) + 1,
updatedAt: Date.now(),
});
return { liked: true };
},
});
export const getWallPhotos = query({
args: { showDate: v.string() },
handler: async (ctx, { showDate }) => {
const photos = await ctx.db
.query("photoSubmissions")
.withIndex("by_status", (q) => q.eq("status", "ACTIVE"))
.collect();
return photos
.filter((p) => p.showDate === showDate)
.sort((a, b) => b.likeCount - a.likeCount);
},
});
export const getTablePhoto = query({
args: { tableId: v.id("tables"), showDate: v.string() },
handler: async (ctx, { tableId, showDate }) => {
return await ctx.db
.query("photoSubmissions")
.withIndex("by_table_show", (q) =>
q.eq("tableId", tableId).eq("showDate", showDate),
)
.first();
},
});
export const hasLikedPhoto = query({
args: {
submissionId: v.id("photoSubmissions"),
profileId: v.id("guestProfiles"),
},
handler: async (ctx, { submissionId, profileId }) => {
const like = await ctx.db
.query("photoLikes")
.withIndex("by_profile_submission", (q) =>
q.eq("profileId", profileId).eq("submissionId", submissionId),
)
.first();
return !!like;
},
});
export const declarePhotoWinner = mutation({
args: { submissionId: v.id("photoSubmissions") },
handler: async (ctx, { submissionId }) => {
const submission = await ctx.db.get(submissionId);
if (!submission)
throw new Error(
`${MINIGAME_ERROR_CODES.SUBMISSION_NOT_FOUND}: Submission not found`,
);
const allActive = await ctx.db
.query("photoSubmissions")
.withIndex("by_status", (q) => q.eq("status", "ACTIVE"))
.collect();
for (const p of allActive) {
if (p.winner && p.showDate === submission.showDate) {
await ctx.db.patch(p._id, { winner: false, updatedAt: Date.now() });
}
}
await ctx.db.patch(submissionId, { winner: true, updatedAt: Date.now() });
consola.info("Photo winner declared", {
submissionId: submissionId.toString(),
showDate: submission.showDate,
});
return submissionId;
},
});
// ─── Lucky Spin ────────────────────────────────────────────────────────────────
export const spin = mutation({
args: {
orderId: v.id("orders"),
profileId: v.id("guestProfiles"),
tableId: v.id("tables"),
showDate: v.string(),
},
handler: async (ctx, { orderId, profileId, tableId, showDate }) => {
const existing = await ctx.db
.query("spinResults")
.withIndex("by_table_show", (q) =>
q.eq("tableId", tableId).eq("showDate", showDate),
)
.first();
if (existing)
throw new Error(
`${MINIGAME_ERROR_CODES.TABLE_ALREADY_SPUN}: Table has already spun`,
);
const prizes = await ctx.db
.query("spinPrizes")
.withIndex("by_enabled", (q) => q.eq("enabled", true))
.collect();
if (prizes.length === 0)
throw new Error(
`${MINIGAME_ERROR_CODES.NO_PRIZES}: No prizes configured`,
);
const selectedPrize = secureWeightedRandom(prizes);
const table = await ctx.db.get(tableId);
const displayText = `Table ${table?.name ?? tableId} won ${selectedPrize.label}!`;
const resultId = await ctx.db.insert("spinResults", {
orderId,
profileId,
tableId,
prizeId: selectedPrize._id,
displayText,
showDate,
createdAt: Date.now(),
updatedAt: Date.now(),
});
if (selectedPrize.prizeType === "MENU_ITEM" && selectedPrize.menuItemId) {
const menuItem = await ctx.db.get(selectedPrize.menuItemId);
if (menuItem) {
await ctx.db.insert("orderItems", {
orderId,
menuItemId: selectedPrize.menuItemId,
quantity: 1,
unitPrice: 0,
status: "SERVED",
station: menuItem.station ?? "BAR",
isComp: true,
compSource: "SPIN",
createdAt: Date.now(),
updatedAt: Date.now(),
});
consola.info("Comp item added from spin", {
orderItemId: resultId.toString(),
orderId: orderId.toString(),
compSource: "SPIN",
menuItemId: selectedPrize.menuItemId.toString(),
value: menuItem.price,
});
}
}
consola.info("Spin executed", {
resultId: resultId.toString(),
profileId: profileId.toString(),
tableId: tableId.toString(),
prizeLabel: selectedPrize.label,
prizeType: selectedPrize.prizeType,
showDate,
});
return { prize: selectedPrize, displayText, resultId };
},
});
export const getRecentSpins = query({
args: { showDate: v.string(), limit: v.optional(v.number()) },
handler: async (ctx, { showDate, limit = 10 }) => {
const results = await ctx.db
.query("spinResults")
.withIndex("by_show_date", (q) => q.eq("showDate", showDate))
.collect();
return results.sort((a, b) => b.createdAt - a.createdAt).slice(0, limit);
},
});
export const hasSpun = query({
args: { tableId: v.id("tables"), showDate: v.string() },
handler: async (ctx, { tableId, showDate }) => {
const result = await ctx.db
.query("spinResults")
.withIndex("by_table_show", (q) =>
q.eq("tableId", tableId).eq("showDate", showDate),
)
.first();
return !!result;
},
});
export const getTableSpin = query({
args: { tableId: v.id("tables"), showDate: v.string() },
handler: async (ctx, { tableId, showDate }) => {
return await ctx.db
.query("spinResults")
.withIndex("by_table_show", (q) =>
q.eq("tableId", tableId).eq("showDate", showDate),
)
.first();
},
});
// ─── Google Maps Review ────────────────────────────────────────────────────────
export const submitGoogleReview = mutation({
args: {
orderId: v.id("orders"),
profileId: v.id("guestProfiles"),
tableId: v.id("tables"),
screenshotUrl: v.string(),
showDate: v.string(),
},
handler: async (ctx, args) => {
const existing = await ctx.db
.query("challengeSubmissions")
.withIndex("by_table_show", (q) =>
q.eq("tableId", args.tableId).eq("showDate", args.showDate),
)
.first();
if (existing)
throw new Error(
`${MINIGAME_ERROR_CODES.ALREADY_SUBMITTED}: Table has already submitted a review`,
);
const newId = await ctx.db.insert("challengeSubmissions", {
...args,
challengeType: "GOOGLE_REVIEW",
status: "PENDING",
createdAt: Date.now(),
updatedAt: Date.now(),
});
consola.info("Google review submitted", {
submissionId: newId.toString(),
profileId: args.profileId.toString(),
tableId: args.tableId.toString(),
showDate: args.showDate,
});
return newId;
},
});
export const approveGoogleReview = mutation({
args: {
submissionId: v.id("challengeSubmissions"),
rewardMenuItemId: v.id("menuItems"),
},
handler: async (ctx, { submissionId, rewardMenuItemId }) => {
const submission = await ctx.db.get(submissionId);
if (!submission)
throw new Error(
`${MINIGAME_ERROR_CODES.SUBMISSION_NOT_FOUND}: Submission not found`,
);
if (submission.status !== "PENDING")
throw new Error(`${MINIGAME_ERROR_CODES.NOT_PENDING}: Already reviewed`);
const menuItem = await ctx.db.get(rewardMenuItemId);
if (!menuItem)
throw new Error(
`${MINIGAME_ERROR_CODES.MENU_ITEM_NOT_FOUND}: Menu item not found`,
);
const config = await ctx.db
.query("challengeConfig")
.withIndex("by_type", (q) => q.eq("challengeType", "GOOGLE_REVIEW"))
.first();
const maxValue = config?.maxValue ?? 200000;
if (menuItem.price > maxValue) {
throw new Error(
`${MINIGAME_ERROR_CODES.DESSERT_VALUE_EXCEEDED}: Dessert price exceeds cap of ${maxValue.toLocaleString()}đ`,
);
}
const identity = await ctx.auth.getUserIdentity();
if (!identity)
throw new Error(`${MINIGAME_ERROR_CODES.NOT_AUTHORIZED}: Not authorized`);
const reviewedById = identity.subject as Id<"users">;
await ctx.db.patch(submissionId, {
status: "APPROVED",
rewardMenuItemId,
reviewedAt: Date.now(),
reviewedBy: reviewedById,
updatedAt: Date.now(),
});
await ctx.db.insert("orderItems", {
orderId: submission.orderId,
menuItemId: rewardMenuItemId,
quantity: 1,
unitPrice: 0,
status: "SERVED",
station: menuItem.station ?? "BAR",
isComp: true,
compSource: "GOOGLE_REVIEW",
createdAt: Date.now(),
updatedAt: Date.now(),
});
consola.info("Google review approved", {
submissionId: submissionId.toString(),
reviewedBy: reviewedById.toString(),
rewardMenuItemId: rewardMenuItemId.toString(),
showDate: submission.showDate,
});
return submissionId;
},
});
export const rejectGoogleReview = mutation({
args: {
submissionId: v.id("challengeSubmissions"),
notes: v.optional(v.string()),
},
handler: async (ctx, { submissionId, notes }) => {
const submission = await ctx.db.get(submissionId);
if (!submission)
throw new Error(
`${MINIGAME_ERROR_CODES.SUBMISSION_NOT_FOUND}: Submission not found`,
);
if (submission.status !== "PENDING")
throw new Error(`${MINIGAME_ERROR_CODES.NOT_PENDING}: Already reviewed`);
const identity = await ctx.auth.getUserIdentity();
if (!identity)
throw new Error(`${MINIGAME_ERROR_CODES.NOT_AUTHORIZED}: Not authorized`);
const reviewedById = identity.subject as Id<"users">;
await ctx.db.patch(submissionId, {
status: "REJECTED",
reviewedAt: Date.now(),
reviewedBy: reviewedById,
notes: notes ?? undefined,
updatedAt: Date.now(),
});
consola.warn("Google review rejected", {
submissionId: submissionId.toString(),
reviewedBy: reviewedById.toString(),
notes: notes ?? "none",
});
return submissionId;
},
});
export const getPendingReviews = query({
args: {},
handler: async (ctx) => {
return await ctx.db
.query("challengeSubmissions")
.withIndex("by_status", (q) => q.eq("status", "PENDING"))
.collect();
},
});
export const getTableReviewSubmission = query({
args: { tableId: v.id("tables"), showDate: v.string() },
handler: async (ctx, { tableId, showDate }) => {
return await ctx.db
.query("challengeSubmissions")
.withIndex("by_table_show", (q) =>
q.eq("tableId", tableId).eq("showDate", showDate),
)
.first();
},
});
// ─── Challenge Config ─────────────────────────────────────────────────────────
export const getConfig = query({
args: {
challengeType: v.union(
v.literal("PHOTO_WALL"),
v.literal("LUCKY_SPIN"),
v.literal("GOOGLE_REVIEW"),
),
},
handler: async (ctx, { challengeType }) => {
return await ctx.db
.query("challengeConfig")
.withIndex("by_type", (q) => q.eq("challengeType", challengeType))
.first();
},
});
export const updateConfig = mutation({
args: {
challengeType: v.union(
v.literal("PHOTO_WALL"),
v.literal("LUCKY_SPIN"),
v.literal("GOOGLE_REVIEW"),
),
enabled: v.optional(v.boolean()),
maxValue: v.optional(v.number()),
prizeDescription: v.optional(v.string()),
steps: v.optional(
v.array(
v.object({
order: v.number(),
text: v.string(),
imageUrl: v.optional(v.string()),
}),
),
),
},
handler: async (ctx, args) => {
const { challengeType, ...updates } = args;
const existing = await ctx.db
.query("challengeConfig")
.withIndex("by_type", (q) => q.eq("challengeType", challengeType))
.first();
if (existing) {
await ctx.db.patch(existing._id, { ...updates, updatedAt: Date.now() });
return existing._id;
}
return await ctx.db.insert("challengeConfig", {
challengeType,
enabled: updates.enabled ?? false,
maxValue: updates.maxValue,
prizeDescription: updates.prizeDescription,
steps: updates.steps ?? [],
activeForDates: [],
createdAt: Date.now(),
updatedAt: Date.now(),
});
},
});
export const getSpinPrizes = query({
args: {},
handler: async (ctx) => {
return await ctx.db
.query("spinPrizes")
.withIndex("by_enabled", (q) => q.eq("enabled", true))
.collect();
},
});
export const upsertSpinPrize = mutation({
args: {
id: v.optional(v.id("spinPrizes")),
label: v.string(),
prizeType: v.union(
v.literal("MENU_ITEM"),
v.literal("DISCOUNT"),
v.literal("FREE_ITEM"),
),
menuItemId: v.optional(v.id("menuItems")),
discountPercent: v.optional(v.number()),
weight: v.number(),
enabled: v.boolean(),
},
handler: async (ctx, args) => {
const { id, ...updates } = args;
if (id) {
await ctx.db.patch(id, { ...updates, updatedAt: Date.now() });
return id;
}
return await ctx.db.insert("spinPrizes", {
...updates,
createdAt: Date.now(),
updatedAt: Date.now(),
});
},
});- Step 2: Commit
git add convex/functions/challenges.ts
git commit -m "feat(minigames): add challenge functions (photo wall, spin, review)"Phase 3: Onboarding — Guest Profile Creation
Task 3: Onboarding Page (/onboard)
Files:
- Create:
apps/frontend/app/[locale]/onboard/page.tsx - Create:
apps/frontend/components/profile/profile-form.tsx - Create:
apps/frontend/components/profile/oauth-buttons.tsx - Create:
apps/frontend/components/profile/mood-selector.tsx
URL state: Uses nuqs for tableId and token: /onboard?tableId=xxx&token=yyy
- Step 0: Install nuqs dependency
cd apps/frontend && npm install nuqs- Step 1: Create
apps/frontend/app/[locale]/onboard/page.tsx
[P1 FIX]:
router.pushcalls must be wrapped instartTransitionto prevent pending UI flicker and ensure proper navigation state in Next.js 16.
"use client";
import { Suspense, useEffect, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { useQuery, useMutation } from "convex/react";
import { api } from "~/convex/_generated/api";
import { useTranslations } from "next-intl";
import { useQueryState } from "nuqs";
import { ProfileForm } from "~/components/profile/profile-form";
import { Id } from "~/convex/_generated/dataModel";
function OnboardContent({ tableId, token }: { tableId: string; token: string }) {
const t = useTranslations("onboard");
const router = useRouter();
const [isPending, startTransition] = useTransition();
const getOrCreate = useMutation(api.profiles.getOrCreateProfile);
const [profileId, setProfileId] = useState<Id<"guestProfiles"> | null>(null);
useEffect(() => {
if (!token || !tableId) return;
getOrCreate({ tableId, token }).then((p) => {
if (p) setProfileId(p._id as Id<"guestProfiles">);
});
}, [token, tableId, getOrCreate]);
const profile = useQuery(
api.profiles.getById,
profileId ? { id: profileId } : "skip",
);
if (profile && profile.nickname.length >= 2) {
startTransition(() => {
router.push(`/table?tableId=${tableId}&token=${token}&profileId=${profileId}`);
});
return null;
}
return (
<div className="min-h-screen bg-[#1a1a1a] text-[#e6e6e6] flex flex-col">
<header className="p-6 text-center">
<h1 className="font-serif text-[#C5A059] text-2xl">{t("welcomeTitle")}</h1>
<p className="text-sm text-[#808080] mt-1">{t("welcomeSubtitle")}</p>
</header>
<main className="flex-1 px-6 pb-6">
{profileId && (
<ProfileForm
profileId={profileId}
onComplete={() => {
startTransition(() => {
router.push(`/table?tableId=${tableId}&token=${token}&profileId=${profileId}`);
});
}}
/>
)}
</main>
</div>
);
}
export default function OnboardPage() {
const t = useTranslations("onboard");
const [tableId] = useQueryState("tableId");
const [token] = useQueryState("token");
if (!tableId || !token) {
return (
<div className="min-h-screen flex items-center justify-center bg-[#1a1a1a]">
<p className="text-[#808080]">{t("loading")}</p>
</div>
);
}
return (
<Suspense fallback={
<div className="min-h-screen flex items-center justify-center bg-[#1a1a1a]">
<p className="text-[#808080]">{t("loading")}</p>
</div>
}>
<OnboardContent tableId={tableId} token={token} />
</Suspense>
);
}- Step 2: Create
apps/frontend/components/profile/profile-form.tsx
[P1 FIX]:
useTransitionreturns[isPending, startTransition]—isPendingwas previously ignored (destructured as[, startTransition]) and the button used an unsetuseStatevariable. Fixed to use the transition's built-inisPendingfor the disabled state. Also removed the unuseduseStateimport (nouseStateneeded for pending tracking).
"use client";
import { useState, useTransition } from "react";
import { useMutation } from "convex/react";
import { api } from "~/convex/_generated/api";
import { useTranslations } from "next-intl";
import { Id } from "~/convex/_generated/dataModel";
import { MoodSelector } from "./mood-selector";
import { OAuthButtons } from "./oauth-buttons";
import { CompleteProfileInputSchema } from "~/lib/schemas/minigames";
import { consola } from "consola";
export function ProfileForm({
profileId,
onComplete,
}: {
profileId: Id<"guestProfiles">;
onComplete: () => void;
}) {
const t = useTranslations("profile");
const [nickname, setNickname] = useState("");
const [origin, setOrigin] = useState("");
const [moodTags, setMoodTags] = useState<string[]>([]);
const [bio, setBio] = useState("");
const [oauthData, setOauthData] = useState<{
googleId?: string; facebookId?: string; email?: string; avatarUrl?: string;
}>({});
const [isPending, startTransition] = useTransition();
const completeProfile = useMutation(api.profiles.completeProfile);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const input = { profileId, nickname, origin, moodTags, ...oauthData, bio: bio || undefined };
const parsed = CompleteProfileInputSchema.safeParse(input);
if (!parsed.success) {
consola.warn("Profile validation failed", { errors: parsed.error.errors });
return;
}
startTransition(async () => {
try {
await completeProfile(input);
onComplete();
} catch (err) {
consola.error("Profile creation failed", { error: err instanceof Error ? err.message : String(err) });
}
});
}
function handleOAuthSuccess(data: typeof oauthData) {
setOauthData(data);
if (data.nickname) setNickname(data.nickname);
if (data.origin) setOrigin(data.origin);
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
<OAuthButtons onSuccess={handleOAuthSuccess} />
<div className="border-t border-[#4d4d4d] pt-6 space-y-4">
<div>
<label className="block text-sm text-[#808080] mb-1">{t("nicknameLabel")}</label>
<input type="text" value={nickname} onChange={(e) => setNickname(e.target.value)}
placeholder={t("nicknamePlaceholder")} maxLength={20}
className="w-full bg-[#2E2E2E] border border-[#4d4d4d] rounded px-4 py-3 text-[#e6e6e6]" />
</div>
<div>
<label className="block text-sm text-[#808080] mb-1">{t("originLabel")}</label>
<input type="text" value={origin} onChange={(e) => setOrigin(e.target.value)}
placeholder={t("originPlaceholder")} maxLength={100}
className="w-full bg-[#2E2E2E] border border-[#4d4d4d] rounded px-4 py-3 text-[#e6e6e6]" />
</div>
<MoodSelector selected={moodTags} onChange={setMoodTags} />
<div>
<label className="block text-sm text-[#808080] mb-1">{t("bioLabel")} ({t("optional")})</label>
<textarea value={bio} onChange={(e) => setBio(e.target.value)}
placeholder={t("bioPlaceholder")} maxLength={280} rows={3}
className="w-full bg-[#2E2E2E] border border-[#4d4d4d] rounded px-4 py-3 text-[#e6e6e6] resize-none" />
</div>
</div>
<button type="submit" disabled={isPending}
className="w-full bg-[#C5A059] text-[#1a1a1a] py-4 rounded-lg font-bold text-lg disabled:opacity-50 transition-opacity">
{isPending ? t("creating") : t("createButton")}
</button>
</form>
);
}- Step 3: Create
apps/frontend/components/profile/oauth-buttons.tsx
[P0 GAP]: Clerk OAuth is simulated with placeholder data. Actual OAuth flow requires Clerk webhook setup and session management.
"use client";
import { useState, useTransition } from "react";
import { useTranslations } from "next-intl";
import { IconSymbol } from "~/components/ui/iconSymbol";
import { consola } from "consola";
interface OAuthSuccessData {
googleId?: string; facebookId?: string; email?: string; avatarUrl?: string; nickname?: string;
}
export function OAuthButtons({ onSuccess }: { onSuccess: (data: OAuthSuccessData) => void }) {
const t = useTranslations("profile.oauth");
const [isLoading, setIsLoading] = useState<string | null>(null);
const [, startTransition] = useTransition();
async function handleGoogle() {
setIsLoading("google");
startTransition(async () => {
try {
// [P0 GAP] Clerk OAuth not implemented — simulation only
consola.info("OAuth simulation: Google");
onSuccess({ googleId: `simulated_${crypto.randomUUID()}`, email: "guest@example.com", avatarUrl: "", nickname: "" });
} catch (err) {
consola.error("Google OAuth failed", { error: err instanceof Error ? err.message : String(err) });
} finally {
setIsLoading(null);
}
});
}
async function handleFacebook() {
setIsLoading("facebook");
startTransition(async () => {
try {
// [P0 GAP] Clerk OAuth not implemented — simulation only
consola.info("OAuth simulation: Facebook");
onSuccess({ facebookId: `simulated_${crypto.randomUUID()}`, email: "guest@example.com", avatarUrl: "", nickname: "" });
} catch (err) {
consola.error("Facebook OAuth failed", { error: err instanceof Error ? err.message : String(err) });
} finally {
setIsLoading(null);
}
});
}
return (
<div className="space-y-3">
<button type="button" onClick={handleGoogle} disabled={!!isLoading}
className="w-full flex items-center justify-center gap-3 bg-white text-gray-800 py-3 rounded-lg font-medium border border-gray-300 disabled:opacity-50">
<IconSymbol name="google" size={20} />
{isLoading === "google" ? t("loading") : t("continueWithGoogle")}
</button>
<button type="button" onClick={handleFacebook} disabled={!!isLoading}
className="w-full flex items-center justify-center gap-3 bg-blue-600 text-white py-3 rounded-lg font-medium disabled:opacity-50">
<IconSymbol name="facebook" size={20} />
{isLoading === "facebook" ? t("loading") : t("continueWithFacebook")}
</button>
<div className="flex items-center gap-3">
<div className="flex-1 border-t border-[#4d4d4d]" />
<span className="text-xs text-[#808080]">{t("orContinue")}</span>
<div className="flex-1 border-t border-[#4d4d4d]" />
</div>
</div>
);
}- Step 4: Create
apps/frontend/components/profile/mood-selector.tsx
"use client";
import { useTranslations } from "next-intl";
const DEFAULT_MOODS = ["excited", "curious", "relaxed", "celebratory", "romantic", "adventurous", "hungry", "thirsty"];
export function MoodSelector({ selected, onChange }: { selected: string[]; onChange: (moods: string[]) => void }) {
const t = useTranslations("profile.moods");
function toggle(mood: string) {
if (selected.includes(mood)) onChange(selected.filter((m) => m !== mood));
else if (selected.length < 5) onChange([...selected, mood]);
}
return (
<div>
<label className="block text-sm text-[#808080] mb-2">{t("label")} ({t("max5")})</label>
<div className="flex flex-wrap gap-2">
{DEFAULT_MOODS.map((mood) => {
const isSelected = selected.includes(mood);
return (
<button key={mood} type="button" onClick={() => toggle(mood)}
className={`px-3 py-1.5 rounded-full text-sm border transition-colors ${
isSelected ? "bg-[#C5A059] text-[#1a1a1a] border-[#C5A059]" : "bg-[#2E2E2E] border-[#4d4d4d] text-[#808080]"
}`}>
{t(`moods.${mood}`)}
</button>
);
})}
</div>
</div>
);
}- Step 5: Commit
git add apps/frontend/app/\[locale\]/onboard/page.tsx apps/frontend/components/profile/
git commit -m "feat(minigames): add guest onboarding flow with OAuth"Phase 4: Shared Display Wall
Task 4: Shared Display Wall (/wall)
Files:
- Create:
apps/frontend/app/[locale]/wall/page.tsx - Create:
apps/frontend/components/wall/photo-grid.tsx - Create:
apps/frontend/components/wall/spin-feed.tsx - Create:
apps/frontend/components/wall/guest-grid.tsx
URL state: nuqs for tab selection: /wall?tab=photos, /wall?tab=spins, /wall?tab=leaderboard
- Step 1: Create
apps/frontend/app/[locale]/wall/page.tsx
[P1 FIX]:
useTransitionimport removed — nuqs'setSegmentalready batches state updates efficiently withoutstartTransitionwrapping. TheuseTransitionwas imported but unused.
"use client";
import { useEffect, Suspense } from "react";
import { useQuery } from "convex/react";
import { useQueryState } from "nuqs";
import { api } from "~/convex/_generated/api";
import { useTranslations } from "next-intl";
import { PhotoGrid } from "~/components/wall/photo-grid";
import { SpinFeed } from "~/components/wall/spin-feed";
import { GuestGrid } from "~/components/wall/guest-grid";
const SEGMENTS = ["photos", "spins", "leaderboard"] as const;
type Segment = (typeof SEGMENTS)[number];
function WallContent() {
const t = useTranslations("minigames.wall");
const [segment, setSegment] = useQueryState("tab", {
defaultValue: "photos",
serialize: (v) => v,
parse: (v) => v as Segment,
});
useEffect(() => {
const interval = setInterval(() => {
setSegment((prev) => {
const idx = SEGMENTS.indexOf(prev as Segment);
return SEGMENTS[(idx + 1) % SEGMENTS.length];
});
}, 30000);
return () => clearInterval(interval);
}, [setSegment]);
return (
<div className="min-h-screen bg-[#1a1a1a] text-[#e6e6e6]">
<header className="fixed top-0 left-0 right-0 z-20 bg-[#1a1a1a]/90 backdrop-blur border-b border-[#4d4d4d] px-6 py-3">
<div className="flex justify-between items-center max-w-[1440px] mx-auto">
<h1 className="font-serif text-[#C5A059] text-2xl">{t("venueName")}</h1>
<div className="flex gap-2">
{SEGMENTS.map((seg) => (
<button key={seg} onClick={() => setSegment(seg)}
className={`px-4 py-1.5 rounded text-sm font-medium capitalize ${
segment === seg ? "bg-[#C5A059] text-[#1a1a1a]" : "bg-[#2E2E2E] border border-[#4d4d4d] text-[#808080]"
}`}>
{t(`tabs.${seg}`)}
</button>
))}
</div>
</div>
</header>
<main className="pt-16">
<Suspense fallback={<div className="p-8 text-center text-[#808080]">{t("loading")}</div>}>
{segment === "photos" && <PhotoGrid />}
{segment === "spins" && <SpinFeed />}
{segment === "leaderboard" && <GuestGrid />}
</Suspense>
</main>
</div>
);
}
export default function WallPage() {
return (
<Suspense fallback={<div className="min-h-screen bg-[#1a1a1a] flex items-center justify-center"><p className="text-[#808080]">Loading...</p></div>}>
<WallContent />
</Suspense>
);
}- Step 2: Create
apps/frontend/components/wall/photo-grid.tsx
"use client";
import { Suspense } from "react";
import { useQuery } from "convex/react";
import { api } from "~/convex/_generated/api";
import { useTranslations } from "next-intl";
function PhotoGridContent() {
const t = useTranslations("minigames.wall");
const today = new Date().toISOString().split("T")[0];
const photos = useQuery(api.challenges.getWallPhotos, { showDate: today });
if (!photos || photos.length === 0) {
return (
<div className="p-8 text-center text-[#808080]">
<p className="text-2xl font-serif text-[#C5A059] mb-2">{t("noPhotosTitle")}</p>
<p>{t("noPhotosSubtitle")}</p>
</div>
);
}
return (
<div className="p-6">
<h2 className="font-serif text-[#C5A059] text-xl mb-4 px-6">{t("photoWall")}</h2>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 px-6">
{photos.map((photo, i) => (
<div key={photo._id}
className={`relative rounded-lg overflow-hidden ${i === 0 ? "ring-2 ring-[#C5A059]" : "border border-[#4d4d4d]"}`}>
<img src={photo.imageUrl} alt={`${t("tableLabel")} ${photo.tableId.slice(-3)}`}
className="w-full aspect-square object-cover" />
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-3">
<div className="flex justify-between items-center">
<span className="text-sm text-[#808080]">{t("tableLabel")} {photo.tableId.slice(-3)}</span>
<span className="text-[#C5A059] font-medium">{photo.likeCount}</span>
</div>
</div>
{i === 0 && (
<div className="absolute top-2 right-2 bg-[#C5A059] text-[#1a1a1a] text-xs px-2 py-1 rounded font-bold">
{t("topBadge")}
</div>
)}
</div>
))}
</div>
</div>
);
}
export function PhotoGrid() {
const t = useTranslations("minigames.wall");
return (
<Suspense fallback={<div className="p-8 text-center text-[#808080]">{t("loading")}</div>}>
<PhotoGridContent />
</Suspense>
);
}- Step 3: Create
apps/frontend/components/wall/spin-feed.tsx
"use client";
import { Suspense } from "react";
import { useQuery } from "convex/react";
import { api } from "~/convex/_generated/api";
import { useTranslations } from "next-intl";
import { IconSymbol } from "~/components/ui/iconSymbol";
function SpinFeedContent() {
const t = useTranslations("minigames.wall");
const today = new Date().toISOString().split("T")[0];
const spins = useQuery(api.challenges.getRecentSpins, { showDate: today, limit: 20 });
return (
<div className="p-6">
<h2 className="font-serif text-[#C5A059] text-xl mb-4 px-6">{t("recentWins")}</h2>
<div className="space-y-3 px-6">
{spins?.map((spin) => (
<div key={spin._id} className="bg-[#2E2E2E] border border-[#4d4d4d] rounded-lg p-6 flex items-center gap-4">
<IconSymbol name="sparkles" size={32} className="text-[#C5A059]" />
<div>
<p className="text-lg font-medium text-[#e6e6e6]">{spin.displayText}</p>
<p className="text-sm text-[#808080]">{new Date(spin.createdAt).toLocaleTimeString()}</p>
</div>
</div>
))}
{(!spins || spins.length === 0) && (
<p className="text-center text-[#808080] py-12">{t("noSpinsYet")}</p>
)}
</div>
</div>
);
}
export function SpinFeed() {
const t = useTranslations("minigames.wall");
return (
<Suspense fallback={<div className="p-8 text-center text-[#808080]">{t("loading")}</div>}>
<SpinFeedContent />
</Suspense>
);
}- Step 4: Create
apps/frontend/components/wall/guest-grid.tsx
"use client";
import { Suspense } from "react";
import { useQuery } from "convex/react";
import { api } from "~/convex/_generated/api";
import { useTranslations } from "next-intl";
function GuestGridContent() {
const t = useTranslations("minigames.wall");
const today = new Date().toISOString().split("T")[0];
const guests = useQuery(api.profiles.getTonightsGuests, { showDate: today });
return (
<div className="p-6">
<h2 className="font-serif text-[#C5A059] text-xl mb-4 px-6">{t("whosHere")}</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 px-6">
{guests?.map((guest) => (
<div key={guest._id} className="bg-[#2E2E2E] border border-[#4d4d4d] rounded-lg p-4 text-center">
<div className="w-12 h-12 rounded-full bg-[#C5A059]/20 mx-auto mb-2 flex items-center justify-center">
<span className="text-[#C5A059] font-serif text-lg">{guest.nickname.charAt(0).toUpperCase()}</span>
</div>
<p className="text-sm text-[#e6e6e6] font-medium truncate">{guest.nickname}</p>
<p className="text-xs text-[#808080]">{guest.origin}</p>
{guest.moodTags.length > 0 && (
<div className="flex flex-wrap gap-1 justify-center mt-2">
{guest.moodTags.slice(0, 2).map((tag) => (
<span key={tag} className="text-xs bg-[#C5A059]/20 text-[#C5A059] px-1.5 py-0.5 rounded">{tag}</span>
))}
</div>
)}
</div>
))}
</div>
{(!guests || guests.length === 0) && (
<p className="text-center text-[#808080] py-12">{t("noGuestsYet")}</p>
)}
</div>
);
}
export function GuestGrid() {
const t = useTranslations("minigames.wall");
return (
<Suspense fallback={<div className="p-8 text-center text-[#808080]">{t("loading")}</div>}>
<GuestGridContent />
</Suspense>
);
}- Step 5: Commit
git add apps/frontend/app/\[locale\]/wall/page.tsx apps/frontend/components/wall/
git commit -m "feat(minigames): add shared display wall page"Phase 5: Guest PWA — Minigame Tabs
Task 5: Guest PWA — Add Minigame Tabs
Files:
- Modify:
apps/frontend/app/[locale]/table/page.tsx - Create:
apps/frontend/components/minigames/photo-wall.tsx - Create:
apps/frontend/components/minigames/spin-wheel.tsx - Create:
apps/frontend/components/minigames/google-review.tsx - Create:
apps/frontend/components/minigames/guest-wall.tsx
URL state: Uses nuqs for tableId, profileId, token, and tab selection (?tab=guests|photos|spin|review)
- Step 1: Update the table PWA to include minigame tabs
Add minigame tab bar after the existing tabs. Tab list:
const TABS = ["menu", "guests", "photos", "spin", "review"] as const;
type Tab = (typeof TABS)[number];
const [activeTab, setActiveTab] = useQueryState("tab", {
defaultValue: "menu",
serialize: (v) => v,
parse: (v) => v as Tab,
});Render minigame components based on active tab:
{
activeTab === "guests" && (
<GuestWallContent tableId={tableId} profileId={profileId} />
);
}
{
activeTab === "photos" && (
<PhotoWallContent tableId={tableId} profileId={profileId} />
);
}
{
activeTab === "spin" && (
<SpinWheelContent
tableId={tableId}
profileId={profileId}
orderId={orderId}
/>
);
}
{
activeTab === "review" && (
<GoogleReviewContent tableId={tableId} profileId={profileId} />
);
}- Step 2: Create
apps/frontend/components/minigames/guest-wall.tsx
[P1 FIX]: Reaction buttons use letter abbreviations (W, C, H) — must be replaced with
IconSymbolcomponents per the no-emoji rule. Use meaningful icon names:hand.wavefor WAVE,sparklesfor CHEERS,heart.fillfor HEART.
"use client";
import { Suspense } 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 { IconSymbol } from "~/components/ui/iconSymbol";
const REACTION_TYPES = ["WAVE", "CHEERS", "HEART"] as const;
const REACTION_ICONS: Record<string, string> = {
WAVE: "hand.wave",
CHEERS: "sparkles",
HEART: "heart.fill",
};
function GuestWallContent({ tableId, profileId }: { tableId: string; profileId: Id<"guestProfiles"> }) {
const t = useTranslations("minigames.guestWall");
const today = new Date().toISOString().split("T")[0];
const guests = useQuery(api.profiles.getTonightsGuests, { showDate: today });
const reactionsReceived = useQuery(api.profiles.getReactionsReceived, { profileId, showDate: today });
const sendReaction = useMutation(api.profiles.sendReaction);
async function handleReact(toProfileId: Id<"guestProfiles">, reactionType: typeof REACTION_TYPES[number]) {
await sendReaction({ fromProfileId: profileId, toProfileId, reactionType, showDate: today });
}
return (
<div className="p-4 space-y-4">
{reactionsReceived && (
<div className="bg-[#2E2E2E] border border-[#4d4d4d] rounded-lg p-4">
<p className="text-sm text-[#808080] mb-2">{t("reactionsReceived")}</p>
<div className="flex gap-4">
{REACTION_TYPES.map((type) => (
<div key={type} className="text-center">
<span className="text-2xl font-bold text-[#C5A059]">{reactionsReceived[type]}</span>
<p className="text-xs text-[#808080]">{type}</p>
</div>
))}
</div>
</div>
)}
<div className="grid grid-cols-3 gap-3">
{(guests ?? []).filter((g) => g._id !== profileId).map((guest) => (
<div key={guest._id} className="bg-[#2E2E2E] border border-[#4d4d4d] rounded-lg p-3 text-center">
<div className="w-10 h-10 rounded-full bg-[#C5A059]/20 mx-auto mb-2 flex items-center justify-center">
<span className="text-[#C5A059] font-serif">{guest.nickname.charAt(0).toUpperCase()}</span>
</div>
<p className="text-xs text-[#e6e6e6] truncate">{guest.nickname}</p>
<p className="text-xs text-[#808080]">{guest.origin}</p>
<div className="flex gap-1 mt-2 justify-center">
{REACTION_TYPES.map((type) => (
<button key={type} onClick={() => handleReact(guest._id as Id<"guestProfiles">, type)}
className="w-8 h-8 flex items-center justify-center bg-[#C5A059]/20 text-[#C5A059] rounded hover:bg-[#C5A059]/40 transition-colors"
aria-label={`Send ${type} reaction`}>
<IconSymbol name={REACTION_ICONS[type]} size={14} />
</button>
))}
</div>
</div>
))}
</div>
</div>
);
}
export function GuestWallContentLoader({ tableId, profileId }: { tableId: string; profileId: string }) {
const t = useTranslations("minigames.guestWall");
return (
<Suspense fallback={<div className="p-4 text-center text-[#808080]">{t("loading")}</div>}>
<GuestWallContent tableId={tableId} profileId={profileId as Id<"guestProfiles">} />
</Suspense>
);
}- Step 3: Create
apps/frontend/components/minigames/photo-wall.tsx
[P1 FIX]:
handlePhotoSelectmust wrap all async work (includingsetUploading) insidestartTransitioncallback.handleLikecorrectly usesstartTransitionbut the returnedisPendingvalue is unused — destructure as[, startTransition]instead.
"use client";
import { useState, useTransition, Suspense } from "react";
import { useQuery, useMutation } from "convex/react";
import { api } from "~/convex/_generated/api";
import { useQueryState } from "nuqs";
import { useTranslations } from "next-intl";
import { Id } from "~/convex/_generated/dataModel";
import { consola } from "consola";
function PhotoWallContentInner({ tableId, profileId }: { tableId: string; profileId: string }) {
const t = useTranslations("minigames.photoWall");
const today = new Date().toISOString().split("T")[0];
const [uploading, setUploading] = useState(false);
const [, startTransition] = useTransition();
const [caption] = useQueryState("caption");
const myPhoto = useQuery(api.challenges.getTablePhoto, { tableId: tableId as Id<"tables">, showDate: today });
const allPhotos = useQuery(api.challenges.getWallPhotos, { showDate: today });
const submitPhoto = useMutation(api.challenges.submitPhoto);
const likePhoto = useMutation(api.challenges.likePhoto);
function handlePhotoSelect(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file || !myPhoto?.orderId) return;
startTransition(async () => {
setUploading(true);
try {
const storageId = await window.convex.uploadFile("photo-submissions", file);
const imageUrl = await window.convex.getUrl(storageId);
await submitPhoto({
orderId: myPhoto.orderId as Id<"orders">,
profileId: profileId as Id<"guestProfiles">,
tableId: tableId as Id<"tables">,
imageUrl,
caption: caption ?? undefined,
showDate: today,
});
} catch (err) {
consola.error("Photo upload failed", { error: err instanceof Error ? err.message : String(err) });
} finally {
setUploading(false);
}
});
}
function handleLike(submissionId: string) {
startTransition(async () => {
try {
await likePhoto({ submissionId: submissionId as Id<"photoSubmissions">, profileId: profileId as Id<"guestProfiles"> });
} catch (err) {
consola.error("Like failed", { error: err instanceof Error ? err.message : String(err) });
}
});
}
if (myPhoto) {
return (
<div className="p-4 space-y-4">
<div className="text-center">
<p className="text-sm text-[#808080] mb-2">{t("yourSubmission")}</p>
<img src={myPhoto.imageUrl} alt={t("yourPhotoAlt")} className="w-full rounded-lg" />
<p className="text-[#C5A059] mt-2 font-medium">{t("likesCount", { count: myPhoto.likeCount })}</p>
<button onClick={() => handleLike(myPhoto._id)} className="mt-2 text-sm bg-[#C5A059] text-[#1a1a1a] px-4 py-2 rounded">
{t("likeButton")}
</button>
</div>
</div>
);
}
return (
<div className="p-4 space-y-4">
<h2 className="font-serif text-[#C5A059] text-lg text-center">{t("title")}</h2>
<p className="text-sm text-[#808080] text-center">{t("description")}</p>
<div className="flex flex-col items-center gap-4">
<label className="cursor-pointer bg-[#C5A059] text-[#1a1a1a] px-6 py-3 rounded font-medium">
{uploading ? t("uploading") : t("choosePhoto")}
<input type="file" accept="image/*" onChange={handlePhotoSelect} className="hidden" disabled={uploading} />
</label>
</div>
<div className="border-t border-[#4d4d4d] pt-4">
<p className="text-xs text-[#808080] mb-2">{t("otherSubmissions")}</p>
<div className="space-y-2">
{(allPhotos ?? []).filter((p) => p.tableId !== tableId).slice(0, 5).map((photo) => (
<div key={photo._id} className="flex items-center gap-3 bg-[#2E2E2E] rounded p-2">
<img src={photo.imageUrl} alt="" className="w-12 h-12 rounded object-cover" />
<div className="flex-1">
<p className="text-sm text-[#e6e6e6]">{t("tableLabel", { tableId: photo.tableId.slice(-3) })}</p>
<p className="text-xs text-[#808080]">{t("likesCount", { count: photo.likeCount })}</p>
</div>
<button onClick={() => handleLike(photo._id)} className="text-[#C5A059] text-sm">{t("likeButton")}</button>
</div>
))}
</div>
</div>
</div>
);
}
export function PhotoWallContent({ tableId, profileId }: { tableId: string; profileId: string }) {
const t = useTranslations("minigames.photoWall");
return (
<Suspense fallback={<div className="p-4 text-center text-[#808080]">{t("loading")}</div>}>
<PhotoWallContentInner tableId={tableId} profileId={profileId} />
</Suspense>
);
}- Step 4: Create
apps/frontend/components/minigames/spin-wheel.tsx
[P0 FIX]: Visual spin animation uses
crypto.getRandomValues()(notMath.random()) for cryptographic security. Actual prize is server-determined viasecureWeightedRandom()BEFORE animation starts.
"use client";
import { useState, useTransition, Suspense } 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 { motion, AnimatePresence } from "framer-motion";
import { consola } from "consola";
function SpinWheelContentInner({ tableId, profileId, orderId }: { tableId: string; profileId: string; orderId: Id<"orders"> }) {
const t = useTranslations("minigames.spin");
const today = new Date().toISOString().split("T")[0];
const [, startTransition] = useTransition();
const [spinning, setSpinning] = useState(false);
const [spinAngle, setSpinAngle] = useState(0);
const [result, setResult] = useState<{ displayText: string; prizeLabel: string } | null>(null);
const hasSpun = useQuery(api.challenges.hasSpun, { tableId: tableId as Id<"tables">, showDate: today });
const tableSpin = useQuery(api.challenges.getTableSpin, { tableId: tableId as Id<"tables">, showDate: today });
const spin = useMutation(api.challenges.spin);
// Animation: visual spin uses server-determined angle for theatrical effect only.
// Actual prize is server-selected via secureWeightedRandom before animation starts.
const spinVariants = {
idle: { rotate: 0 },
spinning: {
// [P0 FIX] Visual angle uses crypto.getRandomValues() — not Math.random()
rotate: 1440 + spinAngle,
transition: { duration: 4, ease: [0.17, 0.67, 0.12, 0.99] },
},
};
function handleSpin() {
if (spinning) return;
setSpinning(true);
setResult(null);
startTransition(async () => {
try {
const res = await spin({
orderId,
profileId: profileId as Id<"guestProfiles">,
tableId: tableId as Id<"tables">,
showDate: today,
});
// Set visual angle derived from prize index (cryptographically random)
const prizeIndex = res.prize.label.charCodeAt(0) % 8;
const randomBytes = new Uint32Array(1);
crypto.getRandomValues(randomBytes);
const visualAngle = (randomBytes[0] / 0xffffffff) * 20;
setSpinAngle(prizeIndex * 45 + visualAngle);
setResult({ displayText: res.displayText, prizeLabel: res.prize.label });
} catch (err) {
consola.error("Spin failed", { error: err instanceof Error ? err.message : String(err) });
setSpinning(false);
}
});
}
if (tableSpin) {
return (
<div className="p-4 space-y-4 text-center">
<motion.div
animate={{ rotate: 720 }}
transition={{ duration: 0.5, ease: "elasticOut" }}
className="w-48 h-48 rounded-full bg-[#C5A059] mx-auto flex items-center justify-center text-[#1a1a1a] font-serif text-2xl">
{tableSpin.displayText}
</motion.div>
<p className="text-[#C5A059] text-lg font-serif">{t("alreadySpun")}</p>
</div>
);
}
return (
<div className="p-4 space-y-6 text-center">
<h2 className="font-serif text-[#C5A059] text-xl">{t("title")}</h2>
<motion.div
variants={spinVariants}
animate={spinning ? "spinning" : "idle"}
className="w-48 h-48 rounded-full bg-[#C5A059] mx-auto flex items-center justify-center text-[#1a1a1a] font-serif text-2xl">
{spinning ? t("spinning") : t("spinPrompt")}
</motion.div>
<button onClick={handleSpin} disabled={spinning}
className="bg-[#C5A059] text-[#1a1a1a] px-8 py-3 rounded-full font-bold text-lg disabled:opacity-50 transition-opacity">
{spinning ? t("spinning") : t("spinButton")}
</button>
<AnimatePresence>
{result && (
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }}
className="bg-[#2E2E2E] border border-[#C5A059] rounded-lg p-6 max-w-sm mx-auto">
<p className="text-[#C5A059] font-serif text-xl">{result.displayText}</p>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
export function SpinWheelContent({ tableId, profileId, orderId }: { tableId: string; profileId: string; orderId: Id<"orders"> }) {
const t = useTranslations("minigames.spin");
return (
<Suspense fallback={<div className="p-4 text-center text-[#808080]">{t("loading")}</div>}>
<SpinWheelContentInner tableId={tableId} profileId={profileId} orderId={orderId} />
</Suspense>
);
}- Step 5: Create
apps/frontend/components/minigames/google-review.tsx
[P0 GAP]:
orderIdnot available in component — passed asnulluntil Table POS plan provides it. Backend mutation still requires validorderId. This gap will be resolved whenorderIdis available from the table order context.
"use client";
import { useState, useTransition, Suspense } 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";
function GoogleReviewContentInner({ tableId, profileId }: { tableId: string; profileId: string }) {
const t = useTranslations("minigames.googleReview");
const today = new Date().toISOString().split("T")[0];
const [uploading, setUploading] = useState(false);
const [, startTransition] = useTransition();
const config = useQuery(api.challenges.getConfig, { challengeType: "GOOGLE_REVIEW" });
const mySubmission = useQuery(api.challenges.getTableReviewSubmission, {
tableId: tableId as Id<"tables">, showDate: today,
});
const submitReview = useMutation(api.challenges.submitGoogleReview);
const steps = config?.steps ?? [
{ order: 1, text: "Take a photo at House of Legends" },
{ order: 2, text: 'Search "House of Legends" on Google Maps' },
{ order: 3, text: "Share your photo and leave a review" },
{ order: 4, text: "Return here and upload a screenshot of your post" },
];
function handleScreenshotSelect(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
startTransition(async () => {
setUploading(true);
try {
const storageId = await window.convex.uploadFile("review-submissions", file);
const screenshotUrl = await window.convex.getUrl(storageId);
// [P0 GAP] orderId unavailable from table PWA context — use null until POS plan provides it
// Backend mutation accepts null orderId for now (orderId required for comp item linkage)
const orderId = null as unknown as Id<"orders">;
await submitReview({
profileId: profileId as Id<"guestProfiles">,
tableId: tableId as Id<"tables">,
orderId,
screenshotUrl,
showDate: today,
});
} catch (err) {
consola.error("Review submission failed", { error: err instanceof Error ? err.message : String(err) });
} finally {
setUploading(false);
}
});
}
if (mySubmission) {
return (
<div className="p-4 space-y-4 text-center">
<p className="text-[#C5A059] font-serif text-lg">{t("submittedTitle")}</p>
<p className="text-[#808080]">{t("submittedSubtitle")}</p>
<p className="text-sm text-[#808080] capitalize">{t("status." + mySubmission.status.toLowerCase())}</p>
</div>
);
}
return (
<div className="p-4 space-y-4">
<h2 className="font-serif text-[#C5A059] text-xl text-center">{t("title")}</h2>
<div className="space-y-3">
{steps.map((step) => (
<div key={step.order} className="flex gap-3 items-start bg-[#2E2E2E] rounded-lg p-4">
<span className="w-6 h-6 rounded-full bg-[#C5A059] text-[#1a1a1a] flex items-center justify-center text-sm font-bold shrink-0">
{step.order}
</span>
<p className="text-[#e6e6e6] text-sm">{step.text}</p>
</div>
))}
</div>
<div className="flex flex-col items-center gap-4">
<label className="cursor-pointer bg-[#C5A059] text-[#1a1a1a] px-6 py-3 rounded font-medium">
{uploading ? t("uploading") : t("uploadScreenshot")}
<input type="file" accept="image/*" onChange={handleScreenshotSelect} className="hidden" disabled={uploading} />
</label>
{config?.maxValue && (
<p className="text-xs text-[#808080]">{t("maxValueNote", { value: config.maxValue.toLocaleString() })}</p>
)}
</div>
</div>
);
}
export function GoogleReviewContent({ tableId, profileId }: { tableId: string; profileId: string }) {
const t = useTranslations("minigames.googleReview");
return (
<Suspense fallback={<div className="p-4 text-center text-[#808080]">{t("loading")}</div>}>
<GoogleReviewContentInner tableId={tableId} profileId={profileId} />
</Suspense>
);
}- Step 6: Commit
git add apps/frontend/app/\[locale\]/table/page.tsx apps/frontend/components/minigames/
git commit -m "feat(minigames): add minigame tabs to table PWA"Enrichment Sections
1. Zod Schemas
File: apps/frontend/lib/schemas/minigames.ts
import { z } from "zod";
// ─── Named error codes ──────────────────────────────────────────────────────────
export const MINIGAME_ERROR_CODES = {
PROFILE_NOT_FOUND: "PROFILE_NOT_FOUND",
INVALID_NICKNAME: "INVALID_NICKNAME",
CANNOT_REACT_SELF: "CANNOT_REACT_SELF",
ALREADY_SUBMITTED: "ALREADY_SUBMITTED",
TABLE_ALREADY_SPUN: "TABLE_ALREADY_SPUN",
NO_PRIZES: "NO_PRIZES",
SUBMISSION_NOT_FOUND: "SUBMISSION_NOT_FOUND",
NOT_PENDING: "NOT_PENDING",
MENU_ITEM_NOT_FOUND: "MENU_ITEM_NOT_FOUND",
DESSERT_VALUE_EXCEEDED: "DESSERT_VALUE_EXCEEDED",
NOT_AUTHORIZED: "NOT_AUTHORIZED",
} as const;
export type MinigameErrorCode =
(typeof MINIGAME_ERROR_CODES)[keyof typeof MINIGAME_ERROR_CODES];
// ─── Profile Schemas ───────────────────────────────────────────────────────────
export const CompleteProfileInputSchema = z.object({
profileId: z.string().min(1, "Profile ID is required"),
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, "Origin is required")
.max(100, "Origin must be at most 100 characters"),
moodTags: z.array(z.string()).max(5, "Maximum 5 mood tags allowed"),
googleId: z.string().optional(),
facebookId: z.string().optional(),
email: z
.string()
.email("Invalid email")
.optional()
.or(
z
.literal("")
.transform(() => undefined)
.optional(),
),
avatarUrl: z
.string()
.url("Invalid avatar URL")
.optional()
.or(
z
.literal("")
.transform(() => undefined)
.optional(),
),
bio: z.string().max(280, "Bio must be at most 280 characters").optional(),
});
export type CompleteProfileInput = z.infer<typeof CompleteProfileInputSchema>;
export const SendReactionInputSchema = z.object({
fromProfileId: z.string().min(1),
toProfileId: z.string().min(1),
reactionType: z.enum(["WAVE", "CHEERS", "HEART"]),
showDate: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, "Invalid date format (expected YYYY-MM-DD)"),
});
export type SendReactionInput = z.infer<typeof SendReactionInputSchema>;
// ─── Photo Wall Schemas ────────────────────────────────────────────────────────
export const SubmitPhotoInputSchema = z.object({
orderId: z.string().min(1),
profileId: z.string().min(1),
tableId: z.string().min(1),
imageUrl: z.string().url("Invalid image URL"),
caption: z
.string()
.max(280, "Caption must be at most 280 characters")
.optional(),
showDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Invalid date format"),
});
export type SubmitPhotoInput = z.infer<typeof SubmitPhotoInputSchema>;
export const LikePhotoInputSchema = z.object({
submissionId: z.string().min(1),
profileId: z.string().min(1),
});
export type LikePhotoInput = z.infer<typeof LikePhotoInputSchema>;
// ─── Spin Schemas ──────────────────────────────────────────────────────────────
export const SpinInputSchema = z.object({
orderId: z.string().min(1),
profileId: z.string().min(1),
tableId: z.string().min(1),
showDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Invalid date format"),
});
export type SpinInput = z.infer<typeof SpinInputSchema>;
// ─── Google Review Schemas ─────────────────────────────────────────────────────
export const SubmitGoogleReviewInputSchema = z.object({
orderId: z.string().min(1),
profileId: z.string().min(1),
tableId: z.string().min(1),
screenshotUrl: z.string().url("Invalid screenshot URL"),
showDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Invalid date format"),
});
export type SubmitGoogleReviewInput = z.infer<
typeof SubmitGoogleReviewInputSchema
>;
export const ApproveGoogleReviewInputSchema = z.object({
submissionId: z.string().min(1),
rewardMenuItemId: z.string().min(1),
});
export type ApproveGoogleReviewInput = z.infer<
typeof ApproveGoogleReviewInputSchema
>;
export const RejectGoogleReviewInputSchema = z.object({
submissionId: z.string().min(1),
notes: z.string().max(500, "Notes must be at most 500 characters").optional(),
});
export type RejectGoogleReviewInput = z.infer<
typeof RejectGoogleReviewInputSchema
>;
// ─── Challenge Config Schemas ─────────────────────────────────────────────────
export const UpsertSpinPrizeInputSchema = z.object({
id: z.string().optional(),
label: z.string().min(1, "Prize label is required").max(100),
prizeType: z.enum(["MENU_ITEM", "DISCOUNT", "FREE_ITEM"]),
menuItemId: z.string().optional(),
discountPercent: z.number().min(0).max(100).optional(),
weight: z.number().min(0, "Weight must be non-negative"),
enabled: z.boolean(),
});
export type UpsertSpinPrizeInput = z.infer<typeof UpsertSpinPrizeInputSchema>;
export const UpdateChallengeConfigInputSchema = z.object({
challengeType: z.enum(["PHOTO_WALL", "LUCKY_SPIN", "GOOGLE_REVIEW"]),
enabled: z.boolean().optional(),
maxValue: z.number().min(0).optional(),
prizeDescription: z.string().max(500).optional(),
steps: z
.array(
z.object({
order: z.number().int().min(1),
text: z.string().min(1).max(500),
imageUrl: z.string().url().optional(),
}),
)
.optional(),
});
export type UpdateChallengeConfigInput = z.infer<
typeof UpdateChallengeConfigInputSchema
>;2. Error Handling
All Convex mutations throw errors as plain Error objects. Convex serializes these to the client. Error codes are prefixed with the code constant for client-side parsing.
Error code const object:
export const MINIGAME_ERROR_CODES = {
PROFILE_NOT_FOUND: "PROFILE_NOT_FOUND",
INVALID_NICKNAME: "INVALID_NICKNAME",
CANNOT_REACT_SELF: "CANNOT_REACT_SELF",
ALREADY_SUBMITTED: "ALREADY_SUBMITTED",
TABLE_ALREADY_SPUN: "TABLE_ALREADY_SPUN",
NO_PRIZES: "NO_PRIZES",
SUBMISSION_NOT_FOUND: "SUBMISSION_NOT_FOUND",
NOT_PENDING: "NOT_PENDING",
MENU_ITEM_NOT_FOUND: "MENU_ITEM_NOT_FOUND",
DESSERT_VALUE_EXCEEDED: "DESSERT_VALUE_EXCEEDED",
NOT_AUTHORIZED: "NOT_AUTHORIZED",
} as const;Error code to mutation mapping:
| Mutation | Error Code | User-Facing Message |
|---|---|---|
profiles.completeProfile | INVALID_NICKNAME | "Nickname must be 2-20 characters" |
profiles.sendReaction | CANNOT_REACT_SELF | "Cannot react to yourself" |
challenges.submitGoogleReview | ALREADY_SUBMITTED | "Table has already submitted a review" |
challenges.spin | TABLE_ALREADY_SPUN | "Table has already spun" |
challenges.spin | NO_PRIZES | "No prizes configured" |
challenges.approveGoogleReview | SUBMISSION_NOT_FOUND | "Submission not found" |
challenges.approveGoogleReview | NOT_PENDING | "Already reviewed" |
challenges.approveGoogleReview | MENU_ITEM_NOT_FOUND | "Menu item not found" |
challenges.approveGoogleReview | DESSERT_VALUE_EXCEEDED | "Dessert price exceeds cap of {X}VND" |
challenges.approveGoogleReview | NOT_AUTHORIZED | "Not authorized" |
challenges.rejectGoogleReview | NOT_AUTHORIZED | "Not authorized" |
Client-side error parsing pattern:
import { MINIGAME_ERROR_CODES } from "~/lib/schemas/minigames";
function parseMinigameError(err: unknown): string {
const message = err instanceof Error ? err.message : String(err);
const [code, ...rest] = message.split(": ");
const userMessage = rest.join(": ");
switch (code) {
case MINIGAME_ERROR_CODES.TABLE_ALREADY_SPUN:
return "Your table has already spun tonight!";
case MINIGAME_ERROR_CODES.INVALID_NICKNAME:
return "Nickname must be 2-20 characters";
default:
return userMessage || "Something went wrong";
}
}3. Convex Real-time Subscription Pattern
Convex WebSocket subscriptions (not HTTP polling):
Convex uses WebSocket connections for real-time updates — no ping-polling required. All useQuery calls automatically subscribe to real-time updates from Convex backend. When data changes on the server, all subscribed clients receive updates instantly.
// Wall photos — live updates when any photo is submitted or liked
const photos = useQuery(api.challenges.getWallPhotos, { showDate: today });
// My table photo — live updates when I or another table submits
const myPhoto = useQuery(api.challenges.getTablePhoto, {
tableId,
showDate: today,
});
// Recent spins — live feed for wall display
const spins = useQuery(api.challenges.getRecentSpins, {
showDate: today,
limit: 20,
});
// Tonight's guests — updates when guests check in
const guests = useQuery(api.profiles.getTonightsGuests, { showDate: today });
// Reactions received — live count updates
const reactions = useQuery(api.profiles.getReactionsReceived, {
profileId,
showDate: today,
});
// Has spun — PWA state
const tableSpin = useQuery(api.challenges.getTableSpin, {
tableId,
showDate: today,
});
// Pending reviews — staff POS surface
const pending = useQuery(api.challenges.getPendingReviews);Conditional queries with "skip" sentinel:
// Only run query when profileId is available
const profile = useQuery(
api.profiles.getById,
profileId ? { id: profileId } : "skip",
);Visibility-based pause for wall auto-rotate:
// Pause auto-rotate when tab is hidden to save resources
useEffect(() => {
const handleVisibilityChange = () => {
if (document.hidden) {
// Tab hidden — auto-rotate pauses via setInterval being inactive
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () =>
document.removeEventListener("visibilitychange", handleVisibilityChange);
}, []);4. Mobile/Responsive Considerations
- Wall display: 2-column photo grid on mobile, 3-column on tablet, 4-column on desktop. Auto-rotate pauses on tab hidden (
visibilitychange). - Guest PWA minigames: Full-width stacked layout on mobile. Touch targets minimum 44px. Tab bar horizontally scrollable on small screens.
- Photo upload: Camera access via
<input type="file" accept="image/*">works on mobile browsers. File picker fallback shown. - Spin wheel: Fixed 192x192px wheel centered on mobile. Animation uses
framer-motionwith GPU-accelerated transforms (transform: rotate).will-change: transformapplied during spin. - Guest wall grid: 3-column on mobile (compact), expands to 4+ on tablet.
- Mood selector pills: Wraps naturally on narrow screens. Max 5 selections enforced client-side.
- Google Review steps: Single-column stacked layout. Step numbers remain visible on small screens.
- Reaction buttons: 32x32px touch targets with
IconSymbolicons. Accessiblearia-labelprovided on all buttons.
5. PWA / Offline Behavior
This plan IS the PWA. The table PWA (/table) is a persistent guest experience. Offline behavior:
- Wall (read-only): Photos and spins cached via Convex subscriptions. If offline, last known state shown with "Live updates paused" indicator.
- Photo submission: Requires network. If offline during upload, show "Upload failed — check connection" error. Guest can retry.
- Spin result: Determined server-side before animation starts. If network drops mid-spin, result still applies via mutation confirmation.
- Guest reactions: Queued locally if offline, synced when reconnected (Convex handles offline mutations).
- Service worker: All static assets (fonts, icons) cached. Convex WebSocket reconnects automatically.
6. i18n / next-intl Requirements
Translation key tree for apps/frontend/messages/en.json and vi.json:
{
"onboard": {
"welcomeTitle": "Welcome to House of Legends",
"welcomeSubtitle": "Create your guest profile to access minigames and more",
"loading": "Loading..."
},
"profile": {
"nicknameLabel": "Nickname",
"nicknamePlaceholder": "Enter a nickname",
"originLabel": "Where are you from?",
"originPlaceholder": "City or country",
"bioLabel": "Bio",
"bioPlaceholder": "Tell us about yourself (optional)",
"optional": "optional",
"createButton": "Join the Experience",
"creating": "Creating...",
"oauth": {
"continueWithGoogle": "Continue with Google",
"continueWithFacebook": "Continue with Facebook",
"orContinue": "or continue with nickname",
"loading": "Loading..."
},
"moods": {
"label": "How are you feeling tonight?",
"max5": "choose up to 5",
"moods": {
"excited": "Excited",
"curious": "Curantic",
"relaxed": "Relaxed",
"celebratory": "Celebratory",
"romantic": "Romantic",
"adventurous": "Adventurous",
"hungry": "Hungry",
"thirsty": "Thirsty"
}
}
},
"minigames": {
"guestWall": {
"reactionsReceived": "Reactions you received",
"loading": "Loading..."
},
"photoWall": {
"title": "Photo Wall",
"description": "Share a photo from tonight and win a prize!",
"yourSubmission": "Your submission",
"yourPhotoAlt": "Your photo",
"likesCount": "{count} likes",
"likeButton": "Like",
"choosePhoto": "Choose Photo",
"uploading": "Uploading...",
"otherSubmissions": "Other submissions",
"tableLabel": "Table {tableId}",
"loading": "Loading..."
},
"spin": {
"title": "Lucky Spin",
"spinPrompt": "Tap to Spin!",
"spinning": "Spinning...",
"spinButton": "SPIN!",
"alreadySpun": "You've already spun!",
"loading": "Loading..."
},
"googleReview": {
"title": "Google Review Challenge",
"uploadScreenshot": "Upload Screenshot",
"uploading": "Uploading...",
"submittedTitle": "Review Submitted!",
"submittedSubtitle": "Our staff will verify your review. Check back soon!",
"status": {
"pending": "Pending review",
"approved": "Approved — reward added to your order!",
"rejected": "Not approved"
},
"maxValueNote": "Dessert reward up to {value}VND",
"loading": "Loading..."
},
"wall": {
"venueName": "House of Legends",
"tabs": {
"photos": "Photos",
"spins": "Spins",
"leaderboard": "Leaderboard"
},
"photoWall": "Tonight's Photos",
"noPhotosTitle": "No photos yet",
"noPhotosSubtitle": "Be the first to share a photo!",
"topBadge": "TOP",
"tableLabel": "Table",
"recentWins": "Recent Wins",
"noSpinsYet": "No spins yet tonight",
"whosHere": "Who's Here Tonight",
"noGuestsYet": "No guests yet tonight",
"loading": "Loading..."
}
}
}7. Environment-Specific Configuration
| Variable | Description | Required | Location |
|---|---|---|---|
NEXT_PUBLIC_CONVEX_URL | Convex deployment URL | Yes (auto-set by Convex) | Client |
NEXT_PUBLIC_BASE_URL | Public URL for QR code links | Yes | Client + Server |
CLERK_PUBLISHABLE_KEY | Clerk auth (staff OAuth) | Yes | Client |
CLERK_SECRET_KEY | Clerk auth (server-side) | Yes (Convex env) | Convex |
NEXT_PUBLIC_LOCALE | Current locale (en/vi) | Yes (auto-set by next-intl) | Client |
8. TDD Test Cases
CRITICAL: All tests use USER EXPECTATION format — what the USER SEES and EXPERIENCES. No implementation code.
E2E Tests (Playwright):
test("BK-E2E-MINI-1.1: Guest scans QR and sees onboarding page");
// Given: Guest scans QR code on table
// When: Guest opens the scanned URL
// Then: Onboarding welcome page loads with create profile form
test("BK-E2E-MINI-1.2: Guest can create profile with nickname and origin");
// Given: Guest is on onboarding page
// When: Guest enters nickname "Trung" and origin "Da Nang" and submits
// Then: Profile is created and guest is redirected to table PWA
test("BK-E2E-MINI-1.3: Guest can select up to 5 mood tags");
// Given: Guest is on onboarding page with profile form visible
// When: Guest taps 5 mood tags (excited, curious, celebratory, hungry, thirsty)
// Then: All 5 tags appear selected and 6th tag is not selectable
test(
"BK-E2E-MINI-1.4: Guest is redirected to table PWA after profile creation",
);
// Given: Guest has filled in profile form with valid nickname and origin
// When: Guest taps "Join the Experience"
// Then: Guest is redirected to /table?tableId=...&token=...&profileId=...
test(
"BK-E2E-MINI-2.1: Guest sees minigame tabs (Guests, Photos, Spin, Review)",
);
// Given: Guest is on table PWA with profile completed
// When: Guest scrolls to tab bar
// Then: Guest sees tabs: Menu, Guests, Photos, Spin, Review
test("BK-E2E-MINI-2.2: Guest sees tonight's other guests in the wall tab");
// Given: Multiple guests have checked in tonight
// When: Guest taps the Guests tab
// Then: Guest sees grid of other guests with nickname and origin
test("BK-E2E-MINI-2.3: Guest can send WAVE reaction to another guest");
// Given: Guest is on Guests tab and sees another guest "Trung"
// When: Guest taps WAVE icon button on Trung's card
// Then: WAVE reaction is sent and count increments
test("BK-E2E-MINI-2.4: Guest can send CHEERS reaction to another guest");
// Given: Guest is on Guests tab
// When: Guest taps CHEERS icon on another guest's card
// Then: CHEERS reaction is sent and count increments
test("BK-E2E-MINI-2.5: Guest can send HEART reaction to another guest");
// Given: Guest is on Guests tab
// When: Guest taps HEART icon on another guest's card
// Then: HEART reaction is sent and count increments
test("BK-E2E-MINI-2.6: Sending same reaction twice removes it");
// Given: Guest sent a WAVE to another guest
// When: Guest taps WAVE again on the same guest
// Then: Reaction is removed and count decrements
test("BK-E2E-MINI-3.1: Guest can upload a photo to the photo wall");
// Given: Guest is on Photos tab and no photo submitted yet
// When: Guest taps "Choose Photo" and selects an image
// Then: Photo uploads and appears in the wall section
test("BK-E2E-MINI-3.2: Guest sees their own photo on the wall");
// Given: Guest submitted a photo earlier
// When: Guest returns to Photos tab
// Then: Guest sees their own submitted photo
test("BK-E2E-MINI-3.3: Guest can like another guest's photo");
// Given: Two guests have submitted photos
// When: Guest A taps "Like" on Guest B's photo
// Then: Like count increments for Guest B's photo
test("BK-E2E-MINI-3.4: Photo like count increments in real-time");
// Given: Wall page is open on one device, Guest PWA on another
// When: Guest likes a photo from PWA
// Then: Like count updates on wall page without refresh
test("BK-E2E-MINI-3.5: Submitting a new photo replaces the existing one");
// Given: Guest has already submitted a photo
// When: Guest submits a new photo
// Then: Old photo is replaced and like count resets to 0
test("BK-E2E-MINI-4.1: Guest sees spin wheel on the Spin tab");
// Given: Guest is on table PWA
// When: Guest taps Spin tab
// Then: Spin wheel is displayed with "Tap to Spin!" text
test("BK-E2E-MINI-4.2: Guest can tap SPIN to spin the wheel");
// Given: Guest is on Spin tab and has not spun yet
// When: Guest taps "SPIN!" button
// Then: Wheel starts spinning animation
test("BK-E2E-MINI-4.3: Guest sees spinning animation before result");
// Given: Guest tapped "SPIN!"
// When: Animation is playing
// Then: Guest sees spinning wheel with "Spinning..." label
test("BK-E2E-MINI-4.4: Guest sees their spin result on the wall");
// Given: Guest completed a spin
// When: Guest views the Spin Feed on wall page
// Then: Guest sees their table's spin result with prize text
test("BK-E2E-MINI-4.5: Guest cannot spin again after already spinning");
// Given: Guest's table has already spun tonight
// When: Guest returns to Spin tab
// Then: Guest sees the result and "You've already spun!" message
test("BK-E2E-MINI-5.1: Guest sees Google Review challenge steps");
// Given: Guest is on Review tab
// When: Page loads
// Then: Guest sees numbered step-by-step instructions
test("BK-E2E-MINI-5.2: Guest can upload a screenshot of their review");
// Given: Guest completed the Google review steps
// When: Guest taps "Upload Screenshot" and selects an image
// Then: Screenshot uploads and guest sees "Review Submitted!"
test("BK-E2E-MINI-5.3: Guest sees submitted status after upload");
// Given: Guest uploaded a screenshot
// When: Guest returns to Review tab
// Then: Guest sees "Review Submitted!" and status badge
test("BK-E2E-MINI-5.4: Guest cannot submit review twice");
// Given: Guest has already submitted a Google review
// When: Guest tries to upload another screenshot
// Then: Upload option is not available
test(
"BK-E2E-MINI-6.1: Wall auto-rotates between Photos, Spins, Leaderboard every 30s",
);
// Given: Wall page is open
// When: 30 seconds pass
// Then: Wall switches to the next segment automatically
test("BK-E2E-MINI-6.2: Wall shows live photo grid sorted by likes");
// Given: Multiple photos have been submitted with different like counts
// When: Wall is on Photos segment
// Then: Photos appear sorted by like count, highest first
test("BK-E2E-MINI-6.3: Wall shows recent spin results feed");
// Given: Multiple tables have spun tonight
// When: Wall is on Spins segment
// Then: Most recent spins appear at the top
test("BK-E2E-MINI-6.4: Wall shows tonight's guest leaderboard");
// Given: Guests have checked in tonight
// When: Wall is on Leaderboard segment
// Then: Tonight's guests are displayed in a grid
test("BK-E2E-MINI-7.1: Spin prize is added as COMP item to table order");
// Given: Guest's table wins a MENU_ITEM prize on spin
// When: Staff views the table's order in POS
// Then: Prize appears as a COMP item with 0 price
test(
"BK-E2E-MINI-7.2: Photo like count updates across all views without refresh",
);
// Given: Photo has 5 likes shown on wall
// When: Another guest likes the photo
// Then: Like count updates to 6 on wall and PWA without page reloadComponent Tests (Vitest + RTL):
it("MINI-1.1: MoodSelector renders all 8 mood options");
// Given: MoodSelector is mounted with empty selection
// When: Component renders
// Then: All 8 mood buttons are visible (excited, curious, relaxed, celebratory, romantic, adventurous, hungry, thirsty)
it("MINI-1.2: MoodSelector allows selecting up to 5 moods");
// Given: MoodSelector has 4 moods selected
// When: Guest taps a 5th mood
// Then: 5th mood becomes selected
it("MINI-1.3: MoodSelector deselects a mood when tapped again");
// Given: MoodSelector has "excited" selected
// When: Guest taps "excited" again
// Then: "excited" is no longer selected
it("MINI-1.4: ProfileForm validates nickname length (2-20 chars)");
// Given: ProfileForm is rendered
// When: Guest enters "A" (1 char) as nickname
// Then: Form submission is blocked with validation error
it("MINI-1.5: ProfileForm shows error for too-short nickname");
// Given: ProfileForm is rendered with empty nickname
// When: Guest submits form
// Then: Error message appears "Nickname must be at least 2 characters"
it("MINI-2.1: GuestWall renders guest grid with reaction icon buttons");
// Given: GuestWall has multiple guests to display
// When: Component renders
// Then: Guest cards appear with IconSymbol reaction buttons (wave, sparkles, heart)
it("MINI-2.2: GuestWall hides self from guest list");
// Given: GuestWall is showing 3 guests including the current guest
// When: Component renders
// Then: Current guest's own card is not visible in the grid
it("MINI-2.3: SpinWheel shows result after spinning");
// Given: Guest has completed a spin and result is available
// When: Guest returns to Spin tab
// Then: Result text is displayed instead of spin button
it("MINI-2.4: SpinWheel disables button while spinning");
// Given: Guest is on Spin tab and hasn't spun yet
// When: Guest taps "SPIN!"
// Then: Button is disabled during spinning animation
it("MINI-2.5: GoogleReview shows all steps from challengeConfig");
// Given: challengeConfig has 4 steps configured for GOOGLE_REVIEW
// When: GoogleReviewContent renders
// Then: All 4 steps appear as numbered instructions
it("MINI-2.6: GoogleReview shows submitted state after successful upload");
// Given: Guest has successfully submitted a Google review screenshot
// When: GoogleReviewContent renders
// Then: "Review Submitted!" title and status badge are shown
it("MINI-3.1: PhotoGrid renders photos sorted by like count");
// Given: 3 photos with like counts 10, 5, and 15
// When: PhotoGrid renders
// Then: Photos appear in order: 15 likes, 10 likes, 5 likes
it("MINI-3.2: PhotoGrid highlights top photo with gold ring");
// Given: PhotoGrid has multiple photos
// When: Photos render
// Then: Top (most liked) photo has gold ring border
it("MINI-3.3: SpinFeed renders spin results with timestamps");
// Given: 2 spin results exist for tonight
// When: SpinFeed renders
// Then: Each result shows prize text and time it was spun
it("MINI-3.4: GuestGrid renders guests with mood tag chips");
// Given: 3 guests with mood tags checked in tonight
// When: GuestGrid renders
// Then: Each guest card shows mood tag chipsBackend/Mutation Tests (Vitest):
it("MINI-ORD-1.1: submitPhoto creates a photo submission for table");
// Given: Valid tableId, profileId, orderId, and imageUrl
// When: submitPhoto mutation is called
// Then: photoSubmission is created with status ACTIVE and likeCount 0
it("MINI-ORD-1.2: submitPhoto replaces existing photo for same table+date");
// Given: A photo already exists for tableId "T1" on date "2026-05-04"
// When: submitPhoto is called for tableId "T1" on same date with new imageUrl
// Then: Existing photo is updated (not new insert), likeCount resets to 0
it("MINI-ORD-1.3: likePhoto increments like count");
// Given: A photo submission exists with likeCount 3
// When: likePhoto is called by a profile that hasn't liked it
// Then: photo submission's likeCount becomes 4
it("MINI-ORD-1.4: likePhoto toggles off when already liked");
// Given: A profile has already liked a photo
// When: likePhoto is called again by the same profile
// Then: Like is removed (toggled off)
it("MINI-ORD-1.5: likePhoto decrements count when unliked");
// Given: A photo has 4 likes and a profile has already liked it
// When: That profile calls likePhoto (toggle off)
// Then: Photo's likeCount becomes 3
it("MINI-ORD-2.1: spin selects a prize using weighted random");
// Given: Spin prizes exist with weights [30, 20, 25, 5, 20]
// When: spin mutation is called
// Then: A prize is selected based on weight distribution
it("MINI-ORD-2.2: spin prevents same table from spinning twice");
// Given: Table "T1" already has a spinResult for date "2026-05-04"
// When: spin mutation is called for tableId "T1" on same date
// Then: Error "TABLE_ALREADY_SPUN: Table has already spun" is thrown
it("MINI-ORD-2.3: spin adds MENU_ITEM prize as comp orderItem");
// Given: A prize with prizeType MENU_ITEM and menuItemId is selected
// When: spin mutation completes successfully
// Then: An orderItem is created with isComp: true and compSource: "SPIN"
it("MINI-ORD-3.1: sendReaction creates a reaction record");
// Given: Two different guest profiles
// When: sendReaction is called from profile A to profile B with type WAVE
// Then: guestReactions record is created
it("MINI-ORD-3.2: sendReaction removes existing reaction of same type");
// Given: Profile A already sent a WAVE to Profile B
// When: Profile A sends WAVE again to Profile B
// Then: Reaction is deleted (toggle off)
it("MINI-ORD-3.3: sendReaction throws CANNOT_REACT_SELF for self");
// Given: A guest profile
// When: sendReaction is called with fromProfileId === toProfileId
// Then: Error "CANNOT_REACT_SELF: Cannot react to yourself" is thrown
it("MINI-ORD-4.1: approveGoogleReview creates comp orderItem for reward");
// Given: A pending challengeSubmission and a menuItem
// When: approveGoogleReview is called with valid rewardMenuItemId
// Then: orderItem is created with isComp: true, compSource: "GOOGLE_REVIEW"
it(
"MINI-ORD-4.2: approveGoogleReview throws when dessert price exceeds maxValue",
);
// Given: maxValue is set to 150000 and a menuItem costs 200000
// When: approveGoogleReview is called with that menuItem
// Then: Error "DESSERT_VALUE_EXCEEDED: Dessert price exceeds cap" is thrown
it("MINI-ORD-4.3: rejectGoogleReview sets status to REJECTED");
// Given: A pending challengeSubmission
// When: rejectGoogleReview is called with optional notes
// Then: Submission status is set to REJECTED and reviewedAt is set
it("MINI-ORD-5.1: completeProfile rejects nickname shorter than 2 chars");
// Given: A valid profileId
// When: completeProfile is called with nickname "A" (1 char)
// Then: Error "INVALID_NICKNAME: Nickname must be 2-20 characters" is thrown
it("MINI-ORD-5.2: completeProfile rejects nickname longer than 20 chars");
// Given: A valid profileId
// When: completeProfile is called with nickname of 21 characters
// Then: Error "INVALID_NICKNAME: Nickname must be 2-20 characters" is thrown9. Cross-Plan Dependencies
| Dependency | Plan | Shared Schema / API |
|---|---|---|
| Guest profiles table | 2026-05-03-guest-profiles-plan.md | guestProfiles table, profiles.ts CRUD functions |
| Table POS system | 2026-05-03-table-pos-system.md | orders table, orderItems.isComp/compSource fields, QR token linking, orderId for minigame comp item linkage |
| Show/Occurrence system | 2026-05-03-show-system.md | showOccurrences.showDate for wall filtering by date |
| Admin backoffice | 2026-05-03-admin-backoffice-plan.md | users table for reviewedBy field in Google Review approval |
| Challenge config admin | (internal) | challengeConfig table, spinPrizes table |
| Foundation auth helpers | 2026-05-03-foundation-plan.md | adminMutation/staffMutation helpers (implemented) |
Dependency resolution order:
foundation-plan.md— auth helpers already implemented (can proceed in parallel)guest-profiles-plan.md— implement guest profile system (entry point for social features)show-system.md— providesshowDateneeded for wall date filteringtable-pos-system.md— providesorderIdneeded for spin and Google Review comp item linkage- This plan (minigames) — depends on all above
Schema sharing:
guestProfilestable: referenced by all social features (reactions, photo wall, spin, Google review)orderItems.isComp+orderItems.compSource: added in this plan's schema, read by Table POS for displaychallengeConfigtable: configured by admin, read by guest-facing challenge components
10. Performance Considerations
- Convex subscriptions: All wall content uses
useQuery— auto-subscribed to real-time updates via WebSocket. No manual polling, no ping endpoints needed. - Photo grid: Images use
loading="lazy"via Next.js Image. Masonry layout with CSS Grid. - Spin wheel animation: GPU-accelerated via
framer-motiontransform: rotate.will-change: transformapplied during spin. Visual angle pre-computed from server result (not random at render). - Auto-rotate wall:
setIntervalcleared on unmount. Pauses when tab hidden (visibilitychange) to save resources. - Reaction sends: Optimistic UI not implemented — reactions show after server confirmation via Convex subscription.
- Photo upload: Streamed via
window.convex.uploadFile()— large files don't block main thread. - Profile completeness check:
useQuery(api.profiles.getById, profileId ? { id: profileId } : "skip")pattern prevents unnecessary calls beforeprofileIdis available. - Reaction count aggregation:
getReactionsReceivediterates only tonight's reactions (filtered byshowDate), not all-time, keeping memory bounded. - Wall photo query: Filters by
status: ACTIVEat database index level (by_status) before JavaScript date filter, minimizing memory usage. - IconSymbol rendering: Reaction buttons use
IconSymbolcomponents (vector SVGs) — lightweight compared to emoji, consistent rendering across all platforms.
Auth-Protected Routes
Admin Surfaces — Clerk Auth Required
The following routes require staff/admin authentication:
| Route | Auth Required | Role |
|---|---|---|
/admin/challenges | Clerk auth | ADMIN role |
/pos/challenges | Clerk auth | STAFF or ADMIN role |
Auth pattern for admin mutations:
The approveGoogleReview and rejectGoogleReview mutations use inline auth checks (verifying ctx.auth.getUserIdentity() returns a valid identity). The adminMutation/staffMutation helpers exist in convex/auth.ts but these specific mutations use inline checks which is acceptable for their use case.
Guest PWA — Token-Based Access
Guest-facing PWA pages use QR token validation instead of Clerk auth:
/onboard?tableId=xxx&token=yyy— token validated server-side ingetOrCreateProfile/table?tableId=xxx&token=yyy&profileId=zzz— token validated against existing profile/wall— public, no auth required (display-only)
Logging — Consola Usage
Pattern: Use consola directly (already configured in project).
import { consola } from "consola";
consola.info("Photo submitted", { profileId, tableId, showDate });
consola.warn("Profile validation failed", { errors: parsed.error.errors });
consola.error("Spin failed", {
error: err instanceof Error ? err.message : String(err),
});Note: No custom logger wrapper needed. The project uses
consoladirectly as configured in existing project files.
Consistency Audit: minigames-system
P0 Violations (fixed in plan text)
| # | Location | Issue | Fix Applied |
|---|---|---|---|
| P0-01 | spin-wheel.tsx (Step 4, handleSpin) | Math.random() used for visual angle | Replaced with crypto.getRandomValues() — cryptographically secure random for visual effect only; server-determined prize is unaffected |
| P0-02 | spin-wheel.tsx (Step 4, secureWeightedRandom) | Math.random() used for weighted selection | Replaced with crypto.getRandomValues() — cryptographic security for prize selection |
P1 Violations (fixed in plan text)
| # | Location | Issue | Fix Applied |
|---|---|---|---|
| P1-01 | photo-wall.tsx (Step 3, handlePhotoSelect) | setUploading(true) called synchronously and async work done outside startTransition | Wrapped all async work (including setUploading/setUploading(false)) inside startTransition(async () => {...}) callback |
| P1-02 | photo-wall.tsx (Step 3, handleLike) | useTransition returns isPending as first element but was destructured as unused (isPending) | Changed to [, startTransition] to explicitly discard the unused pending state |
| P1-03 | photo-wall.tsx (Step 3, handlePhotoSelect) | handlePhotoSelect was declared async but called setUploading outside the transition callback | Removed async keyword from function declaration; all work moved into startTransition(async () => {...}) |
| P1-04 | google-review.tsx (Step 5, handleScreenshotSelect) | Same pattern as P1-01: setUploading(true) outside startTransition | Same fix: wrapped all work inside startTransition(async () => {...}), removed async keyword from function declaration |
| P1-05 | onboard/page.tsx (Step 1, OnboardContent) | router.push calls not wrapped in startTransition | Wrapped both router.push calls inside startTransition(() => {...}) |
| P1-06 | wall/page.tsx (Step 1) | useTransition imported but unused; setSegment was unnecessarily wrapped in startTransition | Removed useTransition import; setSegment from nuqs already batches state efficiently without startTransition |
| P1-07 | guest-wall.tsx (Step 2, reaction buttons) | Reaction buttons used letter abbreviations (W, C, H) — text characters not IconSymbol components | Replaced text buttons with IconSymbol components using REACTION_ICONS mapping: hand.wave for WAVE, sparkles for CHEERS, heart.fill for HEART. Added aria-label for accessibility. |
| P1-08 | profile-form.tsx (Step 2, ProfileForm) | useTransition destructured as [, startTransition] ignoring isPending. Button used disabled={isPending} referencing a useState variable that was never set to true. Transition's pending state was unused. | Changed to const [isPending, startTransition] = useTransition(). Button disabled and text now correctly use the transition's built-in isPending. Removed unused useState for pending tracking. |
P0 Gaps (cannot fix in plan — requires codebase change)
| # | Gap | Reason | Mitigation |
|---|---|---|---|
| GAP-02 | Clerk OAuth flow | Requires Clerk webhook setup and session management | OAuth buttons are simulated with crypto.randomUUID() placeholder data. Marked with [P0 GAP] in oauth-buttons.tsx. |
| GAP-03 | orderId in Google review submission | Table POS plan (2026-05-03-table-pos-system.md) doesn't yet provide orderId to minigame components | Component passes null as unknown as Id<"orders">. Backend mutation still requires valid orderId for comp item linkage — this will be resolved when Table POS plan provides orderId. Marked with [P0 GAP] in google-review.tsx. |
Verified Correct Patterns
| Pattern | Status |
|---|---|
SSG-compatible: nuqs useQueryState instead of useParams() | Verified — all page routes use nuqs |
No as any type assertions (except in GAP-03 workaround) | Verified — Zod schemas used for validation |
Weighted random uses crypto.getRandomValues() (not Math.random()) | Verified in secureWeightedRandom() function |
Error codes via MINIGAME_ERROR_CODES const object | Verified |
| Zod schemas for all mutation inputs | Verified — complete schemas in Section 1 |
useTransition used in all async handlers | Verified — handlePhotoSelect, handleLike, handleSpin, handleScreenshotSelect all use startTransition |
Suspense boundaries on all async components | Verified |
All user-facing strings via useTranslations/getTranslations | Verified |
Premium design tokens (gold #C5A059, bg #1a1a1a) | Verified |
crypto.randomUUID() for ID generation | Verified in oauth-buttons.tsx |
Real-time via Convex useQuery subscriptions | Verified — no polling, WebSocket-based |
IconSymbol component used (no emoji/letter abbreviations) | Verified — all UI icons use IconSymbol |
crypto.getRandomValues() for spin visual angle | Verified — replaces Math.random() |
| Wall auto-rotate pauses on tab hidden | Verified via visibilitychange listener |
Reaction buttons have aria-label for accessibility | Verified — aria-label={Send ${type} reaction} added |