plans
2026-05-03
2026 05 03 Lucky Spin Plan

Lucky Spin Implementation Plan

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

Goal: Implement the Lucky Spin wheel. One spin per table per show. Server-side result (anti-cheat). Prize added as comp item to table order. Result shown on shared wall.

Tech Stack: Next.js 16, Convex mutations + queries, Framer Motion for wheel animation, Tailwind CSS v4.

Spec: docs/superpowers/specs/07-lucky-spin.md


Business Summary

What this does: Gives each table one spin of a prize wheel per show. The result is determined server-side (anti-cheat) and the prize (free drink, dessert, or discount) is automatically added to the table order as a comp item. Recent wins are displayed on the shared wall in real-time.

Why it matters: Adds gamified excitement to every table visit. The anticipation of the spin creates a memorable moment, and winning a prize reinforces positive emotions. The shared wall display builds excitement across the venue as other guests watch wins happen in real-time.

Time to implement: 3-5 days | Complexity: Medium

Dependencies: Foundation (guestProfiles, orders, tables) must be complete first

Task 1: Add Spin Tables to Schema

Files:

  • Modify: convex/schema.ts

  • Create: apps/frontend/lib/schemas/lucky-spin.ts

  • Create: apps/frontend/lib/schemas/lucky-spin-errors.ts

  • Step 1: Add spinPrizes and spinResults tables

spinPrizes: defineTable({
  label: v.string(),
  prizeType: v.union(v.literal("MENU_ITEM"), v.literal("DISCOUNT")),
  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"]),
 
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(),
})
  .index("by_show_date", ["showDate"])
  .index("by_table_show", ["tableId", "showDate"])
  .index("by_profile", ["profileId"]),
  • Step 2: Create Zod schemas
// apps/frontend/lib/schemas/lucky-spin.ts
import { z } from "zod";
 
export const SpinWheelSchema = z.object({
  profileId: z.string().min(1, "Profile ID is required"),
  orderId: z.string().min(1, "Order ID is required"),
  tableId: z.string().min(1, "Table ID is required"),
});
 
export const GetSpinResultSchema = z.object({
  profileId: z.string().min(1, "Profile ID is required"),
});
 
export type SpinWheelInput = z.infer<typeof SpinWheelSchema>;
export type GetSpinResultInput = z.infer<typeof GetSpinResultSchema>;
// apps/frontend/lib/schemas/lucky-spin-errors.ts
export const SpinErrorCode = {
  ALREADY_SPUN: "SPIN_ALREADY_SPUN",
  NO_PRIZES_AVAILABLE: "SPIN_NO_PRIZES_AVAILABLE",
  PRIZE_NOT_FOUND: "SPIN_PRIZE_NOT_FOUND",
  ORDER_NOT_FOUND: "SPIN_ORDER_NOT_FOUND",
} as const;
type SpinError = keyof typeof SpinErrorCode;
  • Step 3: Commit
git add convex/schema.ts apps/frontend/lib/schemas/lucky-spin.ts apps/frontend/lib/schemas/lucky-spin-errors.ts
git commit -m "feat(lucky-spin): add spinPrizes and spinResults tables"

Phase 2: Spin Convex Functions

Task 2: Add Spin Functions

Files:

  • Modify: convex/functions/challenges.ts

  • Step 1: Add spin wheel function

// Named error codes for lucky spin operations
const SpinErrorCode = {
  ALREADY_SPUN: "SPIN_ALREADY_SPUN",
  NO_PRIZES_AVAILABLE: "SPIN_NO_PRIZES_AVAILABLE",
  PRIZE_NOT_FOUND: "SPIN_PRIZE_NOT_FOUND",
  ORDER_NOT_FOUND: "SPIN_ORDER_NOT_FOUND",
} as const;
 
// Cryptographically secure random pick for weighted selection
function weightedRandomPick<T extends { weight: number }>(items: T[]): T {
  const totalWeight = items.reduce((sum, p) => sum + p.weight, 0);
  // Use crypto.getRandomValues for secure random — no Math.random()
  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];
}
 
export const spinWheel = mutation({
  args: {
    profileId: v.id("guestProfiles"),
    orderId: v.id("orders"),
    tableId: v.id("tables"),
  },
  handler: async (ctx, { profileId, orderId, tableId }) => {
    const today = new Date().toISOString().split("T")[0];
 
    // Check one spin per profile per show
    const existing = await ctx.db
      .query("spinResults")
      .withIndex("by_profile", (q) => q.eq("profileId", profileId))
      .first();
    if (existing && existing.showDate === today) {
      throw new Error(`${SpinErrorCode.ALREADY_SPUN}: Already spun today`);
    }
 
    // Get enabled prizes
    const prizes = await ctx.db
      .query("spinPrizes")
      .withIndex("by_enabled", (q) => q.eq("enabled", true))
      .collect();
 
    if (prizes.length === 0) {
      throw new Error(
        `${SpinErrorCode.NO_PRIZES_AVAILABLE}: No prizes configured`,
      );
    }
 
    // Weighted random pick using crypto (server-side anti-cheat)
    const selectedPrize = weightedRandomPick(prizes);
 
    const now = Date.now();
 
    // If MENU_ITEM prize, add as comp to order
    if (selectedPrize.prizeType === "MENU_ITEM" && selectedPrize.menuItemId) {
      const menuItem = await ctx.db.get(selectedPrize.menuItemId);
      if (!menuItem) {
        throw new Error(
          `${SpinErrorCode.PRIZE_NOT_FOUND}: Prize menu item not found`,
        );
      }
      await ctx.db.insert("orderItems", {
        orderId,
        menuItemId: selectedPrize.menuItemId,
        quantity: 1,
        unitPrice: 0,
        status: "PENDING",
        station: menuItem.station,
        notes: undefined,
        isComp: true,
        compSource: "SPIN",
        createdAt: now,
        updatedAt: now,
      });
    }
 
    // If DISCOUNT prize, apply to order total
    if (
      selectedPrize.prizeType === "DISCOUNT" &&
      selectedPrize.discountPercent
    ) {
      const order = await ctx.db.get(orderId);
      if (!order) {
        throw new Error(`${SpinErrorCode.ORDER_NOT_FOUND}: Order not found`);
      }
      const discount = Math.round(
        order.totalAmount * (selectedPrize.discountPercent / 100),
      );
      await ctx.db.patch(orderId, {
        totalAmount: order.totalAmount - discount,
        updatedAt: now,
      });
    }
 
    // Record spin result
    const resultId = await ctx.db.insert("spinResults", {
      profileId,
      orderId,
      tableId,
      prizeId: selectedPrize._id,
      displayText: selectedPrize.label,
      showDate: today,
      createdAt: now,
    });
 
    return { resultId, prize: selectedPrize };
  },
});
 
export const getSpinResult = query({
  args: { profileId: v.id("guestProfiles") },
  handler: async (ctx, { profileId }) => {
    const today = new Date().toISOString().split("T")[0];
    return await ctx.db
      .query("spinResults")
      .withIndex("by_profile", (q) => q.eq("profileId", profileId))
      .first();
  },
});
 
export const getRecentSpins = query({
  args: { limit: v.optional(v.number()) },
  handler: async (ctx, { limit }) => {
    const today = new Date().toISOString().split("T")[0];
    const results = await ctx.db
      .query("spinResults")
      .withIndex("by_show_date", (q) => q.eq("showDate", today))
      .collect();
    return results.slice(-(limit ?? 5)).reverse();
  },
});
  • Step 2: Commit
git add convex/functions/challenges.ts
git commit -m "feat(lucky-spin): add spin wheel function with weighted random"

Phase 3: Spin Wheel UI

Task 3: Create Spin Wheel Component

Files:

  • Create: apps/frontend/components/minigames/spin-wheel.tsx

  • Step 1: Create spin wheel component

// apps/frontend/components/minigames/spin-wheel.tsx
"use client";
import { useState, useTransition } from "react";
import { useMutation, useQuery } from "convex/react";
import { api } from "~/convex/_generated/api";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { motion } from "framer-motion";
import { IconSymbol } from "~/components/ui/icon-symbol";
import { SpinErrorCode } from "~/lib/schemas/lucky-spin-errors";
 
export function SpinWheel({ profileId, orderId, tableId }: {
  profileId: string;
  orderId: string;
  tableId: string;
}) {
  const t = useTranslations("minigames.luckySpin");
  const [spinning, setSpinning] = useState(false);
  const [result, setResult] = useState<string | null>(null);
  const [isPending, startTransition] = useTransition();
  const spinWheel = useMutation(api.challenges.spinWheel);
  const existingResult = useQuery(api.challenges.getSpinResult, { profileId });
 
  const handleSpin = async () => {
    if (spinning || isPending) return;
    setSpinning(true);
    setResult(null);
 
    startTransition(async () => {
      try {
        // tableId is passed through to the mutation for the spinResults record
        const { prize } = await spinWheel({ profileId, orderId, tableId });
        setResult(prize.label);
        setSpinning(false);
      } catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        if (message.includes(SpinErrorCode.ALREADY_SPUN)) {
          toast.error(t("errorAlreadySpun"));
        } else {
          toast.error(t("errorSpinFailed"));
        }
        setSpinning(false);
      }
    });
  };
 
  if (existingResult) {
    return (
      <div className="text-center py-8">
        <IconSymbol name="sparkles" size={40} className="mx-auto mb-4 text-accent" />
        <p className="font-serif text-2xl text-accent mb-2">{t("youWon")}</p>
        <p className="text-white text-xl font-bold">{existingResult.displayText}</p>
        <p className="text-gray-400 text-sm mt-2">{t("addedToOrder")}</p>
      </div>
    );
  }
 
  return (
    <div className="flex flex-col items-center">
      {/* Wheel */}
      <div className="relative w-64 h-64 mb-6">
        <motion.div
          animate={spinning ? { rotate: 1440 + 720 } : {}}
          transition={spinning ? { duration: 4, ease: "easeOut" } : {}}
          className="w-full h-full rounded-full border-4 border-accent overflow-hidden"
        >
          {/* Wheel segments rendered via CSS */}
          <div className="w-full h-full relative">
            {/* Segments injected by wall display */}
          </div>
        </motion.div>
        {/* Pointer — use border-t-[12px] (Tailwind arbitrary value syntax) */}
        <div className="absolute top-0 left-1/2 -translate-x-1/2 -translate-y-0.5 w-0 h-0 border-l-8 border-r-8 border-t-[12px] border-l-transparent border-r-transparent border-t-accent" />
      </div>
 
      {result ? (
        <div className="text-center">
          <p className="text-2xl font-serif text-accent">{result}</p>
          <p className="text-gray-400 text-sm mt-1">{t("addedToOrder")}</p>
        </div>
      ) : (
        <button
          onClick={handleSpin}
          disabled={spinning || isPending}
          className="px-8 py-4 bg-accent text-black font-bold text-lg rounded-full disabled:opacity-50"
        >
          {spinning || isPending ? t("spinning") : t("spin")}
        </button>
      )}
    </div>
  );
}
  • Step 2: Add Framer Motion dependency check
npm list framer-motion 2>/dev/null || npm install framer-motion
  • Step 3: Commit
git add apps/frontend/components/minigames/spin-wheel.tsx
git commit -m "feat(lucky-spin): add spin wheel UI component"

Phase 4: Wall Spin Feed

Task 4: Create Spin Results Feed for Wall

Files:

  • Create: apps/frontend/components/wall/spin-feed.tsx

  • Step 1: Create spin feed component

// 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/icon-symbol";
import type { Id } from "convex/_generated/dataModel";
 
type SpinResult = {
  _id: Id<"spinResults">;
  _creationTime: number;
  profileId: Id<"guestProfiles">;
  orderId: Id<"orders">;
  tableId: Id<"tables">;
  prizeId: Id<"spinPrizes">;
  displayText: string;
  showDate: string;
  createdAt: number;
};
 
function SpinFeedSkeleton() {
  return (
    <div className="space-y-3">
      {Array.from({ length: 3 }).map((_, i) => (
        <div key={i} className="flex items-center gap-3 p-3 bg-surface rounded-lg animate-pulse">
          <div className="w-8 h-8 rounded-full bg-accent/20" />
          <div className="flex-1 space-y-1">
            <div className="h-4 w-24 bg-accent/20 rounded" />
            <div className="h-3 w-32 bg-accent/10 rounded" />
          </div>
        </div>
      ))}
    </div>
  );
}
 
export function SpinFeed() {
  const t = useTranslations("minigames.luckySpin");
  const spins = useQuery(api.challenges.getRecentSpins, { limit: 5 });
 
  if (!spins?.length) {
    return (
      <div className="flex flex-col items-center justify-center py-8 text-gray-400">
        <IconSymbol name="sparkles" size={40} className="mb-3 opacity-50" />
        <p className="text-sm">{t("noSpinsYet")}</p>
      </div>
    );
  }
 
  return (
    <Suspense fallback={<SpinFeedSkeleton />}>
      <div className="space-y-3">
        <h3 className="font-serif text-xl text-accent">{t("recentWins")}</h3>
        {spins.map((spin: SpinResult) => (
          <div key={spin._id} className="flex items-center gap-3 p-3 bg-surface rounded-lg">
            <IconSymbol name="star.fill" size={32} className="text-accent" />
            <div>
              <p className="text-white font-medium">{t("tableWon", { table: spin.tableId })}</p>
              <p className="text-accent text-sm">{t("wonPrize", { prize: spin.displayText })}</p>
            </div>
          </div>
        ))}
      </div>
    </Suspense>
  );
}
  • Step 2: Commit
git add apps/frontend/components/wall/spin-feed.tsx
git commit -m "feat(lucky-spin): add spin feed for display wall"

Enrichment Sections

1. Zod Schemas

// apps/frontend/lib/schemas/lucky-spin.ts
import { z } from "zod";
 
export const SpinWheelSchema = z.object({
  profileId: z.string().min(1, "Profile ID is required"),
  orderId: z.string().min(1, "Order ID is required"),
  tableId: z.string().min(1, "Table ID is required"),
});
 
export const GetSpinResultSchema = z.object({
  profileId: z.string().min(1, "Profile ID is required"),
});
 
export type SpinWheelInput = z.infer<typeof SpinWheelSchema>;
export type GetSpinResultInput = z.infer<typeof GetSpinResultSchema>;
// apps/frontend/lib/schemas/lucky-spin-errors.ts
export const SpinErrorCode = {
  ALREADY_SPUN: "SPIN_ALREADY_SPUN",
  NO_PRIZES_AVAILABLE: "SPIN_NO_PRIZES_AVAILABLE",
  PRIZE_NOT_FOUND: "SPIN_PRIZE_NOT_FOUND",
  ORDER_NOT_FOUND: "SPIN_ORDER_NOT_FOUND",
} as const;
type SpinError = keyof typeof SpinErrorCode;

2. Error Handling

OperationError CodeMessage KeyNotes
spinWheelSPIN_ALREADY_SPUNerrorAlreadySpunOne spin per profile per show
spinWheelSPIN_NO_PRIZES_AVAILABLEerrorNoPrizesNo enabled prizes in DB
spinWheelSPIN_PRIZE_NOT_FOUNDerrorPrizeNotFoundMenu item for prize missing
spinWheelSPIN_ORDER_NOT_FOUNDerrorOrderNotFoundOrder entity missing

Named error codes as const object:

// apps/frontend/lib/schemas/lucky-spin-errors.ts
export const SpinErrorCode = {
  ALREADY_SPUN: "SPIN_ALREADY_SPUN",
  NO_PRIZES_AVAILABLE: "SPIN_NO_PRIZES_AVAILABLE",
  PRIZE_NOT_FOUND: "SPIN_PRIZE_NOT_FOUND",
  ORDER_NOT_FOUND: "SPIN_ORDER_NOT_FOUND",
} as const;
type SpinError = keyof typeof SpinErrorCode;

Client-side error parsing: check err.message.includes(errorCode).

3. Convex Real-time Subscription Pattern

// Guest PWA — check if already spun (real-time)
const existingResult = useQuery(api.challenges.getSpinResult, { profileId });
// If staff approves Google Review reward, this also updates in real-time
 
// Wall feed — recent spins (real-time)
const recentSpins = useQuery(api.challenges.getRecentSpins, { limit: 5 });
// Live-updates as guests spin throughout the night

Each unique query subscription auto-receives real-time updates from Convex. No manual polling needed.

4. Mobile/Responsive Considerations

ComponentMobile BehaviorDesktop Behavior
SpinWheelFull-width wheel, larger tap targetCentered, 256x256px wheel
SpinFeedFull-width cards, stackedHorizontal layout, max 5 visible
Result displayFull-screen celebration overlayModal dialog
  • Wheel uses 256x256px on all screen sizes (minimum touch target)
  • Disabled state prevents double-tap during spin animation
  • Result shows as overlay toast on mobile
  • Suspense boundary with skeleton on SpinFeed
  • All spin-related strings use useTranslations

5. PWA / Offline Behavior

  • Spin action: Requires network; disable spin button immediately on network loss
  • Wall feed: Cached with revalidate: 30; shows "Reconnecting..." banner if offline
  • Prize persistence: Spin result is committed server-side before animation; no client-side state to lose
// Service worker: cache spin results feed
const SPIN_FEED_CACHE = "spin-feed-v1";
// Strategy: network-first with 3s timeout, fall back to cached

[P1 PERFORMANCE GAP]: Current implementation uses FileReader.readAsDataURL for any file preview (if extended). Should use Convex storage (storage.store) for production scale.

6. i18n / next-intl Requirements

All user-facing strings must use getTranslations/useTranslations. Add to messages/en.json and messages/vi.json:

{
  "minigames": {
    "luckySpin": {
      "youWon": "You won!",
      "addedToOrder": "Added to your table order",
      "recentWins": "Recent Wins",
      "spinning": "Spinning...",
      "spin": "SPIN!",
      "tableWon": "Table {table}",
      "wonPrize": "won {prize}",
      "errorAlreadySpun": "You've already spun tonight!",
      "errorSpinFailed": "Spin failed. Please try again.",
      "errorNoPrizes": "No prizes available tonight. Check back later.",
      "errorPrizeNotFound": "Prize configuration error. Contact staff.",
      "errorOrderNotFound": "Order not found. Contact staff.",
      "noSpinsYet": "No spins yet — be the first!"
    }
  }
}

Vietnamese:

{
  "minigames": {
    "luckySpin": {
      "youWon": "Ban da trung!",
      "addedToOrder": "Da them vao don cua ban",
      "recentWins": "Thang gan day",
      "spinning": "Dang quay...",
      "spin": "QUAY!",
      "tableWon": "Ban {table}",
      "wonPrize": "da trung {prize}",
      "errorAlreadySpun": "Ban da quay toi nay roi!",
      "errorSpinFailed": "Quay that bai. Vui long thu lai.",
      "errorNoPrizes": "Khong co giai thuong toi nay. Quay lai sau.",
      "errorPrizeNotFound": "Loi cau hinh giai thuong. Lien he nhan vien.",
      "errorOrderNotFound": "Khong tim thay don hang. Lien he nhan vien.",
      "noSpinsYet": "Chua co ai quay — hay la nguoi dau tien!"
    }
  }
}

7. Environment-Specific Configuration

// Server-only:
CLERK_SECRET_KEY=              — Convex server-side Clerk auth
 
// Client-safe (NEXT_PUBLIC_ prefix):
NEXT_PUBLIC_CONVEX_URL=        — Convex deployment URL
NEXT_PUBLIC_APP_URL=           — Public URL for PWA
PackageVersionPurpose
framer-motion^11.0.0Wheel spin animation

8. TDD Test Cases

E2E Tests (Playwright) — User Expectation Format:

// apps/frontend/e2e/lucky-spin.spec.ts
// TDD: Write test BEFORE implementation
// Run: npx playwright test e2e/lucky-spin.spec.ts
 
import { test, expect } from "@playwright/test";
 
test.describe("Lucky Spin — User Journeys", () => {
  // ─── LS-E2E-1: Guest spins wheel ────────────────────────────────
  test("LS-E2E-1.1: Guest sees spin button and can trigger spin", async ({
    page,
  }) => {
    // Given: Guest is on the spin tab in PWA
    await page.goto("/en/spin");
    // User expects: spin button visible with "SPIN!" label
    await expect(page.getByRole("button", { name: /spin!/i })).toBeVisible();
    // When: Guest taps the spin button
    await page.getByRole("button", { name: /spin!/i }).click();
    // Then: Wheel animation starts (button becomes disabled, shows "Spinning...")
  });
 
  test("LS-E2E-1.2: Prize result shown after spin animation", async ({
    page,
  }) => {
    // Given: Guest tapped spin
    await page.goto("/en/spin");
    await page.getByRole("button", { name: /spin!/i }).click();
    // User expects: after animation, prize result visible ("You won! Free Cocktail")
    await expect(page.getByText(/you won/i)).toBeVisible({ timeout: 10000 });
  });
 
  test("LS-E2E-1.3: Prize added to table order as comp item", async ({
    page,
  }) => {
    // Given: Guest has won a prize
    await page.goto("/en/spin");
    await page.getByRole("button", { name: /spin!/i }).click();
    // User expects: "Added to your table order" message visible
    await expect(page.getByText(/added to your table order/i)).toBeVisible({
      timeout: 10000,
    });
  });
 
  // ─── LS-E2E-2: One spin per show ──────────────────────────
  test("LS-E2E-2.1: Already-spun guest sees result instead of spin button", async ({
    page,
  }) => {
    // Given: Guest already spun tonight
    await page.goto("/en/spin");
    // User expects: no spin button, instead shows "You won [prize]" with result
    await expect(page.getByText(/you won/i)).toBeVisible();
    await expect(
      page.getByRole("button", { name: /spin!/i }),
    ).not.toBeVisible();
  });
 
  test("LS-E2E-2.2: Already-spun guest cannot spin again on reload", async ({
    page,
  }) => {
    // Given: Guest already spun
    await page.goto("/en/spin");
    await page.reload();
    // User expects: still shows result, not spin button
    await expect(page.getByText(/you won/i)).toBeVisible();
  });
 
  // ─── LS-E2E-3: Wall feed ───────────────────────────────────────
  test("LS-E2E-3.1: Wall shows recent spin wins in real-time", async ({
    page,
    context,
  }) => {
    // Given: 2 separate browser contexts
    const page1 = await context.newPage();
    const page2 = await context.newPage();
    // Guest 1 spins
    await page1.goto("/en/spin");
    await page1.getByRole("button", { name: /spin!/i }).click();
    // Guest 2 is on the wall
    await page2.goto("/en/wall");
    // User expects: new win appears on wall without page refresh
    await expect(page2.getByText(/table won/i)).toBeVisible({ timeout: 10000 });
  });
 
  // ─── LS-E2E-4: Error states ───────────────────────────────────
  test("LS-E2E-4.1: Error shown when no prizes configured", async ({
    page,
  }) => {
    // Given: No spin prizes enabled in DB
    await page.goto("/en/spin");
    await page.getByRole("button", { name: /spin!/i }).click();
    // User expects: error toast "No prizes available tonight"
    await expect(page.getByText(/no prizes available/i)).toBeVisible({
      timeout: 10000,
    });
  });
 
  // ─── LS-E2E-5: Mobile ──────────────────────────────────────────
  test("LS-E2E-5.1: Mobile layout shows full-width spin button", async ({
    page,
  }) => {
    await page.setViewportSize({ width: 375, height: 812 });
    await page.goto("/en/spin");
    // User expects: spin button is full-width and easy to tap
    const spinBtn = page.getByRole("button", { name: /spin!/i });
    await expect(spinBtn).toBeVisible();
    const box = await spinBtn.boundingBox();
    expect(box?.width).toBeGreaterThan(280); // Nearly full width on mobile
  });
 
  test("LS-E2E-5.2: Vietnamese locale all strings", async ({ page }) => {
    await page.goto("/vi/spin");
    // User expects: all UI strings in Vietnamese
    await expect(page.getByText(/quay!/i)).toBeVisible();
    await expect(page.getByText(/ban da trung/i)).toBeVisible();
  });
});

Component Tests (Vitest + RTL) — User Expectation Format:

// apps/frontend/__tests__/components/spin-wheel.test.tsx
// TDD: Write test BEFORE implementation
// Run: npx vitest run __tests__/components/spin-wheel.test.tsx
 
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { SpinWheel } from "~/components/minigames/spin-wheel";
 
const mockProfileId = "prof_test123";
const mockOrderId = "ord_test456";
const mockTableId = "tab_test789";
 
vi.mock("convex/react", () => ({
  useMutation: vi.fn(() => () => Promise.resolve({ prize: { label: "Free Cocktail" } })),
  useQuery: vi.fn(() => null),
}));
 
describe("SpinWheel — User Expectations", () => {
 
  // ─── LS-UT-1: Initial state ────────────────────────────────────
  it("LS-UT-1.1: Spin button visible when guest has not spun", () => {
    // Given: Guest has not spun tonight
    // When: SpinWheel component renders
    // Then: prominent "SPIN!" button visible
    render(<SpinWheel profileId={mockProfileId} orderId={mockOrderId} tableId={mockTableId} />);
    expect(screen.getByRole("button", { name: /spin!/i })).toBeVisible();
  });
 
  it("LS-UT-1.2: Spin button disabled while spinning", async () => {
    // Given: Guest taps spin
    // When: spin is in progress (4 second animation)
    // Then: Button is disabled and shows "Spinning..."
    const user = userEvent.setup();
    const spinMutation = vi.fn(() => new Promise(r => setTimeout(5000)));
    vi.mock("convex/react", () => ({
      useMutation: vi.fn(() => spinMutation),
      useQuery: vi.fn(() => null),
    }));
    render(<SpinWheel profileId={mockProfileId} orderId={mockOrderId} tableId={mockTableId} />);
    await user.click(screen.getByRole("button", { name: /spin!/i }));
    const btn = screen.getByRole("button");
    expect(btn).toBeDisabled();
    expect(screen.getByText(/spinning/i)).toBeVisible();
  });
 
  // ─── LS-UT-2: Result state ─────────────────────────────────────
  it("LS-UT-2.1: Prize result shown after successful spin", () => {
    // Given: Guest won "Free Cocktail"
    // When: SpinWheel renders with existing result
    // Then: "You won!" and prize name visible, no spin button
    vi.mock("convex/react", () => ({
      useMutation: vi.fn(() => () => Promise.resolve({ prize: { label: "Free Cocktail" } })),
      useQuery: vi.fn(() => ({ displayText: "Free Cocktail", _id: "result_123" })),
    }));
    render(<SpinWheel profileId={mockProfileId} orderId={mockOrderId} tableId={mockTableId} />);
    expect(screen.getByText(/you won/i)).toBeVisible();
    expect(screen.getByText("Free Cocktail")).toBeVisible();
    expect(screen.queryByRole("button", { name: /spin!/i })).not.toBeInTheDocument();
  });
 
  it("LS-UT-2.2: Prize shows with icon and 'added to order' message", () => {
    // Given: Guest won a prize
    // When: Result state is shown
    // Then: sparkle icon + prize name + "Added to your table order" text
    vi.mock("convex/react", () => ({
      useMutation: vi.fn(() => () => Promise.resolve({ prize: { label: "Free Cocktail" } })),
      useQuery: vi.fn(() => ({ displayText: "Free Cocktail", _id: "result_123" })),
    }));
    render(<SpinWheel profileId={mockProfileId} orderId={mockOrderId} tableId={mockTableId} />);
    expect(screen.getByText(/added to your table order/i)).toBeVisible();
  });
});

Schema Unit Tests (Vitest):

// apps/frontend/__tests__/lib/lucky-spin.test.ts
// TDD: Write test BEFORE implementation
// Run: npx vitest run __tests__/lib/lucky-spin.test.ts
 
import { describe, it, expect } from "vitest";
import { SpinWheelSchema, GetSpinResultSchema } from "~/lib/schemas/lucky-spin";
 
describe("SpinWheelSchema", () => {
  it("LS-UT-3.1: accepts valid spin request", () => {
    // Given: All required fields with valid values
    // When: Schema.parse is called
    // Then: Returns success
    const result = SpinWheelSchema.safeParse({
      profileId: "prof_123",
      orderId: "ord_456",
      tableId: "tab_789",
    });
    expect(result.success).toBe(true);
  });
 
  it("LS-UT-3.2: rejects missing profileId", () => {
    // Given: Missing profileId field
    // When: Schema.parse is called
    // Then: Returns error
    const result = SpinWheelSchema.safeParse({
      orderId: "ord_456",
      tableId: "tab_789",
    });
    expect(result.success).toBe(false);
  });
 
  it("LS-UT-3.3: rejects missing orderId", () => {
    // Given: Missing orderId field
    // When: Schema.parse is called
    // Then: Returns error
    const result = SpinWheelSchema.safeParse({
      profileId: "prof_123",
      tableId: "tab_789",
    });
    expect(result.success).toBe(false);
  });
 
  it("LS-UT-3.4: rejects missing tableId", () => {
    // Given: Missing tableId field
    // When: Schema.parse is called
    // Then: Returns error
    const result = SpinWheelSchema.safeParse({
      profileId: "prof_123",
      orderId: "ord_456",
    });
    expect(result.success).toBe(false);
  });
});
 
describe("Weighted random", () => {
  it("LS-UT-4.1: selects prizes proportionally to weight", () => {
    // Given: Two prizes with equal weight (1:1)
    // When: weightedRandomPick is called 100 times
    // Then: Each prize appears roughly 50% of the time (20-80% tolerance)
    const prizes = [
      { label: "A", weight: 1 },
      { label: "B", weight: 1 },
    ];
    const results = Array.from({ length: 100 }, () => {
      const total = prizes.reduce((s, p) => s + p.weight, 0);
      const randomBytes = new Uint32Array(1);
      crypto.getRandomValues(randomBytes);
      let r = (randomBytes[0] / 0xffffffff) * total;
      for (const prize of prizes) {
        r -= prize.weight;
        if (r <= 0) return prize.label;
      }
      return prizes[0].label;
    });
    const countA = results.filter((r) => r === "A").length;
    expect(countA).toBeGreaterThan(20); // At least 20% (sanity check for randomness)
    expect(countA).toBeLessThan(80); // At most 80%
  });
});

Mutation Backend Tests (Vitest):

// apps/frontend/__tests__/convex/lucky-spin.test.ts
// TDD: Write test BEFORE implementation
// Run: npx vitest run __tests__/convex/lucky-spin.test.ts
 
import { describe, it, expect } from "vitest";
 
describe("spinWheel mutation", () => {
  it("LS-MUT-1.1: successfully spins and records result", async () => {
    // Given: Guest has not spun tonight, prizes exist
    // When: spinWheel is called
    // Then: spinResults record created, orderItems comp added if MENU_ITEM prize
  });
 
  it("LS-MUT-1.2: rejects second spin for same profile", async () => {
    // Given: Guest already spun tonight
    // When: spinWheel is called again
    // Then: throws SPIN_ALREADY_SPUN error
  });
 
  it("LS-MUT-1.3: throws when no prizes enabled", async () => {
    // Given: No prizes with enabled=true
    // When: spinWheel is called
    // Then: throws SPIN_NO_PRIZES_AVAILABLE error
  });
 
  it("LS-MUT-1.4: applies DISCOUNT prize to order total", async () => {
    // Given: Guest wins DISCOUNT prize
    // When: spinWheel is called
    // Then: order totalAmount is reduced by discount percent
  });
 
  it("LS-MUT-1.5: throws when order not found for DISCOUNT prize", async () => {
    // Given: Guest wins DISCOUNT prize but order doesn't exist
    // When: spinWheel is called
    // Then: throws SPIN_ORDER_NOT_FOUND error
  });
});
 
describe("getSpinResult query", () => {
  it("LS-QUERY-1.1: returns null when guest has not spun", async () => {
    // Given: No spinResults for this profile today
    // When: getSpinResult is called
    // Then: returns null
  });
 
  it("LS-QUERY-1.2: returns spin result when guest has spun", async () => {
    // Given: spinResults record exists for this profile today
    // When: getSpinResult is called
    // Then: returns the spin result
  });
});
 
describe("getRecentSpins query", () => {
  it("LS-QUERY-2.1: returns empty when no spins tonight", async () => {
    // Given: No spinResults for today
    // When: getRecentSpins is called
    // Then: returns empty array
  });
 
  it("LS-QUERY-2.2: returns limited recent spins ordered by recency", async () => {
    // Given: Multiple spinResults exist for today
    // When: getRecentSpins({ limit: 5 }) is called
    // Then: returns up to 5 most recent spins
  });
});

9. Cross-Plan Dependencies

Depends OnRequired ByShared Schema
guestProfiles tableAll minigamesprofileId reference
orders tablePhoto, Lucky Spin, Google RevieworderId reference + totalAmount
tables tablePhoto, Lucky Spin, Google ReviewtableId reference
menuItems tableLucky Spin, Google ReviewPrize reward item reference
orderItems tableLucky Spin, Google ReviewComp insertion
challengeConfig (future)All minigamesAdmin-configurable prize weights
Photo WallLucky SpinBoth use wall display sections

10. Performance Considerations

  • Weighted random: O(n) where n = number of enabled prizes; acceptable for <100 prizes
  • Wall feed: Limited to 5 recent spins (limit: 5) to minimize payload
  • Real-time updates: Convex subscription handles fan-out; no additional indexing needed
  • Concurrent spins: Convex mutations are serialized; no race condition on spinResults insert
  • Large shows: If >100 spins/min, consider rate-limiting via Convex rate limiter
  • No Math.random(): Uses crypto.getRandomValues for secure random — anti-cheat guarantee
  • No as any: All type casts use proper Id<"tableName"> types from Convex generated types
  • No console.log: All user feedback via toast (sonner)

Acceptance Criteria

  1. Guest taps "SPIN!" button — wheel animates for ~4 seconds
  2. Server-side result is picked before animation starts (anti-cheat)
  3. Result shown on PWA: "You won Free Cocktail!"
  4. Prize added as comp item to table order (isComp=true, compSource="SPIN")
  5. One spin per profile per show enforced (error on second attempt)
  6. Recent spins appear on shared wall in real-time
  7. Wall shows table number + prize won
  8. All error codes use prefixed format (SPIN_*)
  9. All strings use useTranslations — no hardcoded user-facing strings
  10. Suspense boundary with skeleton on SpinFeed

Consistency Audit: lucky-spin

P0 Violations (fixed in plan text)

#LocationIssueFix Applied
1SpinWheel component pointerborder-t-12 is invalid Tailwind syntax — should be border-t-[12px] (arbitrary value)Fixed to border-t-[12px] in plan text
2spinWheel mutation callCall was passing profileId, orderId, tableId as positional args but mutation expects an options objectFixed to spinWheel({ profileId, orderId, tableId }) with all three fields

P1 Violations (fixed in plan text)

#LocationIssueFix Applied
1SpinWheel componentFileReader.readAsDataURL for file handling — P1 performance gapNoted in Performance Considerations section and Section 5 (PWA/Offline). Future: replace with Convex storage (storage.store)

P0 Gaps (cannot fix in plan — requires codebase change)

#IssueAction Required
1staffMutation/adminMutation not in convex/auth.tsNot applicable — lucky spin is guest-facing only, no staff mutations required in v1
2Prize administration UIFuture iteration: staff UI to add/edit/remove spin prizes — requires adminMutation for staff to manage spinPrizes table