plans
2026-05-03
2026 05 03 Photo Wall Plan

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 photoSubmissions and photoLikes tables

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

OperationError CodeMessage KeyNotes
submitPhotoPHOTO_TABLE_ALREADY_SUBMITTEDerrorTableAlreadySubmittedOne photo per table per show
submitPhotoPHOTO_INVALID_URLerrorSubmitFailedInvalid image URL
submitPhotoPHOTO_CAPTION_TOO_LONGerrorSubmitFailedCaption exceeds 280 chars
likePhotoPHOTO_ALREADY_LIKEDerrorAlreadyLikedDuplicate like prevented
likePhotoPHOTO_SUBMISSION_NOT_FOUNDerrorLikeFailedPhoto no longer exists
Generic failureerrorSubmitFailed / errorLikeFailedCatch-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

ComponentMobile BehaviorDesktop Behavior
PhotoSubmitFull-width camera capture, bottom-sticky submitCentered card, file upload
PhotoCardFull-width, tap to enlargeGrid layout 2-3 columns
WallPhotoGrid2-column grid, swipeable3-column grid, auto-cycle
  • Camera input uses capture="environment" for rear camera on mobile
  • Photo preview uses aspect-square on both mobile and desktop
  • Caption textarea expands on focus for mobile keyboard
  • Suspense boundary 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 OnRequired ByShared Schema
guestProfiles tableAll minigamesprofileId reference
orders tablePhoto, Lucky Spin, Google RevieworderId reference
tables tablePhoto, Lucky Spin, Google ReviewtableId reference
challengeConfig (future)Google Review, Lucky SpinAdmin-configurable prize values
orderItems tableLucky Spin, Google ReviewComp item insertion
Trust SignalsPhoto WallSocial proof stats on wall screen

Shares schema with:

  • 07-lucky-spinorderItems comp insertion pattern
  • 08-google-reviewchallengeSubmissions table structure (if generalized)

10. Performance Considerations

  • [P1 PERFORMANCE GAP] Image storage: Current plan uses FileReader.readAsDataURL for 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: useQuery auto-batches subscriptions; limit concurrent subscriptions to 3 per component
  • Large shows: If >50 tables, add cursor pagination to getTonightsPhotos
  • No as any violations: All type casts use proper Id<"tableName"> types from Convex generated types
  • No Math.random(): No randomization needed — all ordering is deterministic
  • No console.log: All error handling uses toast (sonner) for user feedback

Acceptance Criteria

  1. Guest can take/upload photo in PWA
  2. One photo per table per show enforced (error if trying second)
  3. Photo appears on shared wall instantly after submission
  4. Other guests can like a photo (one like per guest per photo)
  5. Like count updates in real-time via Convex subscription (not local state)
  6. Photo grid on wall shows table number, not guest name
  7. Wall photo grid auto-cycles (handled by wall layout)
  8. All error codes use prefixed format (PHOTO_*)
  9. All strings use useTranslations — no hardcoded user-facing strings
  10. Suspense boundary with skeleton on WallPhotoGrid

Consistency Audit: photo-wall

P0 Violations (fixed in plan text)

#LocationIssueFix Applied
1PhotoCard componenthasLiked was hardcoded to false with a [TODO] — missing hasLikedPhoto queryAdded 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)

#LocationIssueFix Applied
1PhotoSubmit componentFileReader.readAsDataURL for base64 image preview — inflates payload sizeNoted 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 — photo wall is guest-facing only, no staff mutations required in v1
2Content moderation for photosFuture iteration: staff can hide photos via HIDDEN status — requires adminMutation for staff to update photoSubmissions.status
3Top-liked winner determinationFuture iteration: end-of-show winner selection — requires scheduled mutation to set winner=true on top photo