From e7379ccdb1bb9ba264282d88ba4107d3bd460a52 Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Wed, 31 Dec 2025 12:28:26 +0000 Subject: [PATCH] Update --- .claude/agents.md | 73 +++ CLAUDE.md | 11 +- apps/admin/src/pages/RedemptionCodesPage.tsx | 496 +++++++++++++++++++ apps/mobile/android/.gitignore | 3 + apps/mobile/android/app/build.gradle | 8 +- apps/mobile/android/app/google-services.json | 29 ++ apps/mobile/android/sauci-release.keystore | Bin 0 -> 2774 bytes apps/supabase/functions/redeem-code/index.ts | 73 +++ apps/web/.env.local.example | 3 + apps/web/app/redeem/page.tsx | 32 +- 10 files changed, 717 insertions(+), 11 deletions(-) create mode 100644 .claude/agents.md create mode 100644 apps/admin/src/pages/RedemptionCodesPage.tsx create mode 100644 apps/mobile/android/app/google-services.json create mode 100644 apps/mobile/android/sauci-release.keystore create mode 100644 apps/supabase/functions/redeem-code/index.ts diff --git a/.claude/agents.md b/.claude/agents.md new file mode 100644 index 0000000..6eab68f --- /dev/null +++ b/.claude/agents.md @@ -0,0 +1,73 @@ +# Agents Documentation + +This file contains important project-specific context for Claude Code agents working on this repository. + +## Redemption Code System + +**IMPORTANT: Codes can only be redeemed on the website, NOT in the mobile app.** + +Apple's App Store guidelines require that any premium features purchased within an iOS app must go through In-App Purchase. Adding a redemption code feature to the mobile app would violate these guidelines and risk app rejection. + +### Overview +Promotional codes that grant users premium access without going through RevenueCat/App Store subscription flow. + +### Database Schema + +**`redemption_codes` table:** +- `id` (UUID) - Primary key +- `code` (TEXT, UNIQUE) - The redemption code +- `description` (TEXT) - Optional description +- `max_uses` (INTEGER) - Maximum number of redemptions allowed +- `current_uses` (INTEGER) - Current number of redemptions +- `expires_at` (TIMESTAMPTZ) - Optional expiration date +- `is_active` (BOOLEAN) - Whether the code can be redeemed +- `created_by` (UUID) - Admin who created the code +- `created_at` (TIMESTAMPTZ) +- `updated_at` (TIMESTAMPTZ) + +**`code_redemptions` table:** +- `id` (UUID) - Primary key +- `code_id` (UUID) - Reference to redemption_codes +- `user_id` (UUID) - User who redeemed the code +- `redeemed_at` (TIMESTAMPTZ) +- UNIQUE constraint on (code_id, user_id) - Each user can only redeem a code once + +### Components + +1. **Admin UI** (`apps/admin/src/pages/RedemptionCodesPage.tsx`) + - Super admins can generate codes + - View all codes with usage stats + - Toggle active/inactive + - View who redeemed each code + - Delete codes + +2. **Edge Function** (`apps/supabase/functions/redeem-code/index.ts`) + - **Unauthenticated** - accepts email + code in request body + - Calls the `redeem_code_by_email()` database function + - Returns success/error response + +3. **Database Function** (`redeem_code_by_email(p_email TEXT, p_code TEXT)`) + - Atomic operation with row locking + - Looks up user by email (must have signed up in app first) + - Validates: code exists, is active, not expired, has uses remaining + - Checks user hasn't already redeemed + - Records redemption in `code_redemptions` + - Increments `current_uses` + - Sets `profiles.is_premium = true` + +4. **Web UI** (`apps/web/app/redeem/page.tsx`) + - Public page at `/redeem` + - User enters email + redemption code + - Calls edge function via fetch + - Shows success or error message + +### Environment Variables + +The web app requires: +- `NEXT_PUBLIC_SUPABASE_URL` - Supabase project URL for API calls + +### DO NOT + +- **DO NOT** add redemption code UI to the mobile app +- **DO NOT** create any in-app mechanism to bypass App Store purchases +- **DO NOT** expose the redeem endpoint in mobile app Supabase calls diff --git a/CLAUDE.md b/CLAUDE.md index 770722a..b6200f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -145,14 +145,15 @@ Promotional codes that grant users premium access. **Codes can only be redeemed **Components:** - **Admin UI** (`apps/admin/src/pages/RedemptionCodesPage.tsx`) - Super admins can generate, manage, and view redemption codes -- **Edge Function** (`apps/supabase/functions/redeem-code/`) - Validates and processes code redemption -- **Database Function** (`redeem_code(p_code)`) - Atomic redemption with validation (active, not expired, has uses remaining, user hasn't redeemed) +- **Edge Function** (`apps/supabase/functions/redeem-code/`) - Validates and processes code redemption (unauthenticated, accepts email + code) +- **Database Function** (`redeem_code_by_email(p_email, p_code)`) - Atomic redemption with validation (active, not expired, has uses remaining, user hasn't redeemed) +- **Web UI** (`apps/web/app/redeem/page.tsx`) - Public redemption page **Flow:** 1. Admin generates code in admin dashboard -2. User enters code on website (NOT mobile app) -3. `redeem-code` edge function calls `redeem_code()` database function -4. On success, user's `profiles.is_premium` is set to `true` +2. User visits `/redeem` on website and enters email + code +3. `redeem-code` edge function calls `redeem_code_by_email()` database function +4. Database function looks up user by email, validates code, and sets `profiles.is_premium = true` **Important:** Do NOT add redemption code UI to the mobile app. Apple requires in-app purchases for premium features purchased within iOS apps. diff --git a/apps/admin/src/pages/RedemptionCodesPage.tsx b/apps/admin/src/pages/RedemptionCodesPage.tsx new file mode 100644 index 0000000..2e1b3c6 --- /dev/null +++ b/apps/admin/src/pages/RedemptionCodesPage.tsx @@ -0,0 +1,496 @@ +import { useState, useEffect } from 'react'; +import { supabase } from '@/config'; +import { auditedSupabase } from '@/hooks/useAuditedSupabase'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Switch } from '@/components/ui/switch'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { Search, Ticket, Trash2, Plus, Copy, Users } from 'lucide-react'; +import { format } from 'date-fns'; +import { toast } from 'sonner'; + +interface RedemptionCode { + id: string; + code: string; + description: string | null; + max_uses: number; + current_uses: number; + expires_at: string | null; + is_active: boolean; + created_by: string | null; + created_at: string; + updated_at: string; +} + +interface CodeRedemption { + id: string; + user_id: string; + redeemed_at: string; + profile?: { + name: string | null; + email: string | null; + }; +} + +function generateCode(length: number = 8): string { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Removed confusing chars like 0, O, I, 1 + let result = ''; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +} + +export function RedemptionCodesPage() { + const [codes, setCodes] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); + + // Add Code State + const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [formData, setFormData] = useState({ + code: '', + description: '', + max_uses: 1, + expires_at: '', + }); + + // View Redemptions State + const [viewingCode, setViewingCode] = useState(null); + const [redemptions, setRedemptions] = useState([]); + const [loadingRedemptions, setLoadingRedemptions] = useState(false); + + // Initial load + useEffect(() => { + fetchCodes(); + }, []); + + const fetchCodes = async () => { + setLoading(true); + try { + const { data, error } = await supabase + .from('redemption_codes') + .select('*') + .order('created_at', { ascending: false }); + + if (error) throw error; + setCodes(data || []); + } catch (error: any) { + console.error('Failed to load codes:', error); + toast.error("Failed to load redemption codes"); + } finally { + setLoading(false); + } + }; + + const fetchRedemptions = async (codeId: string) => { + setLoadingRedemptions(true); + try { + const { data: redemptionData, error: redemptionError } = await supabase + .from('code_redemptions') + .select('id, user_id, redeemed_at') + .eq('code_id', codeId) + .order('redeemed_at', { ascending: false }); + + if (redemptionError) throw redemptionError; + + if (redemptionData && redemptionData.length > 0) { + const userIds = redemptionData.map(r => r.user_id); + const { data: profileData } = await supabase + .from('profiles') + .select('id, name, email') + .in('id', userIds); + + const joined = redemptionData.map(r => ({ + ...r, + profile: profileData?.find(p => p.id === r.user_id) + })); + setRedemptions(joined); + } else { + setRedemptions([]); + } + } catch (error: any) { + console.error('Failed to load redemptions:', error); + toast.error("Failed to load redemptions"); + } finally { + setLoadingRedemptions(false); + } + }; + + const handleAddCode = async () => { + if (!formData.code.trim()) { + toast.error("Please enter a code"); + return; + } + + setIsSubmitting(true); + try { + const { error } = await auditedSupabase.insert('redemption_codes', { + code: formData.code.toUpperCase().trim(), + description: formData.description.trim() || null, + max_uses: formData.max_uses, + current_uses: 0, + expires_at: formData.expires_at || null, + is_active: true, + }); + + if (error) throw error; + + toast.success("Redemption code created"); + setIsAddDialogOpen(false); + resetForm(); + fetchCodes(); + } catch (error: any) { + console.error('Failed to create code:', error); + if (error.message?.includes('duplicate') || error.code === '23505') { + toast.error("This code already exists"); + } else { + toast.error(error.message || "Failed to create code"); + } + } finally { + setIsSubmitting(false); + } + }; + + const handleToggleActive = async (code: RedemptionCode) => { + try { + const { error } = await auditedSupabase.update('redemption_codes', code.id, { + is_active: !code.is_active, + }); + + if (error) throw error; + + toast.success(code.is_active ? "Code deactivated" : "Code activated"); + fetchCodes(); + } catch (error: any) { + toast.error("Failed to update code"); + } + }; + + const handleDeleteCode = async (code: RedemptionCode) => { + if (!confirm(`Are you sure you want to delete the code "${code.code}"? This cannot be undone.`)) { + return; + } + + try { + const { error } = await auditedSupabase.delete('redemption_codes', code.id); + + if (error) throw error; + + toast.success("Code deleted"); + fetchCodes(); + } catch (error: any) { + toast.error("Failed to delete code"); + } + }; + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text); + toast.success("Copied to clipboard"); + }; + + const resetForm = () => { + setFormData({ + code: '', + description: '', + max_uses: 1, + expires_at: '', + }); + }; + + const handleOpenAddDialog = () => { + resetForm(); + setFormData(prev => ({ ...prev, code: generateCode() })); + setIsAddDialogOpen(true); + }; + + const handleViewRedemptions = (code: RedemptionCode) => { + setViewingCode(code); + fetchRedemptions(code.id); + }; + + const filteredCodes = codes.filter(code => { + if (!search) return true; + const query = search.toLowerCase(); + return ( + code.code.toLowerCase().includes(query) || + code.description?.toLowerCase().includes(query) + ); + }); + + const getStatusBadge = (code: RedemptionCode) => { + if (!code.is_active) { + return Inactive; + } + if (code.expires_at && new Date(code.expires_at) < new Date()) { + return Expired; + } + if (code.current_uses >= code.max_uses) { + return Exhausted; + } + return Active; + }; + + return ( +
+
+
+

Redemption Codes

+

+ Generate and manage codes that grant premium access +

+
+
+
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+ + + + + + + Generate Redemption Code + + Create a new code that users can redeem for premium access. + + +
+
+ +
+ setFormData(prev => ({ ...prev, code: e.target.value.toUpperCase() }))} + placeholder="e.g., PREMIUM2024" + className="font-mono" + /> + +
+
+
+ + setFormData(prev => ({ ...prev, description: e.target.value }))} + placeholder="e.g., Early adopter reward" + /> +
+
+ + setFormData(prev => ({ ...prev, max_uses: parseInt(e.target.value) || 1 }))} + /> +

+ How many times this code can be redeemed +

+
+
+ + setFormData(prev => ({ ...prev, expires_at: e.target.value }))} + /> +
+
+ + + + +
+
+
+
+ +
+ + + + Code + Description + Usage + Expires + Status + Created + Actions + + + + {loading ? ( + + +
+
+
+ ) : filteredCodes.length === 0 ? ( + + + {search ? 'No codes match your search.' : 'No redemption codes yet. Generate your first one!'} + + + ) : ( + filteredCodes.map((code) => ( + + +
+ + {code.code} + + +
+
+ + {code.description || '—'} + + + {code.current_uses} + / {code.max_uses} + + + {code.expires_at + ? format(new Date(code.expires_at), 'MMM d, yyyy HH:mm') + : 'Never'} + + + {getStatusBadge(code)} + + + {format(new Date(code.created_at), 'MMM d, yyyy')} + + +
+ + handleToggleActive(code)} + title={code.is_active ? "Deactivate" : "Activate"} + /> + +
+
+
+ )) + )} +
+
+
+ + {/* View Redemptions Dialog */} + !open && setViewingCode(null)}> + + + + Redemptions for {viewingCode?.code} + + + {viewingCode?.current_uses || 0} of {viewingCode?.max_uses} uses + + +
+ {loadingRedemptions ? ( +
+ +
+ ) : redemptions.length === 0 ? ( +

+ No one has redeemed this code yet. +

+ ) : ( + + + + User + Redeemed At + + + + {redemptions.map((r) => ( + + +
+
{r.profile?.name || 'Unknown'}
+
{r.profile?.email}
+
+
+ + {format(new Date(r.redeemed_at), 'MMM d, yyyy HH:mm')} + +
+ ))} +
+
+ )} +
+ + + +
+
+
+ ); +} diff --git a/apps/mobile/android/.gitignore b/apps/mobile/android/.gitignore index 8a6be07..f31efc0 100644 --- a/apps/mobile/android/.gitignore +++ b/apps/mobile/android/.gitignore @@ -14,3 +14,6 @@ local.properties # Bundle artifacts *.jsbundle + +# Release signing (upload keystore - can be reset via Play Console) +upload-keystore.jks diff --git a/apps/mobile/android/app/build.gradle b/apps/mobile/android/app/build.gradle index 8e34f18..c4d4e59 100644 --- a/apps/mobile/android/app/build.gradle +++ b/apps/mobile/android/app/build.gradle @@ -103,13 +103,19 @@ android { keyAlias 'androiddebugkey' keyPassword 'android' } + release { + storeFile file('upload-keystore.jks') + storePassword System.getenv("KEYSTORE_PASSWORD") ?: "changeit" + keyAlias 'upload' + keyPassword System.getenv("KEY_PASSWORD") ?: "changeit" + } } buildTypes { debug { signingConfig signingConfigs.debug } release { - signingConfig signingConfigs.debug + signingConfig signingConfigs.release shrinkResources (findProperty('android.enableShrinkResourcesInReleaseBuilds')?.toBoolean() ?: false) minifyEnabled enableProguardInReleaseBuilds proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" diff --git a/apps/mobile/android/app/google-services.json b/apps/mobile/android/app/google-services.json new file mode 100644 index 0000000..c6da8a1 --- /dev/null +++ b/apps/mobile/android/app/google-services.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "764866133492", + "project_id": "sauci-6840e", + "storage_bucket": "sauci-6840e.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:764866133492:android:eabdb78009a18d2ffe4da1", + "android_client_info": { + "package_name": "com.sauci.app" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyDOAqIXAd_dy7L9ZflPvNkJdNXWMaGZ1O4" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/apps/mobile/android/sauci-release.keystore b/apps/mobile/android/sauci-release.keystore new file mode 100644 index 0000000000000000000000000000000000000000..fcf3bb6612ab14abe042cfca99c3e3ff37f170c2 GIT binary patch literal 2774 zcma);c{CJ`7RSvrGt5}CRLU};hOy1qV+PUBUb1Fi%334KSSGSGG>o-{glu7KlPx3_ z#=b>FgJ)+*2-%}N@4R>Vo%h#!=iGblxu5U7_s{RSC?aeS2snWv!tO($NRl3DmmRwmV`U;tna5v+L(F(|h`8(?;nHA?00 zmJKBd;(ZXWAHFqY!(nFTc>U$fyLu9F2>}EmxPSmSiVec@?~6bvmHC49xDA&pd_05w?BR4;B{?ln zdO7Ms+Tmf@z`>S7y-Rq=wTLqmC9N_%Z5Vl4{Thwj9|6xDRcTccF$gZ9B`ye#8($Ji zsJg5sroj>06^k@0;f%@%dJ@8j{Zd0sugabln4VnuDv%Ym>^Rk68j~CKzGy@1yRRx2 zCTPvkxCxH-oUBpNvsE)>L-{LNbk@)2;_y za%%%}{)5%~oP)NYi&CCJhM0n%R9r-iD4cmpm;)rb3;sb^T=mfR_s*1(j0nO)ks2qF z8X(=(U+vRLKdW4%mZ?}AMrQAl36|&O1073@r$kQR8k0*p&%FK*F`XzlUbA&u(W>3# z|f{ z52XrMWb_ZPm`rA!2^kMd1;)NKqLi5YhyeE8NEUvP(Fv8eW3;0nOFLhd@tZY=?Il0_CY>ttvXx@+ z@`RYZLwdChulecBCCIOvpn$??w zw}QincG0>?ChFY65}#iL`}Oew*P-)6JwDG9hTiB7{BBN@3AmsrlO3EtA;+t2H8(Om zpd>Ha& zS#y`9baY}t64xB|M~p`4!5ZuD*{)QbvVj+cwqaBK|SJ`x#za%DpycI145tNhx&Xh6K%$+;4W+!{q~< zOTMScdBZQPtKzV^D0@QiP(Dv1A)|s`%3FBtjOFXQM-|>5%X{TZ_J)t%zz!gqA^}6f z3Ng`lzN=F4L*l=*7{4!qDZ5>CP8^T!sgXMzY3ZCR>W=weE*k=n;Q&&om;1=Ge-Nv} zH2seJLJGn+};XZDB2R??4nCeQJ!s^(Jl>6tUtrVD$-x;R@^ZR zU*h^amV{PE@&B<)C@-2#2*!kGa%Ku(y2a#)I{&xChvo%wn|pe>!qLh~%4lU3#p6KB zqllo&KSRur93trDG0Fl0n2t;8-wNP=ne_Tzrnwc-H{H(m7evA;&%o8GyZZkz>E%xd zv8BXKpXAFM7SE)=Rz?s(@NFP}LA@EpfBcPQcbp2hD6fe)W8041g4~qoPe2!X8uOi# zU{u%Hca;fk$Rq+5Dje6KrjzOlOQhl#l9U8OQMVBNYTb&cxuxD1^ZHyaMc(@9c8tXK zr~PxqMPC5~zF66FiWhW50#h*<*~)?PY??I|R#MIe0mDV|X2 zckY16n!mmSOAs{tS0o>;~u@be*R6TTVNwF$&6 z0-v-N_y}48$Nyp1DhhHC6f*zVzWvp(@wF-jKcB?PxO;coSf{{AMIG0(+*mU1ff?0Q*BxLw^XBvmq3zy>SGFvc(3Yovs~WQ+^z!pPth`*FI#&QG zgf_9fdDTL8YWQMn-4~gd$_e|M9AW~FHxNZBDP=T1Fa2THD<^RdCL(vwl`>Th0#!aN zSce`2pT3AO83|B!dQZOky6}2E=qIHb*{VRtF?(}{VMdO;Y~TEFcJaKMQj&#kYP46= zwfCN18(@vVvff+T>i9JjX$LcfHOfRKy5P>TSo~aQX%^|Znz8h3KLYaJTm}=zl|9hJ zNX|Yn;~n4Ca1(4bX;}HVvoB$v&N1%UY4-`bDteBk0%x@927OlJuS(=420O{jH|_R>EV;4Y+vvvog?sxjnpI}^ zQR;`XB4>|=`|}TG%a@)($O;xMWr&c62n#Jw&&HvF0BRj_COc1jl6ESuOR^=)_ zCCvvzUeG^#LUX-c>Vx|(pgY+Jh3O<@J(6lb&`?)VXcYUOUk?ZXGlAfgD-h?$?uvkD zt#*^XoZqh83qmuQ47XfhV}%L++Wj#QZ(zzYq`UkrA-9r { + // Handle CORS preflight + if (req.method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + + try { + // Use service role for database operations since this is unauthenticated + const supabase = createClient( + Deno.env.get("SUPABASE_URL")!, + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")! + ); + + // Parse request body + const { email, code }: RedeemCodeBody = await req.json(); + + if (!email || typeof email !== "string" || email.trim() === "") { + return new Response( + JSON.stringify({ success: false, error: "Please enter your email address" }), + { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + if (!code || typeof code !== "string" || code.trim() === "") { + return new Response( + JSON.stringify({ success: false, error: "Please enter a redemption code" }), + { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + // Call the database function to redeem the code + const { data, error } = await supabase.rpc("redeem_code_by_email", { + p_email: email.trim(), + p_code: code.trim(), + }); + + if (error) { + console.error("RPC error:", error); + return new Response( + JSON.stringify({ success: false, error: "Failed to redeem code. Please try again." }), + { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } + + // The RPC returns a JSONB object with success, error, or message + return new Response( + JSON.stringify(data), + { + status: data.success ? 200 : 400, + headers: { ...corsHeaders, "Content-Type": "application/json" } + } + ); + } catch (error) { + console.error("Error:", error); + return new Response( + JSON.stringify({ success: false, error: "Internal server error" }), + { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } } + ); + } +}); diff --git a/apps/web/.env.local.example b/apps/web/.env.local.example index 8a9a93c..4f8a81b 100644 --- a/apps/web/.env.local.example +++ b/apps/web/.env.local.example @@ -2,3 +2,6 @@ # Get these values from your PostHog project settings NEXT_PUBLIC_POSTHOG_KEY=phc_your_project_key_here NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com + +# Supabase +NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co diff --git a/apps/web/app/redeem/page.tsx b/apps/web/app/redeem/page.tsx index d303143..819d244 100644 --- a/apps/web/app/redeem/page.tsx +++ b/apps/web/app/redeem/page.tsx @@ -34,12 +34,34 @@ export default function RedeemPage() { setIsLoading(true) - // TODO: Wire up to backend - // For now, just simulate a delay and show success - await new Promise(resolve => setTimeout(resolve, 1500)) + try { + const response = await fetch( + `${process.env.NEXT_PUBLIC_SUPABASE_URL}/functions/v1/redeem-code`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + email: email.trim(), + code: code.trim(), + }), + } + ) - setIsLoading(false) - setIsRedeemed(true) + const data = await response.json() + + if (data.success) { + setIsRedeemed(true) + } else { + setError(data.error || 'Failed to redeem code. Please try again.') + } + } catch (err) { + console.error('Redemption error:', err) + setError('Something went wrong. Please try again.') + } finally { + setIsLoading(false) + } } // Success state