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
challengeSubmissionstable (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
| Operation | Error Code | Message Key | Notes |
|---|---|---|---|
submitGoogleReview | GOOGLE_REVIEW_ALREADY_SUBMITTED | errorAlreadySubmitted | One per table per show |
approveGoogleReview | GOOGLE_REVIEW_SUBMISSION_NOT_FOUND | errorSubmissionNotFound | DB get failed |
approveGoogleReview | GOOGLE_REVIEW_ALREADY_REVIEWED | errorAlreadyReviewed | Non-PENDING status |
approveGoogleReview | GOOGLE_REVIEW_MENU_ITEM_NOT_FOUND | errorMenuItemNotFound | Reward item missing |
rejectGoogleReview | GOOGLE_REVIEW_SUBMISSION_NOT_FOUND | errorSubmissionNotFound | DB get failed |
rejectGoogleReview | GOOGLE_REVIEW_ALREADY_REVIEWED | errorAlreadyReviewed | Non-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 listPOS 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
| Component | Mobile Behavior | Desktop Behavior |
|---|---|---|
GoogleReviewChallenge | Step list condensed, full-width buttons | Wider card layout |
POSChallengesPage | Full-width cards, stacked layout | 2-column grid for cards |
| Screenshot preview | Full-width on mobile | Max-width 400px centered |
- Camera capture via
capture="environment"for rear camera on mobile - Staff POS page uses
min-h-screenand responsive grid Suspenseboundary 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| Variable | Required | Description |
|---|---|---|
NEXT_PUBLIC_CONVEX_URL | Yes | Convex deployment URL |
NEXT_PUBLIC_APP_URL | Yes | Public URL for PWA |
CLERK_PUBLISHABLE_KEY | Yes (staff) | Clerk auth for POS |
CLERK_SECRET_KEY | Yes (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 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 |
menuItems table | Lucky Spin, Google Review | Prize/dessert reward reference |
orderItems table | Lucky Spin, Google Review | Comp insertion |
users table | Google Review POS | reviewerId for staff |
| Challenge Config (future) | All minigames | Max reward value cap |
| Lucky Spin | Google Review | challengeSubmissions shared table |
Shares schema with:
07-lucky-spin—orderItemscomp insertion pattern06-photo-wall— Uses samechallengeSubmissionstable 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:
getPendingSubmissionsreturns all pending across all shows — addshowDatefilter 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 anyviolations: POS page resolvesreviewerIdviagetCurrentUserquery instead of type casting - Security:
approveGoogleReviewandrejectGoogleReviewusestaffMutationwrapper for staff-only access control.
Acceptance Criteria
- Guest sees step-by-step instructions for Google Maps review
- Screenshot upload works via camera or file picker
- Submission saved with PENDING status
- Staff sees pending submissions in
/admin/pos/challenges - Staff can Approve — free dessert added to table order as comp
- Staff can Reject — submission marked rejected
- Guest sees status update (pending/approved) in real-time
- One submission per table per show enforced
- All error codes use prefixed format (GOOGLEREVIEW*)
- All strings use
useTranslations— no hardcoded user-facing strings Suspenseboundary with skeleton on POS page
Consistency Audit: google-review
P0 Violations (resolved)
| # | Location | Issue | Status |
|---|---|---|---|
| 1 | approveGoogleReview, rejectGoogleReview mutations | Originally lacked staffMutation wrapper | RESOLVED: staffMutation/adminMutation helpers are implemented in convex/auth.ts |
P1 Violations (fixed in plan text)
| # | Location | Issue | Fix Applied |
|---|---|---|---|
| 1 | Screenshot upload | FileReader.readAsDataURL for base64 encoding — P1 performance gap | Noted in Performance Considerations and Section 5 (PWA/Offline). Future: base64 to Convex storage migration tracked. |
P0 Gaps (resolved — helpers implemented)
| # | Issue | Status |
|---|---|---|
| 1 | staffMutation/adminMutation | RESOLVED: Helpers are implemented in convex/auth.ts |
| 2 | getCurrentUser for reviewerId | Pattern used correctly — resolves Convex userId from Clerk identity |
| 3 | Prize administration UI | Future iteration: staff UI to configure reward menu items — requires adminMutation for managing spinPrizes and challenge configuration |