plans
2026-05-03
2026 05 03 Minigames System

Minigames System

Spec file: docs/superpowers/specs/15-minigames.md

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Implement 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 wall

SSG Constraint: NO useParams() — all page routes use nuqs useQueryState for tableId, token, profileId, and tab. 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 with nuqs for 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 guestProfiles table
  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 guestReactions table
  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 challengeConfig table
  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 photoSubmissions table

[P1 FIX]: Must include by_table_show compound 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 photoLikes table

[P1 FIX]: updatedAt removed — mutation only sets createdAt.

  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 spinPrizes table

[P1 FIX]: prizeType uses FREE_ITEM per spec section 5.3 — not DISCOUNT.

  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 spinResults table
  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 challengeSubmissions table
  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 / compSource to orderItems

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 codegen

Expected: 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.push calls must be wrapped in startTransition to 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]: useTransition returns [isPending, startTransition]isPending was previously ignored (destructured as [, startTransition]) and the button used an unset useState variable. Fixed to use the transition's built-in isPending for the disabled state. Also removed the unused useState import (no useState needed 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]: useTransition import removed — nuqs' setSegment already batches state updates efficiently without startTransition wrapping. The useTransition was 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 IconSymbol components per the no-emoji rule. Use meaningful icon names: hand.wave for WAVE, sparkles for CHEERS, heart.fill for 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]: handlePhotoSelect must wrap all async work (including setUploading) inside startTransition callback. handleLike correctly uses startTransition but the returned isPending value 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() (not Math.random()) for cryptographic security. Actual prize is server-determined via secureWeightedRandom() 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]: orderId not available in component — passed as null until Table POS plan provides it. Backend mutation still requires valid orderId. This gap will be resolved when orderId is 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:

MutationError CodeUser-Facing Message
profiles.completeProfileINVALID_NICKNAME"Nickname must be 2-20 characters"
profiles.sendReactionCANNOT_REACT_SELF"Cannot react to yourself"
challenges.submitGoogleReviewALREADY_SUBMITTED"Table has already submitted a review"
challenges.spinTABLE_ALREADY_SPUN"Table has already spun"
challenges.spinNO_PRIZES"No prizes configured"
challenges.approveGoogleReviewSUBMISSION_NOT_FOUND"Submission not found"
challenges.approveGoogleReviewNOT_PENDING"Already reviewed"
challenges.approveGoogleReviewMENU_ITEM_NOT_FOUND"Menu item not found"
challenges.approveGoogleReviewDESSERT_VALUE_EXCEEDED"Dessert price exceeds cap of {X}VND"
challenges.approveGoogleReviewNOT_AUTHORIZED"Not authorized"
challenges.rejectGoogleReviewNOT_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-motion with GPU-accelerated transforms (transform: rotate). will-change: transform applied 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 IconSymbol icons. Accessible aria-label provided 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

VariableDescriptionRequiredLocation
NEXT_PUBLIC_CONVEX_URLConvex deployment URLYes (auto-set by Convex)Client
NEXT_PUBLIC_BASE_URLPublic URL for QR code linksYesClient + Server
CLERK_PUBLISHABLE_KEYClerk auth (staff OAuth)YesClient
CLERK_SECRET_KEYClerk auth (server-side)Yes (Convex env)Convex
NEXT_PUBLIC_LOCALECurrent 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 reload

Component 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 chips

Backend/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 thrown

9. Cross-Plan Dependencies

DependencyPlanShared Schema / API
Guest profiles table2026-05-03-guest-profiles-plan.mdguestProfiles table, profiles.ts CRUD functions
Table POS system2026-05-03-table-pos-system.mdorders table, orderItems.isComp/compSource fields, QR token linking, orderId for minigame comp item linkage
Show/Occurrence system2026-05-03-show-system.mdshowOccurrences.showDate for wall filtering by date
Admin backoffice2026-05-03-admin-backoffice-plan.mdusers table for reviewedBy field in Google Review approval
Challenge config admin(internal)challengeConfig table, spinPrizes table
Foundation auth helpers2026-05-03-foundation-plan.mdadminMutation/staffMutation helpers (implemented)

Dependency resolution order:

  1. foundation-plan.md — auth helpers already implemented (can proceed in parallel)
  2. guest-profiles-plan.md — implement guest profile system (entry point for social features)
  3. show-system.md — provides showDate needed for wall date filtering
  4. table-pos-system.md — provides orderId needed for spin and Google Review comp item linkage
  5. This plan (minigames) — depends on all above

Schema sharing:

  • guestProfiles table: 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 display
  • challengeConfig table: 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-motion transform: rotate. will-change: transform applied during spin. Visual angle pre-computed from server result (not random at render).
  • Auto-rotate wall: setInterval cleared 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 before profileId is available.
  • Reaction count aggregation: getReactionsReceived iterates only tonight's reactions (filtered by showDate), not all-time, keeping memory bounded.
  • Wall photo query: Filters by status: ACTIVE at database index level (by_status) before JavaScript date filter, minimizing memory usage.
  • IconSymbol rendering: Reaction buttons use IconSymbol components (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:

RouteAuth RequiredRole
/admin/challengesClerk authADMIN role
/pos/challengesClerk authSTAFF 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 in getOrCreateProfile
  • /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 consola directly as configured in existing project files.


Consistency Audit: minigames-system

P0 Violations (fixed in plan text)

#LocationIssueFix Applied
P0-01spin-wheel.tsx (Step 4, handleSpin)Math.random() used for visual angleReplaced with crypto.getRandomValues() — cryptographically secure random for visual effect only; server-determined prize is unaffected
P0-02spin-wheel.tsx (Step 4, secureWeightedRandom)Math.random() used for weighted selectionReplaced with crypto.getRandomValues() — cryptographic security for prize selection

P1 Violations (fixed in plan text)

#LocationIssueFix Applied
P1-01photo-wall.tsx (Step 3, handlePhotoSelect)setUploading(true) called synchronously and async work done outside startTransitionWrapped all async work (including setUploading/setUploading(false)) inside startTransition(async () => {...}) callback
P1-02photo-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-03photo-wall.tsx (Step 3, handlePhotoSelect)handlePhotoSelect was declared async but called setUploading outside the transition callbackRemoved async keyword from function declaration; all work moved into startTransition(async () => {...})
P1-04google-review.tsx (Step 5, handleScreenshotSelect)Same pattern as P1-01: setUploading(true) outside startTransitionSame fix: wrapped all work inside startTransition(async () => {...}), removed async keyword from function declaration
P1-05onboard/page.tsx (Step 1, OnboardContent)router.push calls not wrapped in startTransitionWrapped both router.push calls inside startTransition(() => {...})
P1-06wall/page.tsx (Step 1)useTransition imported but unused; setSegment was unnecessarily wrapped in startTransitionRemoved useTransition import; setSegment from nuqs already batches state efficiently without startTransition
P1-07guest-wall.tsx (Step 2, reaction buttons)Reaction buttons used letter abbreviations (W, C, H) — text characters not IconSymbol componentsReplaced 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-08profile-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)

#GapReasonMitigation
GAP-02Clerk OAuth flowRequires Clerk webhook setup and session managementOAuth buttons are simulated with crypto.randomUUID() placeholder data. Marked with [P0 GAP] in oauth-buttons.tsx.
GAP-03orderId in Google review submissionTable POS plan (2026-05-03-table-pos-system.md) doesn't yet provide orderId to minigame componentsComponent 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

PatternStatus
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 objectVerified
Zod schemas for all mutation inputsVerified — complete schemas in Section 1
useTransition used in all async handlersVerified — handlePhotoSelect, handleLike, handleSpin, handleScreenshotSelect all use startTransition
Suspense boundaries on all async componentsVerified
All user-facing strings via useTranslations/getTranslationsVerified
Premium design tokens (gold #C5A059, bg #1a1a1a)Verified
crypto.randomUUID() for ID generationVerified in oauth-buttons.tsx
Real-time via Convex useQuery subscriptionsVerified — no polling, WebSocket-based
IconSymbol component used (no emoji/letter abbreviations)Verified — all UI icons use IconSymbol
crypto.getRandomValues() for spin visual angleVerified — replaces Math.random()
Wall auto-rotate pauses on tab hiddenVerified via visibilitychange listener
Reaction buttons have aria-label for accessibilityVerified — aria-label={Send ${type} reaction} added