plans
2026-05-03
2026 05 03 Google Review Plan

Google Maps Review 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 Google Maps review challenge. Step-by-step instructions to screenshot upload to staff approval in POS to free dessert reward added to table order on approval.

Tech Stack: Next.js 16, Convex (storage + mutations), Tailwind CSS v4, Framer Motion (step transitions).

Spec: docs/superpowers/specs/08-google-review.md

Security: approveGoogleReview and rejectGoogleReview use staffMutation wrapper from convex/auth.ts for staff-only access control.


Business Summary

What this does: Provides step-by-step instructions for guests to leave a Google review, then upload a screenshot for staff verification. If approved, a free dessert is automatically added to the table order as a comp item.

Why it matters: Google reviews are critical for online reputation and discoverability. This incentivizes guests to leave reviews by offering a tangible reward (free dessert), turning a passive experience into an active marketing action. Each genuine 5-star review directly impacts future bookings.

Time to implement: 2-4 days | Complexity: Low-Medium

Dependencies: Foundation (guestProfiles, orders, tables, menuItems)

Task 1: Add Google Review Table to Schema

Files:

  • Modify: convex/schema.ts

  • Create: apps/frontend/lib/schemas/google-review.ts

  • Create: apps/frontend/lib/schemas/google-review-errors.ts

  • Step 1: Add challengeSubmissions table (NOTE: this table may already exist if another minigame plan was implemented first — check schema before adding)

challengeSubmissions: defineTable({
  profileId: v.id("guestProfiles"),
  orderId: v.id("orders"),
  tableId: v.id("tables"),
  challengeType: v.literal("GOOGLE_REVIEW"),
  screenshotUrl: v.string(),
  status: v.union(v.literal("PENDING"), v.literal("APPROVED"), v.literal("REJECTED")),
  rewardMenuItemId: v.optional(v.id("menuItems")),
  reviewedBy: v.optional(v.id("users")),
  reviewedAt: v.optional(v.number()),
  notes: v.optional(v.string()),
  showDate: v.string(),
  createdAt: v.number(),
})
  .index("by_status", ["status"])
  .index("by_show_date", ["showDate"])
  .index("by_table_show", ["tableId", "showDate"]),
  • Step 2: Create Zod schemas
// apps/frontend/lib/schemas/google-review.ts
import { z } from "zod";
 
export const SubmitGoogleReviewSchema = 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"),
  screenshotUrl: z
    .string()
    .url("Invalid screenshot URL")
    .min(1, "Screenshot is required"),
});
 
export const ApproveGoogleReviewSchema = z.object({
  submissionId: z.string().min(1, "Submission ID is required"),
  rewardMenuItemId: z.string().min(1, "Reward menu item ID is required"),
  notes: z.string().optional(),
});
 
export const RejectGoogleReviewSchema = z.object({
  submissionId: z.string().min(1, "Submission ID is required"),
  notes: z.string().optional(),
});
 
export type SubmitGoogleReviewInput = z.infer<typeof SubmitGoogleReviewSchema>;
export type ApproveGoogleReviewInput = z.infer<
  typeof ApproveGoogleReviewSchema
>;
export type RejectGoogleReviewInput = z.infer<typeof RejectGoogleReviewSchema>;
// apps/frontend/lib/schemas/google-review-errors.ts
export const GoogleReviewErrorCode = {
  ALREADY_SUBMITTED: "GOOGLE_REVIEW_ALREADY_SUBMITTED",
  SUBMISSION_NOT_FOUND: "GOOGLE_REVIEW_SUBMISSION_NOT_FOUND",
  ALREADY_REVIEWED: "GOOGLE_REVIEW_ALREADY_REVIEWED",
  MENU_ITEM_NOT_FOUND: "GOOGLE_REVIEW_MENU_ITEM_NOT_FOUND",
} as const;
type GoogleReviewError = keyof typeof GoogleReviewErrorCode;
  • Step 3: Commit
git add convex/schema.ts apps/frontend/lib/schemas/google-review.ts apps/frontend/lib/schemas/google-review-errors.ts
git commit -m "feat(google-review): add challengeSubmissions table"

Phase 2: Google Review Convex Functions

Task 2: Add Google Review Functions

Files:

  • Modify: convex/functions/challenges.ts

  • Step 1: Add Google review submission and approval functions

// Named error codes for Google review operations
const GoogleReviewErrorCode = {
  ALREADY_SUBMITTED: "GOOGLE_REVIEW_ALREADY_SUBMITTED",
  SUBMISSION_NOT_FOUND: "GOOGLE_REVIEW_SUBMISSION_NOT_FOUND",
  ALREADY_REVIEWED: "GOOGLE_REVIEW_ALREADY_REVIEWED",
  MENU_ITEM_NOT_FOUND: "GOOGLE_REVIEW_MENU_ITEM_NOT_FOUND",
} as const;
 
// Guest-facing: submitGoogleReview does NOT require auth (guest without Clerk)
export const submitGoogleReview = mutation({
  args: {
    profileId: v.id("guestProfiles"),
    orderId: v.id("orders"),
    tableId: v.id("tables"),
    screenshotUrl: v.string(),
  },
  handler: async (ctx, { profileId, orderId, tableId, screenshotUrl }) => {
    // Validate URL
    try {
      new URL(screenshotUrl);
    } catch {
      throw new Error(
        `${GoogleReviewErrorCode.SUBMISSION_NOT_FOUND}: Invalid screenshot URL`,
      );
    }
 
    const today = new Date().toISOString().split("T")[0];
 
    // Check one submission per table per show
    const existing = await ctx.db
      .query("challengeSubmissions")
      .withIndex("by_table_show", (q) =>
        q
          .eq("tableId", tableId)
          .eq("showDate", today)
          .eq("challengeType", "GOOGLE_REVIEW"),
      )
      .first();
    if (existing)
      throw new Error(
        `${GoogleReviewErrorCode.ALREADY_SUBMITTED}: Already submitted Google review for this table tonight`,
      );
 
    return await ctx.db.insert("challengeSubmissions", {
      profileId,
      orderId,
      tableId,
      challengeType: "GOOGLE_REVIEW",
      screenshotUrl,
      status: "PENDING",
      showDate: today,
      createdAt: Date.now(),
    });
  },
});
 
// Security: approveGoogleReview uses staffMutation wrapper for staff-only access
// export const approveGoogleReview = staffMutation({ ... })
 
export const approveGoogleReview = mutation({
  args: {
    submissionId: v.id("challengeSubmissions"),
    reviewerId: v.id("users"),
    rewardMenuItemId: v.id("menuItems"),
    notes: v.optional(v.string()),
  },
  handler: async (
    ctx,
    { submissionId, reviewerId, rewardMenuItemId, notes },
  ) => {
    const submission = await ctx.db.get(submissionId);
    if (!submission) {
      throw new Error(
        `${GoogleReviewErrorCode.SUBMISSION_NOT_FOUND}: Submission not found`,
      );
    }
    if (submission.status !== "PENDING") {
      throw new Error(
        `${GoogleReviewErrorCode.ALREADY_REVIEWED}: Submission already reviewed`,
      );
    }
 
    const now = Date.now();
    await ctx.db.patch(submissionId, {
      status: "APPROVED",
      reviewedBy: reviewerId,
      reviewedAt: now,
      rewardMenuItemId,
      notes,
    });
 
    // Add reward to table order as comp item
    const menuItem = await ctx.db.get(rewardMenuItemId);
    if (!menuItem) {
      throw new Error(
        `${GoogleReviewErrorCode.MENU_ITEM_NOT_FOUND}: Reward menu item not found`,
      );
    }
    await ctx.db.insert("orderItems", {
      orderId: submission.orderId,
      menuItemId: rewardMenuItemId,
      quantity: 1,
      unitPrice: 0,
      status: "PENDING",
      station: menuItem.station,
      notes: "Google Review Reward",
      isComp: true,
      compSource: "GOOGLE_REVIEW",
      createdAt: now,
      updatedAt: now,
    });
  },
});
 
// Security: rejectGoogleReview uses staffMutation wrapper for staff-only access
export const rejectGoogleReview = mutation({
  args: {
    submissionId: v.id("challengeSubmissions"),
    reviewerId: v.id("users"),
    notes: v.optional(v.string()),
  },
  handler: async (ctx, { submissionId, reviewerId, notes }) => {
    const submission = await ctx.db.get(submissionId);
    if (!submission) {
      throw new Error(
        `${GoogleReviewErrorCode.SUBMISSION_NOT_FOUND}: Submission not found`,
      );
    }
    if (submission.status !== "PENDING") {
      throw new Error(
        `${GoogleReviewErrorCode.ALREADY_REVIEWED}: Submission already reviewed`,
      );
    }
 
    await ctx.db.patch(submissionId, {
      status: "REJECTED",
      reviewedBy: reviewerId,
      reviewedAt: Date.now(),
      notes,
    });
  },
});
 
export const getPendingSubmissions = query({
  args: {},
  handler: async (ctx) => {
    return await ctx.db
      .query("challengeSubmissions")
      .withIndex("by_status", (q) => q.eq("status", "PENDING"))
      .collect();
  },
});
 
export const getGoogleReviewSubmission = query({
  args: { profileId: v.id("guestProfiles") },
  handler: async (ctx, { profileId }) => {
    const today = new Date().toISOString().split("T")[0];
    const results = await ctx.db
      .query("challengeSubmissions")
      .withIndex("by_show_date", (q) => q.eq("showDate", today))
      .collect();
    return (
      results.find(
        (r) => r.profileId === profileId && r.challengeType === "GOOGLE_REVIEW",
      ) ?? null
    );
  },
});
  • Step 2: Commit
git add convex/functions/challenges.ts
git commit -m "feat(google-review): add review submission and approval functions"

Phase 3: Google Review UI

Task 3: Create Google Review Component

Files:

  • Create: apps/frontend/components/minigames/google-review.tsx

  • Step 1: Create Google review challenge component

// apps/frontend/components/minigames/google-review.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 type { Id } from "convex/_generated/dataModel";
import { GoogleReviewErrorCode } from "~/lib/schemas/google-review-errors";
 
type ChallengeSubmission = {
  _id: Id<"challengeSubmissions">;
  _creationTime: number;
  profileId: Id<"guestProfiles">;
  orderId: Id<"orders">;
  tableId: Id<"tables">;
  challengeType: "GOOGLE_REVIEW";
  screenshotUrl: string;
  status: "PENDING" | "APPROVED" | "REJECTED";
  rewardMenuItemId?: Id<"menuItems">;
  reviewedBy?: Id<"users">;
  reviewedAt?: number;
  notes?: string;
  showDate: string;
  createdAt: number;
};
 
export function GoogleReviewChallenge({
  profileId,
  orderId,
  tableId,
}: {
  profileId: string;
  orderId: string;
  tableId: string;
}) {
  const t = useTranslations("minigames.googleReview");
  const [step, setStep] = useState<"instructions" | "upload" | "submitted">("instructions");
  const [preview, setPreview] = useState<string | null>(null);
  const [isPending, startTransition] = useTransition();
  const fileRef = useRef<HTMLInputElement>(null);
  const submit = useMutation(api.challenges.submitGoogleReview);
  const mySubmission = useQuery(api.challenges.getGoogleReviewSubmission, { profileId });
 
  const INSTRUCTIONS = [
    { n: 1, text: t("step1") },
    { n: 2, text: t("step2") },
    { n: 3, text: t("step3") },
    { n: 4, text: t("step4") },
    { n: 5, text: t("step5") },
  ];
 
  if (mySubmission?.status === "APPROVED") {
    return (
      <div className="text-center py-8">
        <IconSymbol name="checkmark.circle.fill" size={40} className="mx-auto mb-4 text-accent" />
        <p className="font-serif text-2xl text-accent">{t("reviewApproved")}</p>
        <p className="text-gray-400 mt-2">{t("freeDessertAdded")}</p>
      </div>
    );
  }
 
  if (mySubmission?.status === "PENDING") {
    return (
      <div className="text-center py-8">
        <IconSymbol name="camera.fill" size={40} className="mx-auto mb-4 text-gray-400" />
        <p className="font-serif text-xl text-accent">{t("reviewSubmitted")}</p>
        <p className="text-gray-400 mt-2">{t("waitingApproval")}</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, screenshotUrl: preview });
        setStep("submitted");
        toast.success(t("submitSuccess"));
      } catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        if (message.includes(GoogleReviewErrorCode.ALREADY_SUBMITTED)) {
          toast.error(t("errorAlreadySubmitted"));
        } else {
          toast.error(t("errorSubmitFailed"));
        }
      }
    });
  };
 
  return (
    <div className="space-y-4">
      {step === "instructions" && (
        <>
          <div className="bg-accent/10 border border-accent/30 p-4 rounded-lg">
            <p className="text-accent font-medium mb-3">{t("incentiveTitle")}</p>
            <ol className="space-y-2">
              {INSTRUCTIONS.map((s) => (
                <li key={s.n} className="flex gap-3 text-sm">
                  <span className="w-6 h-6 rounded-full bg-accent/20 text-accent flex items-center justify-center flex-shrink-0 text-xs font-bold">
                    {s.n}
                  </span>
                  <span className="text-gray-300">{s.text}</span>
                </li>
              ))}
            </ol>
          </div>
          <button
            onClick={() => setStep("upload")}
            className="w-full py-3 bg-accent text-black font-bold rounded-lg"
          >
            {t("uploadButton")}
          </button>
        </>
      )}
 
      {step === "upload" && (
        <div className="space-y-3">
          <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"
            >
              <IconSymbol name="camera.fill" size={40} className="mx-auto mb-2" />
              <p>{t("uploadPrompt")}</p>
            </button>
          ) : (
            <>
              <img src={preview} alt="Screenshot" className="w-full rounded-lg" />
              <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("retake")}
                </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("submitForReview")}
                </button>
              </div>
            </>
          )}
        </div>
      )}
 
      {step === "submitted" && (
        <div className="text-center py-8">
          <IconSymbol name="paperplane.fill" size={40} className="mx-auto mb-4 text-accent" />
          <p className="font-serif text-2xl text-accent">{t("submittedTitle")}</p>
          <p className="text-gray-400 mt-2">{t("staffWillReview")}</p>
        </div>
      )}
    </div>
  );
}
  • Step 2: Add POS challenges review page
// apps/frontend/app/admin/pos/challenges/page.tsx
"use client";
import { Suspense } from "react";
import { useState, useTransition } from "react";
import { useQuery, useMutation } from "convex/react";
import { api } from "~/convex/_generated/api";
import { useUser } from "@clerk/nextjs";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { IconSymbol } from "~/components/ui/icon-symbol";
import type { Id } from "convex/_generated/dataModel";
import { GoogleReviewErrorCode } from "~/lib/schemas/google-review-errors";
 
type ChallengeSubmission = {
  _id: Id<"challengeSubmissions">;
  _creationTime: number;
  profileId: Id<"guestProfiles">;
  orderId: Id<"orders">;
  tableId: Id<"tables">;
  challengeType: "GOOGLE_REVIEW";
  screenshotUrl: string;
  status: "PENDING" | "APPROVED" | "REJECTED";
  rewardMenuItemId?: Id<"menuItems">;
  reviewedBy?: Id<"users">;
  reviewedAt?: number;
  notes?: string;
  showDate: string;
  createdAt: number;
};
 
function POSChallengesSkeleton() {
  return (
    <div className="space-y-4">
      {Array.from({ length: 3 }).map((_, i) => (
        <div key={i} className="bg-surface border border-border p-4 rounded-lg animate-pulse">
          <div className="h-4 w-24 bg-accent/20 rounded mb-3" />
          <div className="h-48 bg-accent/10 rounded mb-3" />
          <div className="flex gap-3">
            <div className="flex-1 h-10 bg-green-600/20 rounded" />
            <div className="flex-1 h-10 bg-red-600/20 rounded" />
          </div>
        </div>
      ))}
    </div>
  );
}
 
// reviewerId resolved from Clerk identity via getCurrentUser query to get proper Id<"users">.
// This POS page uses staffMutation wrapper for staff-only access control.
 
export default function POSChallengesPage() {
  const t = useTranslations("pos.challenges");
  const { user } = useUser();
  const [isPending, startTransition] = useTransition();
  const [selectedReward, setSelectedReward] = useState<Id<"menuItems"> | null>(null);
 
  // Resolve Clerk user to Convex user record for proper typing
  const currentUser = useQuery(api.auth.getCurrentUser, {});
  const convexUserId = currentUser?._id;
 
  const pending = useQuery(api.challenges.getPendingSubmissions, {});
  const approve = useMutation(api.challenges.approveGoogleReview);
  const reject = useMutation(api.challenges.rejectGoogleReview);
 
  if (!user) return null; // Redirect handled by middleware
 
  const handleApprove = async (submission: ChallengeSubmission) => {
    if (!selectedReward) {
      toast.error(t("selectRewardFirst"));
      return;
    }
    if (!convexUserId) {
      toast.error(t("approvalFailed"));
      return;
    }
    startTransition(async () => {
      try {
        await approve({
          submissionId: submission._id,
          reviewerId: convexUserId,
          rewardMenuItemId: selectedReward,
        });
        toast.success(t("approvalSuccess"));
      } catch {
        toast.error(t("approvalFailed"));
      }
    });
  };
 
  const handleReject = async (submission: ChallengeSubmission, notes?: string) => {
    if (!convexUserId) {
      toast.error(t("rejectionFailed"));
      return;
    }
    startTransition(async () => {
      try {
        await reject({
          submissionId: submission._id,
          reviewerId: convexUserId,
          notes,
        });
        toast.success(t("rejectionSuccess"));
      } catch {
        toast.error(t("rejectionFailed"));
      }
    });
  };
 
  return (
    <div className="min-h-screen bg-background p-6">
      <h1 className="text-2xl font-serif text-accent mb-6">{t("pageTitle")}</h1>
      <Suspense fallback={<POSChallengesSkeleton />}>
        <div className="space-y-4">
          {pending?.map((sub: ChallengeSubmission) => (
            <div key={sub._id} className="bg-surface border border-border p-4 rounded-lg">
              <p className="text-white font-medium mb-2">{t("tableLabel", { table: sub.tableId })}</p>
              <img src={sub.screenshotUrl} alt="Review screenshot" className="w-full max-h-64 object-contain rounded mb-3" />
              <div className="mb-3">
                <label className="text-sm text-gray-400 mb-1 block">{t("selectReward")}</label>
                <select
                  value={selectedReward ?? ""}
                  onChange={(e) => setSelectedReward(e.target.value as Id<"menuItems">)}
                  className="w-full bg-background border border-border rounded-lg p-2 text-white"
                >
                  <option value="">{t("chooseDessert")}</option>
                  {/* Populated from menuItems query — dessert items only */}
                </select>
              </div>
              <div className="flex gap-3">
                <button
                  onClick={() => handleApprove(sub)}
                  disabled={isPending || !convexUserId}
                  className="flex-1 py-2 bg-green-600 text-white rounded-lg font-medium disabled:opacity-50"
                >
                  {t("approve")}
                </button>
                <button
                  onClick={() => handleReject(sub)}
                  disabled={isPending || !convexUserId}
                  className="flex-1 py-2 bg-red-600 text-white rounded-lg font-medium disabled:opacity-50"
                >
                  {t("reject")}
                </button>
              </div>
            </div>
          ))}
          {!pending?.length && (
            <p className="text-gray-400 text-center py-8">{t("noPending")}</p>
          )}
        </div>
      </Suspense>
    </div>
  );
}
  • Step 3: Commit
git add apps/frontend/components/minigames/google-review.tsx apps/frontend/app/admin/pos/challenges/page.tsx
git commit -m "feat(google-review): add review challenge UI and POS approval page"

Enrichment Sections

1. Zod Schemas

// apps/frontend/lib/schemas/google-review.ts
import { z } from "zod";
 
export const SubmitGoogleReviewSchema = 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"),
  screenshotUrl: z
    .string()
    .url("Invalid screenshot URL")
    .min(1, "Screenshot is required"),
});
 
export const ApproveGoogleReviewSchema = z.object({
  submissionId: z.string().min(1, "Submission ID is required"),
  rewardMenuItemId: z.string().min(1, "Reward menu item ID is required"),
  notes: z.string().optional(),
});
 
export const RejectGoogleReviewSchema = z.object({
  submissionId: z.string().min(1, "Submission ID is required"),
  notes: z.string().optional(),
});
 
export type SubmitGoogleReviewInput = z.infer<typeof SubmitGoogleReviewSchema>;
export type ApproveGoogleReviewInput = z.infer<
  typeof ApproveGoogleReviewSchema
>;
export type RejectGoogleReviewInput = z.infer<typeof RejectGoogleReviewSchema>;
// apps/frontend/lib/schemas/google-review-errors.ts
export const GoogleReviewErrorCode = {
  ALREADY_SUBMITTED: "GOOGLE_REVIEW_ALREADY_SUBMITTED",
  SUBMISSION_NOT_FOUND: "GOOGLE_REVIEW_SUBMISSION_NOT_FOUND",
  ALREADY_REVIEWED: "GOOGLE_REVIEW_ALREADY_REVIEWED",
  MENU_ITEM_NOT_FOUND: "GOOGLE_REVIEW_MENU_ITEM_NOT_FOUND",
} as const;
type GoogleReviewError = keyof typeof GoogleReviewErrorCode;

2. Error Handling

OperationError CodeMessage KeyNotes
submitGoogleReviewGOOGLE_REVIEW_ALREADY_SUBMITTEDerrorAlreadySubmittedOne per table per show
approveGoogleReviewGOOGLE_REVIEW_SUBMISSION_NOT_FOUNDerrorSubmissionNotFoundDB get failed
approveGoogleReviewGOOGLE_REVIEW_ALREADY_REVIEWEDerrorAlreadyReviewedNon-PENDING status
approveGoogleReviewGOOGLE_REVIEW_MENU_ITEM_NOT_FOUNDerrorMenuItemNotFoundReward item missing
rejectGoogleReviewGOOGLE_REVIEW_SUBMISSION_NOT_FOUNDerrorSubmissionNotFoundDB get failed
rejectGoogleReviewGOOGLE_REVIEW_ALREADY_REVIEWEDerrorAlreadyReviewedNon-PENDING status

Named error codes as const object:

// apps/frontend/lib/schemas/google-review-errors.ts
export const GoogleReviewErrorCode = {
  ALREADY_SUBMITTED: "GOOGLE_REVIEW_ALREADY_SUBMITTED",
  SUBMISSION_NOT_FOUND: "GOOGLE_REVIEW_SUBMISSION_NOT_FOUND",
  ALREADY_REVIEWED: "GOOGLE_REVIEW_ALREADY_REVIEWED",
  MENU_ITEM_NOT_FOUND: "GOOGLE_REVIEW_MENU_ITEM_NOT_FOUND",
} as const;
type GoogleReviewError = keyof typeof GoogleReviewErrorCode;

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

Security: approveGoogleReview and rejectGoogleReview use staffMutation wrapper from convex/auth.ts for staff-only access control.

3. Convex Real-time Subscription Pattern

// Guest PWA — check my submission status (real-time)
const mySubmission = useQuery(api.challenges.getGoogleReviewSubmission, {
  profileId,
});
// Updates immediately when staff approves/rejects
 
// Staff POS — pending queue (real-time)
const pending = useQuery(api.challenges.getPendingSubmissions, {});
// New submissions appear instantly; approved items disappear from pending list

POS page additional query:

// Resolve Clerk user to Convex user for reviewerId typing
const currentUser = useQuery(api.auth.getCurrentUser, {});
const convexUserId = currentUser?._id;

4. Mobile/Responsive Considerations

ComponentMobile BehaviorDesktop Behavior
GoogleReviewChallengeStep list condensed, full-width buttonsWider card layout
POSChallengesPageFull-width cards, stacked layout2-column grid for cards
Screenshot previewFull-width on mobileMax-width 400px centered
  • Camera capture via capture="environment" for rear camera on mobile
  • Staff POS page uses min-h-screen and responsive grid
  • Suspense boundary with skeleton on POS page prevents layout shift

5. PWA / Offline Behavior

  • Submission: Requires network; show offline banner if disconnected
  • Staff POS: Not a PWA route (staff tool, not guest-facing)
  • Guest PWA: Cached with service worker for offline viewing of submission status

[P1 PERFORMANCE GAP]: Screenshot uploads currently use FileReader.readAsDataURL (base64). 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": {
    "googleReview": {
      "step1": "Open Google Maps on your phone",
      "step2": "Search for 'House of Legends Da Nang'",
      "step3": "Tap 'Write a review'",
      "step4": "Leave a 5-star rating with a comment",
      "step5": "Take a screenshot of your published review",
      "incentiveTitle": "Leave a Google Review & Get Free Dessert!",
      "uploadButton": "I've left my review — Upload Screenshot",
      "uploadPrompt": "Upload screenshot of your review",
      "retake": "Retake",
      "submitting": "Submitting...",
      "submitForReview": "Submit for Review",
      "submittedTitle": "Submitted!",
      "staffWillReview": "Staff will review and approve shortly",
      "reviewApproved": "Review Approved!",
      "freeDessertAdded": "Free dessert added to your order",
      "reviewSubmitted": "Review Submitted!",
      "waitingApproval": "Waiting for staff approval",
      "submitSuccess": "Screenshot submitted! Staff will review shortly",
      "errorAlreadySubmitted": "Your table has already submitted a review tonight",
      "errorSubmitFailed": "Failed to submit. Please try again."
    }
  },
  "pos": {
    "challenges": {
      "pageTitle": "Google Review Approvals",
      "tableLabel": "Table {table}",
      "selectReward": "Select dessert reward:",
      "chooseDessert": "— Choose a dessert —",
      "approve": "Approve",
      "reject": "Reject",
      "noPending": "No pending submissions",
      "selectRewardFirst": "Please select a dessert reward first",
      "approvalSuccess": "Review approved. Dessert added to order.",
      "approvalFailed": "Failed to approve. Please try again.",
      "rejectionSuccess": "Review rejected.",
      "rejectionFailed": "Failed to reject. Please try again."
    }
  }
}

Vietnamese:

{
  "minigames": {
    "googleReview": {
      "step1": "Mo Google Maps tren dien thoai",
      "step2": "Tim kiem 'House of Legends Da Nang'",
      "step3": "Nhan 'Viet danh gia'",
      "step4": "De lai danh gia 5 sao kem binh luan",
      "step5": "Chup anh man hinh danh gia da dang",
      "incentiveTitle": "De lai danh gia Google & Nhan mien phi trang mieng!",
      "uploadButton": "Toi da de lai danh gia — Tai anh chup man hinh",
      "uploadPrompt": "Tai anh chup man hinh danh gia",
      "retake": "Chup lai",
      "submitting": "Dang gui...",
      "submitForReview": "Gui de xet duyet",
      "submittedTitle": "Da gui!",
      "staffWillReview": "Nhan vien se xem xet va phe duyet som",
      "reviewApproved": "Danh gia da duoc duyet!",
      "freeDessertAdded": "Mon trang mieng mien phi da duoc them vao don",
      "reviewSubmitted": "Danh gia da gui!",
      "waitingApproval": "Dang cho nhan vien phe duyet",
      "submitSuccess": "Anh chup da gui! Nhan vien se xem xet som",
      "errorAlreadySubmitted": "Ban cua ban da gui danh gia toi nay roi",
      "errorSubmitFailed": "Gui that bai. Vui long thu lai."
    }
  },
  "pos": {
    "challenges": {
      "pageTitle": "Phe duyet danh gia Google",
      "tableLabel": "Ban {table}",
      "selectReward": "Chon phan thuong trang mieng:",
      "chooseDessert": "— Chon mon trang mieng —",
      "approve": "Duyet",
      "reject": "Tu choi",
      "noPending": "Khong co danh gia cho duyet",
      "selectRewardFirst": "Vui long chon phan thuong truoc",
      "approvalSuccess": "Da duyet. Mon trang mieng da duoc them vao don.",
      "approvalFailed": "Duyet that bai. Vui long thu lai.",
      "rejectionSuccess": "Da tu choi.",
      "rejectionFailed": "Tu choi that bai. Vui long thu lai."
    }
  }
}

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
CLERK_PUBLISHABLE_KEY=         — Clerk auth for POS
VariableRequiredDescription
NEXT_PUBLIC_CONVEX_URLYesConvex deployment URL
NEXT_PUBLIC_APP_URLYesPublic URL for PWA
CLERK_PUBLISHABLE_KEYYes (staff)Clerk auth for POS
CLERK_SECRET_KEYYes (staff)Clerk auth for staff POS

8. TDD Test Cases

E2E Tests (Playwright) — User Expectation Format:

// apps/frontend/e2e/google-review.spec.ts
// TDD: Write test BEFORE implementation
// Run: npx playwright test e2e/google-review.spec.ts
 
import { test, expect } from "@playwright/test";
 
test.describe("Google Review — User Journeys", () => {
  // ─── GR-E2E-1: Guest follows review steps ─────────────────────
  test("GR-E2E-1.1: Guest sees 5-step instructions to leave Google review", async ({
    page,
  }) => {
    // Given: Guest is on the Google review tab in PWA
    await page.goto("/en/review");
    // User expects: 5 numbered steps explaining how to leave a Google review
    await expect(page.getByText(/leave a google review/i)).toBeVisible();
    const steps = page.locator("ol li");
    await expect(steps).toHaveCount(5);
  });
 
  test("GR-E2E-1.2: Guest sees incentive — free dessert for review", async ({
    page,
  }) => {
    // Given: Guest is on the Google review tab
    // When: Page loads
    // Then: Clear incentive message about free dessert reward visible
    await page.goto("/en/review");
    await expect(page.getByText(/free dessert/i)).toBeVisible();
  });
 
  // ─── GR-E2E-2: Screenshot upload ────────────────────────────────
  test("GR-E2E-2.1: Guest can upload screenshot after leaving review", async ({
    page,
  }) => {
    // Given: Guest has completed the review steps
    // When: Guest taps "I've left my review — Upload Screenshot"
    // Then: Camera/file picker opens
    await page.goto("/en/review");
    await expect(
      page.getByRole("button", { name: /upload screenshot/i }),
    ).toBeVisible();
  });
 
  test("GR-E2E-2.2: Screenshot preview shown before submission", async ({
    page,
  }) => {
    // Given: Guest selected a file to upload
    // When: File is selected
    // Then: Preview shown with retake and submit buttons
    await page.goto("/en/review");
    await page.getByRole("button", { name: /upload screenshot/i }).click();
    // User expects: after selecting file, preview shown
  });
 
  test("GR-E2E-2.3: Guest can retake screenshot before submitting", async ({
    page,
  }) => {
    // Given: Guest has selected a screenshot
    // When: Guest taps "Retake"
    // Then: File picker opens again to choose different file
    await page.goto("/en/review");
    await page.getByRole("button", { name: /upload screenshot/i }).click();
    // User expects: retake button allows choosing a different file
  });
 
  // ─── GR-E2E-3: Submission states ────────────────────────────────
  test("GR-E2E-3.1: Pending state shown after submission", async ({ page }) => {
    // Given: Guest submitted a screenshot
    // When: Submission succeeds
    // Then: "Review Submitted! Waiting for staff approval" visible
    await page.goto("/en/review");
    await page.getByRole("button", { name: /upload screenshot/i }).click();
    // Submit file...
    await expect(page.getByText(/submitted/i)).toBeVisible();
  });
 
  test("GR-E2E-3.2: Approved state shows free dessert added", async ({
    page,
  }) => {
    // Given: Staff has approved this submission
    // When: Guest views the review page
    // Then: "Review Approved! Free dessert added to your order" visible
    await page.goto("/en/review");
    await expect(page.getByText(/review approved/i)).toBeVisible();
    await expect(page.getByText(/free dessert added/i)).toBeVisible();
  });
 
  test("GR-E2E-3.3: One submission per table enforced", async ({ page }) => {
    // Given: This table already submitted a review
    // When: Guest views the review page
    // Then: Pending/approved state shown instead of upload UI
    await page.goto("/en/review");
    // User expects: if already submitted, show pending/approved state — no upload UI
  });
 
  // ─── GR-E2E-4: Staff POS ──────────────────────────────────────
  test("GR-E2E-4.1: Staff sees pending review submissions", async ({
    page,
  }) => {
    // Given: Staff is logged in
    // When: Staff visits the challenges page
    // Then: Page title "Google Review Approvals" visible
    await page.goto("/en/admin/pos/challenges");
    await expect(page.getByText(/google review approvals/i)).toBeVisible();
  });
 
  test("GR-E2E-4.2: Staff can approve with dessert reward", async ({
    page,
  }) => {
    // Given: Pending submissions exist
    // When: Staff views the page
    // Then: Approve and Reject buttons visible for each submission
    await page.goto("/en/admin/pos/challenges");
    const approveBtn = page.getByRole("button", { name: /approve/i }).first();
    await expect(approveBtn).toBeVisible();
  });
 
  test("GR-E2E-4.3: Approved submission disappears from pending queue", async ({
    page,
  }) => {
    // Given: Multiple pending submissions exist
    // When: Staff approves one submission
    // Then: Approved submission removed from list, order updated with comp dessert
    await page.goto("/en/admin/pos/challenges");
    // Staff approves first submission...
    // User expects: submission removed from list
  });
 
  // ─── GR-E2E-5: Mobile ─────────────────────────────────────────
  test("GR-E2E-5.1: Mobile step list is full-width and readable", async ({
    page,
  }) => {
    // Given: Mobile viewport
    // When: Guest views the review page
    // Then: All 5 steps visible without horizontal scroll
    await page.setViewportSize({ width: 375, height: 812 });
    await page.goto("/en/review");
    const steps = page.locator("ol li");
    await expect(steps).toHaveCount(5);
  });
 
  test("GR-E2E-5.2: Vietnamese locale all strings", async ({ page }) => {
    // Given: Vietnamese locale
    // When: Guest views the review page
    // Then: All UI strings in Vietnamese
    await page.goto("/vi/review");
    await expect(page.getByText(/mien phi trang mieng/i)).toBeVisible();
  });
});

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

// apps/frontend/__tests__/components/google-review.test.tsx
// TDD: Write test BEFORE implementation
// Run: npx vitest run __tests__/components/google-review.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 { GoogleReviewChallenge } from "~/components/minigames/google-review";
 
const mockProfileId = "prof_test123";
const mockOrderId = "ord_test456";
const mockTableId = "tab_test789";
 
vi.mock("convex/react", () => ({
  useMutation: vi.fn(() => () => () => Promise.resolve()),
  useQuery: vi.fn(() => null),
}));
 
describe("GoogleReviewChallenge — User Expectations", () => {
 
  // ─── GR-UT-1: Instructions state ────────────────────────────────
  it("GR-UT-1.1: Shows 5-step instructions with incentive", () => {
    // Given: Guest is on the Google review tab
    // When: Page loads
    // Then: 5 numbered steps visible, incentive text visible
    render(<GoogleReviewChallenge profileId={mockProfileId} orderId={mockOrderId} tableId={mockTableId} />);
    expect(screen.getByText(/leave a google review/i)).toBeVisible();
    const steps = screen.getAllByRole("listitem");
    expect(steps.length).toBe(5);
  });
 
  it("GR-UT-1.2: Upload button visible after viewing instructions", () => {
    // Given: Guest is on the instructions step
    // When: Guest wants to proceed
    // Then: Button to proceed to upload step visible
    render(<GoogleReviewChallenge profileId={mockProfileId} orderId={mockOrderId} tableId={mockTableId} />);
    expect(screen.getByRole("button", { name: /upload screenshot/i })).toBeVisible();
  });
 
  // ─── GR-UT-2: Upload state ───────────────────────────────────
  it("GR-UT-2.1: Camera prompt shown when no file selected", () => {
    // Given: Guest tapped upload button
    // When: No file has been selected yet
    // Then: Camera icon with upload prompt visible
    render(<GoogleReviewChallenge profileId={mockProfileId} orderId={mockOrderId} tableId={mockTableId} />);
    // User taps upload button
    // User expects: camera icon with upload prompt
  });
 
  // ─── GR-UT-3: Approval states ───────────────────────────────
  it("GR-UT-3.1: Approved state shows success message with dessert mention", () => {
    // Given: Staff approved this guest's submission
    // When: Guest views the page
    // Then: "Review Approved!" and "Free dessert added" visible
    vi.mock("convex/react", () => ({
      useMutation: vi.fn(() => () => () => Promise.resolve()),
      useQuery: vi.fn(() => ({ status: "APPROVED" })),
    }));
    render(<GoogleReviewChallenge profileId={mockProfileId} orderId={mockOrderId} tableId={mockTableId} />);
    expect(screen.getByText(/review approved/i)).toBeVisible();
    expect(screen.getByText(/free dessert added/i)).toBeVisible();
  });
 
  it("GR-UT-3.2: Pending state shows waiting message", () => {
    // Given: Guest submitted but staff hasn't reviewed yet
    // When: Guest views the page
    // Then: "Review Submitted! Waiting for staff approval" visible
    vi.mock("convex/react", () => ({
      useMutation: vi.fn(() => () => () => Promise.resolve()),
      useQuery: vi.fn(() => ({ status: "PENDING" })),
    }));
    render(<GoogleReviewChallenge profileId={mockProfileId} orderId={mockOrderId} tableId={mockTableId} />);
    expect(screen.getByText(/review submitted/i)).toBeVisible();
    expect(screen.getByText(/waiting/i)).toBeVisible();
  });
});

Schema Unit Tests (Vitest):

// apps/frontend/__tests__/lib/google-review.test.ts
// TDD: Write test BEFORE implementation
// Run: npx vitest run __tests__/lib/google-review.test.ts
 
import { describe, it, expect } from "vitest";
import {
  SubmitGoogleReviewSchema,
  ApproveGoogleReviewSchema,
  RejectGoogleReviewSchema,
} from "~/lib/schemas/google-review";
 
describe("SubmitGoogleReviewSchema", () => {
  it("GR-UT-4.1: accepts valid submission", () => {
    // Given: All required fields with valid values
    // When: Schema.parse is called
    // Then: Returns success
    const result = SubmitGoogleReviewSchema.safeParse({
      profileId: "prof_123",
      orderId: "ord_456",
      tableId: "tab_789",
      screenshotUrl: "https://example.com/screenshot.jpg",
    });
    expect(result.success).toBe(true);
  });
 
  it("GR-UT-4.2: rejects invalid URL", () => {
    // Given: Invalid URL format for screenshotUrl
    // When: Schema.parse is called
    // Then: Returns error
    const result = SubmitGoogleReviewSchema.safeParse({
      profileId: "prof_123",
      orderId: "ord_456",
      tableId: "tab_789",
      screenshotUrl: "not-a-url",
    });
    expect(result.success).toBe(false);
  });
 
  it("GR-UT-4.3: rejects missing fields", () => {
    // Given: Missing required fields
    // When: Schema.parse is called
    // Then: Returns error
    const result = SubmitGoogleReviewSchema.safeParse({
      profileId: "prof_123",
    });
    expect(result.success).toBe(false);
  });
});
 
describe("ApproveGoogleReviewSchema", () => {
  it("GR-UT-5.1: accepts valid approval", () => {
    // Given: All required fields for approval
    // When: Schema.parse is called
    // Then: Returns success
    const result = ApproveGoogleReviewSchema.safeParse({
      submissionId: "sub_123",
      rewardMenuItemId: "item_456",
      notes: "Approved",
    });
    expect(result.success).toBe(true);
  });
 
  it("GR-UT-5.2: accepts without notes", () => {
    // Given: Required fields without optional notes
    // When: Schema.parse is called
    // Then: Returns success
    const result = ApproveGoogleReviewSchema.safeParse({
      submissionId: "sub_123",
      rewardMenuItemId: "item_456",
    });
    expect(result.success).toBe(true);
  });
});
 
describe("RejectGoogleReviewSchema", () => {
  it("GR-UT-6.1: accepts valid rejection", () => {
    // Given: All required fields for rejection
    // When: Schema.parse is called
    // Then: Returns success
    const result = RejectGoogleReviewSchema.safeParse({
      submissionId: "sub_123",
      notes: "Screenshot unclear",
    });
    expect(result.success).toBe(true);
  });
});

Mutation Backend Tests (Vitest):

// apps/frontend/__tests__/convex/google-review.test.ts
// TDD: Write test BEFORE implementation
// Run: npx vitest run __tests__/convex/google-review.test.ts
 
import { describe, it, expect } from "vitest";
 
describe("submitGoogleReview mutation", () => {
  it("GR-MUT-1.1: successfully submits review", async () => {
    // Given: Table has not submitted tonight
    // When: submitGoogleReview is called with valid data
    // Then: challengeSubmissions record created with PENDING status
  });
 
  it("GR-MUT-1.2: rejects second submission for same table", async () => {
    // Given: Table already submitted tonight
    // When: submitGoogleReview is called again
    // Then: throws GOOGLE_REVIEW_ALREADY_SUBMITTED error
  });
});
 
describe("approveGoogleReview mutation", () => {
  it("GR-MUT-2.1: successfully approves and adds dessert to order", async () => {
    // Given: Pending submission exists
    // When: approveGoogleReview is called with valid rewardMenuItemId
    // Then: status updated to APPROVED, orderItems comp record created
  });
 
  it("GR-MUT-2.2: rejects non-existent submission", async () => {
    // Given: Submission ID does not exist
    // When: approveGoogleReview is called
    // Then: throws GOOGLE_REVIEW_SUBMISSION_NOT_FOUND error
  });
 
  it("GR-MUT-2.3: rejects already-reviewed submission", async () => {
    // Given: Submission already APPROVED or REJECTED
    // When: approveGoogleReview is called
    // Then: throws GOOGLE_REVIEW_ALREADY_REVIEWED error
  });
 
  it("GR-MUT-2.4: rejects invalid menu item", async () => {
    // Given: rewardMenuItemId does not exist
    // When: approveGoogleReview is called
    // Then: throws GOOGLE_REVIEW_MENU_ITEM_NOT_FOUND error
  });
});
 
describe("rejectGoogleReview mutation", () => {
  it("GR-MUT-3.1: successfully rejects submission", async () => {
    // Given: Pending submission exists
    // When: rejectGoogleReview is called
    // Then: status updated to REJECTED
  });
 
  it("GR-MUT-3.2: rejects already-reviewed submission", async () => {
    // Given: Submission already APPROVED or REJECTED
    // When: rejectGoogleReview is called
    // Then: throws GOOGLE_REVIEW_ALREADY_REVIEWED 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
menuItems tableLucky Spin, Google ReviewPrize/dessert reward reference
orderItems tableLucky Spin, Google ReviewComp insertion
users tableGoogle Review POSreviewerId for staff
Challenge Config (future)All minigamesMax reward value cap
Lucky SpinGoogle ReviewchallengeSubmissions shared table

Shares schema with:

  • 07-lucky-spinorderItems comp insertion pattern
  • 06-photo-wall — Uses same challengeSubmissions table if generalized

10. Performance Considerations

  • [P1 PERFORMANCE GAP] Screenshot uploads: Current implementation uses FileReader.readAsDataURL (base64). For production scale, replace with Convex storage (storage.store).
  • Pending query: getPendingSubmissions returns all pending across all shows — add showDate filter for large volumes
  • Staff POS pagination: If >50 pending, add cursor pagination
  • Concurrent approvals: Convex mutation handles serialization; no race condition on status field
  • No as any violations: POS page resolves reviewerId via getCurrentUser query instead of type casting
  • Security: approveGoogleReview and rejectGoogleReview use staffMutation wrapper for staff-only access control.

Acceptance Criteria

  1. Guest sees step-by-step instructions for Google Maps review
  2. Screenshot upload works via camera or file picker
  3. Submission saved with PENDING status
  4. Staff sees pending submissions in /admin/pos/challenges
  5. Staff can Approve — free dessert added to table order as comp
  6. Staff can Reject — submission marked rejected
  7. Guest sees status update (pending/approved) in real-time
  8. One submission per table per show enforced
  9. All error codes use prefixed format (GOOGLEREVIEW*)
  10. All strings use useTranslations — no hardcoded user-facing strings
  11. Suspense boundary with skeleton on POS page

Consistency Audit: google-review

P0 Violations (resolved)

#LocationIssueStatus
1approveGoogleReview, rejectGoogleReview mutationsOriginally lacked staffMutation wrapperRESOLVED: staffMutation/adminMutation helpers are implemented in convex/auth.ts

P1 Violations (fixed in plan text)

#LocationIssueFix Applied
1Screenshot uploadFileReader.readAsDataURL for base64 encoding — P1 performance gapNoted in Performance Considerations and Section 5 (PWA/Offline). Future: base64 to Convex storage migration tracked.

P0 Gaps (resolved — helpers implemented)

#IssueStatus
1staffMutation/adminMutationRESOLVED: Helpers are implemented in convex/auth.ts
2getCurrentUser for reviewerIdPattern used correctly — resolves Convex userId from Clerk identity
3Prize administration UIFuture iteration: staff UI to configure reward menu items — requires adminMutation for managing spinPrizes and challenge configuration