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;
+};