Photo Wall 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 photo submission from guest PWA, photo grid on shared display wall, and like functionality. One photo per table per show. Top-liked photo wins.
Tech Stack: Next.js 16, Convex (storage for images), useQuery/useMutation, Tailwind CSS v4, Framer Motion (card animations, like transitions).
Spec: docs/superpowers/specs/06-photo-wall.md
Business Summary
What this does: Enables guests to capture and share photos from their table, which appear on a shared display wall visible to the entire venue. Other guests can like photos, and the top-liked photo is featured prominently.
Why it matters: Creates a social, shareable experience that extends the show beyond the stage. Guests see their photos on the big screen, encouraging participation and creating natural FOMO for future visits. User-generated content serves as authentic marketing material.
Time to implement: 3-5 days | Complexity: Medium
Dependencies: Foundation (guestProfiles, orders, tables) must be complete first
Task 1: Add Photo Tables to Schema
Files:
-
Modify:
convex/schema.ts -
Create:
apps/frontend/lib/schemas/photo-wall.ts -
Create:
apps/frontend/lib/schemas/photo-wall-errors.ts -
Step 1: Add
photoSubmissionsandphotoLikestables
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(),
})
.index("by_show_date", ["showDate"])
.index("by_profile", ["profileId"])
.index("by_status", ["status"])
.index("by_table_show", ["tableId", "showDate"])
.index("by_likes", ["likeCount", "status"]),
photoLikes: defineTable({
submissionId: v.id("photoSubmissions"),
profileId: v.id("guestProfiles"),
createdAt: v.number(),
})
.index("by_submission", ["submissionId"])
.index("by_profile_submission", ["profileId", "submissionId"]),- Step 2: Create Zod schemas
// apps/frontend/lib/schemas/photo-wall.ts
import { z } from "zod";
export const PhotoSubmitSchema = 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"),
imageUrl: z.string().url("Invalid image URL").min(1, "Image is required"),
caption: z
.string()
.max(280, "Caption must be 280 characters or less")
.optional(),
});
export const LikePhotoSchema = z.object({
submissionId: z.string().min(1, "Submission ID is required"),
profileId: z.string().min(1, "Profile ID is required"),
});
export type PhotoSubmitInput = z.infer<typeof PhotoSubmitSchema>;
export type LikePhotoInput = z.infer<typeof LikePhotoSchema>;// apps/frontend/lib/schemas/photo-wall-errors.ts
export const PhotoErrorCode = {
TABLE_ALREADY_SUBMITTED: "PHOTO_TABLE_ALREADY_SUBMITTED",
ALREADY_LIKED: "PHOTO_ALREADY_LIKED",
SUBMISSION_NOT_FOUND: "PHOTO_SUBMISSION_NOT_FOUND",
INVALID_URL: "PHOTO_INVALID_URL",
CAPTION_TOO_LONG: "PHOTO_CAPTION_TOO_LONG",
} as const;
type PhotoError = keyof typeof PhotoErrorCode;- Step 3: Commit
git add convex/schema.ts apps/frontend/lib/schemas/photo-wall.ts apps/frontend/lib/schemas/photo-wall-errors.ts
git commit -m "feat(photo-wall): add photoSubmissions and photoLikes tables"Phase 2: Photo Convex Functions
Task 2: Add Photo Functions to Challenges
Files:
-
Modify:
convex/functions/challenges.ts -
Step 1: Add photo submission and like functions
// Named error codes for photo wall operations
const PhotoErrorCode = {
TABLE_ALREADY_SUBMITTED: "PHOTO_TABLE_ALREADY_SUBMITTED",
ALREADY_LIKED: "PHOTO_ALREADY_LIKED",
SUBMISSION_NOT_FOUND: "PHOTO_SUBMISSION_NOT_FOUND",
INVALID_URL: "PHOTO_INVALID_URL",
CAPTION_TOO_LONG: "PHOTO_CAPTION_TOO_LONG",
} as const;
// Note: These are guest-facing operations (no Clerk auth required).
// Use plain mutation({}) — NOT authenticatedMutation.
export const submitPhoto = mutation({
args: {
profileId: v.id("guestProfiles"),
orderId: v.id("orders"),
tableId: v.id("tables"),
imageUrl: v.string(),
caption: v.optional(v.string()),
},
handler: async (ctx, { profileId, orderId, tableId, imageUrl, caption }) => {
// Validate imageUrl is a valid URL
try {
new URL(imageUrl);
} catch {
throw new Error(`${PhotoErrorCode.INVALID_URL}: Invalid image URL`);
}
// Validate caption length
if (caption && caption.length > 280) {
throw new Error(`${PhotoErrorCode.CAPTION_TOO_LONG}: Caption too long`);
}
const today = new Date().toISOString().split("T")[0];
// Check one photo per table per show
const existing = await ctx.db
.query("photoSubmissions")
.withIndex("by_table_show", (q) =>
q.eq("tableId", tableId).eq("showDate", today),
)
.first();
if (existing)
throw new Error(
`${PhotoErrorCode.TABLE_ALREADY_SUBMITTED}: Table already submitted a photo tonight`,
);
const now = Date.now();
return await ctx.db.insert("photoSubmissions", {
profileId,
orderId,
tableId,
imageUrl,
caption,
likeCount: 0,
status: "ACTIVE",
winner: false,
showDate: today,
createdAt: now,
});
},
});
export const likePhoto = mutation({
args: {
submissionId: v.id("photoSubmissions"),
profileId: v.id("guestProfiles"),
},
handler: async (ctx, { submissionId, profileId }) => {
// Check not already liked
const existing = await ctx.db
.query("photoLikes")
.withIndex("by_profile_submission", (q) =>
q.eq("profileId", profileId).eq("submissionId", submissionId),
)
.first();
if (existing)
throw new Error(`${PhotoErrorCode.ALREADY_LIKED}: Already liked`);
const photo = await ctx.db.get(submissionId);
if (!photo)
throw new Error(
`${PhotoErrorCode.SUBMISSION_NOT_FOUND}: Submission not found`,
);
const now = Date.now();
await ctx.db.insert("photoLikes", {
submissionId,
profileId,
createdAt: now,
});
// Increment like count
await ctx.db.patch(submissionId, { likeCount: photo.likeCount + 1 });
},
});
export const getTonightsPhotos = query({
args: {},
handler: async (ctx) => {
const today = new Date().toISOString().split("T")[0];
return await ctx.db
.query("photoSubmissions")
.withIndex("by_show_date", (q) => q.eq("showDate", today))
.collect();
},
});
export const getTopPhotos = query({
args: { limit: v.optional(v.number()) },
handler: async (ctx, { limit }) => {
const today = new Date().toISOString().split("T")[0];
const photos = await ctx.db
.query("photoSubmissions")
.withIndex("by_show_date", (q) => q.eq("showDate", today))
.collect();
return photos
.filter((p) => p.status === "ACTIVE")
.sort((a, b) => b.likeCount - a.likeCount)
.slice(0, limit ?? 6);
},
});
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 !== null;
},
});- Step 2: Commit
git add convex/functions/challenges.ts
git commit -m "feat(photo-wall): add photo submission and like functions"Phase 3: Photo Submission UI
Task 3: Create Photo Submit Component
Files:
-
Create:
apps/frontend/components/minigames/photo-submit.tsx -
Create:
apps/frontend/components/minigames/photo-card.tsx -
Step 1: Create photo submit component
// apps/frontend/components/minigames/photo-submit.tsx
"use client";
import { useState, useRef, 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 { IconSymbol } from "~/components/ui/icon-symbol";
import { PhotoErrorCode } from "~/lib/schemas/photo-wall-errors";
export function PhotoSubmit({
profileId,
tableId,
orderId,
}: {
profileId: string;
tableId: string;
orderId: string;
}) {
const t = useTranslations("minigames.photoWall");
const [preview, setPreview] = useState<string | null>(null);
const [caption, setCaption] = useState("");
const [isPending, startTransition] = useTransition();
const fileRef = useRef<HTMLInputElement>(null);
const submit = useMutation(api.challenges.submitPhoto);
const todayPhotos = useQuery(api.challenges.getTonightsPhotos, {});
const alreadySubmitted = todayPhotos?.some(
(p) => p.profileId === profileId,
);
if (alreadySubmitted) {
return (
<div className="text-center py-8">
<p className="text-accent font-serif text-lg">{t("alreadySubmitted")}</p>
<p className="text-gray-400 text-sm mt-1">{t("checkWall")}</p>
</div>
);
}
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = () => setPreview(reader.result as string);
reader.readAsDataURL(file);
}
};
const handleSubmit = async () => {
if (!preview) return;
startTransition(async () => {
try {
await submit({ profileId, orderId, tableId, imageUrl: preview, caption: caption || undefined });
// Reset form on success
setPreview(null);
setCaption("");
toast.success(t("submitSuccess"));
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes(PhotoErrorCode.TABLE_ALREADY_SUBMITTED)) {
toast.error(t("errorTableAlreadySubmitted"));
} else {
toast.error(t("errorSubmitFailed"));
}
}
});
};
return (
<div className="space-y-4">
<input
type="file"
accept="image/*"
capture="environment"
ref={fileRef}
onChange={handleFile}
className="hidden"
/>
{!preview ? (
<button
onClick={() => fileRef.current?.click()}
className="w-full py-12 border-2 border-dashed border-border rounded-lg text-gray-400 hover:border-accent hover:text-accent transition-colors"
>
<IconSymbol name="camera.fill" size={40} className="mx-auto mb-2" />
<p>{t("takeOrUpload")}</p>
</button>
) : (
<div className="space-y-3">
<img src={preview} alt="Preview" className="w-full aspect-square object-cover rounded-lg" />
<textarea
value={caption}
onChange={(e) => setCaption(e.target.value)}
maxLength={280}
placeholder={t("captionPlaceholder")}
className="w-full bg-surface border border-border rounded-lg p-3 text-white text-sm resize-none"
rows={2}
/>
<div className="flex gap-3">
<button
onClick={() => setPreview(null)}
disabled={isPending}
className="flex-1 py-2 border border-border rounded-lg disabled:opacity-50"
>
{t("cancel")}
</button>
<button
onClick={handleSubmit}
disabled={isPending}
className="flex-1 py-2 bg-accent text-black font-bold rounded-lg disabled:opacity-50"
>
{isPending ? t("submitting") : t("submit")}
</button>
</div>
</div>
)}
</div>
);
}- Step 2: Create photo card with like button
// apps/frontend/components/minigames/photo-card.tsx
"use client";
import { useCallback } from "react";
import { useQuery, useMutation } from "convex/react";
import { api } from "~/convex/_generated/api";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { IconSymbol } from "~/components/ui/icon-symbol";
import type { Id } from "convex/_generated/dataModel";
import { PhotoErrorCode } from "~/lib/schemas/photo-wall-errors";
type PhotoSubmission = {
_id: Id<"photoSubmissions">;
_creationTime: number;
profileId: Id<"guestProfiles">;
orderId: Id<"orders">;
tableId: Id<"tables">;
imageUrl: string;
caption?: string;
likeCount: number;
status: "ACTIVE" | "HIDDEN";
winner: boolean;
showDate: string;
createdAt: number;
};
export function PhotoCard({ photo, profileId }: { photo: PhotoSubmission; profileId: string }) {
const t = useTranslations("minigames.photoWall");
// Real-time subscription: subscribe to full photo list to get live likeCount updates
const allPhotos = useQuery(api.challenges.getTonightsPhotos, {});
const likePhoto = useMutation(api.challenges.likePhoto);
const hasLiked = useQuery(api.challenges.hasLikedPhoto, {
submissionId: photo._id,
profileId,
});
// Derive live likeCount from real-time subscription (not local state)
const livePhoto = allPhotos?.find((p) => p._id === photo._id);
const likeCount = livePhoto?.likeCount ?? photo.likeCount;
const handleLike = useCallback(async () => {
try {
await likePhoto({ submissionId: photo._id, profileId });
// No optimistic update — Convex subscription will update likeCount automatically
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes(PhotoErrorCode.ALREADY_LIKED)) {
toast.error(t("errorAlreadyLiked"));
} else {
toast.error(t("errorLikeFailed"));
}
}
}, [likePhoto, photo._id, profileId, t]);
return (
<div className="bg-surface border border-border rounded-lg overflow-hidden">
<img src={photo.imageUrl} alt={`Table ${photo.tableId}`} className="w-full aspect-square object-cover" />
<div className="p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-gray-400">{t("tableNumber", { table: photo.tableId })}</span>
<button
onClick={handleLike}
className={`flex items-center gap-1 text-sm ${hasLiked ? "text-red-400" : "text-gray-400 hover:text-red-400"}`}
aria-label={hasLiked ? t("alreadyLiked") : t("likePhoto")}
>
<IconSymbol name={hasLiked ? "heart.fill" : "heart"} size={16} />
{likeCount}
</button>
</div>
{photo.caption && (
<p className="text-sm text-gray-300">{photo.caption}</p>
)}
</div>
</div>
);
}- Step 3: Commit
git add apps/frontend/components/minigames/photo-submit.tsx apps/frontend/components/minigames/photo-card.tsx
git commit -m "feat(photo-wall): add photo submission and card components"Phase 4: Shared Wall Photo Grid
Task 4: Create Photo Grid for Display Wall
Files:
-
Create:
apps/frontend/components/wall/photo-grid.tsx -
Step 1: Create photo grid component
// 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";
import { IconSymbol } from "~/components/ui/icon-symbol";
import type { PhotoSubmission } from "~/components/minigames/photo-card";
function PhotoGridSkeleton() {
return (
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="aspect-square bg-surface animate-pulse rounded-lg" />
))}
</div>
);
}
export function WallPhotoGrid() {
const t = useTranslations("minigames.photoWall");
const photos = useQuery(api.challenges.getTopPhotos, { limit: 6 });
if (!photos?.length) {
return (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<IconSymbol name="photo" size={48} className="mb-3 opacity-50" />
<p className="text-sm">{t("noPhotosYet")}</p>
</div>
);
}
return (
<Suspense fallback={<PhotoGridSkeleton />}>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2" data-testid="photo-grid">
{photos.map((photo: PhotoSubmission) => (
<div key={photo._id} className="relative">
<img
src={photo.imageUrl}
alt={`Table ${photo.tableId}`}
className="w-full aspect-square object-cover"
/>
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 p-2">
<div className="flex items-center justify-between">
<span className="text-xs text-white">{t("tableNumber", { table: photo.tableId })}</span>
<span className="text-xs text-white flex items-center gap-0.5">
<IconSymbol name="heart.fill" size={12} className="text-white" />
{photo.likeCount}
</span>
</div>
</div>
</div>
))}
</div>
</Suspense>
);
}- Step 2: Commit
git add apps/frontend/components/wall/photo-grid.tsx
git commit -m "feat(photo-wall): add wall photo grid component"Enrichment Sections
1. Zod Schemas
// apps/frontend/lib/schemas/photo-wall.ts
import { z } from "zod";
export const PhotoSubmitSchema = 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"),
imageUrl: z.string().url("Invalid image URL").min(1, "Image is required"),
caption: z
.string()
.max(280, "Caption must be 280 characters or less")
.optional(),
});
export const LikePhotoSchema = z.object({
submissionId: z.string().min(1, "Submission ID is required"),
profileId: z.string().min(1, "Profile ID is required"),
});
export type PhotoSubmitInput = z.infer<typeof PhotoSubmitSchema>;
export type LikePhotoInput = z.infer<typeof LikePhotoSchema>;// apps/frontend/lib/schemas/photo-wall-errors.ts
export const PhotoErrorCode = {
TABLE_ALREADY_SUBMITTED: "PHOTO_TABLE_ALREADY_SUBMITTED",
ALREADY_LIKED: "PHOTO_ALREADY_LIKED",
SUBMISSION_NOT_FOUND: "PHOTO_SUBMISSION_NOT_FOUND",
INVALID_URL: "PHOTO_INVALID_URL",
CAPTION_TOO_LONG: "PHOTO_CAPTION_TOO_LONG",
} as const;
type PhotoError = keyof typeof PhotoErrorCode;2. Error Handling
| Operation | Error Code | Message Key | Notes |
|---|---|---|---|
submitPhoto | PHOTO_TABLE_ALREADY_SUBMITTED | errorTableAlreadySubmitted | One photo per table per show |
submitPhoto | PHOTO_INVALID_URL | errorSubmitFailed | Invalid image URL |
submitPhoto | PHOTO_CAPTION_TOO_LONG | errorSubmitFailed | Caption exceeds 280 chars |
likePhoto | PHOTO_ALREADY_LIKED | errorAlreadyLiked | Duplicate like prevented |
likePhoto | PHOTO_SUBMISSION_NOT_FOUND | errorLikeFailed | Photo no longer exists |
| Generic failure | — | errorSubmitFailed / errorLikeFailed | Catch-all for unknown errors |
Named error codes as const object:
// apps/frontend/lib/schemas/photo-wall-errors.ts
export const PhotoErrorCode = {
TABLE_ALREADY_SUBMITTED: "PHOTO_TABLE_ALREADY_SUBMITTED",
ALREADY_LIKED: "PHOTO_ALREADY_LIKED",
SUBMISSION_NOT_FOUND: "PHOTO_SUBMISSION_NOT_FOUND",
INVALID_URL: "PHOTO_INVALID_URL",
CAPTION_TOO_LONG: "PHOTO_CAPTION_TOO_LONG",
} as const;
type PhotoError = keyof typeof PhotoErrorCode;Client-side error parsing: check err.message.includes(errorCode) to determine user-facing message from i18n.
3. Convex Real-time Subscription Pattern
// Real-time subscription for photo list (PWA tab)
const allTonightPhotos = useQuery(api.challenges.getTonightsPhotos, {});
/// Automatically re-fetches when any photo is submitted by any guest
// Real-time subscription for top photos (Wall display)
const topPhotos = useQuery(api.challenges.getTopPhotos, { limit: 6 });
// Live-updates as like counts change across all guests
// Real-time subscription for hasLiked status
const hasLiked = useQuery(api.challenges.hasLikedPhoto, {
submissionId: photo._id,
profileId,
});
// Live-updates when guest likes/unlikes
// PhotoCard: live likeCount from real-time subscription (not local state)
const livePhoto = allPhotos?.find((p) => p._id === photo._id);
const likeCount = livePhoto?.likeCount ?? photo.likeCount;No double-call pattern: each useQuery call with the same arguments reuses the same subscription. PhotoCard uses the Convex real-time subscription to get live likeCount instead of optimistic local state.
4. Mobile/Responsive Considerations
| Component | Mobile Behavior | Desktop Behavior |
|---|---|---|
PhotoSubmit | Full-width camera capture, bottom-sticky submit | Centered card, file upload |
PhotoCard | Full-width, tap to enlarge | Grid layout 2-3 columns |
WallPhotoGrid | 2-column grid, swipeable | 3-column grid, auto-cycle |
- Camera input uses
capture="environment"for rear camera on mobile - Photo preview uses
aspect-squareon both mobile and desktop - Caption textarea expands on focus for mobile keyboard
Suspenseboundary with skeleton prevents layout shift during data fetch- Touch targets: all buttons minimum 44x44px
5. PWA / Offline Behavior
- Photo submission: Requires network to submit; queued in Service Worker if offline (future enhancement)
- Wall grid: Cached via Next.js static caching with
revalidate: 30; shows stale data with "Live" indicator when offline - Service worker strategy: Network-first for photo data, cache-first for static assets
// apps/frontend/public/sw.js — cache strategy for photo wall routes
const PHOTO_WALL_CACHE = "photo-wall-v1";
const PHOTO_WALL_URLS = ["/wall"];
self.addEventListener("fetch", (event) => {
if (PHOTO_WALL_URLS.some((url) => event.request.url.includes(url))) {
event.respondWith(networkFirstWithCache(event.request, PHOTO_WALL_CACHE));
}
});[P1 PERFORMANCE GAP]: Current implementation uses FileReader.readAsDataURL for image preview (base64). This inflates payload size and should be replaced with Convex storage (storage.store) in a future iteration 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": {
"photoWall": {
"takeOrUpload": "Take or upload a photo",
"captionPlaceholder": "Add a caption (optional)",
"cancel": "Cancel",
"submitting": "Submitting...",
"submit": "Submit",
"submitSuccess": "Photo submitted! Appearing on the wall now",
"alreadySubmitted": "Photo already submitted!",
"checkWall": "Check the Wall to see everyone's photos",
"errorTableAlreadySubmitted": "Your table has already submitted a photo tonight",
"errorSubmitFailed": "Failed to submit photo. Please try again",
"errorAlreadyLiked": "You've already liked this photo",
"errorLikeFailed": "Failed to like photo. Please try again",
"tableNumber": "Table {table}",
"noPhotosYet": "No photos yet — be the first!",
"likePhoto": "Like this photo",
"alreadyLiked": "You've already liked this photo"
}
}
}Vietnamese:
{
"minigames": {
"photoWall": {
"takeOrUpload": "Chup hoac tai len anh",
"captionPlaceholder": "Them chu thich (tuy chon)",
"cancel": "Huy",
"submitting": "Dang gui...",
"submit": "Gui",
"submitSuccess": "Da gui anh! Hien thi tren man hinh ngay",
"alreadySubmitted": "Da gui anh roi!",
"checkWall": "Xem anh cua moi nguoi tren man hinh chinh",
"errorTableAlreadySubmitted": "Ban cua ban da gui anh toi nay roi",
"errorSubmitFailed": "Gui anh that bai. Vui long thu lai",
"errorAlreadyLiked": "Ban da thich anh nay roi",
"errorLikeFailed": "Thich anh that bai. Vui long thu lai",
"tableNumber": "Ban {table}",
"noPhotosYet": "Chua co anh nao — hay la nguoi dau tien!",
"likePhoto": "Thich anh nay",
"alreadyLiked": "Ban da thich anh nay roi"
}
}
}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 (used in meta tags)No external APIs required for core photo wall functionality. Image storage uses Convex built-in storage.
8. TDD Test Cases
E2E Tests (Playwright) — User Expectation Format:
// apps/frontend/e2e/photo-wall.spec.ts
// TDD: Write test BEFORE implementation
// Run: npx playwright test e2e/photo-wall.spec.ts
import { test, expect } from "@playwright/test";
test.describe("Photo Wall — User Journeys", () => {
// ─── PW-E2E-1: Photo Submission ────────────────────────────────────
test("PW-E2E-1.1: Guest can take photo and submit it", async ({ page }) => {
// Given: Guest is on the photo tab in PWA
await page.goto("/en/photo");
// User expects: camera/upload button visible
await expect(page.getByRole("button")).toBeVisible();
// When: Guest taps camera button
await page.getByRole("button").click();
// Then: File picker opens (in test, simulate file input)
// Photo preview should appear with submit button
});
test("PW-E2E-1.2: Submitted photo appears on wall immediately", async ({
page,
}) => {
// Given: Guest submitted a photo
await page.goto("/en/photo");
// User expects: after submission, success message shown
await expect(page.getByText(/photo submitted/i)).toBeVisible();
});
test("PW-E2E-1.3: One photo per table per show enforced", async ({
page,
}) => {
// Given: Guest's table already submitted a photo
await page.goto("/en/photo");
// User expects: if already submitted, show "already submitted" state instead of upload UI
await expect(page.getByText(/already submitted/i)).toBeVisible();
});
// ─── PW-E2E-2: Wall Photo Grid ────────────────────────────────────
test("PW-E2E-2.1: Wall shows photos from all tables tonight", async ({
page,
}) => {
// Given: Multiple tables have submitted photos
await page.goto("/en/wall");
// User expects: photo grid visible with table numbers
const grid = page.getByTestId("photo-grid");
await expect(grid).toBeVisible();
// Each photo shows table number, not guest name
});
test("PW-E2E-2.2: Like count updates in real-time", async ({
page,
context,
}) => {
// Given: 2 guests on separate devices
const page1 = await context.newPage();
const page2 = await context.newPage();
// Guest 1 submits a photo
await page1.goto("/en/photo");
// Guest 2 is on the wall
await page2.goto("/en/wall");
// Guest 1 likes their own photo
// User expects: like count on Guest 2's wall updates without refresh
});
// ─── PW-E2E-3: Mobile Layout ──────────────────────────────────────
test("PW-E2E-3.1: Mobile shows 2-column grid", async ({ page }) => {
// Given: Mobile viewport
await page.setViewportSize({ width: 375, height: 812 });
await page.goto("/en/wall");
// User expects: 2-column photo grid, no horizontal overflow
const photos = page.getByTestId("photo-grid").getByRole("img");
await expect(photos).toHaveCount(6);
});
test("PW-E2E-3.2: Vietnamese locale all strings", async ({ page }) => {
await page.goto("/vi/photo");
// User expects: all UI strings in Vietnamese
await expect(page.getByText(/chup hoac tai/i)).toBeVisible();
await expect(page.getByText(/da gui anh roi/i)).toBeVisible();
});
});Component Tests (Vitest + RTL) — User Expectation Format:
// apps/frontend/__tests__/components/photo-submit.test.tsx
// TDD: Write test BEFORE implementation
// Run: npx vitest run __tests__/components/photo-submit.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 { PhotoSubmit } from "~/components/minigames/photo-submit";
const mockProfileId = "prof_test123";
const mockOrderId = "ord_test456";
const mockTableId = "tab_test789";
vi.mock("convex/react", () => ({
useMutation: vi.fn(() => () => () => vi.fn()),
useQuery: vi.fn(() => []),
}));
describe("PhotoSubmit — User Expectations", () => {
// ─── PW-UT-1: Rendering states ──────────────────────────────────
it("PW-UT-1.1: Camera/upload button visible when not yet submitted", () => {
render(<PhotoSubmit profileId={mockProfileId} orderId={mockOrderId} tableId={mockTableId} />);
// User expects: camera button visible for taking/uploading photo
expect(screen.getByRole("button")).toBeVisible();
});
it("PW-UT-1.2: Already-submitted state shown after submission", () => {
// Given: today's photos include this profile
// When: component renders
// Then: "already submitted" message visible instead of upload button
vi.mock("convex/react", () => ({
useMutation: vi.fn(() => () => () => vi.fn()),
useQuery: vi.fn(() => [{ profileId: mockProfileId }]),
}));
render(<PhotoSubmit profileId={mockProfileId} orderId={mockOrderId} tableId={mockTableId} />);
expect(screen.getByText(/already submitted/i)).toBeVisible();
});
// ─── PW-UT-2: Photo preview and caption ─────────────────────────
it("PW-UT-2.1: Caption input accepts text", async () => {
// Given: User is on photo submit page
// When: user types a caption
// Then: caption is stored in state (max 280 chars)
const user = userEvent.setup();
render(<PhotoSubmit profileId={mockProfileId} orderId={mockOrderId} tableId={mockTableId} />);
const textarea = screen.getByRole("textbox");
await user.type(textarea, "Great show tonight!");
expect(textarea).toHaveValue("Great show tonight!");
});
it("PW-UT-2.2: Caption limited to 280 characters", async () => {
// Given: User is on photo submit page
// When: user types more than 280 characters
// Then: input is truncated to 280 chars
const user = userEvent.setup();
render(<PhotoSubmit profileId={mockProfileId} orderId={mockOrderId} tableId={mockTableId} />);
const textarea = screen.getByRole("textbox");
await user.type(textarea, "a".repeat(300));
expect(textarea).toHaveValue("a".repeat(280));
});
});// apps/frontend/__tests__/components/photo-card.test.tsx
// TDD: Write test BEFORE implementation
// Run: npx vitest run __tests__/components/photo-card.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 { PhotoCard } from "~/components/minigames/photo-card";
import type { Id } from "convex/_generated/dataModel";
const mockPhoto = {
_id: "photo_test123" as Id<"photoSubmissions">,
_creationTime: Date.now(),
profileId: "prof_test123" as Id<"guestProfiles">,
orderId: "ord_test456" as Id<"orders">,
tableId: "tab_test789" as Id<"tables">,
imageUrl: "https://example.com/photo.jpg",
caption: "Great show!",
likeCount: 5,
status: "ACTIVE" as const,
winner: false,
showDate: new Date().toISOString().split("T")[0],
createdAt: Date.now(),
};
vi.mock("convex/react", () => ({
useQuery: vi.fn(() => [mockPhoto]),
useMutation: vi.fn(() => () => () => vi.fn()),
}));
describe("PhotoCard — User Expectations", () => {
// ─── PW-UT-3: Card rendering ─────────────────────────────────────
it("PW-UT-3.1: Card shows table number and like count", () => {
// Given: A photo submission with 5 likes
// When: PhotoCard renders
// Then: table number and like count visible
render(<PhotoCard photo={mockPhoto} profileId="other_user" />);
expect(screen.getByText(/tab_test789/i)).toBeVisible();
expect(screen.getByText("5")).toBeVisible();
});
it("PW-UT-3.2: Card shows caption when present", () => {
// Given: A photo submission with a caption
// When: PhotoCard renders
// Then: caption text visible below the photo
render(<PhotoCard photo={mockPhoto} profileId="other_user" />);
expect(screen.getByText("Great show!")).toBeInTheDocument();
});
// ─── PW-UT-4: Like interaction ──────────────────────────────────
it("PW-UT-4.1: Heart button is clickable", async () => {
// Given: User is viewing a photo card
// When: User taps the heart button
// Then: likePhoto mutation is called
const user = userEvent.setup();
const likeMutation = vi.fn(() => Promise.resolve());
vi.mock("convex/react", () => ({
useQuery: vi.fn(() => [mockPhoto]),
useMutation: vi.fn(() => likeMutation),
}));
render(<PhotoCard photo={mockPhoto} profileId="other_user" />);
await user.click(screen.getByRole("button"));
expect(likeMutation).toHaveBeenCalled();
});
it("PW-UT-4.2: Heart icon is filled after already liking", () => {
// Given: User has already liked this photo (hasLiked = true)
// When: PhotoCard renders
// Then: filled heart icon shown (red color)
vi.mock("convex/react", () => ({
useQuery: vi.fn(() => [mockPhoto]),
useMutation: vi.fn(() => () => () => vi.fn()),
}));
render(<PhotoCard photo={mockPhoto} profileId="other_user" />);
const heartBtn = screen.getByRole("button");
expect(heartBtn).toHaveClass("text-red-400");
});
});Schema Unit Tests (Vitest):
// apps/frontend/__tests__/lib/photo-wall.test.ts
// TDD: Write test BEFORE implementation
// Run: npx vitest run __tests__/lib/photo-wall.test.ts
import { describe, it, expect } from "vitest";
import { PhotoSubmitSchema, LikePhotoSchema } from "~/lib/schemas/photo-wall";
describe("PhotoSubmitSchema", () => {
it("PW-UT-5.1: accepts valid submission", () => {
// Given: All required fields with valid values
// When: Schema.parse is called
// Then: Returns success
const result = PhotoSubmitSchema.safeParse({
profileId: "prof_123",
orderId: "ord_456",
tableId: "tab_789",
imageUrl: "https://example.com/photo.jpg",
caption: "Great show!",
});
expect(result.success).toBe(true);
});
it("PW-UT-5.2: rejects missing imageUrl", () => {
// Given: Missing required imageUrl field
// When: Schema.parse is called
// Then: Returns error
const result = PhotoSubmitSchema.safeParse({
profileId: "prof_123",
orderId: "ord_456",
tableId: "tab_789",
});
expect(result.success).toBe(false);
});
it("PW-UT-5.3: rejects caption over 280 chars", () => {
// Given: Caption with 281 characters
// When: Schema.parse is called
// Then: Returns error
const result = PhotoSubmitSchema.safeParse({
profileId: "prof_123",
orderId: "ord_456",
tableId: "tab_789",
imageUrl: "https://example.com/photo.jpg",
caption: "a".repeat(281),
});
expect(result.success).toBe(false);
});
it("PW-UT-5.4: rejects invalid URL", () => {
// Given: Invalid URL format
// When: Schema.parse is called
// Then: Returns error
const result = PhotoSubmitSchema.safeParse({
profileId: "prof_123",
orderId: "ord_456",
tableId: "tab_789",
imageUrl: "not-a-url",
});
expect(result.success).toBe(false);
});
});
describe("LikePhotoSchema", () => {
it("PW-UT-6.1: accepts valid like", () => {
// Given: All required fields
// When: Schema.parse is called
// Then: Returns success
const result = LikePhotoSchema.safeParse({
submissionId: "sub_123",
profileId: "prof_456",
});
expect(result.success).toBe(true);
});
it("PW-UT-6.2: rejects missing fields", () => {
// Given: Empty object
// When: Schema.parse is called
// Then: Returns error
const result = LikePhotoSchema.safeParse({});
expect(result.success).toBe(false);
});
});Mutation Backend Tests (Vitest):
// apps/frontend/__tests__/convex/photo-wall.test.ts
// TDD: Write test BEFORE implementation
// Run: npx vitest run __tests__/convex/photo-wall.test.ts
import { describe, it, expect } from "vitest";
describe("submitPhoto mutation", () => {
it("PW-MUT-1.1: successfully submits photo for new table", async () => {
// Given: Table has not submitted tonight
// When: submitPhoto is called with valid data
// Then: photoSubmissions record created with likeCount=0
});
it("PW-MUT-1.2: rejects second submission for same table", async () => {
// Given: Table already submitted tonight
// When: submitPhoto is called again
// Then: throws PHOTO_TABLE_ALREADY_SUBMITTED error
});
it("PW-MUT-1.3: rejects invalid image URL", async () => {
// Given: Invalid URL format
// When: submitPhoto is called
// Then: throws PHOTO_INVALID_URL error
});
it("PW-MUT-1.4: rejects caption over 280 chars", async () => {
// Given: Caption with 281 characters
// When: submitPhoto is called
// Then: throws PHOTO_CAPTION_TOO_LONG error
});
});
describe("likePhoto mutation", () => {
it("PW-MUT-2.1: successfully likes a photo", async () => {
// Given: Guest has not liked this photo
// When: likePhoto is called
// Then: photoLikes record created, likeCount incremented
});
it("PW-MUT-2.2: rejects duplicate like from same guest", async () => {
// Given: Guest already liked this photo
// When: likePhoto is called again
// Then: throws PHOTO_ALREADY_LIKED error
});
it("PW-MUT-2.3: rejects like on non-existent submission", async () => {
// Given: Submission does not exist
// When: likePhoto is called
// Then: throws PHOTO_SUBMISSION_NOT_FOUND error
});
});9. Cross-Plan Dependencies
| Depends On | Required By | Shared Schema |
|---|---|---|
guestProfiles table | All minigames | profileId reference |
orders table | Photo, Lucky Spin, Google Review | orderId reference |
tables table | Photo, Lucky Spin, Google Review | tableId reference |
challengeConfig (future) | Google Review, Lucky Spin | Admin-configurable prize values |
orderItems table | Lucky Spin, Google Review | Comp item insertion |
| Trust Signals | Photo Wall | Social proof stats on wall screen |
Shares schema with:
07-lucky-spin—orderItemscomp insertion pattern08-google-review—challengeSubmissionstable structure (if generalized)
10. Performance Considerations
- [P1 PERFORMANCE GAP] Image storage: Current plan uses
FileReader.readAsDataURLfor image preview (base64). This inflates payload and should be replaced with Convex storage (storage.store) for production scale. Track in: [P1] Photo Wall base64 to Convex storage migration - Like count updates: Batch like increments via scheduled mutation if rate exceeds 100/sec
- Wall grid: Limit to 6 photos with
getTopPhotos({ limit: 6 })— no pagination needed for display - Real-time subscriptions:
useQueryauto-batches subscriptions; limit concurrent subscriptions to 3 per component - Large shows: If >50 tables, add cursor pagination to
getTonightsPhotos - No
as anyviolations: All type casts use properId<"tableName">types from Convex generated types - No
Math.random(): No randomization needed — all ordering is deterministic - No
console.log: All error handling usestoast(sonner) for user feedback
Acceptance Criteria
- Guest can take/upload photo in PWA
- One photo per table per show enforced (error if trying second)
- Photo appears on shared wall instantly after submission
- Other guests can like a photo (one like per guest per photo)
- Like count updates in real-time via Convex subscription (not local state)
- Photo grid on wall shows table number, not guest name
- Wall photo grid auto-cycles (handled by wall layout)
- All error codes use prefixed format (PHOTO_*)
- All strings use
useTranslations— no hardcoded user-facing strings Suspenseboundary with skeleton onWallPhotoGrid
Consistency Audit: photo-wall
P0 Violations (fixed in plan text)
| # | Location | Issue | Fix Applied |
|---|---|---|---|
| 1 | PhotoCard component | hasLiked was hardcoded to false with a [TODO] — missing hasLikedPhoto query | Added hasLikedPhoto query via useQuery(api.challenges.hasLikedPhoto, {...}) in PhotoCard component and added the query function to convex/functions/challenges.ts |
P1 Violations (fixed in plan text)
| # | Location | Issue | Fix Applied |
|---|---|---|---|
| 1 | PhotoSubmit component | FileReader.readAsDataURL for base64 image preview — inflates payload size | Noted 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)
| # | Issue | Action Required |
|---|---|---|
| 1 | staffMutation/adminMutation not in convex/auth.ts | Not applicable — photo wall is guest-facing only, no staff mutations required in v1 |
| 2 | Content moderation for photos | Future iteration: staff can hide photos via HIDDEN status — requires adminMutation for staff to update photoSubmissions.status |
| 3 | Top-liked winner determination | Future iteration: end-of-show winner selection — requires scheduled mutation to set winner=true on top photo |