tr]:last:border-b-0',
47 | className
48 | )}
49 | data-slot="table-footer"
50 | {...props}
51 | />
52 | );
53 | }
54 |
55 | function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
56 | return (
57 |
65 | );
66 | }
67 |
68 | function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
69 | return (
70 | [role=checkbox]]:translate-y-[2px]',
73 | className
74 | )}
75 | data-slot="table-head"
76 | {...props}
77 | />
78 | );
79 | }
80 |
81 | function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
82 | return (
83 | | [role=checkbox]]:translate-y-[2px]',
86 | className
87 | )}
88 | data-slot="table-cell"
89 | {...props}
90 | />
91 | );
92 | }
93 |
94 | function TableCaption({
95 | className,
96 | ...props
97 | }: React.ComponentProps<'caption'>) {
98 | return (
99 |
104 | );
105 | }
106 |
107 | export {
108 | Table,
109 | TableHeader,
110 | TableBody,
111 | TableFooter,
112 | TableHead,
113 | TableRow,
114 | TableCell,
115 | TableCaption,
116 | };
117 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "better-convex",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "build": "next build",
7 | "check": "bun lint && bun typecheck",
8 | "predev": "convex dev --until-success --run init",
9 | "dev": "mprocs",
10 | "dev:app": "next dev --turbopack --port 3005",
11 | "dev:backend": "convex dev",
12 | "gen": "cd convex && npx @better-auth/cli generate -y --output authSchema.ts",
13 | "postinstall": "npx skiller@latest apply",
14 | "lint": "biome check && eslint",
15 | "lint:fix": "biome check --write",
16 | "nuke": "rimraf '**/node_modules'",
17 | "reset": "convex run reset:reset && sleep 3 && convex run init",
18 | "seed": "convex run seed:seed",
19 | "start": "next start",
20 | "studio": "convex dashboard",
21 | "sync": "tsx scripts/sync-convex-env.ts",
22 | "sync:force": "tsx scripts/sync-convex-env.ts --force",
23 | "sync:plate": "node scripts/sync-plate.cjs",
24 | "typecheck": "tsc --noEmit",
25 | "typecheck:watch": "tsc --noEmit --watch"
26 | },
27 | "dependencies": {
28 | "@convex-dev/aggregate": "0.2.0",
29 | "@convex-dev/better-auth": "0.9.11",
30 | "@convex-dev/rate-limiter": "0.3.0",
31 | "@convex-dev/react-query": "0.1.0",
32 | "@convex-dev/resend": "0.2.0",
33 | "@hookform/resolvers": "5.2.2",
34 | "@react-email/components": "1.0.1",
35 | "@react-email/render": "2.0.0",
36 | "@t3-oss/env-nextjs": "0.13.8",
37 | "@tanstack/react-query": "5.90.12",
38 | "better-auth": "1.3.34",
39 | "better-auth-convex": "0.4.8",
40 | "class-variance-authority": "0.7.1",
41 | "clsx": "2.1.1",
42 | "cmdk": "1.1.1",
43 | "common-tags": "1.8.2",
44 | "convex": "1.30.0",
45 | "convex-ents": "0.16.0",
46 | "convex-helpers": "0.1.106",
47 | "date-fns": "4.1.0",
48 | "dotenv": "17.2.3",
49 | "embla-carousel-react": "8.6.0",
50 | "input-otp": "1.4.2",
51 | "jotai-x": "2.3.3",
52 | "lucide-react": "0.555.0",
53 | "next": "16.0.7",
54 | "next-themes": "0.4.6",
55 | "nuqs": "2.8.2",
56 | "radix-ui": "latest",
57 | "react": "19.2.1",
58 | "react-day-picker": "9.11.3",
59 | "react-dom": "19.2.1",
60 | "react-hook-form": "7.68.0",
61 | "react-resizable-panels": "3.0.6",
62 | "recharts": "2.15.4",
63 | "sonner": "2.0.7",
64 | "superjson": "2.2.6",
65 | "tailwind-merge": "3.4.0",
66 | "ts-essentials": "10.1.1",
67 | "tsx": "4.21.0",
68 | "type-fest": "5.3.0",
69 | "use-debounce": "10.0.6",
70 | "vaul": "1.1.2",
71 | "zod": "4.1.13"
72 | },
73 | "devDependencies": {
74 | "@biomejs/biome": "2.3.8",
75 | "@tailwindcss/postcss": "4.1.17",
76 | "@types/common-tags": "1.8.4",
77 | "@types/node": "24.10.1",
78 | "@types/react": "19.2.7",
79 | "@types/react-dom": "19.2.3",
80 | "@typescript-eslint/parser": "8.48.1",
81 | "babel-plugin-react-compiler": "1.0.0",
82 | "eslint": "9.39.1",
83 | "eslint-plugin-react-hooks": "7.0.1",
84 | "lefthook": "2.0.7",
85 | "mprocs": "^0.7.3",
86 | "tailwindcss": "4.1.17",
87 | "tw-animate-css": "1.4.0",
88 | "typescript": "5.9.3",
89 | "typescript-eslint": "8.48.1",
90 | "ultracite": "6.3.9"
91 | },
92 | "packageManager": "bun@1.3.3"
93 | }
94 |
--------------------------------------------------------------------------------
/src/lib/convex/server.ts:
--------------------------------------------------------------------------------
1 | import { api } from '@convex/_generated/api';
2 | import { getAuth } from '@convex/auth';
3 |
4 | import { getToken } from '@convex-dev/better-auth/nextjs';
5 | import type { NextjsOptions } from 'convex/nextjs';
6 | import { fetchMutation, fetchQuery } from 'convex/nextjs';
7 | import type {
8 | ArgsAndOptions,
9 | FunctionReference,
10 | FunctionReturnType,
11 | } from 'convex/server';
12 |
13 | export const getSessionToken = async (): Promise => {
14 | const token = await getToken(getAuth);
15 |
16 | return token;
17 | };
18 |
19 | export const isAuth = async () => {
20 | const token = await getSessionToken();
21 |
22 | try {
23 | return await fetchQuery(api.user.getIsAuthenticated, {}, { token });
24 | } catch {
25 | return false;
26 | }
27 | };
28 |
29 | export const isUnauth = async () => !(await isAuth());
30 |
31 | // Session helper functions using Convex
32 |
33 | export const fetchSessionUser = async () => {
34 | const token = await getSessionToken();
35 |
36 | return await fetchQuery(api.user.getSessionUser, {}, { token });
37 | };
38 |
39 | export async function fetchAuthQuery>(
40 | query: Query,
41 | ...args: ArgsAndOptions
42 | ): Promise | null> {
43 | const token = await getSessionToken();
44 |
45 | if (!token) {
46 | return null;
47 | }
48 | // Handle both cases: with and without args
49 | if (args.length === 0) {
50 | return fetchQuery(query, {}, { token });
51 | }
52 | if (args.length === 1) {
53 | return fetchQuery(query, args[0], { token });
54 | }
55 | // args[1] contains options, merge token into it
56 | return fetchQuery(query, args[0], { token, ...args[1] });
57 | }
58 |
59 | export async function fetchAuthQueryOrThrow<
60 | Query extends FunctionReference<'query'>,
61 | >(
62 | query: Query,
63 | ...args: ArgsAndOptions
64 | ): Promise> {
65 | const token = await getSessionToken();
66 |
67 | if (!token) {
68 | throw new Error('Not authenticated');
69 | }
70 | // Handle both cases: with and without args
71 | if (args.length === 0) {
72 | return fetchQuery(query, {}, { token });
73 | }
74 | if (args.length === 1) {
75 | return fetchQuery(query, args[0], { token });
76 | }
77 | // args[1] contains options, merge token into it
78 | return fetchQuery(query, args[0], { token, ...args[1] });
79 | }
80 |
81 | export async function fetchAuthMutation<
82 | Mutation extends FunctionReference<'mutation'>,
83 | >(
84 | mutation: Mutation,
85 | ...args: ArgsAndOptions
86 | ): Promise | null> {
87 | const token = await getSessionToken();
88 |
89 | if (!token) {
90 | return null;
91 | }
92 | // Handle both cases: with and without args
93 | if (args.length === 0) {
94 | return fetchMutation(mutation, {}, { token });
95 | }
96 | if (args.length === 1) {
97 | return fetchMutation(mutation, args[0], { token });
98 | }
99 | // args[1] contains options, merge token into it
100 | return fetchMutation(mutation, args[0], { token, ...args[1] });
101 | }
102 |
--------------------------------------------------------------------------------
/src/components/ui/pagination.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | ChevronLeftIcon,
3 | ChevronRightIcon,
4 | MoreHorizontalIcon,
5 | } from 'lucide-react';
6 | import type * as React from 'react';
7 | import { type Button, buttonVariants } from '@/components/ui/button';
8 | import { cn } from '@/lib/utils';
9 |
10 | function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
11 | return (
12 |
18 | );
19 | }
20 |
21 | function PaginationContent({
22 | className,
23 | ...props
24 | }: React.ComponentProps<'ul'>) {
25 | return (
26 |
31 | );
32 | }
33 |
34 | function PaginationItem({ ...props }: React.ComponentProps<'li'>) {
35 | return ;
36 | }
37 |
38 | type PaginationLinkProps = {
39 | isActive?: boolean;
40 | } & Pick, 'size'> &
41 | React.ComponentProps<'a'>;
42 |
43 | function PaginationLink({
44 | className,
45 | isActive,
46 | size = 'icon',
47 | ...props
48 | }: PaginationLinkProps) {
49 | return (
50 |
63 | );
64 | }
65 |
66 | function PaginationPrevious({
67 | className,
68 | ...props
69 | }: React.ComponentProps) {
70 | return (
71 |
77 |
78 | Previous
79 |
80 | );
81 | }
82 |
83 | function PaginationNext({
84 | className,
85 | ...props
86 | }: React.ComponentProps) {
87 | return (
88 |
94 | Next
95 |
96 |
97 | );
98 | }
99 |
100 | function PaginationEllipsis({
101 | className,
102 | ...props
103 | }: React.ComponentProps<'span'>) {
104 | return (
105 |
111 |
112 | More pages
113 |
114 | );
115 | }
116 |
117 | export {
118 | Pagination,
119 | PaginationContent,
120 | PaginationLink,
121 | PaginationItem,
122 | PaginationPrevious,
123 | PaginationNext,
124 | PaginationEllipsis,
125 | };
126 |
--------------------------------------------------------------------------------
/.claude/scripts/user-prompt-submit.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | # UserPromptSubmit hook - Combined skills enforcement and verification checklist
3 |
4 | set -euo pipefail
5 |
6 | PROJECT_DIR="${CLAUDE_PROJECT_DIR:-.}"
7 | PROMPT_FILE="$PROJECT_DIR/.claude/prompt.json"
8 |
9 | # Read and format prompt from JSON file (only if file exists)
10 | if [ -f "$PROMPT_FILE" ]; then
11 | # Use Node.js to parse JSON and format output
12 | FORMATTED_OUTPUT=$(node -e "
13 | try {
14 | const data = require('$PROMPT_FILE');
15 | let output = '';
16 |
17 | // Format beforeStart sections
18 | if (data.beforeStart && Array.isArray(data.beforeStart) && data.beforeStart.length > 0) {
19 | data.beforeStart.forEach(section => {
20 | output += \`<\${section.tag}>\n\`;
21 |
22 | // Add header if present
23 | if (section.header) {
24 | output += \`\${section.header}\n\n\`;
25 | }
26 |
27 | // Format instructions
28 | if (section.instructions && Array.isArray(section.instructions)) {
29 | output += \`**Instructions:**\n\`;
30 | section.instructions.forEach(instruction => {
31 | output += \`• \${instruction}\n\`;
32 | });
33 | output += \`\n\`;
34 | }
35 |
36 | // Format todos as checklist
37 | if (section.todos && Array.isArray(section.todos)) {
38 | output += \`**TodoWrite Checklist:**\n\`;
39 | section.todos.forEach(todo => {
40 | output += \`☐ \${todo}\n\`;
41 | });
42 | }
43 |
44 | output += \`\n\${section.tag}>\n\n\`;
45 | });
46 | }
47 |
48 | // Format beforeComplete sections
49 | if (data.beforeComplete && Array.isArray(data.beforeComplete) && data.beforeComplete.length > 0) {
50 | data.beforeComplete.forEach(section => {
51 | output += \`<\${section.tag}>\n\`;
52 |
53 | // Add header if present
54 | if (section.header) {
55 | output += \`\${section.header}\n\n\`;
56 | }
57 |
58 | // Format instructions
59 | if (section.instructions && Array.isArray(section.instructions)) {
60 | output += \`**Instructions:**\n\`;
61 | section.instructions.forEach(instruction => {
62 | output += \`• \${instruction}\n\`;
63 | });
64 | output += \`\n\`;
65 | }
66 |
67 | // Format todos as checklist
68 | if (section.todos && Array.isArray(section.todos)) {
69 | output += \`**TodoWrite Checklist:**\n\`;
70 | section.todos.forEach(todo => {
71 | output += \`- [ ] \${todo}\n\`;
72 | });
73 | }
74 |
75 | output += \`\${section.tag}>\`;
76 | });
77 | }
78 |
79 | console.log(output);
80 | } catch (error) {
81 | // Silently fail on parse errors
82 | }
83 | " 2>&1)
84 | else
85 | FORMATTED_OUTPUT=""
86 | fi
87 |
88 | # Only output JSON if FORMATTED_OUTPUT is non-empty
89 | if [ -n "$FORMATTED_OUTPUT" ]; then
90 | cat < !!(await ctx.auth.getUserIdentity()),
11 | });
12 |
13 | // Get session user (minimal data)
14 | export const getSessionUser = createPublicQuery()({
15 | returns: z.union([
16 | z.object({
17 | id: zid('user'),
18 | activeOrganization: z
19 | .object({
20 | id: zid('organization'),
21 | logo: z.string().nullish(),
22 | name: z.string(),
23 | role: z.string(),
24 | slug: z.string(),
25 | })
26 | .nullable(),
27 | image: z.string().nullish(),
28 | isAdmin: z.boolean(),
29 | name: z.string().optional(),
30 | personalOrganizationId: zid('organization').optional(),
31 | plan: z.string().optional(),
32 | }),
33 | z.null(),
34 | ]),
35 | handler: async ({ user: userEnt }) => {
36 | if (!userEnt) {
37 | return null;
38 | }
39 |
40 | const { doc, edge, edgeX, ...user } = userEnt;
41 |
42 | return {
43 | id: user.id,
44 | activeOrganization: user.activeOrganization,
45 | image: user.image,
46 | isAdmin: user.isAdmin,
47 | name: user.name,
48 | plan: user.plan,
49 | personalOrganizationId: user.personalOrganizationId,
50 | };
51 | },
52 | });
53 |
54 | // Get full user data for the authenticated user
55 | export const getCurrentUser = createPublicQuery()({
56 | returns: z.union([
57 | z.object({
58 | id: zid('user'),
59 | activeOrganization: z
60 | .object({
61 | id: zid('organization'),
62 | logo: z.string().nullish(),
63 | name: z.string(),
64 | role: z.string(),
65 | slug: z.string(),
66 | })
67 | .nullable(),
68 | image: z.string().nullish(),
69 | isAdmin: z.boolean(),
70 | name: z.string().optional(),
71 | personalOrganizationId: zid('organization').optional(),
72 | plan: z.string().optional(),
73 | }),
74 | z.null(),
75 | ]),
76 | handler: async (ctx) => {
77 | const { user } = ctx;
78 |
79 | if (!user) {
80 | return null;
81 | }
82 |
83 | return {
84 | id: user.id,
85 | activeOrganization: user.activeOrganization,
86 | image: user.image,
87 | isAdmin: user.isAdmin,
88 | name: user.name,
89 | plan: user.plan,
90 | personalOrganizationId: user.personalOrganizationId,
91 | };
92 | },
93 | });
94 |
95 | // Update user settings
96 | export const updateSettings = createAuthMutation()({
97 | args: {
98 | bio: z.string().optional(),
99 | name: z.string().optional(),
100 | },
101 | returns: z.object({ success: z.boolean() }),
102 | handler: async (ctx, args) => {
103 | const { user } = ctx;
104 | const { bio, name } = args;
105 |
106 | // Build update object
107 | const updateData: Record = {};
108 |
109 | if (bio !== undefined) {
110 | updateData.bio = bio;
111 | }
112 | if (name !== undefined) {
113 | updateData.name = name;
114 | }
115 |
116 | await user.patch(updateData);
117 |
118 | return { success: true };
119 | },
120 | });
121 |
--------------------------------------------------------------------------------
/convex/aggregates.ts:
--------------------------------------------------------------------------------
1 | import { TableAggregate } from '@convex-dev/aggregate';
2 | import { components } from './_generated/api';
3 | import type { DataModel, Id } from './_generated/dataModel';
4 |
5 | // Aggregate for users
6 | export const aggregateUsers = new TableAggregate<{
7 | DataModel: DataModel;
8 | Key: null; // No sorting, just counting
9 | Namespace: string; // userId
10 | TableName: 'user';
11 | }>(components.aggregateUsers, {
12 | namespace: (doc) => doc._id,
13 | sortKey: () => null, // We only care about counting, not sorting
14 | });
15 |
16 | // Todo counts by user with priority breakdown
17 | export const aggregateTodosByUser = new TableAggregate<{
18 | DataModel: DataModel;
19 | Key: [string, boolean, boolean]; // [priority, completed, isDeleted]
20 | Namespace: Id<'user'>;
21 | TableName: 'todos';
22 | }>(components.aggregateTodosByUser, {
23 | namespace: (doc) => doc.userId,
24 | sortKey: (doc) => {
25 | // Include deletion status in the key to handle soft deletion properly
26 | const isDeleted = doc.deletionTime !== undefined;
27 | return [doc.priority ?? 'none', doc.completed, isDeleted];
28 | },
29 | });
30 |
31 | // Todo counts by project
32 | export const aggregateTodosByProject = new TableAggregate<{
33 | DataModel: DataModel;
34 | Key: [boolean, number, boolean]; // [completed, creationTime, isDeleted]
35 | Namespace: Id<'projects'> | 'no-project';
36 | TableName: 'todos';
37 | }>(components.aggregateTodosByProject, {
38 | namespace: (doc) => doc.projectId ?? 'no-project',
39 | sortKey: (doc) => {
40 | // Include deletion status in the key to handle soft deletion properly
41 | const isDeleted = doc.deletionTime !== undefined;
42 | return [doc.completed, doc._creationTime, isDeleted];
43 | },
44 | });
45 |
46 | // Todo counts by completion status (global)
47 | export const aggregateTodosByStatus = new TableAggregate<{
48 | DataModel: DataModel;
49 | Key: [boolean, string, number, boolean]; // [completed, priority, dueDate, isDeleted]
50 | TableName: 'todos';
51 | }>(components.aggregateTodosByStatus, {
52 | sortKey: (doc) => {
53 | // Include deletion status in the key to handle soft deletion properly
54 | const isDeleted = doc.deletionTime !== undefined;
55 | return [
56 | doc.completed,
57 | doc.priority ?? 'none',
58 | doc.dueDate ?? Number.POSITIVE_INFINITY,
59 | isDeleted,
60 | ];
61 | },
62 | });
63 |
64 | // Tag usage counts (for many:many relationship demo)
65 | export const aggregateTagUsage = new TableAggregate<{
66 | DataModel: DataModel;
67 | Key: number; // usage count (updated via trigger)
68 | Namespace: Id<'tags'>;
69 | TableName: 'todoTags';
70 | }>(components.aggregateTagUsage, {
71 | namespace: (doc) => doc.tagId,
72 | sortKey: () => 1, // Each join counts as 1
73 | sumValue: () => 1, // Sum to get total usage
74 | });
75 |
76 | // Project member counts
77 | export const aggregateProjectMembers = new TableAggregate<{
78 | DataModel: DataModel;
79 | Key: number; // join time
80 | Namespace: Id<'projects'>;
81 | TableName: 'projectMembers';
82 | }>(components.aggregateProjectMembers, {
83 | namespace: (doc) => doc.projectId,
84 | sortKey: (doc) => doc._creationTime,
85 | });
86 |
87 | // Comments count by todo
88 | export const aggregateCommentsByTodo = new TableAggregate<{
89 | DataModel: DataModel;
90 | Key: number; // creation time
91 | Namespace: Id<'todos'>;
92 | TableName: 'todoComments';
93 | }>(components.aggregateCommentsByTodo, {
94 | namespace: (doc) => doc.todoId,
95 | sortKey: (doc) => doc._creationTime,
96 | });
97 |
--------------------------------------------------------------------------------
/src/lib/react-query/query-client.ts:
--------------------------------------------------------------------------------
1 | import { ConvexQueryClient } from '@convex-dev/react-query';
2 | import {
3 | defaultShouldDehydrateQuery,
4 | QueryClient,
5 | } from '@tanstack/react-query';
6 | import type { ConvexReactClient } from 'convex/react';
7 | import { formatDistanceToNow } from 'date-fns';
8 | import { toast } from 'sonner';
9 | import SuperJSON from 'superjson';
10 |
11 | export const createQueryClient = ({
12 | convex,
13 | }: {
14 | convex?: ConvexReactClient;
15 | } = {}) => {
16 | const convexQueryClient = convex ? new ConvexQueryClient(convex) : null;
17 |
18 | const queryClient = new QueryClient({
19 | defaultOptions: {
20 | dehydrate: {
21 | serializeData: SuperJSON.serialize,
22 | shouldDehydrateQuery: (query) =>
23 | defaultShouldDehydrateQuery(query) ||
24 | query.state.status === 'pending',
25 | shouldRedactErrors: () => {
26 | // We should not catch Next.js server errors
27 | // as that's how Next.js detects dynamic pages
28 | // so we cannot redact them.
29 | // Next.js also automatically redacts errors for us
30 | // with better digests.
31 | return false;
32 | },
33 | },
34 | hydrate: {
35 | deserializeData: SuperJSON.deserialize,
36 | },
37 | mutations: {
38 | onError: (error: any) => {
39 | if (error.message.includes('limit')) {
40 | const messages = [
41 | 'Whoa there, speedster! 🏃♂️',
42 | 'Easy tiger! 🐅',
43 | 'Hold your horses! 🐴',
44 | 'Pump the brakes! 🚦',
45 | 'Slow down, turbo! 🚀',
46 | 'Take a breather! 😮💨',
47 | ];
48 |
49 | const randomMessage =
50 | messages[Math.floor(Math.random() * messages.length)];
51 | let retryInMessage = '';
52 |
53 | if (error.data?.retryAfter) {
54 | retryInMessage +=
55 | ' Try again ' +
56 | formatDistanceToNow(Date.now() + error.data.retryAfter, {
57 | addSuffix: true,
58 | }) +
59 | '.';
60 | }
61 |
62 | toast.error(`${randomMessage}${retryInMessage}`);
63 | } else {
64 | const genericMessages = [
65 | 'Oops! Something went sideways 🤷',
66 | 'Houston, we have a problem 🚀',
67 | 'Uh oh, gremlins in the system! 👹',
68 | ];
69 |
70 | const randomError =
71 | genericMessages[
72 | Math.floor(Math.random() * genericMessages.length)
73 | ];
74 | toast.error(error.data?.message || randomError);
75 | }
76 | },
77 | },
78 | queries: {
79 | ...(convexQueryClient
80 | ? {
81 | queryFn: convexQueryClient.queryFn(),
82 | queryKeyHashFn: convexQueryClient.hashFn(),
83 | }
84 | : {}),
85 | refetchInterval: false,
86 | refetchOnMount: true, // true
87 | refetchOnReconnect: false, // true
88 | refetchOnWindowFocus: false, // true
89 | retry: false,
90 | retryOnMount: false, // avoid infinite query
91 |
92 | // refetchOnWindowFocus: process.env.NODE_ENV === 'production',
93 | // retry: process.env.NODE_ENV === 'production',
94 | // With SSR, we usually want to set some default staleTime
95 | // above 0 to avoid refetching immediately on the client
96 | staleTime: 30 * 1000,
97 | // staleTime: 0,
98 | },
99 | },
100 | });
101 | convexQueryClient?.connect(queryClient);
102 |
103 | return queryClient;
104 | };
105 |
--------------------------------------------------------------------------------
/convex/_generated/server.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated utilities for implementing server-side Convex query and mutation functions.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * To regenerate, run `npx convex dev`.
8 | * @module
9 | */
10 |
11 | import {
12 | actionGeneric,
13 | httpActionGeneric,
14 | queryGeneric,
15 | mutationGeneric,
16 | internalActionGeneric,
17 | internalMutationGeneric,
18 | internalQueryGeneric,
19 | } from "convex/server";
20 |
21 | /**
22 | * Define a query in this Convex app's public API.
23 | *
24 | * This function will be allowed to read your Convex database and will be accessible from the client.
25 | *
26 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
27 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
28 | */
29 | export const query = queryGeneric;
30 |
31 | /**
32 | * Define a query that is only accessible from other Convex functions (but not from the client).
33 | *
34 | * This function will be allowed to read from your Convex database. It will not be accessible from the client.
35 | *
36 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
37 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
38 | */
39 | export const internalQuery = internalQueryGeneric;
40 |
41 | /**
42 | * Define a mutation in this Convex app's public API.
43 | *
44 | * This function will be allowed to modify your Convex database and will be accessible from the client.
45 | *
46 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
47 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
48 | */
49 | export const mutation = mutationGeneric;
50 |
51 | /**
52 | * Define a mutation that is only accessible from other Convex functions (but not from the client).
53 | *
54 | * This function will be allowed to modify your Convex database. It will not be accessible from the client.
55 | *
56 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
57 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
58 | */
59 | export const internalMutation = internalMutationGeneric;
60 |
61 | /**
62 | * Define an action in this Convex app's public API.
63 | *
64 | * An action is a function which can execute any JavaScript code, including non-deterministic
65 | * code and code with side-effects, like calling third-party services.
66 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
67 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
68 | *
69 | * @param func - The action. It receives an {@link ActionCtx} as its first argument.
70 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible.
71 | */
72 | export const action = actionGeneric;
73 |
74 | /**
75 | * Define an action that is only accessible from other Convex functions (but not from the client).
76 | *
77 | * @param func - The function. It receives an {@link ActionCtx} as its first argument.
78 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible.
79 | */
80 | export const internalAction = internalActionGeneric;
81 |
82 | /**
83 | * Define an HTTP action.
84 | *
85 | * The wrapped function will be used to respond to HTTP requests received
86 | * by a Convex deployment if the requests matches the path and method where
87 | * this action is routed. Be sure to route your httpAction in `convex/http.js`.
88 | *
89 | * @param func - The function. It receives an {@link ActionCtx} as its first argument
90 | * and a Fetch API `Request` object as its second.
91 | * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
92 | */
93 | export const httpAction = httpActionGeneric;
94 |
--------------------------------------------------------------------------------
/scripts/sync-convex-env.ts:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env tsx
2 |
3 | import { execSync } from 'node:child_process';
4 | import * as fs from 'node:fs';
5 | import * as path from 'node:path';
6 | import { parse } from 'dotenv';
7 |
8 | /**
9 | * Sync ALL environment variables from convex/.env to Convex This script reads
10 | * convex/.env and sets all variables in Convex
11 | */
12 |
13 | // Variables that should NOT be synced to Convex (local-only)
14 | const EXCLUDE_VARS: string[] = [];
15 |
16 | async function syncConvexEnv() {
17 | // Parse command line arguments
18 | const args = process.argv.slice(2);
19 | const forceMode = args.includes('--force');
20 |
21 | console.info(
22 | `🔄 Syncing ALL environment variables from convex/.env to Convex...${forceMode ? ' (FORCE MODE)' : ''}\n`
23 | );
24 |
25 | // Read convex/.env
26 | const envPath = path.join(process.cwd(), 'convex', '.env');
27 |
28 | if (!fs.existsSync(envPath)) {
29 | console.error('❌ convex/.env file not found');
30 | process.exit(1);
31 | }
32 |
33 | const envContent = fs.readFileSync(envPath, 'utf-8');
34 | const envVars = parse(envContent);
35 |
36 | // Generate BETTER_AUTH_SECRET if not present
37 | if (!envVars.BETTER_AUTH_SECRET) {
38 | try {
39 | const secret = execSync('openssl rand -base64 32', {
40 | encoding: 'utf-8',
41 | }).trim();
42 | envVars.BETTER_AUTH_SECRET = secret;
43 | console.info('🔐 Generated BETTER_AUTH_SECRET');
44 |
45 | // Append to convex/.env
46 | fs.appendFileSync(
47 | envPath,
48 | `\n# Generated by sync-convex-env\nBETTER_AUTH_SECRET=${secret}\n`
49 | );
50 | } catch (_error) {
51 | console.warn(
52 | '⚠️ Could not generate BETTER_AUTH_SECRET (openssl not available)'
53 | );
54 | }
55 | }
56 |
57 | // Check current Convex deployment
58 | try {
59 | const deployment = execSync('npx convex env get CONVEX_DEPLOYMENT', {
60 | encoding: 'utf-8',
61 | stdio: ['pipe', 'pipe', 'ignore'], // Suppress stderr
62 | }).trim();
63 | console.info(
64 | `📍 Current Convex deployment: ${deployment || 'anonymous'}\n`
65 | );
66 | } catch {
67 | console.info('📍 Using anonymous Convex deployment\n');
68 | }
69 |
70 | // Get all variables to sync (exclude certain ones)
71 | const varsToSync = Object.entries(envVars).filter(
72 | ([key]) => !EXCLUDE_VARS.includes(key)
73 | );
74 |
75 | // Sync each variable
76 | let _successCount = 0;
77 | let _skipCount = 0;
78 | let errorCount = 0;
79 |
80 | for (const [varName, value] of varsToSync) {
81 | if (!value) {
82 | console.info(`⏭️ ${varName}: Empty value, skipping`);
83 | _skipCount++;
84 | continue;
85 | }
86 |
87 | try {
88 | if (!forceMode) {
89 | // Check if already set
90 | const currentValue = execSync(`npx convex env get ${varName}`, {
91 | encoding: 'utf-8',
92 | stdio: ['pipe', 'pipe', 'ignore'], // Suppress stderr
93 | }).trim();
94 |
95 | if (currentValue === value) {
96 | console.info(`✅ ${varName}: Already up to date`);
97 | _successCount++;
98 | continue;
99 | }
100 | }
101 |
102 | // Set the variable
103 | execSync(`npx convex env set ${varName}="${value}"`, {
104 | stdio: ['pipe', 'pipe', 'pipe'], // Don't show output
105 | });
106 | console.info(`✅ ${varName}: ${forceMode ? 'Updated' : 'Updated'}`);
107 | _successCount++;
108 | } catch (_error) {
109 | // Variable might not exist, try to set it
110 | try {
111 | execSync(`npx convex env set ${varName}="${value}"`, {
112 | stdio: ['pipe', 'pipe', 'pipe'], // Don't show output
113 | });
114 | console.info(`✅ ${varName}: Set successfully`);
115 | _successCount++;
116 | } catch (_setError) {
117 | console.error(`❌ ${varName}: Failed to set`);
118 | errorCount++;
119 | }
120 | }
121 | }
122 |
123 | if (errorCount > 0) {
124 | console.info(
125 | '\n⚠️ Some variables failed to sync. Please check your Convex deployment.'
126 | );
127 | process.exit(1);
128 | }
129 | }
130 |
131 | // Run the sync
132 | syncConvexEnv().catch((error) => {
133 | console.error('❌ Sync failed:', error);
134 | process.exit(1);
135 | });
136 |
--------------------------------------------------------------------------------
/.claude/prompt.json:
--------------------------------------------------------------------------------
1 | {
2 | "beforeStart": [
3 | {
4 | "tag": "MANDATORY-FIRST-RESPONSE",
5 | "header": "🚨 STOP - YOUR FIRST TOOL CALL MUST BE TodoWrite",
6 | "instructions": [
7 | "DO NOT analyze the task yet. DO NOT read files. DO NOT edit anything. DO NOT load skills yet.",
8 | "YOUR FIRST ACTION: Call TodoWrite with the todo below, marking it as in_progress",
9 | "Check if the todo's condition applies - if NO, mark completed immediately and proceed with normal work",
10 | "If condition applies: work through all steps → mark completed → proceed with normal work",
11 | "If you skip TodoWrite or jump straight to file operations, you have FAILED to follow instructions"
12 | ],
13 | "todos": [
14 | "Skill analysis (SKIP if message contains 'quick'): (1) Check if rationalizing with thoughts like 'simple question', 'just checking files', 'don't need formal skill', 'overkill', 'might be relevant' - if YES to ANY, stop rationalizing; (2) List ALL available skills from context; (3) For EACH skill: Read its description and check for 'always load first' or foundational patterns - mark these ✓ IMMEDIATELY. Then ask 'Does this task involve [skill topic]?' If YES or MIGHT or MAYBE = mark ✓. Only mark ✗ if DEFINITELY not related; (4) Use Skill tool to load ALL marked ✓ IN ONE PARALLEL CALL - do NOT load one skill then wait to see if it tells you to load others. Load ALL potentially relevant skills upfront; (5) Output '[Skills checked: X available, Y loaded: name1, name2, ...]'. CRITICAL: 'Might be relevant' = MUST load. 'Could apply' = MUST load. '1% chance' = MUST load. Do NOT depend on one skill to tell you to load another."
15 | ]
16 | }
17 | ],
18 | "beforeComplete": [
19 | {
20 | "tag": "VERIFICATION-CHECKLIST",
21 | "header": "Before claiming work is complete, fixed, or passing - NO completion claims without FRESH verification evidence:",
22 | "instructions": [
23 | "Create TodoWrite with ALL todos below (each has its own condition)",
24 | "For EACH todo: Check if its condition applies - if NO, mark completed immediately and skip to next",
25 | "If condition applies: mark in_progress → complete the check → mark completed",
26 | "Work through every todo even if some don't apply (conditions are per-todo, not global)",
27 | "We manually start session with `npm run logs && npm run typecheck:watch` as Background Bash Shell - read output when checking errors",
28 | "Run `npm run typecheck` only when Background Bash Shells are not visible/running",
29 | "NEVER make git commits unless user explicitly asked",
30 | "NEVER run `npm run dev` or `npm run build` unless explicitly asked"
31 | ],
32 | "todos": [
33 | "TypeScript check (ONLY if updated ts files): Verify no `any` used (pause and ask user if `any` seems required)",
34 | "Typecheck (ONLY if updated ts files): Read typecheck:watch output OR run `npm run typecheck` - verify passes",
35 | "Lint: Run `npm run lint:fix` - verify passes"
36 | ]
37 | }
38 | ],
39 | "afterCompact": [
40 | {
41 | "tag": "POST-COMPACT-RECOVERY",
42 | "header": "🚨 CONTEXT WIPED - MANDATORY SKILL RELOAD REQUIRED",
43 | "instructions": [
44 | "STOP. Context compaction has DELETED all previously loaded skills. You have FORGOTTEN everything.",
45 | "DO NOT proceed with any task until you complete skill reloading below",
46 | "DO NOT assume you remember any skills - they are ALL gone from memory",
47 | "YOUR IMMEDIATE ACTION: Complete the mandatory reload checklist below",
48 | "Skipping this = GUARANTEED FAILURE because you lost all behavioral patterns"
49 | ],
50 | "todos": [
51 | "Skill reload after compaction: (1) Check TodoWrite to identify what task you were working on; (2) List ALL available skills from context; (3) For EACH skill: Read its description and check for 'always load first' or foundational patterns - mark these ✓ IMMEDIATELY. Then ask 'Does this apply to my current task?' If YES or MIGHT or MAYBE = mark ✓. Only mark ✗ if DEFINITELY not related; (4) Use Skill tool to load ALL marked ✓ IN ONE PARALLEL CALL (REQUIRED - they were wiped) - do NOT load one skill then wait to see if it tells you to load others. Load ALL potentially relevant skills upfront; (5) ONLY after reloading skills, resume the task. CRITICAL: All previously loaded skills are GONE and MUST be reloaded. 'Might apply' = MUST load."
52 | ]
53 | }
54 | ]
55 | }
56 |
--------------------------------------------------------------------------------
/convex/polar/product.ts:
--------------------------------------------------------------------------------
1 | // ----------------------------------------
2 | // Product Management - Premium Subscription
3 | // ----------------------------------------
4 |
5 | import { z } from 'zod';
6 |
7 | const env = {
8 | POLAR_PRODUCT_PREMIUM: process.env.POLAR_PRODUCT_PREMIUM! || 'premium',
9 | } as const;
10 |
11 | // Simplified product schema for template
12 | export const polarProductSchema = z.object({
13 | createdAt: z.string(),
14 | description: z.string().nullable().optional(),
15 | isArchived: z.boolean(),
16 | isRecurring: z.boolean(),
17 | metadata: z.record(z.string(), z.any()).optional(),
18 | modifiedAt: z.string().nullable().optional(),
19 | name: z.string(),
20 | organizationId: z.string(),
21 | prices: z.array(
22 | z.object({
23 | id: z.string(),
24 | createdAt: z.string(),
25 | isArchived: z.boolean(),
26 | modifiedAt: z.string().nullable(),
27 | priceAmount: z.number().optional(),
28 | priceCurrency: z.string().optional(),
29 | productId: z.string(),
30 | recurringInterval: z.enum(['month', 'year']).nullable().optional(),
31 | type: z.string().optional(),
32 | })
33 | ),
34 | productId: z.string(),
35 | recurringInterval: z.enum(['month', 'year']).nullable().optional(),
36 | });
37 |
38 | export type PolarProduct = z.infer;
39 |
40 | // Single premium product configuration
41 | export const polarProducts: Record = {
42 | // Premium Subscription
43 | [env.POLAR_PRODUCT_PREMIUM]: {
44 | createdAt: '2024-01-01T00:00:00Z',
45 | description: 'Premium subscription with monthly credits',
46 | isArchived: false,
47 | isRecurring: true,
48 | metadata: {
49 | displayName: 'Premium',
50 | monthlyCredits: 2000, // Amount of credits included per month
51 | },
52 | modifiedAt: null,
53 | name: 'Premium',
54 | organizationId: 'org_polar',
55 | prices: [
56 | {
57 | id: 'price_premium_monthly',
58 | createdAt: '2024-01-01T00:00:00Z',
59 | isArchived: false,
60 | modifiedAt: null,
61 | priceAmount: 2000, // $20.00 in cents
62 | priceCurrency: 'USD',
63 | productId: env.POLAR_PRODUCT_PREMIUM,
64 | recurringInterval: 'month',
65 | type: 'recurring',
66 | },
67 | ],
68 | productId: env.POLAR_PRODUCT_PREMIUM,
69 | recurringInterval: 'month',
70 | },
71 | };
72 |
73 | // Helper functions
74 | export function getProduct(productId: string): PolarProduct | null {
75 | return polarProducts[productId] ?? null;
76 | }
77 |
78 | // Helper to get monthly credits from product
79 | export function productToCredits(productId: string): number {
80 | const product = getProduct(productId);
81 | // Use metadata field or default to price amount
82 | return (
83 | (product?.metadata?.monthlyCredits as number) ??
84 | product?.prices[0]?.priceAmount ??
85 | 0
86 | );
87 | }
88 |
89 | // ----------------------------------------
90 | // Plan Types for UI Components
91 | // ----------------------------------------
92 |
93 | export const SubscriptionPlan = {
94 | Free: 'free',
95 | Premium: 'premium',
96 | } as const;
97 |
98 | export type SubscriptionPlan =
99 | (typeof SubscriptionPlan)[keyof typeof SubscriptionPlan];
100 |
101 | export const productToPlan = (productId?: string) => {
102 | if (productId === env.POLAR_PRODUCT_PREMIUM) {
103 | return 'premium';
104 | }
105 | };
106 |
107 | // Simplified plan details for UI
108 | export type PlanDetails = {
109 | key: SubscriptionPlan;
110 | credits: number;
111 | description: string;
112 | name: string;
113 | price: number; // Monthly price in dollars
114 | productId?: string;
115 | };
116 |
117 | // Build plans for UI
118 | export const PLANS: Record = {
119 | [SubscriptionPlan.Free]: {
120 | key: SubscriptionPlan.Free,
121 | credits: 0,
122 | description: 'Get started for free',
123 | name: 'Free',
124 | price: 0,
125 | },
126 | [SubscriptionPlan.Premium]: {
127 | key: SubscriptionPlan.Premium,
128 | credits: productToCredits(env.POLAR_PRODUCT_PREMIUM),
129 | description: 'Premium features with monthly credits',
130 | name: 'Premium',
131 | price: 20, // $20/month
132 | productId: env.POLAR_PRODUCT_PREMIUM,
133 | },
134 | };
135 |
136 | // Constants
137 | export const FREE_PLAN_CREDITS = 0; // No free credits by default (can be customized)
138 |
--------------------------------------------------------------------------------
/src/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { type Label as LabelPrimitive, Slot as SlotPrimitive } from 'radix-ui';
4 |
5 | import * as React from 'react';
6 | import {
7 | Controller,
8 | type ControllerProps,
9 | type FieldPath,
10 | type FieldValues,
11 | FormProvider,
12 | useFormContext,
13 | useFormState,
14 | } from 'react-hook-form';
15 | import { Label } from '@/components/ui/label';
16 | import { cn } from '@/lib/utils';
17 |
18 | const Form = FormProvider;
19 |
20 | type FormFieldContextValue<
21 | TFieldValues extends FieldValues = FieldValues,
22 | TName extends FieldPath = FieldPath,
23 | > = {
24 | name: TName;
25 | };
26 |
27 | const FormFieldContext = React.createContext(
28 | {} as FormFieldContextValue
29 | );
30 |
31 | const FormField = <
32 | TFieldValues extends FieldValues = FieldValues,
33 | TName extends FieldPath = FieldPath,
34 | >({
35 | ...props
36 | }: ControllerProps) => (
37 |
38 |
39 |
40 | );
41 |
42 | const useFormField = () => {
43 | const fieldContext = React.useContext(FormFieldContext);
44 | const itemContext = React.useContext(FormItemContext);
45 | const { getFieldState } = useFormContext();
46 | const formState = useFormState({ name: fieldContext.name });
47 | const fieldState = getFieldState(fieldContext.name, formState);
48 |
49 | if (!fieldContext) {
50 | throw new Error('useFormField should be used within ');
51 | }
52 |
53 | const { id } = itemContext;
54 |
55 | return {
56 | id,
57 | name: fieldContext.name,
58 | formItemId: `${id}-form-item`,
59 | formDescriptionId: `${id}-form-item-description`,
60 | formMessageId: `${id}-form-item-message`,
61 | ...fieldState,
62 | };
63 | };
64 |
65 | type FormItemContextValue = {
66 | id: string;
67 | };
68 |
69 | const FormItemContext = React.createContext(
70 | {} as FormItemContextValue
71 | );
72 |
73 | function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
74 | const id = React.useId();
75 |
76 | return (
77 |
78 |
83 |
84 | );
85 | }
86 |
87 | function FormLabel({
88 | className,
89 | ...props
90 | }: React.ComponentProps) {
91 | const { error, formItemId } = useFormField();
92 |
93 | return (
94 |
101 | );
102 | }
103 |
104 | function FormControl({
105 | ...props
106 | }: React.ComponentProps) {
107 | const { error, formItemId, formDescriptionId, formMessageId } =
108 | useFormField();
109 |
110 | return (
111 |
120 | );
121 | }
122 |
123 | function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
124 | const { formDescriptionId } = useFormField();
125 |
126 | return (
127 |
133 | );
134 | }
135 |
136 | function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
137 | const { error, formMessageId } = useFormField();
138 | const body = error ? String(error?.message ?? '') : props.children;
139 |
140 | if (!body) {
141 | return null;
142 | }
143 |
144 | return (
145 |
151 | {body}
152 |
153 | );
154 | }
155 |
156 | export {
157 | useFormField,
158 | Form,
159 | FormItem,
160 | FormLabel,
161 | FormControl,
162 | FormDescription,
163 | FormMessage,
164 | FormField,
165 | };
166 |
--------------------------------------------------------------------------------
/src/components/ui/alert-dialog.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { AlertDialog as AlertDialogPrimitive } from 'radix-ui';
4 | import type * as React from 'react';
5 | import { buttonVariants } from '@/components/ui/button';
6 | import { cn } from '@/lib/utils';
7 |
8 | function AlertDialog({
9 | ...props
10 | }: React.ComponentProps) {
11 | return ;
12 | }
13 |
14 | function AlertDialogTrigger({
15 | ...props
16 | }: React.ComponentProps) {
17 | return (
18 |
19 | );
20 | }
21 |
22 | function AlertDialogPortal({
23 | ...props
24 | }: React.ComponentProps) {
25 | return (
26 |
27 | );
28 | }
29 |
30 | function AlertDialogOverlay({
31 | className,
32 | ...props
33 | }: React.ComponentProps) {
34 | return (
35 |
43 | );
44 | }
45 |
46 | function AlertDialogContent({
47 | className,
48 | ...props
49 | }: React.ComponentProps) {
50 | return (
51 |
52 |
53 |
61 |
62 | );
63 | }
64 |
65 | function AlertDialogHeader({
66 | className,
67 | ...props
68 | }: React.ComponentProps<'div'>) {
69 | return (
70 |
75 | );
76 | }
77 |
78 | function AlertDialogFooter({
79 | className,
80 | ...props
81 | }: React.ComponentProps<'div'>) {
82 | return (
83 |
91 | );
92 | }
93 |
94 | function AlertDialogTitle({
95 | className,
96 | ...props
97 | }: React.ComponentProps) {
98 | return (
99 |
104 | );
105 | }
106 |
107 | function AlertDialogDescription({
108 | className,
109 | ...props
110 | }: React.ComponentProps) {
111 | return (
112 |
117 | );
118 | }
119 |
120 | function AlertDialogAction({
121 | className,
122 | ...props
123 | }: React.ComponentProps) {
124 | return (
125 |
129 | );
130 | }
131 |
132 | function AlertDialogCancel({
133 | className,
134 | ...props
135 | }: React.ComponentProps) {
136 | return (
137 |
141 | );
142 | }
143 |
144 | export {
145 | AlertDialog,
146 | AlertDialogPortal,
147 | AlertDialogOverlay,
148 | AlertDialogTrigger,
149 | AlertDialogContent,
150 | AlertDialogHeader,
151 | AlertDialogFooter,
152 | AlertDialogTitle,
153 | AlertDialogDescription,
154 | AlertDialogAction,
155 | AlertDialogCancel,
156 | };
157 |
--------------------------------------------------------------------------------
/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { XIcon } from 'lucide-react';
4 | import { Dialog as DialogPrimitive } from 'radix-ui';
5 | import type * as React from 'react';
6 |
7 | import { cn } from '@/lib/utils';
8 |
9 | function Dialog({
10 | ...props
11 | }: React.ComponentProps) {
12 | return ;
13 | }
14 |
15 | function DialogTrigger({
16 | ...props
17 | }: React.ComponentProps) {
18 | return ;
19 | }
20 |
21 | function DialogPortal({
22 | ...props
23 | }: React.ComponentProps) {
24 | return ;
25 | }
26 |
27 | function DialogClose({
28 | ...props
29 | }: React.ComponentProps) {
30 | return ;
31 | }
32 |
33 | function DialogOverlay({
34 | className,
35 | ...props
36 | }: React.ComponentProps) {
37 | return (
38 |
46 | );
47 | }
48 |
49 | function DialogContent({
50 | className,
51 | children,
52 | showCloseButton = true,
53 | ...props
54 | }: React.ComponentProps & {
55 | showCloseButton?: boolean;
56 | }) {
57 | return (
58 |
59 |
60 |
68 | {children}
69 | {showCloseButton && (
70 |
74 |
75 | Close
76 |
77 | )}
78 |
79 |
80 | );
81 | }
82 |
83 | function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
84 | return (
85 |
90 | );
91 | }
92 |
93 | function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
94 | return (
95 |
103 | );
104 | }
105 |
106 | function DialogTitle({
107 | className,
108 | ...props
109 | }: React.ComponentProps) {
110 | return (
111 |
116 | );
117 | }
118 |
119 | function DialogDescription({
120 | className,
121 | ...props
122 | }: React.ComponentProps) {
123 | return (
124 |
129 | );
130 | }
131 |
132 | export {
133 | Dialog,
134 | DialogClose,
135 | DialogContent,
136 | DialogDescription,
137 | DialogFooter,
138 | DialogHeader,
139 | DialogOverlay,
140 | DialogPortal,
141 | DialogTitle,
142 | DialogTrigger,
143 | };
144 |
--------------------------------------------------------------------------------
/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 | @import "tw-animate-css";
3 |
4 | @custom-variant dark (&:is(.dark *));
5 |
6 | :root {
7 | --sidebar: oklch(0.985 0 0);
8 | --sidebar-foreground: oklch(0.145 0 0);
9 | --sidebar-primary: oklch(0.205 0 0);
10 | --sidebar-primary-foreground: oklch(0.985 0 0);
11 | --sidebar-accent: oklch(0.97 0 0);
12 | --sidebar-accent-foreground: oklch(0.205 0 0);
13 | --sidebar-border: oklch(0.922 0 0);
14 | --sidebar-ring: oklch(0.708 0 0);
15 | --radius: 0.625rem;
16 | --background: oklch(1 0 0);
17 | --foreground: oklch(0.145 0 0);
18 | --card: oklch(1 0 0);
19 | --card-foreground: oklch(0.145 0 0);
20 | --popover: oklch(1 0 0);
21 | --popover-foreground: oklch(0.145 0 0);
22 | --primary: oklch(0.205 0 0);
23 | --primary-foreground: oklch(0.985 0 0);
24 | --secondary: oklch(0.97 0 0);
25 | --secondary-foreground: oklch(0.205 0 0);
26 | --muted: oklch(0.97 0 0);
27 | --muted-foreground: oklch(0.556 0 0);
28 | --accent: oklch(0.97 0 0);
29 | --accent-foreground: oklch(0.205 0 0);
30 | --destructive: oklch(0.577 0.245 27.325);
31 | --border: oklch(0.922 0 0);
32 | --input: oklch(0.922 0 0);
33 | --ring: oklch(0.708 0 0);
34 | --chart-1: oklch(0.646 0.222 41.116);
35 | --chart-2: oklch(0.6 0.118 184.704);
36 | --chart-3: oklch(0.398 0.07 227.392);
37 | --chart-4: oklch(0.828 0.189 84.429);
38 | --chart-5: oklch(0.769 0.188 70.08);
39 | }
40 |
41 | @theme inline {
42 | --color-background: var(--background);
43 | --color-foreground: var(--foreground);
44 | --font-sans: var(--font-geist-sans);
45 | --font-mono: var(--font-geist-mono);
46 | --color-sidebar-ring: var(--sidebar-ring);
47 | --color-sidebar-border: var(--sidebar-border);
48 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
49 | --color-sidebar-accent: var(--sidebar-accent);
50 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
51 | --color-sidebar-primary: var(--sidebar-primary);
52 | --color-sidebar-foreground: var(--sidebar-foreground);
53 | --color-sidebar: var(--sidebar);
54 | --color-chart-5: var(--chart-5);
55 | --color-chart-4: var(--chart-4);
56 | --color-chart-3: var(--chart-3);
57 | --color-chart-2: var(--chart-2);
58 | --color-chart-1: var(--chart-1);
59 | --color-ring: var(--ring);
60 | --color-input: var(--input);
61 | --color-border: var(--border);
62 | --color-destructive: var(--destructive);
63 | --color-accent-foreground: var(--accent-foreground);
64 | --color-accent: var(--accent);
65 | --color-muted-foreground: var(--muted-foreground);
66 | --color-muted: var(--muted);
67 | --color-secondary-foreground: var(--secondary-foreground);
68 | --color-secondary: var(--secondary);
69 | --color-primary-foreground: var(--primary-foreground);
70 | --color-primary: var(--primary);
71 | --color-popover-foreground: var(--popover-foreground);
72 | --color-popover: var(--popover);
73 | --color-card-foreground: var(--card-foreground);
74 | --color-card: var(--card);
75 | --radius-sm: calc(var(--radius) - 4px);
76 | --radius-md: calc(var(--radius) - 2px);
77 | --radius-lg: var(--radius);
78 | --radius-xl: calc(var(--radius) + 4px);
79 | }
80 |
81 | .dark {
82 | --sidebar: oklch(0.205 0 0);
83 | --sidebar-foreground: oklch(0.985 0 0);
84 | --sidebar-primary: oklch(0.488 0.243 264.376);
85 | --sidebar-primary-foreground: oklch(0.985 0 0);
86 | --sidebar-accent: oklch(0.269 0 0);
87 | --sidebar-accent-foreground: oklch(0.985 0 0);
88 | --sidebar-border: oklch(1 0 0 / 10%);
89 | --sidebar-ring: oklch(0.556 0 0);
90 | --background: oklch(0.145 0 0);
91 | --foreground: oklch(0.985 0 0);
92 | --card: oklch(0.205 0 0);
93 | --card-foreground: oklch(0.985 0 0);
94 | --popover: oklch(0.205 0 0);
95 | --popover-foreground: oklch(0.985 0 0);
96 | --primary: oklch(0.922 0 0);
97 | --primary-foreground: oklch(0.205 0 0);
98 | --secondary: oklch(0.269 0 0);
99 | --secondary-foreground: oklch(0.985 0 0);
100 | --muted: oklch(0.269 0 0);
101 | --muted-foreground: oklch(0.708 0 0);
102 | --accent: oklch(0.269 0 0);
103 | --accent-foreground: oklch(0.985 0 0);
104 | --destructive: oklch(0.704 0.191 22.216);
105 | --border: oklch(1 0 0 / 10%);
106 | --input: oklch(1 0 0 / 15%);
107 | --ring: oklch(0.556 0 0);
108 | --chart-1: oklch(0.488 0.243 264.376);
109 | --chart-2: oklch(0.696 0.17 162.48);
110 | --chart-3: oklch(0.769 0.188 70.08);
111 | --chart-4: oklch(0.627 0.265 303.9);
112 | --chart-5: oklch(0.645 0.246 16.439);
113 | }
114 |
115 | @layer base {
116 | * {
117 | @apply border-border outline-ring/50;
118 | }
119 | body {
120 | @apply bg-background text-foreground;
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/convex/authSchema.ts:
--------------------------------------------------------------------------------
1 | // eslint-disable
2 | // This file is auto-generated. Do not edit this file manually.
3 | // To regenerate the schema, run:
4 | // `npx @better-auth/cli generate --output authSchema.ts -y`
5 |
6 | import { defineSchema, defineTable } from 'convex/server';
7 | import { v } from 'convex/values';
8 |
9 | export const tables = {
10 | user: defineTable({
11 | name: v.string(),
12 | email: v.string(),
13 | emailVerified: v.boolean(),
14 | image: v.optional(v.union(v.null(), v.string())),
15 | createdAt: v.number(),
16 | updatedAt: v.number(),
17 | role: v.optional(v.union(v.null(), v.string())),
18 | banned: v.optional(v.union(v.null(), v.boolean())),
19 | banReason: v.optional(v.union(v.null(), v.string())),
20 | banExpires: v.optional(v.union(v.null(), v.number())),
21 | userId: v.optional(v.union(v.null(), v.string())),
22 | bio: v.optional(v.union(v.null(), v.string())),
23 | firstName: v.optional(v.union(v.null(), v.string())),
24 | github: v.optional(v.union(v.null(), v.string())),
25 | lastName: v.optional(v.union(v.null(), v.string())),
26 | linkedin: v.optional(v.union(v.null(), v.string())),
27 | location: v.optional(v.union(v.null(), v.string())),
28 | username: v.optional(v.union(v.null(), v.string())),
29 | website: v.optional(v.union(v.null(), v.string())),
30 | x: v.optional(v.union(v.null(), v.string())),
31 | })
32 | .index('email_name', ['email', 'name'])
33 | .index('name', ['name'])
34 | .index('userId', ['userId']),
35 | session: defineTable({
36 | expiresAt: v.number(),
37 | token: v.string(),
38 | createdAt: v.number(),
39 | updatedAt: v.number(),
40 | ipAddress: v.optional(v.union(v.null(), v.string())),
41 | userAgent: v.optional(v.union(v.null(), v.string())),
42 | userId: v.string(),
43 | impersonatedBy: v.optional(v.union(v.null(), v.string())),
44 | activeOrganizationId: v.optional(v.union(v.null(), v.string())),
45 | })
46 | .index('expiresAt', ['expiresAt'])
47 | .index('expiresAt_userId', ['expiresAt', 'userId'])
48 | .index('token', ['token'])
49 | .index('userId', ['userId']),
50 | account: defineTable({
51 | accountId: v.string(),
52 | providerId: v.string(),
53 | userId: v.string(),
54 | accessToken: v.optional(v.union(v.null(), v.string())),
55 | refreshToken: v.optional(v.union(v.null(), v.string())),
56 | idToken: v.optional(v.union(v.null(), v.string())),
57 | accessTokenExpiresAt: v.optional(v.union(v.null(), v.number())),
58 | refreshTokenExpiresAt: v.optional(v.union(v.null(), v.number())),
59 | scope: v.optional(v.union(v.null(), v.string())),
60 | password: v.optional(v.union(v.null(), v.string())),
61 | createdAt: v.number(),
62 | updatedAt: v.number(),
63 | })
64 | .index('accountId', ['accountId'])
65 | .index('accountId_providerId', ['accountId', 'providerId'])
66 | .index('providerId_userId', ['providerId', 'userId'])
67 | .index('userId', ['userId']),
68 | verification: defineTable({
69 | identifier: v.string(),
70 | value: v.string(),
71 | expiresAt: v.number(),
72 | createdAt: v.number(),
73 | updatedAt: v.number(),
74 | })
75 | .index('expiresAt', ['expiresAt'])
76 | .index('identifier', ['identifier']),
77 | organization: defineTable({
78 | name: v.string(),
79 | slug: v.optional(v.union(v.null(), v.string())),
80 | logo: v.optional(v.union(v.null(), v.string())),
81 | createdAt: v.number(),
82 | metadata: v.optional(v.union(v.null(), v.string())),
83 | monthlyCredits: v.number(),
84 | })
85 | .index('name', ['name'])
86 | .index('slug', ['slug']),
87 | member: defineTable({
88 | organizationId: v.string(),
89 | userId: v.string(),
90 | role: v.string(),
91 | createdAt: v.number(),
92 | })
93 | .index('organizationId_userId', ['organizationId', 'userId'])
94 | .index('userId', ['userId'])
95 | .index('role', ['role']),
96 | invitation: defineTable({
97 | organizationId: v.string(),
98 | email: v.string(),
99 | role: v.optional(v.union(v.null(), v.string())),
100 | status: v.string(),
101 | expiresAt: v.number(),
102 | inviterId: v.string(),
103 | })
104 | .index('email_organizationId_status', ['email', 'organizationId', 'status'])
105 | .index('organizationId_status', ['organizationId', 'status'])
106 | .index('role', ['role'])
107 | .index('status', ['status'])
108 | .index('inviterId', ['inviterId']),
109 | jwks: defineTable({
110 | publicKey: v.string(),
111 | privateKey: v.string(),
112 | createdAt: v.number(),
113 | }),
114 | };
115 |
116 | const schema = defineSchema(tables);
117 |
118 | export default schema;
119 |
--------------------------------------------------------------------------------
/src/components/ui/sheet.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { XIcon } from 'lucide-react';
4 | import { Dialog as SheetPrimitive } from 'radix-ui';
5 | import type * as React from 'react';
6 |
7 | import { cn } from '@/lib/utils';
8 |
9 | function Sheet({ ...props }: React.ComponentProps) {
10 | return ;
11 | }
12 |
13 | function SheetTrigger({
14 | ...props
15 | }: React.ComponentProps) {
16 | return ;
17 | }
18 |
19 | function SheetClose({
20 | ...props
21 | }: React.ComponentProps) {
22 | return ;
23 | }
24 |
25 | function SheetPortal({
26 | ...props
27 | }: React.ComponentProps) {
28 | return ;
29 | }
30 |
31 | function SheetOverlay({
32 | className,
33 | ...props
34 | }: React.ComponentProps) {
35 | return (
36 |
44 | );
45 | }
46 |
47 | function SheetContent({
48 | className,
49 | children,
50 | side = 'right',
51 | ...props
52 | }: React.ComponentProps & {
53 | side?: 'top' | 'right' | 'bottom' | 'left';
54 | }) {
55 | return (
56 |
57 |
58 |
74 | {children}
75 |
76 |
77 | Close
78 |
79 |
80 |
81 | );
82 | }
83 |
84 | function SheetHeader({ className, ...props }: React.ComponentProps<'div'>) {
85 | return (
86 |
91 | );
92 | }
93 |
94 | function SheetFooter({ className, ...props }: React.ComponentProps<'div'>) {
95 | return (
96 |
101 | );
102 | }
103 |
104 | function SheetTitle({
105 | className,
106 | ...props
107 | }: React.ComponentProps) {
108 | return (
109 |
114 | );
115 | }
116 |
117 | function SheetDescription({
118 | className,
119 | ...props
120 | }: React.ComponentProps) {
121 | return (
122 |
127 | );
128 | }
129 |
130 | export {
131 | Sheet,
132 | SheetTrigger,
133 | SheetClose,
134 | SheetContent,
135 | SheetHeader,
136 | SheetFooter,
137 | SheetTitle,
138 | SheetDescription,
139 | };
140 |
--------------------------------------------------------------------------------
/src/components/ui/drawer.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import type * as React from 'react';
4 | import { Drawer as DrawerPrimitive } from 'vaul';
5 |
6 | import { cn } from '@/lib/utils';
7 |
8 | function Drawer({
9 | ...props
10 | }: React.ComponentProps) {
11 | return ;
12 | }
13 |
14 | function DrawerTrigger({
15 | ...props
16 | }: React.ComponentProps) {
17 | return ;
18 | }
19 |
20 | function DrawerPortal({
21 | ...props
22 | }: React.ComponentProps) {
23 | return ;
24 | }
25 |
26 | function DrawerClose({
27 | ...props
28 | }: React.ComponentProps) {
29 | return ;
30 | }
31 |
32 | function DrawerOverlay({
33 | className,
34 | ...props
35 | }: React.ComponentProps) {
36 | return (
37 |
45 | );
46 | }
47 |
48 | function DrawerContent({
49 | className,
50 | children,
51 | ...props
52 | }: React.ComponentProps) {
53 | return (
54 |
55 |
56 |
68 |
69 | {children}
70 |
71 |
72 | );
73 | }
74 |
75 | function DrawerHeader({ className, ...props }: React.ComponentProps<'div'>) {
76 | return (
77 |
85 | );
86 | }
87 |
88 | function DrawerFooter({ className, ...props }: React.ComponentProps<'div'>) {
89 | return (
90 |
95 | );
96 | }
97 |
98 | function DrawerTitle({
99 | className,
100 | ...props
101 | }: React.ComponentProps) {
102 | return (
103 |
108 | );
109 | }
110 |
111 | function DrawerDescription({
112 | className,
113 | ...props
114 | }: React.ComponentProps) {
115 | return (
116 |
121 | );
122 | }
123 |
124 | export {
125 | Drawer,
126 | DrawerPortal,
127 | DrawerOverlay,
128 | DrawerTrigger,
129 | DrawerClose,
130 | DrawerContent,
131 | DrawerHeader,
132 | DrawerFooter,
133 | DrawerTitle,
134 | DrawerDescription,
135 | };
136 |
--------------------------------------------------------------------------------
/.claude/rules/jotai-x.mdc:
--------------------------------------------------------------------------------
1 | ---
2 | description: Use when implementing global state management with Jotai X - provides atomic store patterns, type-safe hooks, derived atoms, scoped providers, and state hydration
3 | alwaysApply: false
4 | ---
5 |
6 | # Jotai X State Management
7 |
8 | ## Context
9 |
10 | - When implementing state management with Jotai
11 | - When creating or modifying atom stores
12 | - When accessing state in React components
13 |
14 | ## Requirements
15 |
16 | - Use `createAtomStore` to define state with type-safe hooks
17 | - Follow the naming convention `useValue`, `useStore`, etc.
18 | - Use the appropriate hook for each use case:
19 | - `useValue(key)` for reading values
20 | - `useStore().set(key, value)` for writing values
21 | - `useState(key)` for both reading and writing
22 | - Use the `extend` option for derived atoms
23 | - Use scoped providers when needed for nested state
24 | - Use `initialValues` for hydration and dynamic values for controlled state
25 |
26 | ## Examples
27 |
28 |
29 | // Creating a store with type-safe hooks
30 | const { useAppStore, useAppValue, useAppSet, useAppState, AppProvider } =
31 | createAtomStore(
32 | {
33 | name: 'JotaiX',
34 | stars: 0,
35 | },
36 | {
37 | name: 'app',
38 | }
39 | );
40 |
41 | // Reading values
42 | const name = useAppValue('name');
43 |
44 | // Writing values
45 | const store = useAppStore();
46 | store.set('stars', (s) => s + 1);
47 |
48 | // Both reading and writing
49 | const [name, setName] = useAppState('name');
50 |
51 |
52 |
53 | // Don't create atoms manually when using Jotai X
54 | const nameAtom = atom('JotaiX');
55 | const starsAtom = atom(0);
56 |
57 | // Don't use raw Jotai hooks directly
58 | const name = useAtomValue(nameAtom);
59 | const setStars = useSetAtom(starsAtom);
60 |
61 |
62 | ## Derived Atoms
63 |
64 |
65 | // Using extend for derived atoms
66 | const { useUserValue } = createAtomStore(
67 | {
68 | name: 'Alice',
69 | },
70 | {
71 | name: 'user',
72 | extend: (atoms) => ({
73 | intro: atom((get) => `My name is ${get(atoms.name)}`),
74 | }),
75 | }
76 | );
77 |
78 | // Accessing derived values
79 | const intro = useUserValue('intro');
80 |
81 |
82 | ## Provider Usage
83 |
84 |
85 | // Using the provider with initial values and controlled state
86 |
94 |
95 |
96 |
97 |
98 | # Global App Store Usage
99 |
100 | ## Context
101 |
102 | - When state needs to be shared across multiple pages (AppProvider is rendered at layout level)
103 | - When state needs to persist between sessions
104 | - When prop drilling would make component hierarchy complex
105 |
106 | ## Requirements
107 |
108 | - Import `useAppStore` from the app provider
109 | - Use the appropriate hook method for each use case:
110 | - `useAppStore().useValue()` for reading values
111 | - `useAppStore().useSet()` for writing values
112 | - `useAppStore().useState()` for both reading and writing
113 | - Add new values to the store by updating the object in `createAtomStore`
114 | - Use `atomWithCookie` for values that should persist between sessions
115 |
116 | ## Examples
117 |
118 |
119 | // Importing the app store hook
120 | import { useAppStore } from '@/components/providers/app-provider';
121 |
122 | // Reading values
123 | const count = useAppStore().useCountValue();
124 |
125 | // Writing values
126 | const setCount = useAppStore().useSetCount();
127 | setCount(count + 1);
128 |
129 | // Both reading and writing
130 | const [count, setCount] = useAppStore().useCountState();
131 |
132 |
133 |
134 | // Don't use local state for values that should be shared
135 | const [count, setCount] = useState(0);
136 |
137 | // Don't pass shared state through multiple levels of props
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 | ## Adding New Values
146 |
147 |
148 | // Adding new values to the store
149 | export const { AppProvider, useAppStore } = createAtomStore(
150 | {
151 | count: 0,
152 | theme: 'light',
153 | user: { name: '', email: '' },
154 | // Persisted value
155 | preferences: atomWithCookie('userPrefs', { notifications: true }),
156 | },
157 | {
158 | name: 'app',
159 | }
160 | );
161 |
162 |
163 | ## Provider Usage
164 |
165 |
166 | // Wrapping the application with the provider
167 | function App() {
168 | return (
169 |
170 |
171 |
172 | );
173 | }
174 |
175 |
--------------------------------------------------------------------------------
/src/components/todos/tag-picker.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { api } from '@convex/_generated/api';
4 | import type { Id } from '@convex/_generated/dataModel';
5 | import { Check, Tags, X } from 'lucide-react';
6 | import { useState } from 'react';
7 | import { Badge } from '@/components/ui/badge';
8 | import { Button } from '@/components/ui/button';
9 | import {
10 | Command,
11 | CommandEmpty,
12 | CommandGroup,
13 | CommandInput,
14 | CommandItem,
15 | CommandList,
16 | } from '@/components/ui/command';
17 | import {
18 | Popover,
19 | PopoverContent,
20 | PopoverTrigger,
21 | } from '@/components/ui/popover';
22 | import { useAuthQuery } from '@/lib/convex/hooks';
23 | import { cn } from '@/lib/utils';
24 |
25 | type TagPickerProps = {
26 | selectedTagIds: Id<'tags'>[];
27 | onTagsChange: (tagIds: Id<'tags'>[]) => void;
28 | disabled?: boolean;
29 | };
30 |
31 | export function TagPicker({
32 | selectedTagIds,
33 | onTagsChange,
34 | disabled,
35 | }: TagPickerProps) {
36 | const [open, setOpen] = useState(false);
37 | const { data: tags = [] } = useAuthQuery(api.tags.list, {});
38 |
39 | const selectedTags = tags.filter((tag) => selectedTagIds.includes(tag._id));
40 |
41 | const toggleTag = (tagId: Id<'tags'>) => {
42 | if (selectedTagIds.includes(tagId)) {
43 | onTagsChange(selectedTagIds.filter((id) => id !== tagId));
44 | } else {
45 | onTagsChange([...selectedTagIds, tagId]);
46 | }
47 | };
48 |
49 | const removeTag = (tagId: Id<'tags'>) => {
50 | onTagsChange(selectedTagIds.filter((id) => id !== tagId));
51 | };
52 |
53 | return (
54 |
55 |
56 |
57 |
72 |
73 |
74 |
75 |
76 |
77 | No tags found.
78 |
79 | {tags.map((tag) => (
80 | toggleTag(tag._id)}
83 | value={tag.name}
84 | >
85 |
93 |
94 |
95 |
96 |
100 | {tag.name}
101 | {tag.usageCount > 0 && (
102 |
103 | ({tag.usageCount})
104 |
105 | )}
106 |
107 |
108 | ))}
109 |
110 |
111 |
112 |
113 |
114 |
115 | {selectedTags.length > 0 && (
116 |
117 | {selectedTags.map((tag) => (
118 |
128 | {tag.name}
129 |
137 |
138 | ))}
139 |
140 | )}
141 |
142 | );
143 | }
144 |
--------------------------------------------------------------------------------
/src/lib/convex/components/login-form.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import type { LucideProps } from 'lucide-react';
4 |
5 | import Link from 'next/link';
6 | import { usePathname, useSearchParams } from 'next/navigation';
7 | import { useQueryState } from 'nuqs';
8 | import type * as React from 'react';
9 | import { Button } from '@/components/ui/button';
10 | import { env } from '@/env';
11 | import { signIn } from '@/lib/convex/auth-client';
12 | import { cn } from '@/lib/utils';
13 |
14 | const authRoutes = ['/login', '/signup'];
15 |
16 | export function SignForm() {
17 | let [callbackUrl] = useQueryState('callbackUrl');
18 | const pathname = usePathname();
19 | const searchParams = useSearchParams();
20 |
21 | if (!(callbackUrl || authRoutes.includes(pathname))) {
22 | callbackUrl = encodeURL(pathname, searchParams.toString());
23 | }
24 |
25 | const handleGoogleSignIn = () => {
26 | const callback = callbackUrl ? decodeURIComponent(callbackUrl) : '/';
27 |
28 | signIn.social({
29 | callbackURL: `${env.NEXT_PUBLIC_SITE_URL}${callback}`,
30 | provider: 'google',
31 | });
32 | };
33 |
34 | const handleGithubSignIn = () => {
35 | const callback = callbackUrl ? decodeURIComponent(callbackUrl) : '/';
36 |
37 | signIn.social({
38 | callbackURL: `${env.NEXT_PUBLIC_SITE_URL}${callback}`,
39 | provider: 'github',
40 | });
41 | };
42 |
43 | return (
44 |
45 |
54 |
55 |
64 |
65 |
66 | By continuing, you agree to our{' '}
67 |
68 | Terms of Service
69 | {' '}
70 | and acknowledge you've read our{' '}
71 |
72 | Privacy Policy
73 |
74 | .
75 |
76 |
77 | );
78 | }
79 |
80 | const encodeURL = (pathname: string, search?: string) => {
81 | let callbackUrl = pathname;
82 |
83 | let adjustedSearch = search;
84 |
85 | if (search) {
86 | if (!search.startsWith('?')) {
87 | adjustedSearch = `?${search}`;
88 | }
89 |
90 | callbackUrl += adjustedSearch;
91 | }
92 |
93 | return encodeURIComponent(callbackUrl);
94 | };
95 |
96 | function GoogleIcon(props: LucideProps) {
97 | return (
98 |
130 | );
131 | }
132 |
133 | function GitHubIcon(props: React.SVGProps) {
134 | return (
135 |
142 | );
143 | }
144 |
--------------------------------------------------------------------------------
|