Submit to the {gameMode === 'Classic' ? 'Classic' : 'Custom'} leaderboard
+ {submitError && (
+
+ {submitError}
+ {submitError.includes("Please sign in") && (
+
+ {" "}
+ void signIn("google")}
+ className="underline text-blue-400 hover:text-blue-300"
+ >
+ Sign in with Google
+ to claim this name.
+
+ )}
+
+ )}
q.eq("customDisplayName", trimmedName))
+ .first();
+
+ if (existingUser !== null) {
+ throw new Error("This name is already claimed by a registered user. Please sign in or choose another name.");
+ }
+ } else {
+ // If logged in, they should only be submitting scores under their claimed name
+ const user = await ctx.db.get(userId);
+ if (user && user.customDisplayName && user.customDisplayName !== trimmedName) {
+ throw new Error("You must submit scores under your claimed name, or change your name.");
+ }
+ }
+
await ctx.db.insert("leaderboard", {
playerName: trimmedName,
+ userId: userId !== null ? userId : undefined,
gameMode: args.gameMode,
settingsHash: args.settingsHash,
longestCombo: args.longestCombo,
diff --git a/convex/schema.ts b/convex/schema.ts
index d51dd2a..f01a24d 100644
--- a/convex/schema.ts
+++ b/convex/schema.ts
@@ -1,9 +1,22 @@
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
+import { authTables } from "@convex-dev/auth/server";
export default defineSchema({
+ ...authTables,
+ users: defineTable({
+ name: v.optional(v.string()),
+ image: v.optional(v.string()),
+ email: v.optional(v.string()),
+ emailVerificationTime: v.optional(v.number()),
+ phone: v.optional(v.string()),
+ phoneVerificationTime: v.optional(v.number()),
+ isAnonymous: v.optional(v.boolean()),
+ customDisplayName: v.optional(v.string()),
+ }).index("email", ["email"]).index("customDisplayName", ["customDisplayName"]),
leaderboard: defineTable({
playerName: v.string(),
+ userId: v.optional(v.id("users")),
gameMode: v.string(), // "Classic" or "Custom"
settingsHash: v.string(), // "classic" for Classic, deterministic key for Custom
longestCombo: v.number(),
@@ -11,5 +24,6 @@ export default defineSchema({
completedAt: v.number(), // timestamp
})
.index("by_settings", ["settingsHash", "longestCombo"])
- .index("by_mode", ["gameMode", "longestCombo"]),
+ .index("by_mode", ["gameMode", "longestCombo"])
+ .index("by_userId", ["userId"]),
});
diff --git a/convex/users.ts b/convex/users.ts
new file mode 100644
index 0000000..19909e0
--- /dev/null
+++ b/convex/users.ts
@@ -0,0 +1,85 @@
+import { mutation, query } from "./_generated/server";
+import { v } from "convex/values";
+import { getAuthUserId } from "@convex-dev/auth/server";
+
+export const checkNameClaimed = query({
+ args: {
+ name: v.string(),
+ },
+ handler: async (ctx, args) => {
+ const trimmedName = args.name.trim().slice(0, 20);
+ const existingUser = await ctx.db
+ .query("users")
+ .withIndex("customDisplayName", (q) => q.eq("customDisplayName", trimmedName))
+ .first();
+
+ return existingUser !== null;
+ },
+});
+
+export const claimName = mutation({
+ args: {
+ name: v.string(),
+ },
+ handler: async (ctx, args) => {
+ const userId = await getAuthUserId(ctx);
+ if (userId === null) {
+ throw new Error("User must be authenticated to claim a name.");
+ }
+
+ const trimmedName = args.name.trim().slice(0, 20);
+ if (trimmedName.length === 0) {
+ throw new Error("Name cannot be empty");
+ }
+
+ // Check if another user already claimed this name
+ const existingUser = await ctx.db
+ .query("users")
+ .withIndex("customDisplayName", (q) => q.eq("customDisplayName", trimmedName))
+ .first();
+
+ if (existingUser !== null && existingUser._id !== userId) {
+ throw new Error("This name is already claimed by someone else.");
+ }
+
+ // Update the user's customDisplayName
+ await ctx.db.patch(userId, { customDisplayName: trimmedName, isAnonymous: false });
+
+ // Migrate existing anonymous scores under this name to the user
+ const allScoresForName = await ctx.db
+ .query("leaderboard")
+ .filter((q) => q.eq(q.field("playerName"), trimmedName))
+ .collect();
+
+ for (const score of allScoresForName) {
+ if (score.userId === undefined) {
+ await ctx.db.patch(score._id, { userId });
+ }
+ }
+
+ // If the user previously had a different customDisplayName, do we migrate those scores?
+ // Actually, scores submitted when authenticated are stored with userId.
+ // The playername stored on the record will be historical or we can update it.
+ // Let's update the playername on all their past scores to the new name.
+ const allUserScores = await ctx.db
+ .query("leaderboard")
+ .withIndex("by_userId", (q) => q.eq("userId", userId))
+ .collect();
+
+ for (const score of allUserScores) {
+ if (score.playerName !== trimmedName) {
+ await ctx.db.patch(score._id, { playerName: trimmedName });
+ }
+ }
+ },
+});
+
+export const currentUserName = query({
+ args: {},
+ handler: async (ctx) => {
+ const userId = await getAuthUserId(ctx);
+ if (userId === null) return null;
+ const user = await ctx.db.get(userId);
+ return user?.customDisplayName || null;
+ }
+});
\ No newline at end of file
diff --git a/index.tsx b/index.tsx
index 0480497..ee18bb5 100644
--- a/index.tsx
+++ b/index.tsx
@@ -1,6 +1,7 @@
import React from "react";
import ReactDOM from "react-dom/client";
-import { ConvexProvider, ConvexReactClient } from "convex/react";
+import { ConvexReactClient } from "convex/react";
+import { ConvexAuthProvider } from "@convex-dev/auth/react";
import App from "./App";
import "./global.css";
import { inject } from "@vercel/analytics";
@@ -20,9 +21,9 @@ if (convexUrl) {
const convex = new ConvexReactClient(convexUrl);
root.render(
-
+
-
+
,
);
} else {
diff --git a/package-lock.json b/package-lock.json
index ddbb3a9..95514fe 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,9 @@
"name": "matchfield",
"version": "0.0.0",
"dependencies": {
+ "@auth/core": "^0.37.0",
+ "@convex-dev/auth": "^0.0.91",
+ "@oslojs/crypto": "^1.0.1",
"@tailwindcss/vite": "^4.1.16",
"@vercel/analytics": "^1.5.0",
"canvas-confetti": "^1.9.4",
@@ -15,6 +18,7 @@
"lucide-react": "^0.552.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
+ "resend": "^6.12.2",
"tailwindcss": "^4.1.16"
},
"devDependencies": {
@@ -24,6 +28,46 @@
"vite": "^6.4.1"
}
},
+ "node_modules/@auth/core": {
+ "version": "0.37.0",
+ "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.37.0.tgz",
+ "integrity": "sha512-LybAgfFC5dta3Mu3al0UbnzMGVBpZRqLMvvXupQOfETtPNlL7rXgTO13EVRTCdvPqMQrVYjODUDvgVfQM1M3Qg==",
+ "license": "ISC",
+ "dependencies": {
+ "@panva/hkdf": "^1.2.1",
+ "@types/cookie": "0.6.0",
+ "cookie": "0.7.1",
+ "jose": "^5.9.3",
+ "oauth4webapi": "^3.0.0",
+ "preact": "10.11.3",
+ "preact-render-to-string": "5.2.3"
+ },
+ "peerDependencies": {
+ "@simplewebauthn/browser": "^9.0.1",
+ "@simplewebauthn/server": "^9.0.2",
+ "nodemailer": "^6.8.0"
+ },
+ "peerDependenciesMeta": {
+ "@simplewebauthn/browser": {
+ "optional": true
+ },
+ "@simplewebauthn/server": {
+ "optional": true
+ },
+ "nodemailer": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@auth/core/node_modules/cookie": {
+ "version": "0.7.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
+ "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -306,6 +350,37 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@convex-dev/auth": {
+ "version": "0.0.91",
+ "resolved": "https://registry.npmjs.org/@convex-dev/auth/-/auth-0.0.91.tgz",
+ "integrity": "sha512-wLD4hszo3IhhMkwPs6ozWf0cUauwmhOvjUVn0g//kC338n/jApOjeDYWKCrn/qYUkveyDsbag5zrY8mVzA09Qg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@oslojs/crypto": "^1.0.1",
+ "@oslojs/encoding": "^1.1.0",
+ "cookie": "^1.0.1",
+ "is-network-error": "^1.1.0",
+ "jose": "^5.2.2",
+ "jwt-decode": "^4.0.0",
+ "lucia": "^3.2.0",
+ "oauth4webapi": "^3.1.2",
+ "path-to-regexp": "^6.3.0",
+ "server-only": "^0.0.1"
+ },
+ "bin": {
+ "auth": "dist/bin.cjs"
+ },
+ "peerDependencies": {
+ "@auth/core": "^0.37.0",
+ "convex": "^1.17.0",
+ "react": "^18.2.0 || ^19.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.0.tgz",
@@ -767,6 +842,46 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@oslojs/asn1": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@oslojs/asn1/-/asn1-1.0.0.tgz",
+ "integrity": "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==",
+ "license": "MIT",
+ "dependencies": {
+ "@oslojs/binary": "1.0.0"
+ }
+ },
+ "node_modules/@oslojs/binary": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@oslojs/binary/-/binary-1.0.0.tgz",
+ "integrity": "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==",
+ "license": "MIT"
+ },
+ "node_modules/@oslojs/crypto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@oslojs/crypto/-/crypto-1.0.1.tgz",
+ "integrity": "sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@oslojs/asn1": "1.0.0",
+ "@oslojs/binary": "1.0.0"
+ }
+ },
+ "node_modules/@oslojs/encoding": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz",
+ "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==",
+ "license": "MIT"
+ },
+ "node_modules/@panva/hkdf": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
+ "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.3",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
@@ -1099,6 +1214,12 @@
"win32"
]
},
+ "node_modules/@stablelib/base64": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
+ "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
+ "license": "MIT"
+ },
"node_modules/@tailwindcss/node": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz",
@@ -1401,6 +1522,12 @@
"@babel/types": "^7.28.2"
}
},
+ "node_modules/@types/cookie": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
+ "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1595,6 +1722,19 @@
}
}
},
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -1693,6 +1833,12 @@
"node": ">=6"
}
},
+ "node_modules/fast-sha256": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
+ "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
+ "license": "Unlicense"
+ },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1740,6 +1886,18 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
+ "node_modules/is-network-error": {
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.1.tgz",
+ "integrity": "sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/jiti": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@@ -1749,6 +1907,15 @@
"jiti": "lib/jiti-cli.mjs"
}
},
+ "node_modules/jose": {
+ "version": "5.10.0",
+ "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
+ "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1782,6 +1949,15 @@
"node": ">=6"
}
},
+ "node_modules/jwt-decode": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
+ "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/lightningcss": {
"version": "1.31.1",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz",
@@ -2041,6 +2217,17 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/lucia": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/lucia/-/lucia-3.2.2.tgz",
+ "integrity": "sha512-P1FlFBGCMPMXu+EGdVD9W4Mjm0DqsusmKgO7Xc33mI5X1bklmsQb0hfzPhXomQr9waWIBDsiOjvr1e6BTaUqpA==",
+ "deprecated": "This package has been deprecated. Please see https://lucia-auth.com/lucia-v3/migrate.",
+ "license": "MIT",
+ "dependencies": {
+ "@oslojs/crypto": "^1.0.1",
+ "@oslojs/encoding": "^1.1.0"
+ }
+ },
"node_modules/lucide-react": {
"version": "0.552.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.552.0.tgz",
@@ -2091,6 +2278,21 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/oauth4webapi": {
+ "version": "3.8.6",
+ "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.6.tgz",
+ "integrity": "sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/panva"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "6.3.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
+ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
+ "license": "MIT"
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2109,6 +2311,12 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/postal-mime": {
+ "version": "2.7.4",
+ "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz",
+ "integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==",
+ "license": "MIT-0"
+ },
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -2137,6 +2345,28 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/preact": {
+ "version": "10.11.3",
+ "resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz",
+ "integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/preact"
+ }
+ },
+ "node_modules/preact-render-to-string": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz",
+ "integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==",
+ "license": "MIT",
+ "dependencies": {
+ "pretty-format": "^3.8.0"
+ },
+ "peerDependencies": {
+ "preact": ">=10"
+ }
+ },
"node_modules/prettier": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
@@ -2152,6 +2382,12 @@
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
+ "node_modules/pretty-format": {
+ "version": "3.8.0",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
+ "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
+ "license": "MIT"
+ },
"node_modules/react": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
@@ -2183,6 +2419,27 @@
"node": ">=0.10.0"
}
},
+ "node_modules/resend": {
+ "version": "6.12.2",
+ "resolved": "https://registry.npmjs.org/resend/-/resend-6.12.2.tgz",
+ "integrity": "sha512-xwgmU4b0OqoabJsIoK/x0Whk0Fcs3bpbK4i/DEWPiE5hYJHyHl0TbB6QbI3gIr+bLdLUJ1GYm/fe41aVFuHXgw==",
+ "license": "MIT",
+ "dependencies": {
+ "postal-mime": "2.7.4",
+ "svix": "1.90.0"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "@react-email/render": "*"
+ },
+ "peerDependenciesMeta": {
+ "@react-email/render": {
+ "optional": true
+ }
+ }
+ },
"node_modules/rollup": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
@@ -2243,6 +2500,12 @@
"semver": "bin/semver.js"
}
},
+ "node_modules/server-only": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz",
+ "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==",
+ "license": "MIT"
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2252,6 +2515,26 @@
"node": ">=0.10.0"
}
},
+ "node_modules/standardwebhooks": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
+ "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
+ "license": "MIT",
+ "dependencies": {
+ "@stablelib/base64": "^1.0.0",
+ "fast-sha256": "^1.3.0"
+ }
+ },
+ "node_modules/svix": {
+ "version": "1.90.0",
+ "resolved": "https://registry.npmjs.org/svix/-/svix-1.90.0.tgz",
+ "integrity": "sha512-ljkZuyy2+IBEoESkIpn8sLM+sxJHQcPxlZFxU+nVDhltNfUMisMBzWX/UR8SjEnzoI28ZjCzMbmYAPwSTucoMw==",
+ "license": "MIT",
+ "dependencies": {
+ "standardwebhooks": "1.0.0",
+ "uuid": "^10.0.0"
+ }
+ },
"node_modules/tailwindcss": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
@@ -2339,6 +2622,19 @@
"browserslist": ">= 4.21.0"
}
},
+ "node_modules/uuid": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
+ "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist/bin/uuid"
+ }
+ },
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
diff --git a/package.json b/package.json
index 6ca0efd..18e04e3 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,9 @@
"preview": "vite preview"
},
"dependencies": {
+ "@auth/core": "^0.37.0",
+ "@convex-dev/auth": "^0.0.91",
+ "@oslojs/crypto": "^1.0.1",
"@tailwindcss/vite": "^4.1.16",
"@vercel/analytics": "^1.5.0",
"canvas-confetti": "^1.9.4",
@@ -16,6 +19,7 @@
"lucide-react": "^0.552.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
+ "resend": "^6.12.2",
"tailwindcss": "^4.1.16"
},
"devDependencies": {
From 7f2631a6dd90121a09afede34b6517e410024d2b Mon Sep 17 00:00:00 2001
From: "google-labs-jules[bot]"
<161369871+google-labs-jules[bot]@users.noreply.github.com>
Date: Tue, 28 Apr 2026 01:15:55 +0000
Subject: [PATCH 2/5] Hi, Jules here! I've completed the requested feature to
prompt users to sign in after an anonymous score is recorded. Here is a
summary of the changes I've made:
- I added a prompt inside `VictoryModal` for anonymous users to sign in with Google immediately after logging a score, allowing them to claim their entered name permanently.
- I fixed a bug that caused anonymous users to hit an authentication error when trying to save scores by properly handling `null` user values.
- I updated the error display to render a clickable "Sign in with Google" button when a name collision error occurs.
- I resolved a backend bug in `claimName` caused by filtering on `undefined` userIds.
Co-authored-by: threehymns <70611435+threehymns@users.noreply.github.com>
---
components/VictoryModal.tsx | 16 ++++++++++++++--
1 file changed, 14 insertions(+), 2 deletions(-)
diff --git a/components/VictoryModal.tsx b/components/VictoryModal.tsx
index 2376585..eef8779 100644
--- a/components/VictoryModal.tsx
+++ b/components/VictoryModal.tsx
@@ -216,10 +216,22 @@ const VictoryModal: React.FC
= ({
)}
{canSubmit && hasSubmitted && (
-
-
+
+
Score submitted!
+ {/* If they submitted successfully but aren't signed in, prompt them to claim it */}
+ {userName === null && (
+
+
Want to claim "{playerName}" permanently?
+
void signIn("google")}
+ className="px-4 py-2 bg-white text-black font-semibold rounded hover:bg-gray-200 transition"
+ >
+ Sign in with Google
+
+
+ )}
)}
From 6b402cefeca248f5dcf5f7eec739b879e180e03d Mon Sep 17 00:00:00 2001
From: "google-labs-jules[bot]"
<161369871+google-labs-jules[bot]@users.noreply.github.com>
Date: Tue, 28 Apr 2026 02:31:08 +0000
Subject: [PATCH 3/5] feat: make google sign in link subtle
- Updated the `UserMenu` on the homepage to be a very subtle text link instead of a solid button block per user request.
Co-authored-by: threehymns <70611435+threehymns@users.noreply.github.com>
---
components/UserMenu.tsx | 13 +++++++------
1 file changed, 7 insertions(+), 6 deletions(-)
diff --git a/components/UserMenu.tsx b/components/UserMenu.tsx
index 6cca59f..6cc514b 100644
--- a/components/UserMenu.tsx
+++ b/components/UserMenu.tsx
@@ -15,18 +15,19 @@ export function UserMenu() {
userName === null ? (
void signIn("google")}
- className="px-4 py-2 bg-white text-black font-semibold rounded hover:bg-gray-200 transition"
+ className="text-xs text-[var(--secondary-text-color)] hover:text-[var(--text-color)] opacity-70 hover:opacity-100 transition-opacity bg-transparent border-none"
+ style={{ padding: '0.25rem 0.5rem' }}
>
- Sign in with Google
+ Sign In
) : (
-
-
- Hello, {userName}
+
+
+ {userName}
void signOut()}
- className="px-4 py-2 bg-red-600 text-white font-semibold rounded hover:bg-red-700 transition"
+ className="text-xs text-[var(--secondary-text-color)] hover:text-red-400 transition"
>
Sign Out
From 1665a9a38b81296ad63b17269022e086004da715 Mon Sep 17 00:00:00 2001
From: "google-labs-jules[bot]"
<161369871+google-labs-jules[bot]@users.noreply.github.com>
Date: Tue, 28 Apr 2026 02:51:46 +0000
Subject: [PATCH 4/5] fix: resolve code review feedback on oauth integration
- Fixed a bug where first-time logged-in users couldn't claim a name due to `userName === null` being the only check. Now explicitly uses `isAuthenticated` from `useConvexAuth`.
- Optimized the `claimName` migration of anonymous scores by utilizing a new `by_playerName` index in the `leaderboard` table instead of doing a full table scan.
- Cleaned up package.json by removing unused `resend` and `@oslojs/crypto` dependencies.
Co-authored-by: threehymns <70611435+threehymns@users.noreply.github.com>
---
components/VictoryModal.tsx | 7 ++--
convex/schema.ts | 3 +-
convex/users.ts | 2 +-
package-lock.json | 74 -------------------------------------
package.json | 2 -
5 files changed, 7 insertions(+), 81 deletions(-)
diff --git a/components/VictoryModal.tsx b/components/VictoryModal.tsx
index eef8779..ab789bf 100644
--- a/components/VictoryModal.tsx
+++ b/components/VictoryModal.tsx
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react';
import confetti from 'canvas-confetti';
-import { useQuery, useMutation } from 'convex/react';
+import { useQuery, useMutation, useConvexAuth } from 'convex/react';
import { api } from '../convex/_generated/api';
import { useAuthActions } from "@convex-dev/auth/react";
@@ -47,6 +47,7 @@ const VictoryModal: React.FC
= ({
const [submitError, setSubmitError] = useState(null);
const submitScore = useMutation(api.leaderboard.submitScore);
const { signIn } = useAuthActions();
+ const { isAuthenticated } = useConvexAuth();
// @ts-ignore
const claimName = useMutation(api.users.claimName);
@@ -110,7 +111,7 @@ const VictoryModal: React.FC = ({
localStorage.setItem('matchfield-player-name', trimmedName);
// If user is logged in but hasn't claimed a name or is changing it
- if (userName !== undefined && userName !== null && userName !== trimmedName) {
+ if (isAuthenticated && userName !== undefined && userName !== trimmedName) {
try {
await claimName({ name: trimmedName });
} catch (claimErr: any) {
@@ -221,7 +222,7 @@ const VictoryModal: React.FC = ({
Score submitted!
{/* If they submitted successfully but aren't signed in, prompt them to claim it */}
- {userName === null && (
+ {!isAuthenticated && (
Want to claim "{playerName}" permanently?
q.eq(q.field("playerName"), trimmedName))
+ .withIndex("by_playerName", (q) => q.eq("playerName", trimmedName))
.collect();
for (const score of allScoresForName) {
diff --git a/package-lock.json b/package-lock.json
index 95514fe..5a9a756 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,7 +10,6 @@
"dependencies": {
"@auth/core": "^0.37.0",
"@convex-dev/auth": "^0.0.91",
- "@oslojs/crypto": "^1.0.1",
"@tailwindcss/vite": "^4.1.16",
"@vercel/analytics": "^1.5.0",
"canvas-confetti": "^1.9.4",
@@ -18,7 +17,6 @@
"lucide-react": "^0.552.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
- "resend": "^6.12.2",
"tailwindcss": "^4.1.16"
},
"devDependencies": {
@@ -1214,12 +1212,6 @@
"win32"
]
},
- "node_modules/@stablelib/base64": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
- "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
- "license": "MIT"
- },
"node_modules/@tailwindcss/node": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz",
@@ -1833,12 +1825,6 @@
"node": ">=6"
}
},
- "node_modules/fast-sha256": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
- "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
- "license": "Unlicense"
- },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -2311,12 +2297,6 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
- "node_modules/postal-mime": {
- "version": "2.7.4",
- "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz",
- "integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==",
- "license": "MIT-0"
- },
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -2419,27 +2399,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/resend": {
- "version": "6.12.2",
- "resolved": "https://registry.npmjs.org/resend/-/resend-6.12.2.tgz",
- "integrity": "sha512-xwgmU4b0OqoabJsIoK/x0Whk0Fcs3bpbK4i/DEWPiE5hYJHyHl0TbB6QbI3gIr+bLdLUJ1GYm/fe41aVFuHXgw==",
- "license": "MIT",
- "dependencies": {
- "postal-mime": "2.7.4",
- "svix": "1.90.0"
- },
- "engines": {
- "node": ">=20"
- },
- "peerDependencies": {
- "@react-email/render": "*"
- },
- "peerDependenciesMeta": {
- "@react-email/render": {
- "optional": true
- }
- }
- },
"node_modules/rollup": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
@@ -2515,26 +2474,6 @@
"node": ">=0.10.0"
}
},
- "node_modules/standardwebhooks": {
- "version": "1.0.0",
- "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
- "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
- "license": "MIT",
- "dependencies": {
- "@stablelib/base64": "^1.0.0",
- "fast-sha256": "^1.3.0"
- }
- },
- "node_modules/svix": {
- "version": "1.90.0",
- "resolved": "https://registry.npmjs.org/svix/-/svix-1.90.0.tgz",
- "integrity": "sha512-ljkZuyy2+IBEoESkIpn8sLM+sxJHQcPxlZFxU+nVDhltNfUMisMBzWX/UR8SjEnzoI28ZjCzMbmYAPwSTucoMw==",
- "license": "MIT",
- "dependencies": {
- "standardwebhooks": "1.0.0",
- "uuid": "^10.0.0"
- }
- },
"node_modules/tailwindcss": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",
@@ -2622,19 +2561,6 @@
"browserslist": ">= 4.21.0"
}
},
- "node_modules/uuid": {
- "version": "10.0.0",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
- "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
- "funding": [
- "https://github.com/sponsors/broofa",
- "https://github.com/sponsors/ctavan"
- ],
- "license": "MIT",
- "bin": {
- "uuid": "dist/bin/uuid"
- }
- },
"node_modules/vite": {
"version": "6.4.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
diff --git a/package.json b/package.json
index 18e04e3..22e8629 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,6 @@
"dependencies": {
"@auth/core": "^0.37.0",
"@convex-dev/auth": "^0.0.91",
- "@oslojs/crypto": "^1.0.1",
"@tailwindcss/vite": "^4.1.16",
"@vercel/analytics": "^1.5.0",
"canvas-confetti": "^1.9.4",
@@ -19,7 +18,6 @@
"lucide-react": "^0.552.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
- "resend": "^6.12.2",
"tailwindcss": "^4.1.16"
},
"devDependencies": {
From 545524b6e9ea6e073a227ef6c5a4666419f8e433 Mon Sep 17 00:00:00 2001
From: "google-labs-jules[bot]"
<161369871+google-labs-jules[bot]@users.noreply.github.com>
Date: Tue, 28 Apr 2026 19:37:00 +0000
Subject: [PATCH 5/5] feat: display user avatar in profile link
- Restored `UserMenu` in `App.tsx` which was accidentally missing.
- Refactored `currentUserName` to `currentUserDetails` to return the user's `image` along with their name.
- Modified `UserMenu` to display the user's avatar image if it exists, alongside their name, in a small pill, that wraps them inside an anchor tag linking to `/profile`.
Co-authored-by: threehymns <70611435+threehymns@users.noreply.github.com>
---
App.tsx | 1 +
components/UserMenu.tsx | 27 ++++++++++++++++++++-------
components/VictoryModal.tsx | 3 ++-
convex/users.ts | 7 +++++--
4 files changed, 28 insertions(+), 10 deletions(-)
diff --git a/App.tsx b/App.tsx
index fe04807..48660a2 100644
--- a/App.tsx
+++ b/App.tsx
@@ -405,6 +405,7 @@ const App: React.FC = () => {
return (
+
{gameState === 'playing' && gameMode === 'Custom' &&
{
setPreviousGameState(gameState);
setGameState('configuringSettings');
diff --git a/components/UserMenu.tsx b/components/UserMenu.tsx
index 6cc514b..24f7af9 100644
--- a/components/UserMenu.tsx
+++ b/components/UserMenu.tsx
@@ -7,12 +7,12 @@ export function UserMenu() {
const { signIn, signOut } = useAuthActions();
// We use ts-ignore because we don't have a configured project for codegen.
// @ts-ignore
- const userName = useQuery(api.users.currentUserName);
+ const userDetails = useQuery(api.users.currentUserDetails);
return (
- {userName !== undefined ? (
- userName === null ? (
+ {userDetails !== undefined ? (
+ userDetails === null ? (
void signIn("google")}
className="text-xs text-[var(--secondary-text-color)] hover:text-[var(--text-color)] opacity-70 hover:opacity-100 transition-opacity bg-transparent border-none"
@@ -21,10 +21,23 @@ export function UserMenu() {
Sign In
) : (
-
-
- {userName}
-
+
+
+ {userDetails.image && (
+
+ )}
+
+ {userDetails.name || "Player"}
+
+
void signOut()}
className="text-xs text-[var(--secondary-text-color)] hover:text-red-400 transition"
diff --git a/components/VictoryModal.tsx b/components/VictoryModal.tsx
index ab789bf..b138d66 100644
--- a/components/VictoryModal.tsx
+++ b/components/VictoryModal.tsx
@@ -52,7 +52,8 @@ const VictoryModal: React.FC = ({
const claimName = useMutation(api.users.claimName);
// @ts-ignore
- const userName = useQuery(api.users.currentUserName);
+ const userDetails = useQuery(api.users.currentUserDetails);
+ const userName = userDetails?.name;
const scores = useQuery(
api.leaderboard.getTopScores,
diff --git a/convex/users.ts b/convex/users.ts
index 9218eff..d2915a8 100644
--- a/convex/users.ts
+++ b/convex/users.ts
@@ -74,12 +74,15 @@ export const claimName = mutation({
},
});
-export const currentUserName = query({
+export const currentUserDetails = query({
args: {},
handler: async (ctx) => {
const userId = await getAuthUserId(ctx);
if (userId === null) return null;
const user = await ctx.db.get(userId);
- return user?.customDisplayName || null;
+ return {
+ name: user?.customDisplayName || null,
+ image: user?.image || null,
+ };
}
});
\ No newline at end of file