diff --git a/bun.lock b/bun.lock index a16a193..7dcab6b 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "ink-select-input": "^6.2.0", "ink-text-input": "^6.0.0", "node-html-parser": "^7.0.1", + "papaparse": "^5.5.3", "playwright": "^1.57.0", "react": "^19", "react-dom": "^19", @@ -23,6 +24,7 @@ "devDependencies": { "@types/bun": "latest", "@types/figlet": "^1.7.0", + "@types/papaparse": "^5.3.15", "@types/react": "^19", "@types/react-dom": "^19", "typescript": "^5.9.3", @@ -156,6 +158,8 @@ "@types/node": ["@types/node@25.0.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-gWEkeiyYE4vqjON/+Obqcoeffmk0NF15WSBwSs7zwVA2bAbTaE0SJ7P0WNGoJn8uE7fiaV5a7dKYIJriEqOrmA=="], + "@types/papaparse": ["@types/papaparse@5.5.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA=="], + "@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -264,6 +268,8 @@ "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + "papaparse": ["papaparse@5.5.3", "", {}, "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A=="], + "parseley": ["parseley@0.12.1", "", { "dependencies": { "leac": "^0.6.0", "peberminta": "^0.9.0" } }, "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw=="], "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="], diff --git a/convex/credits.ts b/convex/credits.ts index 35e6db3..ac470f5 100644 --- a/convex/credits.ts +++ b/convex/credits.ts @@ -127,4 +127,35 @@ export const markSent = mutation({ sentAt: Date.now(), }); }, +}); + +// List all sent credits with person info (for redemption checking) +export const listSentWithPerson = query({ + handler: async (ctx) => { + const credits = await ctx.db + .query("credits") + .withIndex("by_status", (q) => q.eq("status", "sent")) + .collect(); + + // Join with people to get names + return Promise.all( + credits.map(async (credit) => { + const person = credit.assignedTo + ? await ctx.db.get(credit.assignedTo) + : null; + return { ...credit, person }; + }) + ); + }, +}); + +// Mark credit as redeemed +export const markRedeemed = mutation({ + args: { creditId: v.id("credits") }, + handler: async (ctx, args) => { + await ctx.db.patch(args.creditId, { + status: "redeemed", + checkedAt: Date.now(), + }); + }, }); \ No newline at end of file diff --git a/convex/schema.ts b/convex/schema.ts index 8527af7..7809673 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -26,5 +26,6 @@ export default defineSchema({ sentAt: v.optional(v.number()), }) .index("by_status", ["status"]) - .index("by_code", ["code"]), + .index("by_code", ["code"]) + .index("by_assignedTo", ["assignedTo"]), }); \ No newline at end of file diff --git a/src/cli.tsx b/src/cli.tsx index e5cd62c..a5979c1 100755 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -7,6 +7,7 @@ import { ConvexProvider, ConvexReactClient } from "convex/react"; import SendCredits from "./screens/SendCredits.js"; import UploadCredits from "./screens/UploadCredits.js"; import UploadAttendees from "./screens/UploadAttendees.js"; +import CheckRedemptions from "./screens/CheckRedemptions.js"; import ModeSelector from "./components/ModeSelector.js"; import { StorageProvider, useStorage, type StorageMode } from "./context/StorageContext.js"; @@ -20,7 +21,7 @@ const hasCloudConfig = Boolean( // Only create Convex client if we have the URL const convex = new ConvexReactClient(process.env.CONVEX_URL!); -type Screen = "menu" | "send" | "upload" | "attendees"; +type Screen = "menu" | "send" | "upload" | "attendees" | "check"; const clearScreen = () => { process.stdout.write("\x1B[2J\x1B[0f"); @@ -66,6 +67,9 @@ const MainMenu = () => { } else if (input === "3") { clearScreen(); setScreen("attendees"); + } else if (input === "4") { + clearScreen(); + setScreen("check"); } }); @@ -73,6 +77,7 @@ const MainMenu = () => { { label: "Send Cursor Credits", value: "send" }, { label: "Upload Cursor Credits", value: "upload" }, { label: "Upload Attendees", value: "attendees" }, + { label: "Check Credit Redemptions", value: "check" }, ]; const handleSelect = (item: { label: string; value: string }) => { @@ -100,6 +105,11 @@ const MainMenu = () => { return ; } + // Render Check Redemptions screen + if (screen === "check") { + return ; + } + // Render main menu return ( @@ -136,6 +146,7 @@ const MainMenu = () => { 1 Send 2 Credits 3 Attendees + 4 Check Q Quit diff --git a/src/hooks/useStorageHooks.ts b/src/hooks/useStorageHooks.ts index de98543..25ac236 100644 --- a/src/hooks/useStorageHooks.ts +++ b/src/hooks/useStorageHooks.ts @@ -186,3 +186,85 @@ export function useSendCredits() { return cloudAction({ personId: personId as Id<"people"> }); }, [isLocal, dataPath, cloudAction]); } + +// Type for sent credit with person info +export interface SentCreditWithPerson { + id: string; + url: string; + code: string; + amount: number; + person: { + firstName: string; + lastName: string; + email: string; + } | null; +} + +// Hook for listing sent credits with person info +export function useSentCredits(): { data: SentCreditWithPerson[] | undefined; refresh: () => void } { + const { isLocal, dataPath } = useStorage(); + const [localData, setLocalData] = useState(undefined); + const [refreshKey, setRefreshKey] = useState(0); + + // Cloud query - use "skip" when in local mode + const cloudData = useConvexQuery(api.credits.listSentWithPerson, isLocal ? "skip" : {}); + + // Load local data + useEffect(() => { + if (isLocal) { + const sentCredits = localStorage.loadSentCreditsWithPerson(dataPath); + setLocalData(sentCredits.map(({ credit, person }) => ({ + id: credit.id, + url: credit.url, + code: credit.code, + amount: credit.amount, + person: person ? { + firstName: person.firstName, + lastName: person.lastName, + email: person.email, + } : null, + }))); + } + }, [isLocal, dataPath, refreshKey]); + + const refresh = useCallback(() => { + setRefreshKey(k => k + 1); + }, []); + + if (isLocal) { + return { data: localData, refresh }; + } + + const data = cloudData?.map(item => ({ + id: item._id, + url: item.url, + code: item.code, + amount: item.amount, + person: item.person ? { + firstName: item.person.firstName, + lastName: item.person.lastName, + email: item.person.email, + } : null, + })); + + return { data, refresh }; +} + +// Hook for marking credit as redeemed +export function useMarkRedeemed() { + const { isLocal, dataPath } = useStorage(); + const cloudMutation = useConvexMutation(api.credits.markRedeemed); + + return useCallback(async (creditId: string): Promise => { + if (isLocal) { + return localStorage.markCreditRedeemed(creditId, dataPath); + } + + try { + await cloudMutation({ creditId: creditId as Id<"credits"> }); + return true; + } catch { + return false; + } + }, [isLocal, dataPath, cloudMutation]); +} diff --git a/src/screens/CheckRedemptions.tsx b/src/screens/CheckRedemptions.tsx new file mode 100644 index 0000000..2c40375 --- /dev/null +++ b/src/screens/CheckRedemptions.tsx @@ -0,0 +1,248 @@ +import React, { useState, useEffect } from "react"; +import { Box, Text, useInput } from "ink"; +import { writeFileSync } from "fs"; +import { ProgressBar } from "../components/ProgressBar.js"; +import { useStorage } from "../context/StorageContext.js"; +import { useSentCredits, useMarkRedeemed, type SentCreditWithPerson } from "../hooks/useStorageHooks.js"; +import { checkCreditsAvailable, initBrowser, closeBrowser } from "../utils/creditChecker.js"; + +interface CheckRedemptionsProps { + onBack: () => void; +} + +interface CheckResult { + credit: SentCreditWithPerson; + status: "redeemed" | "active" | "unknown" | "pending"; +} + +const CheckRedemptions = ({ onBack }: CheckRedemptionsProps) => { + const { isLocal } = useStorage(); + const { data: sentCredits, refresh } = useSentCredits(); + const markRedeemed = useMarkRedeemed(); + + const [checking, setChecking] = useState(false); + const [progress, setProgress] = useState({ current: 0, total: 0 }); + const [currentCode, setCurrentCode] = useState(null); + const [results, setResults] = useState([]); + const [checkComplete, setCheckComplete] = useState(false); + const [exportMessage, setExportMessage] = useState(null); + + // Initialize results when data loads + useEffect(() => { + if (sentCredits && results.length === 0 && !checking) { + setResults(sentCredits.map(credit => ({ credit, status: "pending" as const }))); + setProgress({ current: 0, total: sentCredits.length }); + } + }, [sentCredits]); + + // Handle keyboard input + useInput((input, key) => { + if (checking) return; // Disable input while checking + + if ((input === "q" || key.escape) && !checking) { + onBack(); + } else if (input === "s" && !checking && !checkComplete && sentCredits && sentCredits.length > 0) { + startCheck(); + } else if (input === "e" && checkComplete) { + exportUnusedCredits(); + } + }); + + const startCheck = async () => { + if (!sentCredits || sentCredits.length === 0) return; + + setChecking(true); + setCheckComplete(false); + setProgress({ current: 0, total: sentCredits.length }); + + try { + // Initialize browser + await initBrowser(false); + + for (let i = 0; i < sentCredits.length; i++) { + const credit = sentCredits[i]; + if (!credit) continue; + + setCurrentCode(credit.code); + setProgress({ current: i + 1, total: sentCredits.length }); + + // Check the credit + const result = await checkCreditsAvailable(credit.url); + + let status: "redeemed" | "active" | "unknown"; + if (result.redeemed) { + status = "redeemed"; + // Update database + await markRedeemed(credit.id); + } else if (result.available) { + status = "active"; + } else { + status = "unknown"; + } + + // Update results + setResults(prev => prev.map((r, idx) => + idx === i ? { ...r, status } : r + )); + } + } catch (error) { + // Handle error silently + } finally { + await closeBrowser(); + setChecking(false); + setCheckComplete(true); + setCurrentCode(null); + refresh(); + } + }; + + const exportUnusedCredits = () => { + const activeCredits = results.filter(r => r.status === "active"); + + if (activeCredits.length === 0) { + setExportMessage("No unused credits to export."); + return; + } + + const dateStr = new Date().toISOString().split("T")[0]; + const filename = `unused_credits_${dateStr}.csv`; + + const csvLines = [ + "url,code,amount,assigned_to_email,assigned_to_name", + ...activeCredits.map(r => { + const personName = r.credit.person + ? `${r.credit.person.firstName} ${r.credit.person.lastName}` + : ""; + const personEmail = r.credit.person?.email || ""; + return `${r.credit.url},${r.credit.code},${r.credit.amount},${personEmail},"${personName}"`; + }) + ]; + + writeFileSync(filename, csvLines.join("\n"), "utf-8"); + setExportMessage(`Exported ${activeCredits.length} unused credits to ${filename}`); + }; + + // Calculate summary + const summary = { + redeemed: results.filter(r => r.status === "redeemed").length, + active: results.filter(r => r.status === "active").length, + unknown: results.filter(r => r.status === "unknown").length, + pending: results.filter(r => r.status === "pending").length, + }; + + // Show loading state + if (sentCredits === undefined) { + return ( + + Loading sent credits... + + ); + } + + return ( + + {/* Mode indicator */} + {isLocal && ( + + [LOCAL MODE] + - Changes saved to CSV files + + )} + + {/* Header */} + + Check Credit Redemptions + ({sentCredits.length} sent credits) + + + {/* Progress Bar */} + {(checking || checkComplete) && ( + + + Progress: + {progress.current} + / {progress.total} credits checked + + + + )} + + {/* Current check status */} + {checking && currentCode && ( + + Checking: + {currentCode}... + + )} + + {/* Results list */} + {results.length > 0 && ( + + Results: + + {results.slice(0, 15).map((result, idx) => ( + + {result.credit.person + ? `${result.credit.person.firstName} ${result.credit.person.lastName}` + : result.credit.code + } + - + {result.status === "redeemed" && REDEEMED} + {result.status === "active" && STILL ACTIVE} + {result.status === "unknown" && UNKNOWN} + {result.status === "pending" && PENDING} + + ))} + {results.length > 15 && ( + ... and {results.length - 15} more + )} + + + )} + + {/* Summary */} + {checkComplete && ( + + Summary: + {summary.redeemed} redeemed + | + {summary.active} still active + | + {summary.unknown} unknown + + )} + + {/* Export message */} + {exportMessage && ( + + {exportMessage} + + )} + + {/* No credits message */} + {sentCredits.length === 0 && ( + + No sent credits to check. Send some credits first! + + )} + + {/* Instructions */} + + {!checking && !checkComplete && sentCredits.length > 0 && ( + S Start Check + )} + {checking && ( + Checking in progress... + )} + {checkComplete && summary.active > 0 && ( + E Export Unused + )} + {!checking && ( + Q Back + )} + + + ); +}; + +export default CheckRedemptions; diff --git a/src/utils/localStorage.ts b/src/utils/localStorage.ts index 2508696..71988d5 100644 --- a/src/utils/localStorage.ts +++ b/src/utils/localStorage.ts @@ -26,6 +26,7 @@ export interface LocalCredit { code: string; amount: number; available: boolean; + status?: string; // "available", "sent", "redeemed" assignedTo?: string; createdAt: string; } @@ -223,3 +224,35 @@ export const tallyCredits = (basePath?: string): { }, }; }; + +// Load sent credits with person info (for redemption checking) +export const loadSentCreditsWithPerson = (basePath?: string): Array<{ + credit: LocalCredit; + person: LocalPerson | null; +}> => { + const credits = loadCredits(basePath); + const people = loadPeople(basePath); + + // Filter to only sent credits (assigned but not redeemed) + const sentCredits = credits.filter(c => c.assignedTo && c.status !== "redeemed"); + + return sentCredits.map(credit => { + const person = people.find(p => p.id === credit.assignedTo) || null; + return { credit, person }; + }); +}; + +// Mark a credit as redeemed +export const markCreditRedeemed = (creditId: string, basePath?: string): boolean => { + const credits = loadCredits(basePath); + const index = credits.findIndex(c => c.id === creditId); + + if (index === -1) { + return false; + } + + credits[index]!.status = "redeemed"; + saveCredits(credits, basePath); + + return true; +};