diff --git a/App.tsx b/App.tsx index b1d6af0..48660a2 100644 --- a/App.tsx +++ b/App.tsx @@ -14,6 +14,7 @@ import SettingsButton from './components/SettingsButton'; import SettingsPage from './components/SettingsPage'; import GameModeScreen from './components/GameModeScreen'; import Leaderboard from './components/Leaderboard'; +import { UserMenu } from './components/UserMenu'; import { playMatchSound, playMismatchSound, playVictorySound } from './utils/sounds'; import { buildSettingsHash } from './utils/settingsHash'; @@ -404,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 new file mode 100644 index 0000000..24f7af9 --- /dev/null +++ b/components/UserMenu.tsx @@ -0,0 +1,54 @@ +import React from "react"; +import { useAuthActions } from "@convex-dev/auth/react"; +import { useQuery, useMutation } from "convex/react"; +import { api } from "../convex/_generated/api"; + +export function UserMenu() { + const { signIn, signOut } = useAuthActions(); + // We use ts-ignore because we don't have a configured project for codegen. + // @ts-ignore + const userDetails = useQuery(api.users.currentUserDetails); + + return ( +
+ {userDetails !== undefined ? ( + userDetails === null ? ( + + ) : ( +
+ + {userDetails.image && ( + {userDetails.name + )} + + {userDetails.name || "Player"} + + + +
+ ) + ) : ( +
+ )} +
+ ); +} diff --git a/components/VictoryModal.tsx b/components/VictoryModal.tsx index 217eb72..b138d66 100644 --- a/components/VictoryModal.tsx +++ b/components/VictoryModal.tsx @@ -1,8 +1,9 @@ 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"; interface VictoryModalProps { isOpen: boolean; @@ -43,7 +44,17 @@ const VictoryModal: React.FC = ({ const [playerName, setPlayerName] = useState(''); const [hasSubmitted, setHasSubmitted] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); + 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); + + // @ts-ignore + const userDetails = useQuery(api.users.currentUserDetails); + const userName = userDetails?.name; + const scores = useQuery( api.leaderboard.getTopScores, isOpen ? { settingsHash, limit: 10 } : 'skip', @@ -85,13 +96,30 @@ const VictoryModal: React.FC = ({ } }, [isOpen, isPerfectScore, isTimeUp]); + useEffect(() => { + if (userName) { + setPlayerName(userName); + } + }, [userName]); + const handleSubmitScore = async () => { const trimmedName = playerName.trim(); if (!trimmedName || isSubmitting) return; setIsSubmitting(true); + setSubmitError(null); try { localStorage.setItem('matchfield-player-name', trimmedName); + + // If user is logged in but hasn't claimed a name or is changing it + if (isAuthenticated && userName !== undefined && userName !== trimmedName) { + try { + await claimName({ name: trimmedName }); + } catch (claimErr: any) { + throw new Error(claimErr.message || "Failed to claim name"); + } + } + await submitScore({ playerName: trimmedName, gameMode, @@ -100,8 +128,16 @@ const VictoryModal: React.FC = ({ isPerfectScore, }); setHasSubmitted(true); - } catch (err) { + } catch (err: any) { console.error('Failed to submit score:', err); + // Determine error message safely + let errorMsg = "An error occurred submitting your score."; + if (err instanceof Error) { + // Convex passes the server throw Error message in the Error object message property + const match = err.message.match(/Uncaught Error: (.*)/); + errorMsg = match ? match[1] : err.message; + } + setSubmitError(errorMsg); } finally { setIsSubmitting(false); } @@ -144,6 +180,22 @@ const VictoryModal: React.FC = ({

Submit to the {gameMode === 'Classic' ? 'Classic' : 'Custom'} leaderboard

+ {submitError && ( +
+ {submitError} + {submitError.includes("Please sign in") && ( + + {" "} + to claim this name. + + )} +
+ )}
= ({ )} {canSubmit && hasSubmitted && ( -
-

+

+

Score submitted!

+ {/* If they submitted successfully but aren't signed in, prompt them to claim it */} + {!isAuthenticated && ( +
+

Want to claim "{playerName}" permanently?

+ +
+ )}
)} diff --git a/convex/auth.config.ts b/convex/auth.config.ts new file mode 100644 index 0000000..1874069 --- /dev/null +++ b/convex/auth.config.ts @@ -0,0 +1,8 @@ +export default { + providers: [ + { + domain: process.env.CONVEX_SITE_URL, + applicationID: "convex", + }, + ], +}; \ No newline at end of file diff --git a/convex/auth.ts b/convex/auth.ts new file mode 100644 index 0000000..86be0dc --- /dev/null +++ b/convex/auth.ts @@ -0,0 +1,6 @@ +import Google from "@auth/core/providers/google"; +import { convexAuth } from "@convex-dev/auth/server"; + +export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({ + providers: [Google], +}); \ No newline at end of file diff --git a/convex/http.ts b/convex/http.ts new file mode 100644 index 0000000..4dfcb76 --- /dev/null +++ b/convex/http.ts @@ -0,0 +1,8 @@ +import { httpRouter } from "convex/server"; +import { auth } from "./auth"; + +const http = httpRouter(); + +auth.addHttpRoutes(http); + +export default http; \ No newline at end of file diff --git a/convex/leaderboard.ts b/convex/leaderboard.ts index cc7e51a..0d3e54a 100644 --- a/convex/leaderboard.ts +++ b/convex/leaderboard.ts @@ -72,6 +72,8 @@ export const listBoards = query({ }, }); +import { getAuthUserId } from "@convex-dev/auth/server"; + export const submitScore = mutation({ args: { playerName: v.string(), @@ -86,8 +88,29 @@ export const submitScore = mutation({ throw new Error("Player name cannot be empty"); } + const userId = await getAuthUserId(ctx); + + // If they are not logged in, ensure the name is not claimed + if (userId === null) { + const existingUser = await ctx.db + .query("users") + .withIndex("customDisplayName", (q) => 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..3851066 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,7 @@ 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"]) + .index("by_playerName", ["playerName"]), }); diff --git a/convex/users.ts b/convex/users.ts new file mode 100644 index 0000000..d2915a8 --- /dev/null +++ b/convex/users.ts @@ -0,0 +1,88 @@ +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") + .withIndex("by_playerName", (q) => q.eq("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 currentUserDetails = query({ + args: {}, + handler: async (ctx) => { + const userId = await getAuthUserId(ctx); + if (userId === null) return null; + const user = await ctx.db.get(userId); + return { + name: user?.customDisplayName || null, + image: user?.image || 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..5a9a756 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "matchfield", "version": "0.0.0", "dependencies": { + "@auth/core": "^0.37.0", + "@convex-dev/auth": "^0.0.91", "@tailwindcss/vite": "^4.1.16", "@vercel/analytics": "^1.5.0", "canvas-confetti": "^1.9.4", @@ -24,6 +26,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 +348,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 +840,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", @@ -1401,6 +1514,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 +1714,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", @@ -1740,6 +1872,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 +1893,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 +1935,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 +2203,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 +2264,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", @@ -2137,6 +2325,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 +2362,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", @@ -2243,6 +2459,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", diff --git a/package.json b/package.json index 6ca0efd..22e8629 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "preview": "vite preview" }, "dependencies": { + "@auth/core": "^0.37.0", + "@convex-dev/auth": "^0.0.91", "@tailwindcss/vite": "^4.1.16", "@vercel/analytics": "^1.5.0", "canvas-confetti": "^1.9.4",