diff --git a/.env.example b/.env.example index 5871316..e93bd03 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,13 @@ DATABASE_URL="postgresql://postgres:password@localhost:5432/trackit-saas" BETTER_AUTH_SECRET="your_secret_here" BETTER_AUTH_URL="http://localhost:3000" +# Google OAuth (optional — enables "Continue with Google") +GOOGLE_CLIENT_ID="your_google_client_id" +GOOGLE_CLIENT_SECRET="your_google_client_secret" + +# Better Auth Infrastructure (optional — dashboard + security) +BETTER_AUTH_API_KEY="your_better_auth_api_key" + # ────────────────────────────────────────────── # Email - Primary: Resend (optional, needs paid plan) # ────────────────────────────────────────────── @@ -41,6 +48,7 @@ SMTP_PORT=587 SMTP_USER="you@gmail.com" SMTP_PASS="your_app_password" SMTP_FROM="Trackit " +ADMIN_EMAIL="admin@yourdomain.com" # ────────────────────────────────────────────── # AI (Google Gemini) — server-side only @@ -76,3 +84,10 @@ NEXT_PUBLIC_BETTER_STACK_LOG_LEVEL="debug" # ────────────────────────────────────────────── UPSTASH_REDIS_REST_URL="your_upstash_redis_rest_url" UPSTASH_REDIS_REST_TOKEN="your_upstash_redis_rest_token" + +# ────────────────────────────────────────────── +# Field-Level PII Encryption (optional) +# ────────────────────────────────────────────── +# 256-bit hex key for AES-256-GCM encryption of PII fields. +# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +FIELD_ENCRYPTION_KEY="" diff --git a/README.md b/README.md index 16dc995..c970167 100644 --- a/README.md +++ b/README.md @@ -51,8 +51,8 @@ A personal finance SaaS for bank account management, transaction tracking, hiera ### 1. Clone and Install ```bash -git clone -cd trackit-saas +git clone https://github.com/hasnaintypes/trackit-app.git +cd trackit-app pnpm install ``` diff --git a/middleware.ts b/middleware.ts index 169fbac..07a4bd6 100644 --- a/middleware.ts +++ b/middleware.ts @@ -8,13 +8,14 @@ const AUTH_PATHS = [ "/sign-in", "/sign-up", "/reset-password", + "/forgot-password", "/verify-success", + "/two-factor", ]; // Public pages under src/app/(public) const PUBLIC_PATHS = [ "/", - "/about", "/blog", "/changelog", "/contact", diff --git a/next.config.js b/next.config.js index a383cfc..1e8dd67 100644 --- a/next.config.js +++ b/next.config.js @@ -51,6 +51,11 @@ config.images = { hostname: "randomuser.me", pathname: "/api/portraits/**", }, + { + protocol: "https", + hostname: "www.comarch.com", + pathname: "/**", + }, ], }; diff --git a/package.json b/package.json index a179b7b..bf046b3 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "prepare": "husky install" }, "dependencies": { + "@better-auth/infra": "^0.1.13", "@google/generative-ai": "^0.24.1", "@hookform/resolvers": "^5.2.2", "@logtail/next": "^0.2.1", @@ -68,7 +69,7 @@ "@tsparticles/slim": "^3.9.1", "@upstash/ratelimit": "^2.0.8", "@upstash/redis": "^1.37.0", - "better-auth": "^1.3.27", + "better-auth": "^1.5.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -81,6 +82,7 @@ "hast-util-sanitize": "^5.0.2", "inngest": "^3.18.0", "input-otp": "^1.4.2", + "jspdf": "^4.2.1", "lucide-react": "^0.545.0", "motion": "^12.23.24", "next": "15.5.9", @@ -88,6 +90,7 @@ "nodemailer": "^8.0.2", "pg": "^8.16.3", "prismjs": "^1.30.0", + "qrcode.react": "^4.2.0", "radix-ui": "^1.4.3", "react": "^19.2.4", "react-day-picker": "^9.11.3", @@ -106,7 +109,7 @@ "superjson": "^2.2.1", "tailwind-merge": "^3.3.1", "vaul": "^1.1.2", - "zod": "^3.24.2" + "zod": "^4.3.6" }, "devDependencies": { "@eslint/eslintrc": "^3.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22518bd..a6ea91f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@better-auth/infra': + specifier: ^0.1.13 + version: 0.1.13(edcf4d535d755d3529cd9f862f14cd61) '@google/generative-ai': specifier: ^0.24.1 version: 0.24.1 @@ -103,7 +106,7 @@ importers: version: 1.2.8(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@t3-oss/env-nextjs': specifier: ^0.12.0 - version: 0.12.0(typescript@5.9.3)(valibot@1.1.0(typescript@5.9.3))(zod@3.25.76) + version: 0.12.0(typescript@5.9.3)(valibot@1.1.0(typescript@5.9.3))(zod@4.3.6) '@tabler/icons-react': specifier: ^3.35.0 version: 3.35.0(react@19.2.4) @@ -138,8 +141,8 @@ importers: specifier: ^1.37.0 version: 1.37.0 better-auth: - specifier: ^1.3.27 - version: 1.3.27(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: ^1.5.6 + version: 1.5.6(@opentelemetry/api@1.9.0)(@prisma/client@7.0.0(prisma@7.0.0(@types/react@19.2.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(mysql2@3.15.3)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.16.3)(prisma@7.0.0(@types/react@19.2.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -172,10 +175,13 @@ importers: version: 5.0.2 inngest: specifier: ^3.18.0 - version: 3.47.0(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(hono@4.7.10)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(zod@3.25.76) + version: 3.47.0(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(hono@4.7.10)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(zod@4.3.6) input-otp: specifier: ^1.4.2 version: 1.4.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + jspdf: + specifier: ^4.2.1 + version: 4.2.1 lucide-react: specifier: ^0.545.0 version: 0.545.0(react@19.2.4) @@ -197,6 +203,9 @@ importers: prismjs: specifier: ^1.30.0 version: 1.30.0 + qrcode.react: + specifier: ^4.2.0 + version: 4.2.0(react@19.2.4) radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -252,8 +261,8 @@ importers: specifier: ^1.1.2 version: 1.1.2(@types/react-dom@19.2.1(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) zod: - specifier: ^3.24.2 - version: 3.25.76 + specifier: ^4.3.6 + version: 4.3.6 devDependencies: '@eslint/eslintrc': specifier: ^3.3.1 @@ -355,6 +364,10 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@authenio/xml-encryption@2.0.2': + resolution: {integrity: sha512-cTlrKttbrRHEw3W+0/I609A2Matj5JQaRvfLtEIGZvlN0RaPi+3ANsMeqAyCAVlH/lUIW2tmtBlSMni74lcXeg==} + engines: {node: '>=12'} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -367,14 +380,100 @@ packages: resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} engines: {node: '>=6.9.0'} - '@better-auth/core@1.3.27': - resolution: {integrity: sha512-3Sfdax6MQyronY+znx7bOsfQHI6m1SThvJWb0RDscFEAhfqLy95k1sl+/PgGyg0cwc2cUXoEiAOSqYdFYrg3vA==} + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + + '@better-auth/core@1.5.6': + resolution: {integrity: sha512-Ez9DZdIMFyxHremmoLz1emFPGNQomDC1jqqBPnZ6Ci+6TiGN3R9w/Y03cJn6I8r1ycKgOzeVMZtJ/erOZ27Gsw==} + peerDependencies: + '@better-auth/utils': 0.3.1 + '@better-fetch/fetch': 1.1.21 + '@cloudflare/workers-types': '>=4' + '@opentelemetry/api': ^1.9.0 + better-call: 1.3.2 + jose: ^6.1.0 + kysely: ^0.28.5 + nanostores: ^1.0.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + '@better-auth/drizzle-adapter@1.5.6': + resolution: {integrity: sha512-VfFFmaoFw3ug12SiSuIwzrMoHyIVmkMGWm9gZ4sXdYYVX4HboCL4m3fjzOhppcmK5OGatRuU+N1UX6wxCITcXw==} + peerDependencies: + '@better-auth/core': 1.5.6 + '@better-auth/utils': ^0.3.0 + drizzle-orm: '>=0.41.0' + peerDependenciesMeta: + drizzle-orm: + optional: true + + '@better-auth/infra@0.1.13': + resolution: {integrity: sha512-a5Ryo3OZVEHs+AmqfdhDzz9iQ6FPhNx+Rk/0J3AlpgBnJrlor26PQLE/LvPAXE4c4WlkLkWOmneKxa/f4dXKdA==} + peerDependencies: + '@better-auth/core': '>=1.4.0' + '@better-auth/sso': '>=1.4.0' + better-auth: '>=1.4.0' + zod: '>=4.1.12' + + '@better-auth/kysely-adapter@1.5.6': + resolution: {integrity: sha512-Fnf+h8WVKtw6lEOmVmiVVzDf3shJtM60AYf9XTnbdCeUd6MxN/KnaJZpkgtYnRs7a+nwtkVB+fg4lGETebGFXQ==} + peerDependencies: + '@better-auth/core': 1.5.6 + '@better-auth/utils': ^0.3.0 + kysely: ^0.27.0 || ^0.28.0 + peerDependenciesMeta: + kysely: + optional: true + + '@better-auth/memory-adapter@1.5.6': + resolution: {integrity: sha512-rS7ZsrIl5uvloUgNN0u9LOZJMMXnsZXVdUZ3MrTBKWM2KpoJjzPr9yN3Szyma5+0V7SltnzSGHPkYj2bEzzmlA==} + peerDependencies: + '@better-auth/core': 1.5.6 + '@better-auth/utils': ^0.3.0 - '@better-auth/utils@0.3.0': - resolution: {integrity: sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==} + '@better-auth/mongo-adapter@1.5.6': + resolution: {integrity: sha512-6+M3MS2mor8fTUV3EI1FBLP0cs6QfbN+Ovx9+XxR/GdfKIBoNFzmPEPRbdGt+ft6PvrITsUm+T70+kkHgVSP6w==} + peerDependencies: + '@better-auth/core': 1.5.6 + '@better-auth/utils': ^0.3.0 + mongodb: ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + mongodb: + optional: true - '@better-fetch/fetch@1.1.18': - resolution: {integrity: sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA==} + '@better-auth/prisma-adapter@1.5.6': + resolution: {integrity: sha512-UxY9vQJs1Tt+O+T2YQnseDMlWmUSQvFZSBb5YiFRg7zcm+TEzujh4iX2/csA0YiZptLheovIuVWTP9nriewEBA==} + peerDependencies: + '@better-auth/core': 1.5.6 + '@better-auth/utils': ^0.3.0 + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + '@prisma/client': + optional: true + prisma: + optional: true + + '@better-auth/sso@1.5.6': + resolution: {integrity: sha512-R4mC3Bj9Xy4pBz+XNjs4Z7gpukmAUc7mIgIhg0zmN7R7rjj8Uz9sBBRcf6b/+6fFyXe/mZ0f+Sc3uBaYt4hRaQ==} + peerDependencies: + '@better-auth/core': 1.5.6 + '@better-auth/utils': 0.3.1 + better-auth: 1.5.6 + better-call: 1.3.2 + + '@better-auth/telemetry@1.5.6': + resolution: {integrity: sha512-yXC7NSxnIFlxDkGdpD7KA+J9nqIQAPCJKe77GoaC5bWoe/DALo1MYorZfTgOafS7wrslNtsPT4feV/LJi1ubqQ==} + peerDependencies: + '@better-auth/core': 1.5.6 + + '@better-auth/utils@0.3.1': + resolution: {integrity: sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg==} + + '@better-fetch/fetch@1.1.21': + resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} '@bufbuild/protobuf@2.10.2': resolution: {integrity: sha512-uFsRXwIGyu+r6AMdz+XijIIZJYpoWeYzILt5yZ2d3mCjQrWUTVpVD9WL/jZAbvp+Ed04rOhrsk7FiTcEDseB5A==} @@ -651,9 +750,6 @@ packages: engines: {node: '>=6'} hasBin: true - '@hexagon/base64@1.1.28': - resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==} - '@hono/node-server@1.14.2': resolution: {integrity: sha512-GHjpOeHYbr9d1vkID2sNUYkl5IxumyhDrUJB7wBp7jvqYwPFt+oNKsAPBRcdSbV7kIrXhouLE199ks1QcK4r7A==} engines: {node: '>=18.14.1'} @@ -847,9 +943,6 @@ packages: '@js-sdsl/ordered-map@4.4.2': resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} - '@levischuck/tiny-cbor@0.2.11': - resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==} - '@logtail/next@0.2.1': resolution: {integrity: sha512-kvI7CNzei+fIErWOfMf/o3mgZkmGptR6W/C4850+oyXS0ouJO9C2+5a166Rd6ywzXb/lP9QPOABPS1r26dNnpQ==} engines: {node: '>=18'} @@ -921,8 +1014,8 @@ packages: cpu: [x64] os: [win32] - '@noble/ciphers@2.0.1': - resolution: {integrity: sha512-xHK3XHPUW8DTAobU+G0XT+/w+JLM7/8k1UFdB5xg/zTFPnFCobhftzw8wl4Lw2aq/Rvir5pxfZV5fEazmeCJ2g==} + '@noble/ciphers@2.1.1': + resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} engines: {node: '>= 20.19.0'} '@noble/hashes@2.0.1': @@ -1442,48 +1535,16 @@ packages: resolution: {integrity: sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg==} engines: {node: '>=14'} + '@opentelemetry/semantic-conventions@1.40.0': + resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==} + engines: {node: '>=14'} + '@opentelemetry/sql-common@0.41.2': resolution: {integrity: sha512-4mhWm3Z8z+i508zQJ7r6Xi7y4mmoJpdvH0fZPFRkWrdp5fq7hhZ2HhYokEOLkfqSMgPR4Z9EyB3DBkbKGOqZiQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.1.0 - '@peculiar/asn1-android@2.5.0': - resolution: {integrity: sha512-t8A83hgghWQkcneRsgGs2ebAlRe54ns88p7ouv8PW2tzF1nAW4yHcL4uZKrFpIU+uszIRzTkcCuie37gpkId0A==} - - '@peculiar/asn1-cms@2.5.0': - resolution: {integrity: sha512-p0SjJ3TuuleIvjPM4aYfvYw8Fk1Hn/zAVyPJZTtZ2eE9/MIer6/18ROxX6N/e6edVSfvuZBqhxAj3YgsmSjQ/A==} - - '@peculiar/asn1-csr@2.5.0': - resolution: {integrity: sha512-ioigvA6WSYN9h/YssMmmoIwgl3RvZlAYx4A/9jD2qaqXZwGcNlAxaw54eSx2QG1Yu7YyBC5Rku3nNoHrQ16YsQ==} - - '@peculiar/asn1-ecc@2.5.0': - resolution: {integrity: sha512-t4eYGNhXtLRxaP50h3sfO6aJebUCDGQACoeexcelL4roMFRRVgB20yBIu2LxsPh/tdW9I282gNgMOyg3ywg/mg==} - - '@peculiar/asn1-pfx@2.5.0': - resolution: {integrity: sha512-Vj0d0wxJZA+Ztqfb7W+/iu8Uasw6hhKtCdLKXLG/P3kEPIQpqGI4P4YXlROfl7gOCqFIbgsj1HzFIFwQ5s20ug==} - - '@peculiar/asn1-pkcs8@2.5.0': - resolution: {integrity: sha512-L7599HTI2SLlitlpEP8oAPaJgYssByI4eCwQq2C9eC90otFpm8MRn66PpbKviweAlhinWQ3ZjDD2KIVtx7PaVw==} - - '@peculiar/asn1-pkcs9@2.5.0': - resolution: {integrity: sha512-UgqSMBLNLR5TzEZ5ZzxR45Nk6VJrammxd60WMSkofyNzd3DQLSNycGWSK5Xg3UTYbXcDFyG8pA/7/y/ztVCa6A==} - - '@peculiar/asn1-rsa@2.5.0': - resolution: {integrity: sha512-qMZ/vweiTHy9syrkkqWFvbT3eLoedvamcUdnnvwyyUNv5FgFXA3KP8td+ATibnlZ0EANW5PYRm8E6MJzEB/72Q==} - - '@peculiar/asn1-schema@2.5.0': - resolution: {integrity: sha512-YM/nFfskFJSlHqv59ed6dZlLZqtZQwjRVJ4bBAiWV08Oc+1rSd5lDZcBEx0lGDHfSoH3UziI2pXt2UM33KerPQ==} - - '@peculiar/asn1-x509-attr@2.5.0': - resolution: {integrity: sha512-9f0hPOxiJDoG/bfNLAFven+Bd4gwz/VzrCIIWc1025LEI4BXO0U5fOCTNDPbbp2ll+UzqKsZ3g61mpBp74gk9A==} - - '@peculiar/asn1-x509@2.5.0': - resolution: {integrity: sha512-CpwtMCTJvfvYTFMuiME5IH+8qmDe3yEWzKHe7OOADbGfq7ohxeLaXwQo0q4du3qs0AII3UbLCvb9NF/6q0oTKQ==} - - '@peculiar/x509@1.14.0': - resolution: {integrity: sha512-Yc4PDxN3OrxUPiXgU63c+ZRXKGE8YKF2McTciYhUHFtHVB0KMnjeFSU0qpztGhsp4P0uKix4+J2xEpIEDu8oXg==} - '@playwright/test@1.56.1': resolution: {integrity: sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==} engines: {node: '>=18'} @@ -2335,13 +2396,6 @@ packages: peerDependencies: semantic-release: '>=20.1.0' - '@simplewebauthn/browser@13.2.2': - resolution: {integrity: sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==} - - '@simplewebauthn/server@13.2.2': - resolution: {integrity: sha512-HcWLW28yTMGXpwE9VLx9J+N2KEUaELadLrkPEEI9tpI5la70xNEVEsu/C+m3u7uoq4FulLqZQhgBCzR9IZhFpA==} - engines: {node: '>=20.0.0'} - '@sindresorhus/is@4.6.0': resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} @@ -2353,6 +2407,9 @@ packages: '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} @@ -2739,6 +2796,9 @@ packages: '@types/oracledb@6.5.2': resolution: {integrity: sha512-kK1eBS/Adeyis+3OlBDMeQQuasIDLUYXsi2T15ccNJ0iyUpQ4xDF7svFu3+bGVrI0CMBUclPciz+lsQR3JX3TQ==} + '@types/pako@2.0.4': + resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==} + '@types/pg-pool@2.0.6': resolution: {integrity: sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ==} @@ -2751,6 +2811,9 @@ packages: '@types/prismjs@1.26.5': resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==} + '@types/raf@3.4.3': + resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==} + '@types/react-dom@19.2.1': resolution: {integrity: sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A==} peerDependencies: @@ -2762,6 +2825,9 @@ packages: '@types/tedious@4.0.14': resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -2937,6 +3003,14 @@ packages: '@upstash/redis@1.37.0': resolution: {integrity: sha512-LqOJ3+XWPLSZ2rGSed5DYG3ixybxb8EhZu3yQqF7MdZX1wLBG/FRcI6xcUZXHy/SS7mmXWyadrud0HJHkOc+uw==} + '@xmldom/is-dom-node@1.0.1': + resolution: {integrity: sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q==} + engines: {node: '>= 16'} + + '@xmldom/xmldom@0.8.11': + resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} + engines: {node: '>=10.0.0'} + acorn-import-attributes@1.9.5: resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} peerDependencies: @@ -3051,9 +3125,8 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} - asn1js@3.0.6: - resolution: {integrity: sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA==} - engines: {node: '>=12.0.0'} + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} @@ -3084,27 +3157,62 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base64-arraybuffer@1.0.2: + resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} + engines: {node: '>= 0.6.0'} + before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} - better-auth@1.3.27: - resolution: {integrity: sha512-SwiGAJ7yU6dBhNg0NdV1h5M8T5sa7/AszZVc4vBfMDrLLmvUfbt9JoJ0uRUJUEdKRAAxTyl9yA+F3+GhtAD80w==} + better-auth@1.5.6: + resolution: {integrity: sha512-QSpJTqaT1XVfWRQe/fm3PgeuwOIlz1nWX/Dx7nsHStJ382bLzmDbQk2u7IT0IJ6wS5SRxfqEE1Ev9TXontgyAQ==} peerDependencies: '@lynx-js/react': '*' - '@sveltejs/kit': '*' - next: '*' - react: '*' - react-dom: '*' - solid-js: '*' - svelte: '*' - vue: '*' + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + '@sveltejs/kit': ^2.0.0 + '@tanstack/react-start': ^1.0.0 + '@tanstack/solid-start': ^1.0.0 + better-sqlite3: ^12.0.0 + drizzle-kit: '>=0.31.4' + drizzle-orm: '>=0.41.0' + mongodb: ^6.0.0 || ^7.0.0 + mysql2: ^3.0.0 + next: ^14.0.0 || ^15.0.0 || ^16.0.0 + pg: ^8.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + solid-js: ^1.0.0 + svelte: ^4.0.0 || ^5.0.0 + vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 + vue: ^3.0.0 peerDependenciesMeta: '@lynx-js/react': optional: true + '@prisma/client': + optional: true '@sveltejs/kit': optional: true + '@tanstack/react-start': + optional: true + '@tanstack/solid-start': + optional: true + better-sqlite3: + optional: true + drizzle-kit: + optional: true + drizzle-orm: + optional: true + mongodb: + optional: true + mysql2: + optional: true next: optional: true + pg: + optional: true + prisma: + optional: true react: optional: true react-dom: @@ -3113,11 +3221,26 @@ packages: optional: true svelte: optional: true + vitest: + optional: true vue: optional: true - better-call@1.0.19: - resolution: {integrity: sha512-sI3GcA1SCVa3H+CDHl8W8qzhlrckwXOTKhqq3OOPXjgn5aTOMIqGY34zLY/pHA6tRRMjTUC3lz5Mi7EbDA24Kw==} + better-call@1.3.2: + resolution: {integrity: sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw==} + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + better-call@1.3.4: + resolution: {integrity: sha512-ZhY7Wy1usw/YpanMBsvY+cCsdTa6k96iuetRrndvgpFSjl3Bfdqa6DxC6XJf4lzRYqxxtpJiCTjbBkHdSI7hOQ==} + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true bignumber.js@9.3.1: resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} @@ -3159,12 +3282,20 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@9.0.0: + resolution: {integrity: sha512-TO9xmyXTZ9HUHI8M1OnvExxYB0eYVS/1e5s7IDMTAoIcwUd+aNcFODs6Xk83mobk0velyHFQgA1yIrvYc6wclw==} + engines: {node: '>=20'} + caniuse-lite@1.0.30001750: resolution: {integrity: sha512-cuom0g5sdX6rw00qOoLNSFCJ9/mYIsuSOA+yzpDw8eopiFqcVwQvZHqov0vmEighRxX++cfC0Vg1G+1Iy/mSpQ==} canonicalize@1.0.8: resolution: {integrity: sha512-0CNTVCLZggSh7bc5VkX5WWPWO+cyZbNd07IHIsSXLia/eAq+r836hgk+8BKoEh7949Mda87VUOitx5OddVj64A==} + canvg@3.0.11: + resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==} + engines: {node: '>=10.0.0'} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -3337,6 +3468,9 @@ packages: resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==} engines: {node: '>=12.13'} + core-js@3.49.0: + resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -3360,6 +3494,9 @@ packages: resolution: {integrity: sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==} engines: {node: '>=12'} + css-line-break@2.1.0: + resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -3513,6 +3650,9 @@ packages: dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + dompurify@3.3.3: + resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==} + dot-prop@5.3.0: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} engines: {node: '>=8'} @@ -3630,6 +3770,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@1.0.5: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} @@ -3813,6 +3956,16 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-png@6.4.0: + resolution: {integrity: sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==} + + fast-xml-builder@1.1.4: + resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==} + + fast-xml-parser@5.5.9: + resolution: {integrity: sha512-jldvxr1MC6rtiZKgrFnDSvT8xuH+eJqxqOBThUVjYrxssYTo1avZLGql5l0a0BAERR01CadYzZ83kVEkbyDg+g==} + hasBin: true + fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} @@ -3825,6 +3978,9 @@ packages: picomatch: optional: true + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + figures@2.0.0: resolution: {integrity: sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA==} engines: {node: '>=4'} @@ -4131,6 +4287,10 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + html2canvas@1.4.1: + resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} + engines: {node: '>=8.0.0'} + http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -4265,6 +4425,9 @@ packages: resolution: {integrity: sha512-2dYz766i9HprMBasCMvHMuazJ7u4WzhJwo5kb3iPSiW/iRYV6uPari3zHoqZlnuaR7V1bEiNMxikhp37rdBXbw==} engines: {node: '>=12'} + iobuffer@5.4.0: + resolution: {integrity: sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==} + is-alphabetical@2.0.1: resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} @@ -4454,6 +4617,9 @@ packages: jose@6.1.0: resolution: {integrity: sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==} + jose@6.2.2: + resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -4489,6 +4655,9 @@ packages: jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + jspdf@4.2.1: + resolution: {integrity: sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -4496,8 +4665,8 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - kysely@0.28.8: - resolution: {integrity: sha512-QUOgl5ZrS9IRuhq5FvOKFSsD/3+IA6MLE81/bOOTRA/YQpKDza2sFdN5g6JCB9BOpqMJDGefLCQ9F12hRS13TA==} + kysely@0.28.14: + resolution: {integrity: sha512-SU3lgh0rPvq7upc6vvdVrCsSMUG1h3ChvHVOY7wJ2fw4C9QEB7X3d5eyYEyULUX7UQtxZJtZXGuT6U2US72UYA==} engines: {node: '>=20.0.0'} language-subtag-registry@0.3.23: @@ -4511,6 +4680,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + libphonenumber-js@1.12.40: + resolution: {integrity: sha512-HKGs7GowShNls3Zh+7DTr6wYpPk5jC78l508yQQY3e8ZgJChM3A9JZghmMJZuK+5bogSfuTafpjksGSR3aMIEg==} + lightningcss-darwin-arm64@1.30.1: resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==} engines: {node: '>= 12.0.0'} @@ -4920,8 +5092,8 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - nanostores@1.0.1: - resolution: {integrity: sha512-kNZ9xnoJYKg/AfxjrVL4SS0fKX++4awQReGqWnwTRHxeHGZ1FJFVgTqr/eMrNQdp0Tz7M7tG/TDaX8QfHDwVCw==} + nanostores@1.2.0: + resolution: {integrity: sha512-F0wCzbsH80G7XXo0Jd9/AVQC7ouWY6idUCTnMwW5t/Rv9W8qmO6endavDwg7TNp5GbugwSukFMVZqzPSrSMndg==} engines: {node: ^20.0.0 || >=22.0.0} napi-postinstall@0.3.4: @@ -4981,6 +5153,9 @@ packages: encoding: optional: true + node-rsa@1.1.1: + resolution: {integrity: sha512-Jd4cvbJMryN21r5HgxQOpMEqv+ooke/korixNNK3mGqfGJmy0M77WDDzo/05969+OkMy3XW1UuZsSmW9KQm7Fw==} + nodemailer@8.0.2: resolution: {integrity: sha512-zbj002pZAIkWQFxyAaqoxvn+zoIwRnS40hgjqTXudKOOJkiFFgBeNqjgD3/YCR12sZnrghWYBY+yP1ZucdDRpw==} engines: {node: '>=6.0.0'} @@ -5184,6 +5359,9 @@ packages: resolution: {integrity: sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==} engines: {node: '>=4'} + pako@2.1.0: + resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -5230,6 +5408,10 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-expression-matcher@1.2.0: + resolution: {integrity: sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==} + engines: {node: '>=14.0.0'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -5251,6 +5433,9 @@ packages: perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + performance-now@2.1.0: + resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} + pg-cloudflare@1.2.7: resolution: {integrity: sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==} @@ -5488,12 +5673,10 @@ packages: pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} - pvtsutils@1.3.6: - resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} - - pvutils@1.1.3: - resolution: {integrity: sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==} - engines: {node: '>=6.0.0'} + qrcode.react@4.2.0: + resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -5511,6 +5694,9 @@ packages: '@types/react-dom': optional: true + raf@3.4.1: + resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==} + rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} @@ -5629,9 +5815,6 @@ packages: react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - reflect-metadata@0.2.2: - resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} - reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -5639,6 +5822,9 @@ packages: refractor@4.9.0: resolution: {integrity: sha512-nEG1SPXFoGGx+dcjftjv8cAjEusIh6ED1xhf5DG3C0x/k+rmZ2duKnc3QLpt6qeHv5fPb8uwN3VWN2BT7fr3Og==} + regenerator-runtime@0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + regexp-to-ast@0.5.0: resolution: {integrity: sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==} @@ -5720,8 +5906,12 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} - rou3@0.5.1: - resolution: {integrity: sha512-OXMmJ3zRk2xeXFGfA3K+EOPHC5u7RDFG7lIOx0X1pdnhUkI8MdVrbV+sNsD80ElpUZ+MRHdyxPnFthq9VHs8uQ==} + rgbcolor@1.0.1: + resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==} + engines: {node: '>= 0.8.15'} + + rou3@0.7.12: + resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -5744,6 +5934,9 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + samlify@2.11.0: + resolution: {integrity: sha512-1C9ukjlf0rRsuyqdzztqikdItqa33j9NCCDZgeBiWk0etU6vxNB+SWJKW4Flk07ZlhXeev/twALEKrPhIAyfDg==} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -5780,8 +5973,8 @@ packages: server-only@0.0.1: resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} - set-cookie-parser@2.7.1: - resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==} + set-cookie-parser@3.1.0: + resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} @@ -5892,6 +6085,10 @@ packages: stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + stackblur-canvas@2.7.0: + resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==} + engines: {node: '>=0.1.14'} + std-env@3.9.0: resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} @@ -5983,6 +6180,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strnum@2.2.2: + resolution: {integrity: sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==} + style-to-js@1.1.18: resolution: {integrity: sha512-JFPn62D4kJaPTnhFUI244MThx+FEGbi+9dw1b9yBBQ+1CZpV7QAT8kUtJ7b7EUNdHajjF/0x8fT+16oLJoojLg==} @@ -6026,6 +6226,10 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svg-pathdata@6.0.3: + resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==} + engines: {node: '>=12.0.0'} + tailwind-merge@3.3.1: resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==} @@ -6054,6 +6258,9 @@ packages: resolution: {integrity: sha512-7jDLIdD2Zp0bDe5r3D2qtkd1QOCacylBuL7oa4udvN6v2pqr4+LcCr67C8DR1zkpaZ8XosF5m1yQSabKAW6f2g==} engines: {node: '>=14.16'} + text-segmentation@1.0.3: + resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -6078,6 +6285,13 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -6108,9 +6322,6 @@ packages: tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} - tslib@1.14.1: - resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} - tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -6119,10 +6330,6 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - tsyringe@4.10.0: - resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} - engines: {node: '>= 6.0.0'} - tunnel@0.0.6: resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==} engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} @@ -6284,6 +6491,9 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + utrie@1.0.2: + resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==} + uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true @@ -6385,6 +6595,28 @@ packages: utf-8-validate: optional: true + xml-crypto@6.1.2: + resolution: {integrity: sha512-leBOVQdVi8FvPJrMYoum7Ici9qyxfE4kVi+AkpUoYCSXaQF4IlBm1cneTK9oAxR61LpYxTx7lNcsnBIeRpGW2w==} + engines: {node: '>=16'} + + xml-escape@1.1.0: + resolution: {integrity: sha512-B/T4sDK8Z6aUh/qNr7mjKAwwncIljFuUP+DO/D5hloYFj+90O88z8Wf7oSucZTHxBAsC1/CTP4rtx/x1Uf72Mg==} + + xml@1.0.1: + resolution: {integrity: sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==} + + xpath@0.0.32: + resolution: {integrity: sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==} + engines: {node: '>=0.6.0'} + + xpath@0.0.33: + resolution: {integrity: sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA==} + engines: {node: '>=0.6.0'} + + xpath@0.0.34: + resolution: {integrity: sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==} + engines: {node: '>=0.6.0'} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -6437,11 +6669,8 @@ packages: zeptomatch@2.0.2: resolution: {integrity: sha512-H33jtSKf8Ijtb5BW6wua3G5DhnFjbFML36eFu+VdOoVY4HD9e7ggjqdM6639B+L87rjnR6Y+XeRzBXZdy52B/g==} - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - - zod@4.1.12: - resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -6466,6 +6695,12 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@authenio/xml-encryption@2.0.2': + dependencies: + '@xmldom/xmldom': 0.8.11 + escape-html: 1.0.3 + xpath: 0.0.32 + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -6476,14 +6711,84 @@ snapshots: '@babel/runtime@7.28.4': {} - '@better-auth/core@1.3.27': + '@babel/runtime@7.29.2': {} + + '@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0)': + dependencies: + '@better-auth/utils': 0.3.1 + '@better-fetch/fetch': 1.1.21 + '@opentelemetry/api': 1.9.0 + '@opentelemetry/semantic-conventions': 1.40.0 + '@standard-schema/spec': 1.1.0 + better-call: 1.3.2(zod@4.3.6) + jose: 6.2.2 + kysely: 0.28.14 + nanostores: 1.2.0 + zod: 4.3.6 + + '@better-auth/drizzle-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)': + dependencies: + '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0) + '@better-auth/utils': 0.3.1 + + '@better-auth/infra@0.1.13(edcf4d535d755d3529cd9f862f14cd61)': + dependencies: + '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0) + '@better-auth/sso': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(better-auth@1.5.6(@opentelemetry/api@1.9.0)(@prisma/client@7.0.0(prisma@7.0.0(@types/react@19.2.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(mysql2@3.15.3)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.16.3)(prisma@7.0.0(@types/react@19.2.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(better-call@1.3.2(zod@4.3.6)) + '@better-fetch/fetch': 1.1.21 + better-auth: 1.5.6(@opentelemetry/api@1.9.0)(@prisma/client@7.0.0(prisma@7.0.0(@types/react@19.2.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(mysql2@3.15.3)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.16.3)(prisma@7.0.0(@types/react@19.2.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + better-call: 1.3.4(zod@4.3.6) + jose: 6.1.0 + libphonenumber-js: 1.12.40 + zod: 4.3.6 + + '@better-auth/kysely-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(kysely@0.28.14)': dependencies: - better-call: 1.0.19 - zod: 4.1.12 + '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0) + '@better-auth/utils': 0.3.1 + optionalDependencies: + kysely: 0.28.14 + + '@better-auth/memory-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)': + dependencies: + '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0) + '@better-auth/utils': 0.3.1 + + '@better-auth/mongo-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)': + dependencies: + '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0) + '@better-auth/utils': 0.3.1 - '@better-auth/utils@0.3.0': {} + '@better-auth/prisma-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(@prisma/client@7.0.0(prisma@7.0.0(@types/react@19.2.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(prisma@7.0.0(@types/react@19.2.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))': + dependencies: + '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0) + '@better-auth/utils': 0.3.1 + optionalDependencies: + '@prisma/client': 7.0.0(prisma@7.0.0(@types/react@19.2.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3) + prisma: 7.0.0(@types/react@19.2.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) - '@better-fetch/fetch@1.1.18': {} + '@better-auth/sso@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(better-auth@1.5.6(@opentelemetry/api@1.9.0)(@prisma/client@7.0.0(prisma@7.0.0(@types/react@19.2.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(mysql2@3.15.3)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.16.3)(prisma@7.0.0(@types/react@19.2.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(better-call@1.3.2(zod@4.3.6))': + dependencies: + '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0) + '@better-auth/utils': 0.3.1 + '@better-fetch/fetch': 1.1.21 + better-auth: 1.5.6(@opentelemetry/api@1.9.0)(@prisma/client@7.0.0(prisma@7.0.0(@types/react@19.2.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(mysql2@3.15.3)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.16.3)(prisma@7.0.0(@types/react@19.2.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + better-call: 1.3.2(zod@4.3.6) + fast-xml-parser: 5.5.9 + jose: 6.2.2 + samlify: 2.11.0 + tldts: 6.1.86 + zod: 4.3.6 + + '@better-auth/telemetry@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))': + dependencies: + '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0) + '@better-auth/utils': 0.3.1 + '@better-fetch/fetch': 1.1.21 + + '@better-auth/utils@0.3.1': {} + + '@better-fetch/fetch@1.1.21': {} '@bufbuild/protobuf@2.10.2': {} @@ -6692,8 +6997,6 @@ snapshots: protobufjs: 7.5.4 yargs: 17.7.2 - '@hexagon/base64@1.1.28': {} - '@hono/node-server@1.14.2(hono@4.7.10)': dependencies: hono: 4.7.10 @@ -6843,8 +7146,6 @@ snapshots: '@js-sdsl/ordered-map@4.4.2': {} - '@levischuck/tiny-cbor@0.2.11': {} - '@logtail/next@0.2.1(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': dependencies: next: 15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -6901,7 +7202,7 @@ snapshots: '@next/swc-win32-x64-msvc@15.5.7': optional: true - '@noble/ciphers@2.0.1': {} + '@noble/ciphers@2.1.1': {} '@noble/hashes@2.0.1': {} @@ -7641,107 +7942,13 @@ snapshots: '@opentelemetry/semantic-conventions@1.38.0': {} + '@opentelemetry/semantic-conventions@1.40.0': {} + '@opentelemetry/sql-common@0.41.2(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.0) - '@peculiar/asn1-android@2.5.0': - dependencies: - '@peculiar/asn1-schema': 2.5.0 - asn1js: 3.0.6 - tslib: 2.8.1 - - '@peculiar/asn1-cms@2.5.0': - dependencies: - '@peculiar/asn1-schema': 2.5.0 - '@peculiar/asn1-x509': 2.5.0 - '@peculiar/asn1-x509-attr': 2.5.0 - asn1js: 3.0.6 - tslib: 2.8.1 - - '@peculiar/asn1-csr@2.5.0': - dependencies: - '@peculiar/asn1-schema': 2.5.0 - '@peculiar/asn1-x509': 2.5.0 - asn1js: 3.0.6 - tslib: 2.8.1 - - '@peculiar/asn1-ecc@2.5.0': - dependencies: - '@peculiar/asn1-schema': 2.5.0 - '@peculiar/asn1-x509': 2.5.0 - asn1js: 3.0.6 - tslib: 2.8.1 - - '@peculiar/asn1-pfx@2.5.0': - dependencies: - '@peculiar/asn1-cms': 2.5.0 - '@peculiar/asn1-pkcs8': 2.5.0 - '@peculiar/asn1-rsa': 2.5.0 - '@peculiar/asn1-schema': 2.5.0 - asn1js: 3.0.6 - tslib: 2.8.1 - - '@peculiar/asn1-pkcs8@2.5.0': - dependencies: - '@peculiar/asn1-schema': 2.5.0 - '@peculiar/asn1-x509': 2.5.0 - asn1js: 3.0.6 - tslib: 2.8.1 - - '@peculiar/asn1-pkcs9@2.5.0': - dependencies: - '@peculiar/asn1-cms': 2.5.0 - '@peculiar/asn1-pfx': 2.5.0 - '@peculiar/asn1-pkcs8': 2.5.0 - '@peculiar/asn1-schema': 2.5.0 - '@peculiar/asn1-x509': 2.5.0 - '@peculiar/asn1-x509-attr': 2.5.0 - asn1js: 3.0.6 - tslib: 2.8.1 - - '@peculiar/asn1-rsa@2.5.0': - dependencies: - '@peculiar/asn1-schema': 2.5.0 - '@peculiar/asn1-x509': 2.5.0 - asn1js: 3.0.6 - tslib: 2.8.1 - - '@peculiar/asn1-schema@2.5.0': - dependencies: - asn1js: 3.0.6 - pvtsutils: 1.3.6 - tslib: 2.8.1 - - '@peculiar/asn1-x509-attr@2.5.0': - dependencies: - '@peculiar/asn1-schema': 2.5.0 - '@peculiar/asn1-x509': 2.5.0 - asn1js: 3.0.6 - tslib: 2.8.1 - - '@peculiar/asn1-x509@2.5.0': - dependencies: - '@peculiar/asn1-schema': 2.5.0 - asn1js: 3.0.6 - pvtsutils: 1.3.6 - tslib: 2.8.1 - - '@peculiar/x509@1.14.0': - dependencies: - '@peculiar/asn1-cms': 2.5.0 - '@peculiar/asn1-csr': 2.5.0 - '@peculiar/asn1-ecc': 2.5.0 - '@peculiar/asn1-pkcs9': 2.5.0 - '@peculiar/asn1-rsa': 2.5.0 - '@peculiar/asn1-schema': 2.5.0 - '@peculiar/asn1-x509': 2.5.0 - pvtsutils: 1.3.6 - reflect-metadata: 0.2.2 - tslib: 2.8.1 - tsyringe: 4.10.0 - '@playwright/test@1.56.1': dependencies: playwright: 1.56.1 @@ -8721,44 +8928,33 @@ snapshots: transitivePeerDependencies: - supports-color - '@simplewebauthn/browser@13.2.2': {} - - '@simplewebauthn/server@13.2.2': - dependencies: - '@hexagon/base64': 1.1.28 - '@levischuck/tiny-cbor': 0.2.11 - '@peculiar/asn1-android': 2.5.0 - '@peculiar/asn1-ecc': 2.5.0 - '@peculiar/asn1-rsa': 2.5.0 - '@peculiar/asn1-schema': 2.5.0 - '@peculiar/asn1-x509': 2.5.0 - '@peculiar/x509': 1.14.0 - '@sindresorhus/is@4.6.0': {} '@sindresorhus/merge-streams@4.0.0': {} '@standard-schema/spec@1.0.0': {} + '@standard-schema/spec@1.1.0': {} + '@standard-schema/utils@0.3.0': {} '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 - '@t3-oss/env-core@0.12.0(typescript@5.9.3)(valibot@1.1.0(typescript@5.9.3))(zod@3.25.76)': + '@t3-oss/env-core@0.12.0(typescript@5.9.3)(valibot@1.1.0(typescript@5.9.3))(zod@4.3.6)': optionalDependencies: typescript: 5.9.3 valibot: 1.1.0(typescript@5.9.3) - zod: 3.25.76 + zod: 4.3.6 - '@t3-oss/env-nextjs@0.12.0(typescript@5.9.3)(valibot@1.1.0(typescript@5.9.3))(zod@3.25.76)': + '@t3-oss/env-nextjs@0.12.0(typescript@5.9.3)(valibot@1.1.0(typescript@5.9.3))(zod@4.3.6)': dependencies: - '@t3-oss/env-core': 0.12.0(typescript@5.9.3)(valibot@1.1.0(typescript@5.9.3))(zod@3.25.76) + '@t3-oss/env-core': 0.12.0(typescript@5.9.3)(valibot@1.1.0(typescript@5.9.3))(zod@4.3.6) optionalDependencies: typescript: 5.9.3 valibot: 1.1.0(typescript@5.9.3) - zod: 3.25.76 + zod: 4.3.6 '@tabler/icons-react@3.35.0(react@19.2.4)': dependencies: @@ -9163,6 +9359,8 @@ snapshots: dependencies: '@types/node': 20.19.21 + '@types/pako@2.0.4': {} + '@types/pg-pool@2.0.6': dependencies: '@types/pg': 8.16.0 @@ -9181,6 +9379,9 @@ snapshots: '@types/prismjs@1.26.5': {} + '@types/raf@3.4.3': + optional: true + '@types/react-dom@19.2.1(@types/react@19.2.2)': dependencies: '@types/react': 19.2.2 @@ -9193,6 +9394,9 @@ snapshots: dependencies: '@types/node': 20.19.21 + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -9364,6 +9568,10 @@ snapshots: dependencies: uncrypto: 0.1.3 + '@xmldom/is-dom-node@1.0.1': {} + + '@xmldom/xmldom@0.8.11': {} + acorn-import-attributes@1.9.5(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -9498,11 +9706,9 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 - asn1js@3.0.6: + asn1@0.2.6: dependencies: - pvtsutils: 1.3.6 - pvutils: 1.1.3 - tslib: 2.8.1 + safer-buffer: 2.1.2 ast-types-flow@0.0.8: {} @@ -9522,35 +9728,59 @@ snapshots: balanced-match@1.0.2: {} + base64-arraybuffer@1.0.2: + optional: true + before-after-hook@4.0.0: {} - better-auth@1.3.27(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4): - dependencies: - '@better-auth/core': 1.3.27 - '@better-auth/utils': 0.3.0 - '@better-fetch/fetch': 1.1.18 - '@noble/ciphers': 2.0.1 + better-auth@1.5.6(@opentelemetry/api@1.9.0)(@prisma/client@7.0.0(prisma@7.0.0(@types/react@19.2.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(mysql2@3.15.3)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(pg@8.16.3)(prisma@7.0.0(@types/react@19.2.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0) + '@better-auth/drizzle-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1) + '@better-auth/kysely-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(kysely@0.28.14) + '@better-auth/memory-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1) + '@better-auth/mongo-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1) + '@better-auth/prisma-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(@prisma/client@7.0.0(prisma@7.0.0(@types/react@19.2.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3))(prisma@7.0.0(@types/react@19.2.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)) + '@better-auth/telemetry': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.0)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0)) + '@better-auth/utils': 0.3.1 + '@better-fetch/fetch': 1.1.21 + '@noble/ciphers': 2.1.1 '@noble/hashes': 2.0.1 - '@simplewebauthn/browser': 13.2.2 - '@simplewebauthn/server': 13.2.2 - better-call: 1.0.19 + better-call: 1.3.2(zod@4.3.6) defu: 6.1.4 - jose: 6.1.0 - kysely: 0.28.8 - nanostores: 1.0.1 - zod: 4.1.12 + jose: 6.2.2 + kysely: 0.28.14 + nanostores: 1.2.0 + zod: 4.3.6 optionalDependencies: + '@prisma/client': 7.0.0(prisma@7.0.0(@types/react@19.2.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3) + mysql2: 3.15.3 next: 15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + pg: 8.16.3 + prisma: 7.0.0(@types/react@19.2.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + transitivePeerDependencies: + - '@cloudflare/workers-types' + - '@opentelemetry/api' - better-call@1.0.19: + better-call@1.3.2(zod@4.3.6): dependencies: - '@better-auth/utils': 0.3.0 - '@better-fetch/fetch': 1.1.18 - rou3: 0.5.1 - set-cookie-parser: 2.7.1 - uncrypto: 0.1.3 + '@better-auth/utils': 0.3.1 + '@better-fetch/fetch': 1.1.21 + rou3: 0.7.12 + set-cookie-parser: 3.1.0 + optionalDependencies: + zod: 4.3.6 + + better-call@1.3.4(zod@4.3.6): + dependencies: + '@better-auth/utils': 0.3.1 + '@better-fetch/fetch': 1.1.21 + rou3: 0.7.12 + set-cookie-parser: 3.1.0 + optionalDependencies: + zod: 4.3.6 bignumber.js@9.3.1: {} @@ -9603,10 +9833,24 @@ snapshots: callsites@3.1.0: {} + camelcase@9.0.0: {} + caniuse-lite@1.0.30001750: {} canonicalize@1.0.8: {} + canvg@3.0.11: + dependencies: + '@babel/runtime': 7.29.2 + '@types/raf': 3.4.3 + core-js: 3.49.0 + raf: 3.4.1 + regenerator-runtime: 0.13.11 + rgbcolor: 1.0.1 + stackblur-canvas: 2.7.0 + svg-pathdata: 6.0.3 + optional: true + ccount@2.0.1: {} chalk@2.4.2: @@ -9784,6 +10028,9 @@ snapshots: dependencies: is-what: 4.1.16 + core-js@3.49.0: + optional: true + core-util-is@1.0.3: {} cosmiconfig@9.0.0(typescript@5.9.3): @@ -9811,6 +10058,11 @@ snapshots: dependencies: type-fest: 1.4.0 + css-line-break@2.1.0: + dependencies: + utrie: 1.0.2 + optional: true + cssesc@3.0.0: {} csstype@3.1.3: {} @@ -9940,6 +10192,11 @@ snapshots: '@babel/runtime': 7.28.4 csstype: 3.1.3 + dompurify@3.3.3: + optionalDependencies: + '@types/trusted-types': 2.0.7 + optional: true + dot-prop@5.3.0: dependencies: is-obj: 2.0.0 @@ -10142,6 +10399,8 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@1.0.5: {} escape-string-regexp@4.0.0: {} @@ -10424,6 +10683,22 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-png@6.4.0: + dependencies: + '@types/pako': 2.0.4 + iobuffer: 5.4.0 + pako: 2.1.0 + + fast-xml-builder@1.1.4: + dependencies: + path-expression-matcher: 1.2.0 + + fast-xml-parser@5.5.9: + dependencies: + fast-xml-builder: 1.1.4 + path-expression-matcher: 1.2.0 + strnum: 2.2.2 + fastq@1.19.1: dependencies: reusify: 1.1.0 @@ -10432,6 +10707,8 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fflate@0.8.2: {} + figures@2.0.0: dependencies: escape-string-regexp: 1.0.5 @@ -10807,6 +11084,12 @@ snapshots: html-void-elements@3.0.0: {} + html2canvas@1.4.1: + dependencies: + css-line-break: 2.1.0 + text-segmentation: 1.0.3 + optional: true + http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -10874,7 +11157,7 @@ snapshots: inline-style-parser@0.2.4: {} - inngest@3.47.0(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(hono@4.7.10)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(zod@3.25.76): + inngest@3.47.0(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(hono@4.7.10)(next@15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(typescript@5.9.3)(zod@4.3.6): dependencies: '@bufbuild/protobuf': 2.10.2 '@inngest/ai': 0.1.7 @@ -10900,7 +11183,7 @@ snapshots: strip-ansi: 5.2.0 temporal-polyfill: 0.2.5 ulid: 2.4.0 - zod: 3.25.76 + zod: 4.3.6 optionalDependencies: hono: 4.7.10 next: 15.5.9(@opentelemetry/api@1.9.0)(@playwright/test@1.56.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -10928,6 +11211,8 @@ snapshots: from2: 2.3.0 p-is-promise: 3.0.0 + iobuffer@5.4.0: {} + is-alphabetical@2.0.1: {} is-alphanumerical@2.0.1: @@ -11106,6 +11391,8 @@ snapshots: jose@6.1.0: {} + jose@6.2.2: {} + js-tokens@4.0.0: {} js-yaml@4.1.0: @@ -11138,6 +11425,17 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jspdf@4.2.1: + dependencies: + '@babel/runtime': 7.29.2 + fast-png: 6.4.0 + fflate: 0.8.2 + optionalDependencies: + canvg: 3.0.11 + core-js: 3.49.0 + dompurify: 3.3.3 + html2canvas: 1.4.1 + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -11149,7 +11447,7 @@ snapshots: dependencies: json-buffer: 3.0.1 - kysely@0.28.8: {} + kysely@0.28.14: {} language-subtag-registry@0.3.23: {} @@ -11162,6 +11460,8 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + libphonenumber-js@1.12.40: {} + lightningcss-darwin-arm64@1.30.1: optional: true @@ -11742,7 +12042,7 @@ snapshots: nanoid@3.3.11: {} - nanostores@1.0.1: {} + nanostores@1.2.0: {} napi-postinstall@0.3.4: {} @@ -11795,6 +12095,10 @@ snapshots: dependencies: whatwg-url: 5.0.0 + node-rsa@1.1.1: + dependencies: + asn1: 0.2.6 + nodemailer@8.0.2: {} normalize-package-data@6.0.2: @@ -11933,6 +12237,8 @@ snapshots: p-try@1.0.0: {} + pako@2.1.0: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -11985,6 +12291,8 @@ snapshots: path-exists@4.0.0: {} + path-expression-matcher@1.2.0: {} + path-key@3.1.1: {} path-key@4.0.0: {} @@ -11997,6 +12305,9 @@ snapshots: perfect-debounce@1.0.0: {} + performance-now@2.1.0: + optional: true + pg-cloudflare@1.2.7: optional: true @@ -12172,11 +12483,9 @@ snapshots: pure-rand@6.1.0: {} - pvtsutils@1.3.6: + qrcode.react@4.2.0(react@19.2.4): dependencies: - tslib: 2.8.1 - - pvutils@1.1.3: {} + react: 19.2.4 queue-microtask@1.2.3: {} @@ -12243,6 +12552,11 @@ snapshots: '@types/react': 19.2.2 '@types/react-dom': 19.2.1(@types/react@19.2.2) + raf@3.4.1: + dependencies: + performance-now: 2.1.0 + optional: true + rc9@2.1.2: dependencies: defu: 6.1.4 @@ -12391,8 +12705,6 @@ snapshots: tiny-invariant: 1.3.3 victory-vendor: 36.9.2 - reflect-metadata@0.2.2: {} - reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -12411,6 +12723,9 @@ snapshots: hastscript: 7.2.0 parse-entities: 4.0.2 + regenerator-runtime@0.13.11: + optional: true + regexp-to-ast@0.5.0: {} regexp.prototype.flags@1.5.4: @@ -12528,7 +12843,10 @@ snapshots: rfdc@1.4.1: {} - rou3@0.5.1: {} + rgbcolor@1.0.1: + optional: true + + rou3@0.7.12: {} run-parallel@1.2.0: dependencies: @@ -12557,6 +12875,17 @@ snapshots: safer-buffer@2.1.2: {} + samlify@2.11.0: + dependencies: + '@authenio/xml-encryption': 2.0.2 + '@xmldom/xmldom': 0.8.11 + camelcase: 9.0.0 + node-rsa: 1.1.1 + xml: 1.0.1 + xml-crypto: 6.1.2 + xml-escape: 1.1.0 + xpath: 0.0.34 + scheduler@0.27.0: {} semantic-release@25.0.1(typescript@5.9.3): @@ -12610,7 +12939,7 @@ snapshots: server-only@0.0.1: {} - set-cookie-parser@2.7.1: {} + set-cookie-parser@3.1.0: {} set-function-length@1.2.2: dependencies: @@ -12762,6 +13091,9 @@ snapshots: stable-hash@0.0.5: {} + stackblur-canvas@2.7.0: + optional: true + std-env@3.9.0: {} stop-iteration-iterator@1.1.0: @@ -12876,6 +13208,8 @@ snapshots: strip-json-comments@3.1.1: {} + strnum@2.2.2: {} + style-to-js@1.1.18: dependencies: style-to-object: 1.0.11 @@ -12913,6 +13247,9 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svg-pathdata@6.0.3: + optional: true + tailwind-merge@3.3.1: {} tailwindcss@4.1.14: {} @@ -12942,6 +13279,11 @@ snapshots: type-fest: 2.19.0 unique-string: 3.0.0 + text-segmentation@1.0.3: + dependencies: + utrie: 1.0.2 + optional: true + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -12968,6 +13310,12 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -12993,8 +13341,6 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 - tslib@1.14.1: {} - tslib@2.8.1: {} tsx@4.21.0: @@ -13004,10 +13350,6 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - tsyringe@4.10.0: - dependencies: - tslib: 1.14.1 - tunnel@0.0.6: {} tw-animate-css@1.4.0: {} @@ -13197,6 +13539,11 @@ snapshots: util-deprecate@1.0.2: {} + utrie@1.0.2: + dependencies: + base64-arraybuffer: 1.0.2 + optional: true + uuid@9.0.1: {} valibot@1.1.0(typescript@5.9.3): @@ -13344,6 +13691,22 @@ snapshots: ws@7.5.10: {} + xml-crypto@6.1.2: + dependencies: + '@xmldom/is-dom-node': 1.0.1 + '@xmldom/xmldom': 0.8.11 + xpath: 0.0.33 + + xml-escape@1.1.0: {} + + xml@1.0.1: {} + + xpath@0.0.32: {} + + xpath@0.0.33: {} + + xpath@0.0.34: {} + xtend@4.0.2: {} y18n@5.0.8: {} @@ -13395,8 +13758,6 @@ snapshots: dependencies: grammex: 3.1.11 - zod@3.25.76: {} - - zod@4.1.12: {} + zod@4.3.6: {} zwitch@2.0.4: {} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 87095c5..688ff6e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -22,11 +22,18 @@ model User { gender Gender? country Country? timezone Timezone? + twoFactorEnabled Boolean? @default(false) role String @default("user") accounts Account[] + twoFactor TwoFactor[] bankAccounts BankAccount[] budgets Budget[] categories Category[] + contacts Contact[] @relation("UserContacts") + linkedContacts Contact[] @relation("LinkedContacts") + groups Group[] + expenses Expense[] + settlements Settlement[] displaySettings DisplaySettings? notifications Notification[] notificationPrefs NotificationPreferences? @@ -35,6 +42,7 @@ model User { sessions Session[] transactions Transaction[] userPreferences UserPreferences? + auditLogs AuditLog[] @@map("user") } @@ -142,6 +150,16 @@ model Verification { @@map("verification") } +model TwoFactor { + id String @id + secret String + backupCodes String + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("twoFactor") +} + model BankAccount { id String @id @default(cuid()) userId String @@ -175,6 +193,7 @@ model Category { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt budgets Budget[] + expenses Expense[] parent Category? @relation("CategoryToParent", fields: [parentCategoryId], references: [id]) children Category[] @relation("CategoryToParent") user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -231,6 +250,8 @@ enum NotificationType { BUDGET_ALERT TRANSACTION_RECURRING SYSTEM_ALERT + SPLIT_REMINDER + GROUP_EXPENSE } model Report { @@ -288,6 +309,9 @@ model Transaction { paymentMethod PaymentMethod? account BankAccount @relation(fields: [accountId], references: [id], onDelete: Cascade) category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull) + contact Contact? @relation(fields: [contactId], references: [id], onDelete: SetNull) + group Group? @relation(fields: [groupId], references: [id], onDelete: SetNull) + expenses Expense[] recurringRule RecurringRule? @relation(fields: [recurringRuleId], references: [id], onDelete: SetNull) user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -333,6 +357,154 @@ model RecurringRule { @@map("recurring_rules") } +model Contact { + id String @id @default(cuid()) + userId String + linkedUserId String? + name String + email String? + phone String? + avatarUrl String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation("UserContacts", fields: [userId], references: [id], onDelete: Cascade) + linkedUser User? @relation("LinkedContacts", fields: [linkedUserId], references: [id], onDelete: SetNull) + expenseParticipants ExpenseParticipant[] @relation("ContactPayer") + groupMembers GroupMember[] + transactions Transaction[] + + @@unique([userId, email]) + @@index([userId]) + @@map("contacts") +} + +model Group { + id String @id @default(cuid()) + userId String + name String + description String? + icon String? + color String? + type GroupType @default(OTHER) + currency Currency @default(USD) + isArchived Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + expenses Expense[] + members GroupMember[] + settlements Settlement[] + transactions Transaction[] + + @@index([userId]) + @@map("groups") +} + +model GroupMember { + id String @id @default(cuid()) + groupId String + contactId String? + userId String + role GroupRole @default(MEMBER) + joinedAt DateTime @default(now()) + + group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) + contact Contact? @relation(fields: [contactId], references: [id], onDelete: Cascade) + + @@unique([groupId, contactId]) + @@index([groupId]) + @@map("group_members") +} + +enum GroupType { + ROOMMATES + TRIP + COUPLE + FRIENDS + FAMILY + WORK + OTHER +} + +enum GroupRole { + OWNER + MEMBER +} + +model Expense { + id String @id @default(cuid()) + groupId String + createdById String + description String + notes String? + amount Decimal @db.Decimal(18, 2) + currency Currency @default(USD) + categoryId String? + date DateTime @default(now()) + receiptUrl String? + splitMethod SplitMethod @default(EQUAL) + isSettlement Boolean @default(false) + transactionId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) + createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) + category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull) + transaction Transaction? @relation(fields: [transactionId], references: [id], onDelete: SetNull) + participants ExpenseParticipant[] + + @@index([groupId]) + @@index([createdById]) + @@index([groupId, date]) + @@map("expenses") +} + +model ExpenseParticipant { + id String @id @default(cuid()) + expenseId String + contactId String? + isPayer Boolean @default(false) + paidAmount Decimal @db.Decimal(18, 2) @default(0) + owedAmount Decimal @db.Decimal(18, 2) @default(0) + + expense Expense @relation(fields: [expenseId], references: [id], onDelete: Cascade) + contact Contact? @relation("ContactPayer", fields: [contactId], references: [id], onDelete: Cascade) + + @@unique([expenseId, contactId]) + @@index([expenseId]) + @@map("expense_participants") +} + +enum SplitMethod { + EQUAL + EXACT + PERCENTAGE + SHARES +} + +model Settlement { + id String @id @default(cuid()) + groupId String + createdById String + fromContactId String? + toContactId String? + amount Decimal @db.Decimal(18, 2) + currency Currency @default(USD) + notes String? + date DateTime @default(now()) + transactionId String? + createdAt DateTime @default(now()) + + group Group @relation(fields: [groupId], references: [id], onDelete: Cascade) + createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) + + @@index([groupId]) + @@map("settlements") +} + enum Gender { MALE FEMALE @@ -489,3 +661,40 @@ enum PaymentMethod { UPI OTHER } + +model AuditLog { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + action String + resourceType String + resourceId String? + metadata Json? + ipAddress String? + userAgent String? + createdAt DateTime @default(now()) + + @@index([userId]) + @@index([resourceType, resourceId]) + @@index([createdAt]) + @@index([action]) + @@map("audit_logs") +} + +model WaitlistEntry { + id String @id @default(cuid()) + email String @unique + createdAt DateTime @default(now()) @map("created_at") + + @@map("waitlist_entries") +} + +model ContactMessage { + id String @id @default(cuid()) + name String + email String + message String + createdAt DateTime @default(now()) @map("created_at") + + @@map("contact_messages") +} diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..42b541c --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,754 @@ +import "dotenv/config"; +import { PrismaClient } from "@prisma/client"; +import { PrismaPg } from "@prisma/adapter-pg"; + +const connectionString = process.env.DATABASE_URL; +if (!connectionString) { + throw new Error("DATABASE_URL is required"); +} + +const adapter = new PrismaPg({ connectionString }); +const prisma = new PrismaClient({ adapter } as ConstructorParameters< + typeof PrismaClient +>[0] & { adapter: unknown }); + +function randomBetween(min: number, max: number): number { + return Math.round((Math.random() * (max - min) + min) * 100) / 100; +} + +function randomDate(year: number, month: number): Date { + const daysInMonth = new Date(year, month + 1, 0).getDate(); + const day = Math.floor(Math.random() * daysInMonth) + 1; + const hour = Math.floor(Math.random() * 14) + 8; + return new Date(year, month, day, hour, 0, 0); +} + +function pickRandom(arr: readonly T[]): T { + return arr[Math.floor(Math.random() * arr.length)]!; +} + +const EXPENSE_TEMPLATES = [ + { desc: "Grocery shopping", min: 30, max: 150 }, + { desc: "Electricity bill", min: 60, max: 180 }, + { desc: "Internet subscription", min: 40, max: 80 }, + { desc: "Gas station", min: 25, max: 70 }, + { desc: "Restaurant dinner", min: 20, max: 90 }, + { desc: "Coffee shop", min: 4, max: 12 }, + { desc: "Streaming subscription", min: 10, max: 20 }, + { desc: "Phone bill", min: 30, max: 60 }, + { desc: "Gym membership", min: 25, max: 50 }, + { desc: "Clothing purchase", min: 30, max: 200 }, + { desc: "Home supplies", min: 15, max: 80 }, + { desc: "Car insurance", min: 80, max: 200 }, + { desc: "Medical checkup", min: 50, max: 300 }, + { desc: "Uber ride", min: 8, max: 35 }, + { desc: "Online shopping", min: 15, max: 150 }, + { desc: "Water bill", min: 20, max: 60 }, + { desc: "Pet supplies", min: 20, max: 80 }, + { desc: "Book purchase", min: 10, max: 40 }, + { desc: "Movie tickets", min: 12, max: 30 }, + { desc: "Haircut", min: 15, max: 45 }, + { desc: "Parking fee", min: 5, max: 20 }, + { desc: "Lunch at work", min: 8, max: 18 }, + { desc: "Dentist visit", min: 100, max: 400 }, + { desc: "Home repair", min: 50, max: 500 }, + { desc: "Gift purchase", min: 20, max: 100 }, +]; + +const INCOME_TEMPLATES = [ + { desc: "Monthly salary", min: 3000, max: 5000 }, + { desc: "Freelance payment", min: 200, max: 1500 }, + { desc: "Investment dividend", min: 50, max: 300 }, + { desc: "Side project income", min: 100, max: 800 }, + { desc: "Refund received", min: 20, max: 150 }, + { desc: "Cashback reward", min: 5, max: 50 }, +]; + +const PAYMENT_METHODS = [ + "CARD", + "CASH", + "BANK_TRANSFER", + "AUTO_DEBIT", + "UPI", +] as const; + +// ─── Split-related seed data ───────────────────────────────────────── + +const CONTACT_TEMPLATES = [ + { name: "Sarah Johnson", email: "sarah.j@email.com", phone: "+1-555-0101" }, + { name: "Mike Chen", email: "mike.chen@email.com", phone: "+1-555-0102" }, + { name: "Emily Davis", email: "emily.d@email.com", phone: "+1-555-0103" }, + { name: "Alex Wilson", email: "alex.w@email.com", phone: "+1-555-0104" }, + { name: "Priya Patel", email: "priya.p@email.com", phone: "+1-555-0105" }, + { name: "James Brown", email: "james.b@email.com", phone: "+1-555-0106" }, +]; + +const GROUP_TEMPLATES = [ + { + name: "Apartment 4B", + description: "Monthly apartment expenses", + type: "ROOMMATES" as const, + icon: "Home", + color: "#6366f1", + contactIndices: [0, 1], // Sarah, Mike + }, + { + name: "Bangkok Trip 2026", + description: "Our Thailand vacation", + type: "TRIP" as const, + icon: "Plane", + color: "#f59e0b", + contactIndices: [2, 3, 4], // Emily, Alex, Priya + }, + { + name: "Friday Lunches", + description: "Weekly team lunch splits", + type: "WORK" as const, + icon: "Utensils", + color: "#22c55e", + contactIndices: [5, 2], // James, Emily + }, +]; + +interface ExpenseTemplate { + groupIdx: number; + description: string; + amount: number; + splitMethod: "EQUAL" | "EXACT" | "PERCENTAGE" | "SHARES"; + payerContactIdx: number | null; // null = self + daysAgo: number; + customValues?: (number | undefined)[]; // for PERCENTAGE/EXACT/SHARES - one per participant including self +} + +// Expenses spread across recent months for realistic data +const SPLIT_EXPENSE_TEMPLATES: ExpenseTemplate[] = [ + // ── Apartment 4B (3 members: self + Sarah + Mike) ── + { + groupIdx: 0, + description: "March Rent", + amount: 1800, + splitMethod: "EQUAL", + payerContactIdx: null, + daysAgo: 5, + }, + { + groupIdx: 0, + description: "February Rent", + amount: 1800, + splitMethod: "EQUAL", + payerContactIdx: null, + daysAgo: 35, + }, + { + groupIdx: 0, + description: "Electricity Bill", + amount: 126.5, + splitMethod: "EQUAL", + payerContactIdx: 0, + daysAgo: 8, + }, + { + groupIdx: 0, + description: "Groceries - Costco", + amount: 185.3, + splitMethod: "EQUAL", + payerContactIdx: null, + daysAgo: 3, + }, + { + groupIdx: 0, + description: "Internet Bill", + amount: 59.99, + splitMethod: "EQUAL", + payerContactIdx: 1, + daysAgo: 12, + }, + { + groupIdx: 0, + description: "Cleaning Supplies", + amount: 42.75, + splitMethod: "EQUAL", + payerContactIdx: null, + daysAgo: 15, + }, + { + groupIdx: 0, + description: "Water Bill", + amount: 38.0, + splitMethod: "EQUAL", + payerContactIdx: 0, + daysAgo: 20, + }, + { + groupIdx: 0, + description: "Groceries - Trader Joes", + amount: 94.2, + splitMethod: "EQUAL", + payerContactIdx: 1, + daysAgo: 7, + }, + { + groupIdx: 0, + description: "Couch Repair", + amount: 250.0, + splitMethod: "SHARES", + payerContactIdx: null, + daysAgo: 25, + customValues: [2, 1, 1], + }, + { + groupIdx: 0, + description: "January Rent", + amount: 1800, + splitMethod: "EQUAL", + payerContactIdx: null, + daysAgo: 65, + }, + + // ── Bangkok Trip (4 members: self + Emily + Alex + Priya) ── + { + groupIdx: 1, + description: "Hotel - 3 Nights", + amount: 480.0, + splitMethod: "EQUAL", + payerContactIdx: null, + daysAgo: 14, + }, + { + groupIdx: 1, + description: "Street Food Tour", + amount: 120.0, + splitMethod: "EQUAL", + payerContactIdx: 2, + daysAgo: 13, + }, + { + groupIdx: 1, + description: "Taxi to Airport", + amount: 35.0, + splitMethod: "EQUAL", + payerContactIdx: 3, + daysAgo: 11, + }, + { + groupIdx: 1, + description: "Grand Palace Tickets", + amount: 80.0, + splitMethod: "EQUAL", + payerContactIdx: null, + daysAgo: 12, + }, + { + groupIdx: 1, + description: "Shopping at Chatuchak", + amount: 220.0, + splitMethod: "PERCENTAGE", + payerContactIdx: null, + daysAgo: 13, + customValues: [40, 25, 20, 15], + }, + { + groupIdx: 1, + description: "Thai Massage", + amount: 96.0, + splitMethod: "EQUAL", + payerContactIdx: 2, + daysAgo: 12, + }, + { + groupIdx: 1, + description: "Dinner at Gaggan", + amount: 340.0, + splitMethod: "EQUAL", + payerContactIdx: null, + daysAgo: 11, + }, + { + groupIdx: 1, + description: "Boat Tour", + amount: 160.0, + splitMethod: "EQUAL", + payerContactIdx: 3, + daysAgo: 13, + }, + { + groupIdx: 1, + description: "Snorkeling Day Trip", + amount: 280.0, + splitMethod: "EQUAL", + payerContactIdx: null, + daysAgo: 10, + }, + { + groupIdx: 1, + description: "Travel Insurance", + amount: 200.0, + splitMethod: "EXACT", + payerContactIdx: null, + daysAgo: 30, + customValues: [60, 50, 50, 40], + }, + + // ── Friday Lunches (3 members: self + James + Emily) ── + { + groupIdx: 2, + description: "Pizza at Luigi's", + amount: 48.5, + splitMethod: "EQUAL", + payerContactIdx: null, + daysAgo: 2, + }, + { + groupIdx: 2, + description: "Sushi Platter", + amount: 67.8, + splitMethod: "EQUAL", + payerContactIdx: 5, + daysAgo: 9, + }, + { + groupIdx: 2, + description: "Thai Takeout", + amount: 38.4, + splitMethod: "EQUAL", + payerContactIdx: 2, + daysAgo: 16, + }, + { + groupIdx: 2, + description: "Burger Joint", + amount: 42.0, + splitMethod: "EQUAL", + payerContactIdx: null, + daysAgo: 23, + }, + { + groupIdx: 2, + description: "Indian Buffet", + amount: 54.0, + splitMethod: "EQUAL", + payerContactIdx: 5, + daysAgo: 30, + }, + { + groupIdx: 2, + description: "Taco Tuesday", + amount: 33.6, + splitMethod: "EQUAL", + payerContactIdx: null, + daysAgo: 37, + }, + { + groupIdx: 2, + description: "Poke Bowl", + amount: 45.9, + splitMethod: "EQUAL", + payerContactIdx: 2, + daysAgo: 44, + }, + { + groupIdx: 2, + description: "Vietnamese Pho", + amount: 36.0, + splitMethod: "EQUAL", + payerContactIdx: null, + daysAgo: 51, + }, +]; + +interface SettlementTemplate { + groupIdx: number; + fromContactIdx: number | null; // null = self + toContactIdx: number | null; // null = self + amount: number; + daysAgo: number; + notes?: string; +} + +const SETTLEMENT_TEMPLATES: SettlementTemplate[] = [ + // Apartment: Mike partially settled up for rent + { + groupIdx: 0, + fromContactIdx: 1, + toContactIdx: null, + amount: 600, + daysAgo: 28, + notes: "January rent share", + }, + { + groupIdx: 0, + fromContactIdx: 1, + toContactIdx: null, + amount: 600, + daysAgo: 4, + notes: "February rent share", + }, + // Apartment: Sarah partially settled up + { + groupIdx: 0, + fromContactIdx: 0, + toContactIdx: null, + amount: 500, + daysAgo: 30, + notes: "January expenses", + }, + // Bangkok Trip: self paid Emily back + { + groupIdx: 1, + fromContactIdx: null, + toContactIdx: 2, + amount: 45, + daysAgo: 9, + notes: "For street food tour", + }, + // Bangkok Trip: Priya settled + { + groupIdx: 1, + fromContactIdx: 4, + toContactIdx: null, + amount: 200, + daysAgo: 7, + notes: "Trip settlement", + }, + // Friday Lunches: James settled + { + groupIdx: 2, + fromContactIdx: 5, + toContactIdx: null, + amount: 25, + daysAgo: 6, + notes: "Lunch catch-up", + }, +]; + +// ─── Main seed function ────────────────────────────────────────────── + +async function main() { + const user = await prisma.user.findFirst({ + where: { bankAccounts: { some: {} } }, + include: { + bankAccounts: true, + categories: true, + }, + }); + + if (!user) { + console.log("No user with a bank account found. Create a user first."); + return; + } + + console.log(`Seeding data for user: ${user.email}`); + + const account = user.bankAccounts[0]!; + const expenseCategories = user.categories.filter((c) => c.type === "EXPENSE"); + const incomeCategories = user.categories.filter((c) => c.type === "INCOME"); + + // ── Clean up existing data (order matters for FK constraints) ── + console.log("Cleaning up existing data..."); + await prisma.settlement.deleteMany({ where: { createdById: user.id } }); + await prisma.expenseParticipant.deleteMany({ + where: { expense: { createdById: user.id } }, + }); + await prisma.expense.deleteMany({ where: { createdById: user.id } }); + await prisma.groupMember.deleteMany({ + where: { group: { userId: user.id } }, + }); + await prisma.group.deleteMany({ where: { userId: user.id } }); + await prisma.contact.deleteMany({ where: { userId: user.id } }); + const deletedTxns = await prisma.transaction.deleteMany({ + where: { userId: user.id }, + }); + console.log( + `Deleted ${deletedTxns.count} existing transactions + all splits data.`, + ); + + // ═══════════════════════════════════════════════════════════════════ + // PART 1: Seed transactions (same as before) + // ═══════════════════════════════════════════════════════════════════ + + const now = new Date(); + const START_YEAR = 2025; + const START_MONTH = 0; // January (0-indexed) + + const transactions: { + userId: string; + accountId: string; + categoryId: string | null; + amount: number; + type: "DEBIT" | "CREDIT"; + description: string; + date: Date; + paymentMethod: + | "CARD" + | "CASH" + | "BANK_TRANSFER" + | "AUTO_DEBIT" + | "UPI" + | "OTHER" + | null; + }[] = []; + + const current = new Date(START_YEAR, START_MONTH, 1); + while ( + current.getFullYear() < now.getFullYear() || + (current.getFullYear() === now.getFullYear() && + current.getMonth() <= now.getMonth()) + ) { + const year = current.getFullYear(); + const month = current.getMonth(); + + const incomeCount = Math.random() > 0.3 ? 2 : 1; + for (let i = 0; i < incomeCount; i++) { + const template = pickRandom(INCOME_TEMPLATES); + const category = + incomeCategories.length > 0 ? pickRandom(incomeCategories) : null; + transactions.push({ + userId: user.id, + accountId: account.id, + categoryId: category?.id ?? null, + amount: randomBetween(template.min, template.max), + type: "CREDIT", + description: template.desc, + date: randomDate(year, month), + paymentMethod: pickRandom(PAYMENT_METHODS), + }); + } + + const expenseCount = Math.floor(Math.random() * 8) + 8; + for (let i = 0; i < expenseCount; i++) { + const template = pickRandom(EXPENSE_TEMPLATES); + const category = + expenseCategories.length > 0 ? pickRandom(expenseCategories) : null; + transactions.push({ + userId: user.id, + accountId: account.id, + categoryId: category?.id ?? null, + amount: randomBetween(template.min, template.max), + type: "DEBIT", + description: template.desc, + date: randomDate(year, month), + paymentMethod: pickRandom(PAYMENT_METHODS), + }); + } + + current.setMonth(current.getMonth() + 1); + } + + const txnResult = await prisma.transaction.createMany({ data: transactions }); + const monthCount = + (now.getFullYear() - START_YEAR) * 12 + now.getMonth() - START_MONTH + 1; + console.log( + `Created ${txnResult.count} transactions across ${monthCount} months.`, + ); + + // ═══════════════════════════════════════════════════════════════════ + // PART 2: Seed contacts + // ═══════════════════════════════════════════════════════════════════ + + console.log("\nSeeding contacts..."); + const contacts: { id: string; name: string }[] = []; + for (const tmpl of CONTACT_TEMPLATES) { + const contact = await prisma.contact.create({ + data: { + userId: user.id, + name: tmpl.name, + email: tmpl.email, + phone: tmpl.phone, + }, + select: { id: true, name: true }, + }); + contacts.push(contact); + } + console.log(`Created ${contacts.length} contacts.`); + + // ═══════════════════════════════════════════════════════════════════ + // PART 3: Seed groups + members + // ═══════════════════════════════════════════════════════════════════ + + console.log("Seeding groups..."); + const groups: { + id: string; + name: string; + memberContactIds: (string | null)[]; + }[] = []; + + for (const tmpl of GROUP_TEMPLATES) { + const group = await prisma.group.create({ + data: { + userId: user.id, + name: tmpl.name, + description: tmpl.description, + type: tmpl.type, + icon: tmpl.icon, + color: tmpl.color, + currency: "USD", + }, + select: { id: true, name: true }, + }); + + // Owner member (self, contactId = null) + await prisma.groupMember.create({ + data: { + groupId: group.id, + userId: user.id, + contactId: null, + role: "OWNER", + }, + }); + + // Contact members + const memberContactIds: (string | null)[] = [null]; // self first + for (const idx of tmpl.contactIndices) { + const contact = contacts[idx]!; + await prisma.groupMember.create({ + data: { + groupId: group.id, + userId: user.id, + contactId: contact.id, + role: "MEMBER", + }, + }); + memberContactIds.push(contact.id); + } + + groups.push({ id: group.id, name: group.name, memberContactIds }); + } + console.log(`Created ${groups.length} groups with members.`); + + // ═══════════════════════════════════════════════════════════════════ + // PART 4: Seed expenses with participants + // ═══════════════════════════════════════════════════════════════════ + + console.log("Seeding expenses..."); + let expenseCount = 0; + + for (const tmpl of SPLIT_EXPENSE_TEMPLATES) { + const group = groups[tmpl.groupIdx]!; + const memberContactIds = group.memberContactIds; // [null, contactId1, contactId2, ...] + const numMembers = memberContactIds.length; + const expenseDate = new Date(now.getTime() - tmpl.daysAgo * 86400000); + + // Determine the payer's contactId + const payerContactId = + tmpl.payerContactIdx !== null ? contacts[tmpl.payerContactIdx]!.id : null; // null = self + + // Calculate owedAmount per participant based on split method + let owedAmounts: number[]; + switch (tmpl.splitMethod) { + case "EQUAL": { + const base = Math.floor((tmpl.amount / numMembers) * 100) / 100; + const remainder = + Math.round((tmpl.amount - base * numMembers) * 100) / 100; + owedAmounts = memberContactIds.map((_, i) => + i === 0 ? base + remainder : base, + ); + break; + } + case "PERCENTAGE": { + const pcts = tmpl.customValues!; + owedAmounts = pcts.map( + (pct) => Math.round(tmpl.amount * ((pct ?? 0) / 100) * 100) / 100, + ); + // Adjust rounding + const sum = owedAmounts.reduce((a, b) => a + b, 0); + const diff = Math.round((tmpl.amount - sum) * 100) / 100; + if (diff !== 0) + owedAmounts[0] = Math.round((owedAmounts[0]! + diff) * 100) / 100; + break; + } + case "EXACT": { + owedAmounts = tmpl.customValues!.map((v) => v ?? 0); + break; + } + case "SHARES": { + const shares = tmpl.customValues!; + const totalShares = shares.reduce((a: number, b) => a + (b ?? 1), 0); + owedAmounts = shares.map( + (s) => + Math.round(tmpl.amount * ((s ?? 1) / (totalShares || 1)) * 100) / + 100, + ); + const sum = owedAmounts.reduce((a, b) => a + b, 0); + const diff = Math.round((tmpl.amount - sum) * 100) / 100; + if (diff !== 0) + owedAmounts[0] = Math.round((owedAmounts[0]! + diff) * 100) / 100; + break; + } + } + + // Pick a random expense category if available + const category = + expenseCategories.length > 0 ? pickRandom(expenseCategories) : null; + + const expense = await prisma.expense.create({ + data: { + groupId: group.id, + createdById: user.id, + description: tmpl.description, + amount: tmpl.amount, + currency: "USD", + categoryId: category?.id ?? null, + date: expenseDate, + splitMethod: tmpl.splitMethod, + }, + select: { id: true }, + }); + + // Create participants + const participantData = memberContactIds.map((contactId, i) => { + const isPayer = contactId === payerContactId; + return { + expenseId: expense.id, + contactId, + isPayer, + paidAmount: isPayer ? tmpl.amount : 0, + owedAmount: owedAmounts[i]!, + }; + }); + + await prisma.expenseParticipant.createMany({ data: participantData }); + expenseCount++; + } + console.log(`Created ${expenseCount} expenses with participants.`); + + // ═══════════════════════════════════════════════════════════════════ + // PART 5: Seed settlements + // ═══════════════════════════════════════════════════════════════════ + + console.log("Seeding settlements..."); + let settlementCount = 0; + + for (const tmpl of SETTLEMENT_TEMPLATES) { + const group = groups[tmpl.groupIdx]!; + const settlementDate = new Date(now.getTime() - tmpl.daysAgo * 86400000); + + const fromContactId = + tmpl.fromContactIdx !== null ? contacts[tmpl.fromContactIdx]!.id : null; + const toContactId = + tmpl.toContactIdx !== null ? contacts[tmpl.toContactIdx]!.id : null; + + await prisma.settlement.create({ + data: { + groupId: group.id, + createdById: user.id, + fromContactId, + toContactId, + amount: tmpl.amount, + currency: "USD", + date: settlementDate, + notes: tmpl.notes, + }, + }); + settlementCount++; + } + console.log(`Created ${settlementCount} settlements.`); + + console.log("\nSeed complete!"); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => void prisma.$disconnect()); diff --git a/public/images/logos/Excel.tsx b/public/images/logos/Excel.tsx new file mode 100644 index 0000000..8816383 --- /dev/null +++ b/public/images/logos/Excel.tsx @@ -0,0 +1,29 @@ +import { type SVGProps } from "react"; + +export default function Excel(props: SVGProps) { + return ( + + {"Microsoft Excel"} + + + + + + + + + + + + ); +} diff --git a/public/images/logos/Gemini.tsx b/public/images/logos/Gemini.tsx deleted file mode 100644 index 63612a6..0000000 --- a/public/images/logos/Gemini.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { type SVGProps } from "react"; - -export default function Gemini(props: SVGProps) { - return ( - - {"Gemini"} - - - - - - - - - - ); -} diff --git a/public/images/logos/Gmail.tsx b/public/images/logos/Gmail.tsx new file mode 100644 index 0000000..0669e6e --- /dev/null +++ b/public/images/logos/Gmail.tsx @@ -0,0 +1,22 @@ +import { type SVGProps } from "react"; + +export default function Gmail(props: SVGProps) { + return ( + + {"Gmail"} + + + + + + + + ); +} diff --git a/public/images/logos/GoogleCalendar.tsx b/public/images/logos/GoogleCalendar.tsx new file mode 100644 index 0000000..3d2203c --- /dev/null +++ b/public/images/logos/GoogleCalendar.tsx @@ -0,0 +1,49 @@ +import { type SVGProps } from "react"; + +export default function GoogleCalendar(props: SVGProps) { + return ( + + {"Google Calendar"} + + + + + + + + + + ); +} diff --git a/public/images/logos/GoogleDrive.tsx b/public/images/logos/GoogleDrive.tsx new file mode 100644 index 0000000..c5f7b61 --- /dev/null +++ b/public/images/logos/GoogleDrive.tsx @@ -0,0 +1,41 @@ +import { type SVGProps } from "react"; + +export default function GoogleDrive(props: SVGProps) { + return ( + + {"Google Drive"} + + + + + + + + ); +} diff --git a/public/images/logos/GooglePaLM.tsx b/public/images/logos/GooglePaLM.tsx deleted file mode 100644 index 35627fc..0000000 --- a/public/images/logos/GooglePaLM.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import type { SVGProps } from "react"; - -export default function GooglePaLM(props: SVGProps) { - return ( - - - - - - - - - - ); -} diff --git a/public/images/logos/GoogleSheets.tsx b/public/images/logos/GoogleSheets.tsx new file mode 100644 index 0000000..3139010 --- /dev/null +++ b/public/images/logos/GoogleSheets.tsx @@ -0,0 +1,40 @@ +import { type SVGProps } from "react"; + +export default function GoogleSheets(props: SVGProps) { + return ( + + {"Google Sheets"} + + + + + + + + + ); +} diff --git a/public/images/logos/MagicUI.tsx b/public/images/logos/MagicUI.tsx deleted file mode 100644 index 76359ea..0000000 --- a/public/images/logos/MagicUI.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import type { SVGProps } from "react"; - -export default function MagicUI(props: SVGProps) { - return ( - - - - - - - - - - - - - - - - - - ); -} diff --git a/public/images/logos/MediaWiki.tsx b/public/images/logos/MediaWiki.tsx deleted file mode 100644 index 5fff424..0000000 --- a/public/images/logos/MediaWiki.tsx +++ /dev/null @@ -1,430 +0,0 @@ -import { type SVGProps } from "react"; - -export default function MediaWiki(props: SVGProps) { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -} diff --git a/public/images/logos/Notion.tsx b/public/images/logos/Notion.tsx new file mode 100644 index 0000000..f113529 --- /dev/null +++ b/public/images/logos/Notion.tsx @@ -0,0 +1,33 @@ +import { type SVGProps } from "react"; + +export default function Notion(props: SVGProps) { + return ( + + {"Notion"} + + + + + ); +} diff --git a/public/images/logos/Replit.tsx b/public/images/logos/Replit.tsx deleted file mode 100644 index fdf8fd1..0000000 --- a/public/images/logos/Replit.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { type SVGProps } from "react"; - -export default function Replit(props: SVGProps) { - return ( - - - - - - ); -} diff --git a/public/images/logos/VSCodium.tsx b/public/images/logos/VSCodium.tsx deleted file mode 100644 index c6b949a..0000000 --- a/public/images/logos/VSCodium.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { type SVGProps } from "react"; - -export default function VSCodium(props: SVGProps) { - return ( - - - - - - - - - - ); -} diff --git a/public/images/logos/google-sheets.svg b/public/images/logos/google-sheets.svg deleted file mode 100644 index e2a924c..0000000 --- a/public/images/logos/google-sheets.svg +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/public/images/logos/index.ts b/public/images/logos/index.ts index c7bb231..a33346e 100644 --- a/public/images/logos/index.ts +++ b/public/images/logos/index.ts @@ -1,6 +1,6 @@ -export { default as Gemini } from "./Gemini"; -export { default as Replit } from "./Replit"; -export { default as MagicUI } from "./MagicUI"; -export { default as VSCodium } from "./VSCodium"; -export { default as MediaWiki } from "./MediaWiki"; -export { default as GooglePaLM } from "./GooglePaLM"; +export { default as GoogleSheets } from "./GoogleSheets"; +export { default as Notion } from "./Notion"; +export { default as Excel } from "./Excel"; +export { default as GoogleDrive } from "./GoogleDrive"; +export { default as Gmail } from "./Gmail"; +export { default as GoogleCalendar } from "./GoogleCalendar"; diff --git a/src/app/(auth)/forgot-password/page.tsx b/src/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..ec3265d --- /dev/null +++ b/src/app/(auth)/forgot-password/page.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { useState } from "react"; +import { useAuth } from "@/hooks/use-auth"; +import { Input } from "@ui/input"; +import { Button } from "@ui/button"; +import { Field, FieldGroup, FieldLabel, FieldDescription } from "@ui/field"; +import { toast } from "sonner"; +import { Mail } from "lucide-react"; +import Link from "next/link"; + +export default function ForgotPasswordPage() { + const [email, setEmail] = useState(""); + const [loading, setLoading] = useState(false); + const [sent, setSent] = useState(false); + const { requestPasswordReset } = useAuth(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!email) { + toast.error("Please enter your email address."); + return; + } + setLoading(true); + const loadingToastId = toast.loading("Sending password reset email..."); + try { + await requestPasswordReset(email, "/reset-password"); + setSent(true); + toast.success("Password reset email sent! Check your inbox."); + } catch (err: unknown) { + let errorMsg = "Failed to send reset email."; + if (err && typeof err === "object" && "message" in err) { + errorMsg = (err as { message?: string }).message ?? errorMsg; + } + toast.error(errorMsg); + } finally { + toast.dismiss(loadingToastId); + setLoading(false); + } + }; + + return ( +
+
+
+
+ +
+

+ Forgot your password? +

+

+ {sent + ? "We've sent a password reset link to your email. Check your inbox and follow the instructions." + : "Enter your email address and we'll send you a link to reset your password."} +

+
+ + {!sent && ( + + + Email + setEmail(e.target.value)} + required + disabled={loading} + autoComplete="email" + placeholder="m@example.com" + className="focus-visible:ring-ring transition-all focus-visible:ring-2" + /> + + + + + )} + + {sent && ( + + )} + + + Remember your password?{" "} + + Sign in + + +
+
+ ); +} diff --git a/src/app/(auth)/two-factor/page.tsx b/src/app/(auth)/two-factor/page.tsx new file mode 100644 index 0000000..fcf0e10 --- /dev/null +++ b/src/app/(auth)/two-factor/page.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { useState } from "react"; +import { useAuth } from "@/hooks/use-auth"; +import { toast } from "sonner"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { Button } from "@ui/button"; +import { Checkbox } from "@ui/checkbox"; +import { Label } from "@ui/label"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, + InputOTPSeparator, +} from "@ui/input-otp"; +import { ShieldCheck, KeyRound } from "lucide-react"; + +export default function TwoFactorPage() { + const [code, setCode] = useState(""); + const [backupCode, setBackupCode] = useState(""); + const [useBackup, setUseBackup] = useState(false); + const [trustDevice, setTrustDevice] = useState(false); + const [loading, setLoading] = useState(false); + const { verifyTOTP, verifyBackupCode } = useAuth(); + const router = useRouter(); + + const handleVerifyTotp = async (value: string) => { + if (value.length !== 6) return; + setLoading(true); + try { + await verifyTOTP(value, trustDevice); + toast.success("Verified successfully"); + router.push("/overview"); + } catch { + toast.error("Invalid verification code. Please try again."); + setCode(""); + } finally { + setLoading(false); + } + }; + + const handleVerifyBackup = async (e: React.FormEvent) => { + e.preventDefault(); + if (!backupCode.trim()) { + toast.error("Please enter a backup code"); + return; + } + setLoading(true); + try { + await verifyBackupCode(backupCode.trim(), trustDevice); + toast.success("Verified successfully"); + router.push("/overview"); + } catch { + toast.error("Invalid backup code. Please try again."); + setBackupCode(""); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+
+ {useBackup ? ( + + ) : ( + + )} +
+

+ Two-Factor Authentication +

+

+ {useBackup + ? "Enter one of your backup codes to verify your identity." + : "Enter the 6-digit code from your authenticator app."} +

+
+ + {!useBackup ? ( +
+ { + setCode(value); + if (value.length === 6) { + void handleVerifyTotp(value); + } + }} + disabled={loading} + > + + + + + + + + + + + + + + {loading && ( +

Verifying...

+ )} +
+ ) : ( +
+
+ + setBackupCode(e.target.value)} + placeholder="Enter your backup code" + disabled={loading} + className="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm shadow-sm transition-colors focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50" + autoComplete="off" + /> +
+ +
+ )} + +
+ setTrustDevice(checked === true)} + /> + +
+ +
+ + + Back to sign in + +
+
+
+ ); +} diff --git a/src/app/(features)/accounts/[id]/_client.tsx b/src/app/(features)/accounts/[id]/_client.tsx index 62ace4f..99e3256 100644 --- a/src/app/(features)/accounts/[id]/_client.tsx +++ b/src/app/(features)/accounts/[id]/_client.tsx @@ -95,7 +95,7 @@ export default function AccountDetailPageClient() { const formattedBalance = formatAmount(Number(account.balance)); return ( -
+
diff --git a/src/app/(features)/accounts/_client.tsx b/src/app/(features)/accounts/_client.tsx index 36de46a..047810a 100644 --- a/src/app/(features)/accounts/_client.tsx +++ b/src/app/(features)/accounts/_client.tsx @@ -35,7 +35,7 @@ const mapToInitialValues = (a: Account | ApiAccount) => ({ export default function AccountsPageClient() { const router = useRouter(); const { accounts, isLoading } = useAccounts(); - const { formatAmount } = useFormatter(); + const { formatAmount, formatDate } = useFormatter(); const [modalState, setModalState] = useState<{ isCreateOpen: boolean; @@ -88,7 +88,7 @@ export default function AccountsPageClient() { }, [modalState.deletingId, deleteAccount]); return ( -
+

@@ -108,26 +108,25 @@ export default function AccountsPageClient() {

-
- {isLoading ? ( - - ) : accounts.length === 0 ? ( - - ) : ( -
- {accounts.map((account) => ( - router.push(`/accounts/${account.id}`)} - formatAmount={formatAmount} - /> - ))} -
- )} -
+ {isLoading ? ( + + ) : accounts.length === 0 ? ( + + ) : ( +
+ {accounts.map((account) => ( + router.push(`/accounts/${account.id}`)} + formatAmount={formatAmount} + formatDate={formatDate} + /> + ))} +
+ )} - import("@/components/charts/area-chart").then((m) => ({ - default: m.AreaChart, - })), - { loading: () => }, -); +import { useCategories } from "@/hooks/use-categories"; +import { useFormatter } from "@/hooks/use-formatter"; import { Select, SelectContent, @@ -21,9 +12,13 @@ import { SelectValue, } from "@ui/select"; import type { ChartConfig } from "@ui/chart"; -import { subMonths, format } from "date-fns"; -import { useFormatter } from "@/hooks/use-formatter"; import { CalendarDays } from "lucide-react"; +import { useCategoryBarData } from "./_hooks/use-category-bar-data"; +import { useTopTransactions } from "./_hooks/use-top-transactions"; +import { useAreaChartData } from "./_hooks/use-area-chart-data"; +import { CategoryBarCard } from "@/components/pages/(protected)/analytics/category-bar-card"; +import { TopTransactionsCard } from "@/components/pages/(protected)/analytics/top-transactions-card"; +import { IncomeExpenseChart } from "@/components/pages/(protected)/analytics/income-expense-chart"; type DateRange = "3" | "6" | "12"; @@ -34,94 +29,97 @@ const DATE_RANGE_OPTIONS: { value: DateRange; label: string }[] = [ ]; const areaConfig = { - income: { label: "Income", color: "#3b82f6" }, - expense: { label: "Expense", color: "#06b6d4" }, + income: { label: "Income", color: "#22c55e" }, + expense: { label: "Expense", color: "#ef4444" }, } satisfies ChartConfig; export default function AnalyticsPageClient() { - const { formatAmount } = useFormatter(); + const { formatAmount, formatDate } = useFormatter(); const { listQuery } = useTransactions(); + const { categoryMap, allFlat } = useCategories(); const [dateRange, setDateRange] = useState("6"); - const { data: txData } = listQuery({ limit: 100 }); + const rangeMonths = parseInt(dateRange, 10); + + const { data: txData, isLoading } = listQuery({ limit: 100 }); const transactions = useMemo( () => txData?.transactions ?? [], [txData?.transactions], ); - const areaChartData = useMemo(() => { - const now = new Date(); - const rangeMonths = parseInt(dateRange, 10); - const cutoff = subMonths(now, rangeMonths); + const flatCategories = useMemo(() => allFlat.data ?? [], [allFlat.data]); - const buckets: Record< - string, - { date: string; income: number; expense: number } - > = {}; - transactions.forEach((tx) => { - const txDate = new Date(tx.date); - if (txDate < cutoff) return; + const { data: barData, config: barConfig } = useCategoryBarData( + transactions, + categoryMap, + flatCategories, + ); - const amt = Math.abs(parseFloat(tx.amount)); - const label = format(txDate, "MMM dd"); - buckets[label] ??= { date: label, income: 0, expense: 0 }; - if (tx.type === "CREDIT") buckets[label].income += amt; - else if (tx.type === "DEBIT") buckets[label].expense += amt; - }); - return Object.values(buckets).sort( - (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(), - ); - }, [transactions, dateRange]); + const topTransactions = useTopTransactions( + transactions, + categoryMap, + flatCategories, + ); + const chartData = useAreaChartData(transactions, rangeMonths, "monthly"); + + const rangeLabel = + DATE_RANGE_OPTIONS.find((opt) => opt.value === dateRange)?.label ?? + "the selected period"; return ( -
+
{/* Header */} -
-

- Analytics -

-

- Visualize your income, expenses, and spending patterns -

+
+
+

+ Analytics +

+

+ Visualize your income, expenses, and spending patterns +

+
+ +
+ + {/* Row 1: Bar chart + Top transactions */} +
+ formatAmount(val)} + /> +
- {/* Area Chart */} - - - Income vs Expenses - - - - } - > - formatAmount(val)} - /> - - - + {/* Row 2: Area chart */} + formatAmount(val)} + />
); } diff --git a/src/app/(features)/analytics/_hooks/use-area-chart-data.ts b/src/app/(features)/analytics/_hooks/use-area-chart-data.ts new file mode 100644 index 0000000..466f27b --- /dev/null +++ b/src/app/(features)/analytics/_hooks/use-area-chart-data.ts @@ -0,0 +1,59 @@ +import { useMemo } from "react"; +import { subMonths, format } from "date-fns"; +import type { Transaction } from "@/types/transaction"; + +export type ChartGranularity = "daily" | "monthly"; + +interface ChartDataPoint { + [key: string]: unknown; + date: string; + displayDate: string; + income: number; + expense: number; +} + +export function useAreaChartData( + transactions: Transaction[], + rangeMonths: number, + granularity: ChartGranularity = "daily", +): ChartDataPoint[] { + return useMemo(() => { + const now = new Date(); + const cutoff = subMonths(now, rangeMonths); + + const isMonthly = granularity === "monthly"; + const buckets: Record< + string, + { sortKey: string; displayDate: string; income: number; expense: number } + > = {}; + + for (const tx of transactions) { + const txDate = new Date(tx.date); + if (txDate < cutoff) continue; + + const amt = Math.abs(parseFloat(tx.amount)); + const sortKey = isMonthly + ? format(txDate, "yyyy-MM") + : format(txDate, "yyyy-MM-dd"); + const displayDate = isMonthly + ? format(txDate, "MMM yyyy") + : format(txDate, "MMM dd"); + + buckets[sortKey] ??= { sortKey, displayDate, income: 0, expense: 0 }; + const bucket = buckets[sortKey]; + if (bucket) { + if (tx.type === "CREDIT") bucket.income += amt; + else if (tx.type === "DEBIT") bucket.expense += amt; + } + } + + return Object.values(buckets) + .sort((a, b) => a.sortKey.localeCompare(b.sortKey)) + .map(({ displayDate, income, expense }) => ({ + date: displayDate, + displayDate, + income, + expense, + })); + }, [transactions, rangeMonths, granularity]); +} diff --git a/src/app/(features)/analytics/_hooks/use-category-bar-data.ts b/src/app/(features)/analytics/_hooks/use-category-bar-data.ts new file mode 100644 index 0000000..b387f3d --- /dev/null +++ b/src/app/(features)/analytics/_hooks/use-category-bar-data.ts @@ -0,0 +1,57 @@ +import { useMemo } from "react"; +import type { Transaction } from "@/types/transaction"; +import type { ChartConfig } from "@ui/chart"; + +interface CategoryLike { + id: string; + name: string; + color: string | null; +} + +interface CategoryBarItem { + [key: string]: unknown; + category: string; + amount: number; + fill: string; +} + +export function useCategoryBarData( + transactions: Transaction[], + categoryMap: Map, + categories: CategoryLike[], +): { data: CategoryBarItem[]; config: ChartConfig } { + const data = useMemo(() => { + const totals: Record = {}; + + for (const tx of transactions) { + if (tx.type === "DEBIT") { + const amount = Math.abs(parseFloat(tx.amount)); + const catName = tx.categoryId + ? (categoryMap.get(tx.categoryId) ?? "Other") + : "Uncategorized"; + totals[catName] = (totals[catName] ?? 0) + amount; + } + } + + return Object.entries(totals) + .sort(([, a], [, b]) => b - a) + .slice(0, 6) + .map(([name, amount], index) => { + const category = categories.find((c) => c.name === name); + const fill = category?.color ?? `var(--chart-${(index % 5) + 1})`; + return { category: name, amount, fill }; + }); + }, [transactions, categoryMap, categories]); + + const config = useMemo(() => { + const cfg: ChartConfig = {}; + for (const item of data) { + // Sanitize key — CSS custom property names cannot contain / or & + const key = item.category.replace(/[^a-zA-Z0-9-_]/g, "-"); + cfg[key] = { label: item.category, color: item.fill }; + } + return cfg; + }, [data]); + + return { data, config }; +} diff --git a/src/app/(features)/analytics/_hooks/use-top-transactions.ts b/src/app/(features)/analytics/_hooks/use-top-transactions.ts new file mode 100644 index 0000000..51277b4 --- /dev/null +++ b/src/app/(features)/analytics/_hooks/use-top-transactions.ts @@ -0,0 +1,61 @@ +import { useMemo } from "react"; +import type { Transaction } from "@/types/transaction"; + +interface CategoryLike { + id: string; + name: string; + color: string | null; + icon?: string | null; +} + +export interface TopTransaction { + id: string; + description: string; + amount: string; + type: Transaction["type"]; + date: string; + categoryName: string; + categoryColor: string | null; + categoryIcon: string | null; +} + +export function useTopTransactions( + transactions: Transaction[], + categoryMap: Map, + categories: CategoryLike[], +): TopTransaction[] { + return useMemo(() => { + const colorMap = new Map(); + const iconMap = new Map(); + for (const c of categories) { + colorMap.set(c.id, c.color); + iconMap.set(c.id, c.icon ?? null); + } + + // Show only DEBIT (expenses) so salary doesn't dominate. + // Pick the top 5 by absolute amount. + return transactions + .filter((tx) => tx.type === "DEBIT") + .sort( + (a, b) => + Math.abs(parseFloat(b.amount)) - Math.abs(parseFloat(a.amount)), + ) + .slice(0, 5) + .map((tx) => ({ + id: tx.id, + description: tx.description ?? "No description", + amount: tx.amount, + type: tx.type, + date: tx.date, + categoryName: tx.categoryId + ? (categoryMap.get(tx.categoryId) ?? "Uncategorized") + : "Uncategorized", + categoryColor: tx.categoryId + ? (colorMap.get(tx.categoryId) ?? null) + : null, + categoryIcon: tx.categoryId + ? (iconMap.get(tx.categoryId) ?? null) + : null, + })); + }, [transactions, categoryMap, categories]); +} diff --git a/src/app/(features)/analytics/page.tsx b/src/app/(features)/analytics/page.tsx index ee54170..03650e1 100644 --- a/src/app/(features)/analytics/page.tsx +++ b/src/app/(features)/analytics/page.tsx @@ -5,6 +5,7 @@ export const dynamic = "force-dynamic"; export default async function AnalyticsPage() { void api.transaction.list.prefetch({ limit: 100 }); + void api.category.list.prefetch(); void api.settings.getAll.prefetch(); return ( diff --git a/src/app/(features)/budget/_client.tsx b/src/app/(features)/budget/_client.tsx index fd85091..02aa3c9 100644 --- a/src/app/(features)/budget/_client.tsx +++ b/src/app/(features)/budget/_client.tsx @@ -1,57 +1,24 @@ "use client"; -import React, { Suspense } from "react"; -import dynamic from "next/dynamic"; +import React from "react"; import { api } from "@/trpc/react"; -import { toNum } from "@shared/decimal"; -import { Loader2, Wallet } from "lucide-react"; +import { Wallet } from "lucide-react"; import { BudgetCard } from "@/components/pages/(protected)/budget/budget-card"; import { CreateBudgetDialog } from "@/components/pages/(protected)/budget/create-budget-dialog"; -import { Skeleton } from "@ui/skeleton"; -import type { ChartConfig } from "@ui/chart"; - -const GenericRadarChart = dynamic( - () => - import("@/components/charts/radar-chart").then((m) => ({ - default: m.GenericRadarChart, - })), - { loading: () => }, -); +import { BudgetSkeleton } from "@skeletons/budget-skeleton"; export default function BudgetPageClient() { const { data: budgets, isLoading } = api.budget.all.useQuery(); if (isLoading) { - return ( -
- -
- ); + return ; } const budgetList = budgets ?? []; - // Transform data for Radar Chart - const chartData = budgetList.slice(0, 6).map((b) => ({ - category: b.category.name, - budget: toNum(b.amount), - spent: toNum(b.spentAmount), - })); - - const radarConfig = { - budget: { - label: "Budget", - color: "hsl(var(--primary))", - }, - spent: { - label: "Spent", - color: "hsl(var(--destructive))", - }, - } satisfies ChartConfig; - return ( -
-
+
+

Budget & Goals @@ -78,56 +45,19 @@ export default function BudgetPageClient() {

) : ( -
- {/* Main Content: Budget Cards */} -
-

- Active Budgets -

-
- {budgetList.map((b) => ( - - ))} -
-
- - {/* Sidebar: Analytics */} -
-

Analytics

- } - > - - -
+
+ {budgetList.map((b) => ( + + ))}
)}
diff --git a/src/app/(features)/overview/_client.tsx b/src/app/(features)/overview/_client.tsx index 9587216..097f13c 100644 --- a/src/app/(features)/overview/_client.tsx +++ b/src/app/(features)/overview/_client.tsx @@ -16,32 +16,13 @@ import { type DateRange, } from "@/components/pages/(protected)/overview/spending-overview-card"; import { SpendingByCategoryCard } from "@/components/pages/(protected)/overview/spending-by-category-card"; -import { DefaultView } from "@prisma/client"; -import { useRouter } from "next/navigation"; -import { useSettings } from "@/hooks/use-settings"; -import { useEffect } from "react"; import { useFormatter } from "@/hooks/use-formatter"; import type { Transaction } from "@/types/transaction"; export default function OverviewPageClient() { const { formatAmount } = useFormatter(); - const router = useRouter(); - const { settings, isLoading: settingsLoading } = useSettings(); const [barRange, setBarRange] = useState("6"); - useEffect(() => { - if (!settingsLoading && settings?.display.defaultView) { - const defaultView = settings.display.defaultView; - if (defaultView === DefaultView.TRANSACTIONS) { - router.replace("/transactions"); - } else if (defaultView === DefaultView.NETWORTH) { - router.replace("/reports"); - } else if (defaultView === DefaultView.PORTFOLIO) { - router.replace("/accounts"); - } - } - }, [settings, settingsLoading, router]); - const { accounts, isLoading: accountsLoading } = useAccounts(); const { listQuery, remove } = useTransactions(); const { allFlat, categoryMap } = useCategories(); @@ -95,6 +76,7 @@ export default function OverviewPageClient() { barChartData={barChartData} barRange={barRange} onBarRangeChange={setBarRange} + isLoading={txLoading} formatAmount={formatAmount} /> { - const category = categories.find((c) => c.name === name); - const fill = category?.color ?? `var(--chart-${(index % 5) + 1})`; - return { name, value, fill }; - }); + return Object.entries(categoryTotals) + .sort(([, a], [, b]) => b - a) + .slice(0, 6) + .map(([name, value], index) => { + const category = categories.find((c) => c.name === name); + const fill = category?.color ?? `var(--chart-${(index % 5) + 1})`; + return { name, value, fill }; + }); }, [transactions, categoryMap, categories]); const pieChartConfig = useMemo(() => { diff --git a/src/app/(features)/reports/_client.tsx b/src/app/(features)/reports/_client.tsx index d1a1605..1d4ab55 100644 --- a/src/app/(features)/reports/_client.tsx +++ b/src/app/(features)/reports/_client.tsx @@ -14,6 +14,7 @@ import { Filter, MoreHorizontal, } from "lucide-react"; +import { ReportPreview } from "@/components/pages/(protected)/reports/report-preview"; import { toast } from "sonner"; import { useCallback, useMemo, useState } from "react"; import type { Report } from "@prisma/client"; @@ -140,8 +141,19 @@ export default function ReportsPageClient() { [], ); - const handleExport = useCallback(() => { - toast.info("Export feature coming soon"); + const [exporting, setExporting] = useState(false); + + const handleExportPdf = useCallback(async (report: Report) => { + setExporting(true); + try { + const { exportReportPdf } = await import("@shared/pdf-export"); + await exportReportPdf(report); + } catch (error) { + console.error("PDF export failed:", error); + toast.error("Failed to export PDF"); + } finally { + setExporting(false); + } }, []); const handleFilterChange = useCallback((typeFilter: string) => { @@ -174,9 +186,9 @@ export default function ReportsPageClient() { } return ( -
- {/* Header section with Welcome text similar to other pages */} -
+
+ {/* Header */} +

Reports & Insights @@ -211,14 +223,6 @@ export default function ReportsPageClient() { className="bg-muted/30 ring-offset-background focus-visible:ring-ring h-10 border-none pl-9 focus-visible:ring-1" />

-
- {selectedReport && ( -
-
- - Report Data Summary - - - JSON Format - -
-
-                  {JSON.stringify(selectedReport.data, null, 2)}
-                
-
- )} + {selectedReport && }
diff --git a/src/app/(features)/settings/page.tsx b/src/app/(features)/settings/page.tsx index 7701965..94148d4 100644 --- a/src/app/(features)/settings/page.tsx +++ b/src/app/(features)/settings/page.tsx @@ -6,7 +6,6 @@ export const dynamic = "force-dynamic"; export default async function SettingsPage() { void api.settings.getAll.prefetch(); void api.category.list.prefetch(); - void api.account.list.prefetch(); return ( diff --git a/src/app/(features)/splits/[groupId]/_client.tsx b/src/app/(features)/splits/[groupId]/_client.tsx new file mode 100644 index 0000000..487e0c7 --- /dev/null +++ b/src/app/(features)/splits/[groupId]/_client.tsx @@ -0,0 +1,550 @@ +"use client"; + +import React, { useState, useCallback, useMemo } from "react"; +import dynamic from "next/dynamic"; +import { toast } from "sonner"; +import { useGroupDetail } from "@/hooks/use-group-detail"; +import { useFormatter } from "@/hooks/use-formatter"; +import { GroupHeader } from "@/components/pages/(protected)/splits/group-detail/group-header"; +import { GroupStats } from "@/components/pages/(protected)/splits/group-detail/group-stats"; +import { ExpenseList } from "@/components/pages/(protected)/splits/group-detail/expense-list"; +import { ActivityFeed } from "@/components/pages/(protected)/splits/group-detail/activity-feed"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@ui/tabs"; +import { Card, CardContent, CardHeader, CardTitle } from "@ui/card"; +import { ScrollArea } from "@ui/scroll-area"; +import { + Receipt, + BarChart3, + HandCoins, + Clock, + Radar, + Users, + Scale, + ArrowRight, +} from "lucide-react"; +import { Avatar, AvatarFallback, AvatarImage } from "@ui/avatar"; +import { generateNamedAvatar } from "@/lib/shared/avatar"; +import { Progress } from "@ui/progress"; +import { Button } from "@ui/button"; +import { cn } from "@/lib/utils"; +import type { CreateExpenseInput } from "@/validation/expense"; +import type { CreateSettlementInput } from "@/validation/settlement"; + +const ExpenseFormSheet = dynamic( + () => + import( + "@/components/pages/(protected)/splits/group-detail/expense-form-sheet" + ).then((m) => ({ default: m.ExpenseFormSheet })), + { ssr: false }, +); + +const SettleUpDialog = dynamic( + () => + import( + "@/components/pages/(protected)/splits/group-detail/settle-up-dialog" + ).then((m) => ({ default: m.SettleUpDialog })), + { ssr: false }, +); + +const SpendingRadarChart = dynamic( + () => + import( + "@/components/pages/(protected)/splits/group-detail/spending-radar-chart" + ).then((m) => ({ default: m.SpendingRadarChart })), + { ssr: false }, +); + +const MemberBalancesChart = dynamic( + () => + import( + "@/components/pages/(protected)/splits/group-detail/member-balances-chart" + ).then((m) => ({ default: m.MemberBalancesChart })), + { ssr: false }, +); + +interface SimplifiedDebt { + from: { contactId: string | null; name: string; avatarUrl: string | null }; + to: { contactId: string | null; name: string; avatarUrl: string | null }; + amount: number; +} + +interface BalanceItem { + contactId: string | null; + name: string; + avatarUrl: string | null; + balance: number; +} + +function BalanceSummaryList({ + balances, + isLoading, + formatAmount, +}: { + balances: BalanceItem[]; + isLoading?: boolean; + formatAmount: (value: string | number) => string; +}) { + const maxAbs = Math.max(...balances.map((b) => Math.abs(b.balance)), 1); + + if (isLoading) { + return ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+ ))} +
+ ); + } + + if (balances.length === 0) { + return ( +

+ No expenses yet. Add an expense to see balances. +

+ ); + } + + return ( +
+ {balances.map((item) => { + const pct = (Math.abs(item.balance) / maxAbs) * 100; + const isPositive = item.balance >= 0; + return ( +
+
+
+ + + + {item.name.charAt(0).toUpperCase()} + + + {item.name} +
+ + {isPositive ? "+" : "-"} + {formatAmount(Math.abs(item.balance))} + +
+ div]:bg-emerald-500" : "[&>div]:bg-rose-500", + )} + /> +
+ ); + })} +
+ ); +} + +function SimplifiedDebtsList({ + debts, + isLoading, + formatAmount, + onSettle, +}: { + debts: SimplifiedDebt[]; + isLoading?: boolean; + formatAmount: (value: string | number) => string; + onSettle: (debt: SimplifiedDebt) => void; +}) { + if (isLoading) { + return ( +
+ {[1, 2].map((i) => ( +
+
+
+
+
+
+
+ ))} +
+ ); + } + + if (debts.length === 0) { + return ( +

+ All settled up! No payments needed. +

+ ); + } + + return ( +
+ {debts.map((debt, index) => ( +
+ + + + {debt.from.name.charAt(0).toUpperCase()} + + + {debt.from.name} + + + + + {debt.to.name.charAt(0).toUpperCase()} + + + {debt.to.name} +
+ +
+ ))} +
+ ); +} + +export default function GroupDetailPageClient({ + groupId, +}: { + groupId: string; +}) { + const { formatAmount, formatDate } = useFormatter(); + + const { + group, + balances, + simplifiedDebts, + activityFeed, + expenses, + isLoading, + isBalancesLoading, + isDebtsLoading, + isFeedLoading, + createExpense, + deleteExpense, + createSettlement, + createExpenseStatus, + createSettlementStatus, + } = useGroupDetail(groupId); + + const [expenseSheetOpen, setExpenseSheetOpen] = useState(false); + const [settleDialogOpen, setSettleDialogOpen] = useState(false); + const [settleDebt, setSettleDebt] = useState(null); + + const stats = useMemo(() => { + const totalSpent = expenses.reduce((sum, e) => sum + e.amount, 0); + const selfBalance = + balances.find((b) => b.contactId === null)?.balance ?? 0; + const yourShare = expenses.reduce((sum, e) => { + const selfParticipant = e.participants.find((p) => p.contactId === null); + return sum + (selfParticipant?.owedAmount ?? 0); + }, 0); + return { totalSpent, yourShare, yourBalance: selfBalance }; + }, [expenses, balances]); + + const handleAddExpense = useCallback(() => { + setExpenseSheetOpen(true); + }, []); + + const handleCreateExpense = useCallback( + async (data: CreateExpenseInput) => { + await createExpense(data); + toast.success("Expense added"); + }, + [createExpense], + ); + + const handleDeleteExpense = useCallback( + async (id: string) => { + try { + await deleteExpense({ id }); + toast.success("Expense deleted"); + } catch { + toast.error("Failed to delete expense"); + } + }, + [deleteExpense], + ); + + const handleSettle = useCallback((debt: SimplifiedDebt) => { + setSettleDebt(debt); + setSettleDialogOpen(true); + }, []); + + const handleCreateSettlement = useCallback( + async (data: CreateSettlementInput) => { + await createSettlement(data); + }, + [createSettlement], + ); + + if (!group && !isLoading) { + return ( +
+

Group not found

+
+ ); + } + + return ( +
+ {/* Header */} + {group && ( + + )} + + {/* Stats — 3 cards */} + + + {/* Tabbed content */} + +
+ + + + Expenses + {expenses.length > 0 && ( + + {expenses.length} + + )} + + + + Charts + + + + Settle Up + {simplifiedDebts.length > 0 && ( + + {simplifiedDebts.length} + + )} + + +
+ + {/* Expenses & Activity tab */} + +
+ {/* Expense list — 3 cols */} +
+ + + + + Expenses + {expenses.length > 0 && ( + + {expenses.length} + + )} + +

+ All shared expenses in this group +

+
+ + + + + +
+
+ + {/* Activity feed — 2 cols */} +
+ + + + + Activity + +

+ Recent expenses and settlements +

+
+ + + + + +
+
+
+
+ + {/* Charts tab */} + +
+ {group && ( + + + + + Spending by Category + +

+ Per-member spending across categories +

+
+ + + +
+ )} + + + + + + Member Balances + +

+ Net balance per group member +

+
+ + + +
+
+
+ + {/* Settle Up tab */} + +
+ {/* Balances — 3 cols */} +
+ + + + + Balances + {balances.length > 0 && ( + + {balances.length} + + )} + +

+ Net balance per group member +

+
+ + + +
+
+ + {/* Simplified debts — 2 cols */} +
+ + + + + Settle Up + {simplifiedDebts.length > 0 && ( + + {simplifiedDebts.length} + + )} + +

+ Minimum payments to settle all debts +

+
+ + + +
+
+
+
+
+ + {/* Sheets */} + {group && ( + + )} + + +
+ ); +} diff --git a/src/app/(features)/splits/[groupId]/loading.tsx b/src/app/(features)/splits/[groupId]/loading.tsx new file mode 100644 index 0000000..db5dc9a --- /dev/null +++ b/src/app/(features)/splits/[groupId]/loading.tsx @@ -0,0 +1,91 @@ +import { Skeleton } from "@ui/skeleton"; + +export default function GroupDetailLoading() { + return ( +
+ {/* Header skeleton */} +
+
+ + +
+ +
+ + +
+ {[1, 2, 3].map((i) => ( + + ))} +
+
+
+
+ +
+ + {/* Stats — 3 cards */} +
+ {[1, 2, 3].map((i) => ( +
+
+
+ + +
+ +
+
+ + +
+
+ ))} +
+ + {/* Tabs skeleton */} +
+ +
+ + {/* Content skeleton */} +
+
+ {[1, 2, 3, 4].map((i) => ( +
+ +
+ + +
+ +
+ ))} +
+
+
+ +
+ {[1, 2, 3, 4].map((i) => ( +
+ +
+ + +
+ +
+ ))} +
+
+
+
+
+ ); +} diff --git a/src/app/(features)/splits/[groupId]/page.tsx b/src/app/(features)/splits/[groupId]/page.tsx new file mode 100644 index 0000000..38eab86 --- /dev/null +++ b/src/app/(features)/splits/[groupId]/page.tsx @@ -0,0 +1,26 @@ +import { api, HydrateClient } from "@/trpc/server"; +import GroupDetailPageClient from "./_client"; + +export const dynamic = "force-dynamic"; + +export default async function GroupDetailPage({ + params, +}: { + params: Promise<{ groupId: string }>; +}) { + const { groupId } = await params; + + void api.group.getById.prefetch({ id: groupId }); + void api.group.getBalances.prefetch({ id: groupId }); + void api.group.getSimplifiedDebts.prefetch({ id: groupId }); + void api.group.activityFeed.prefetch({ id: groupId, limit: 30 }); + void api.expense.list.prefetch({ groupId, limit: 30 }); + void api.settlement.list.prefetch({ groupId, limit: 30 }); + void api.settings.getAll.prefetch(); + + return ( + + + + ); +} diff --git a/src/app/(features)/splits/_client.tsx b/src/app/(features)/splits/_client.tsx new file mode 100644 index 0000000..3063306 --- /dev/null +++ b/src/app/(features)/splits/_client.tsx @@ -0,0 +1,286 @@ +"use client"; + +import React, { useState, useCallback, useMemo } from "react"; +import { Plus, UserPlus, Search } from "lucide-react"; +import { toast } from "sonner"; +import { api } from "@/trpc/react"; +import { useGroups } from "@/hooks/use-groups"; +import { useContacts } from "@/hooks/use-contacts"; +import { SplitStatsCards } from "@/components/pages/(protected)/splits/split-stats-cards"; +import { GroupsList } from "@/components/pages/(protected)/splits/groups-list"; +import { ContactsList } from "@/components/pages/(protected)/splits/contacts-list"; +import { CreateGroupSheet } from "@/components/pages/(protected)/splits/create-group-sheet"; +import { ContactSheet } from "@/components/pages/(protected)/splits/contact-sheet"; +import { Tabs, TabsList, TabsTrigger } from "@ui/tabs"; +import { Button } from "@ui/button"; +import { Input } from "@ui/input"; + +export default function SplitsPageClient() { + // Data hooks + const { + groups, + isLoading: groupsLoading, + createGroup, + archiveGroup, + unarchiveGroup, + deleteGroup, + createStatus: groupCreateStatus, + } = useGroups(); + + const { + contacts, + isLoading: contactsLoading, + createContact, + updateContact, + deleteContact, + createStatus: contactCreateStatus, + updateStatus: contactUpdateStatus, + } = useContacts(); + + const { data: splitSummary, isLoading: summaryLoading } = + api.overview.splitSummary.useQuery(undefined, { + staleTime: 1000 * 60 * 2, + }); + + // Sheet state + const [groupSheetOpen, setGroupSheetOpen] = useState(false); + const [contactSheetOpen, setContactSheetOpen] = useState(false); + const [editingContact, setEditingContact] = useState< + (typeof contacts)[number] | null + >(null); + const [activeTab, setActiveTab] = useState("groups"); + const [searchQuery, setSearchQuery] = useState(""); + + // Filtered groups based on search + const filteredGroups = useMemo(() => { + if (!searchQuery) return groups; + const query = searchQuery.toLowerCase(); + return groups.filter( + (g) => + g.name.toLowerCase().includes(query) || + g.type.toLowerCase().includes(query), + ); + }, [groups, searchQuery]); + + // Filtered contacts based on search + const filteredContacts = useMemo(() => { + if (!searchQuery) return contacts; + const query = searchQuery.toLowerCase(); + return contacts.filter( + (c) => + c.name.toLowerCase().includes(query) || + (c.email?.toLowerCase().includes(query) ?? false) || + (c.phone?.includes(query) ?? false), + ); + }, [contacts, searchQuery]); + + // Handlers + const handleCreateGroup = useCallback( + async (data: Parameters[0]) => { + try { + await createGroup(data); + toast.success("Group created successfully"); + } catch { + toast.error("Failed to create group"); + } + }, + [createGroup], + ); + + const handleArchiveGroup = useCallback( + async (id: string) => { + try { + await archiveGroup({ id }); + toast.success("Group archived"); + } catch { + toast.error("Failed to archive group"); + } + }, + [archiveGroup], + ); + + const handleUnarchiveGroup = useCallback( + async (id: string) => { + try { + await unarchiveGroup({ id }); + toast.success("Group unarchived"); + } catch { + toast.error("Failed to unarchive group"); + } + }, + [unarchiveGroup], + ); + + const handleDeleteGroup = useCallback( + async (id: string) => { + try { + await deleteGroup({ id }); + toast.success("Group deleted"); + } catch { + toast.error("Failed to delete group"); + } + }, + [deleteGroup], + ); + + const handleContactSubmit = useCallback( + async (data: { + id?: string; + name: string; + email?: string; + phone?: string; + avatarUrl?: string; + }) => { + try { + if (data.id) { + await updateContact(data as Parameters[0]); + toast.success("Contact updated"); + } else { + await createContact(data); + toast.success("Contact added"); + } + setEditingContact(null); + } catch { + toast.error( + data.id ? "Failed to update contact" : "Failed to add contact", + ); + } + }, + [createContact, updateContact], + ); + + const handleDeleteContact = useCallback( + async (id: string) => { + try { + await deleteContact({ id }); + toast.success("Contact deleted"); + } catch { + toast.error("Failed to delete contact"); + } + }, + [deleteContact], + ); + + const handleEditContact = useCallback( + (contact: (typeof contacts)[number]) => { + setEditingContact(contact); + setContactSheetOpen(true); + }, + [], + ); + + const handleAddContact = useCallback(() => { + setEditingContact(null); + setContactSheetOpen(true); + }, []); + + const isLoading = groupsLoading || contactsLoading || summaryLoading; + + return ( +
+ {/* Stats Cards */} + + + {/* Unified toolbar */} +
+ + + + Groups + {groups.length > 0 && ( + + {groups.length} + + )} + + + Contacts + {contacts.length > 0 && ( + + {contacts.length} + + )} + + + + +
+
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+ {activeTab === "groups" ? ( + + ) : ( + + )} +
+
+ + {/* Main content */} + {activeTab === "groups" ? ( + setGroupSheetOpen(true)} + onArchive={handleArchiveGroup} + onUnarchive={handleUnarchiveGroup} + onDelete={handleDeleteGroup} + /> + ) : ( + + )} + + {/* Sheets */} + + + +
+ ); +} diff --git a/src/app/(features)/splits/loading.tsx b/src/app/(features)/splits/loading.tsx new file mode 100644 index 0000000..02d8d55 --- /dev/null +++ b/src/app/(features)/splits/loading.tsx @@ -0,0 +1,65 @@ +import { Skeleton } from "@ui/skeleton"; + +export default function SplitsLoading() { + return ( +
+ {/* Stats cards skeleton — 3 cards matching overview style */} +
+ {[1, 2, 3].map((i) => ( +
+
+
+ + +
+ +
+
+ + +
+
+ ))} +
+ + {/* Unified toolbar skeleton */} +
+ +
+ + +
+
+ + {/* Groups grid skeleton — full width */} +
+ {[1, 2, 3, 4, 5, 6].map((i) => ( +
+ +
+ +
+ + +
+
+
+
+ {[1, 2, 3].map((j) => ( + + ))} +
+ +
+
+ ))} +
+
+ ); +} diff --git a/src/app/(features)/splits/page.tsx b/src/app/(features)/splits/page.tsx new file mode 100644 index 0000000..2fe3589 --- /dev/null +++ b/src/app/(features)/splits/page.tsx @@ -0,0 +1,17 @@ +import { api, HydrateClient } from "@/trpc/server"; +import SplitsPageClient from "./_client"; + +export const dynamic = "force-dynamic"; + +export default async function SplitsPage() { + void api.group.list.prefetch({ includeArchived: false }); + void api.contact.list.prefetch({ limit: 50 }); + void api.overview.splitSummary.prefetch(); + void api.settings.getAll.prefetch(); + + return ( + + + + ); +} diff --git a/src/app/(features)/transactions/_client.tsx b/src/app/(features)/transactions/_client.tsx index b674f46..a7a2d12 100644 --- a/src/app/(features)/transactions/_client.tsx +++ b/src/app/(features)/transactions/_client.tsx @@ -108,19 +108,21 @@ export default function TransactionsPageClient() { ); return ( -
+
- +
+ +
{ - return ( -
-
- - - - -
-
- ); -}; - -export default AboutPage; diff --git a/src/app/(public)/blog/[slug]/page.tsx b/src/app/(public)/blog/[slug]/page.tsx index 238f825..3905021 100644 --- a/src/app/(public)/blog/[slug]/page.tsx +++ b/src/app/(public)/blog/[slug]/page.tsx @@ -1,6 +1,5 @@ "use client"; -// no local state needed for comments; CommentSection manages local additions import { HeaderSection, CommentSection, ContentSection } from "@component/blog"; import { useRouter, useParams } from "next/navigation"; import { blog } from "@content/site/blog"; @@ -34,39 +33,59 @@ export default function Page() { } } - // Determine comments for this post via id-based mapping. Prefer mappings - // (commentsByPostId + commentsById) and fallback to legacy blog.comments. if (!found) { return ( -
-

Post not found

-

+

+

Post not found

+

We couldn't find the post you're looking for.

- +
); } + // Resolve comments for this post + const resolveComments = (): BlogComment[] => { + const postId = found?.id; + if (!postId) return blog.comments; + const commentsByPostId = ( + blog as unknown as { + commentsByPostId?: Record; + } + ).commentsByPostId; + const commentsById = ( + blog as unknown as { + commentsById?: Record; + } + ).commentsById; + const ids = commentsByPostId?.[postId]; + if (!ids || !commentsById) return blog.comments; + return ids.map((id) => commentsById[id]).filter(Boolean) as BlogComment[]; + }; + return ( -
-
-
+
+
+ {/* Back button */} +
+ {/* Header */} navigate.push("/blog")} /> -
+ {/* Content */} +
-
- {/* resolve by post id -> comment ids -> comment objects */} - { - const postId = found?.id; - if (!postId) return blog.comments; - const commentsByPostId = ( - blog as unknown as { - commentsByPostId?: Record; - } - ).commentsByPostId; - const commentsById = ( - blog as unknown as { - commentsById?: Record; - } - ).commentsById; - const ids = commentsByPostId?.[postId]; - if (!ids || !commentsById) return blog.comments; - return ids - .map((id) => commentsById[id]) - .filter(Boolean) as BlogComment[]; - })()} - /> -
+ {/* Divider */} +
+ + {/* Comments */} +
); diff --git a/src/app/(public)/blog/page.tsx b/src/app/(public)/blog/page.tsx index 1ebe867..c5979e9 100644 --- a/src/app/(public)/blog/page.tsx +++ b/src/app/(public)/blog/page.tsx @@ -1,100 +1,235 @@ +"use client"; + +import { useState } from "react"; import Link from "next/link"; import Image from "next/image"; - -import { Badge } from "@ui/badge"; import { Button } from "@ui/button"; +import { ChevronRight, ChevronLeft, Mail } from "lucide-react"; -import { BlogPostCard, FeaturedPostSidebarItem } from "@component/blog"; import { blog } from "@content/site/blog"; +const categories = [ + "Interviews", + "AI & Insights", + "Budgeting", + "Splits", + "Security", + "Product", + "Engineering", + "Community", +]; + +const postDates = [ + "MAR 20, 2026", + "MAR 14, 2026", + "MAR 8, 2026", + "FEB 28, 2026", + "FEB 20, 2026", + "FEB 12, 2026", + "JAN 30, 2026", + "JAN 22, 2026", + "JAN 15, 2026", + "JAN 8, 2026", + "DEC 28, 2025", +]; + +const postCategories = [ + "AI & Insights", + "Splits", + "Security", + "Engineering", + "Product", + "Budgeting", + "Budgeting", + "Budgeting", + "Engineering", + "AI & Insights", + "Security", +]; + +const postExcerpts: Record = { + "Stop Typing, Start Scanning: The Power of Gemini OCR for Expenses": + "See how Trackit's receipt scanning uses Google Gemini to extract merchant names, amounts, and dates automatically from a photo of any receipt.", + "No More IOUs: Mastering Group Expenses and Instant Settlements": + "Learn how to create groups, split bills four different ways, and use debt simplification to settle up with the fewest transfers possible.", + "Unbreakable Finance: Why We Built Trackit on RBAC and 2FA": + "A deep dive into the security architecture behind Trackit, from role-based access control to two-factor authentication and audit logging.", + "Behind the Scenes: Why the T3 Stack Powers Trackit's Real-Time Sync": + "Explore the technical decisions behind choosing Next.js, tRPC, and Prisma for building a real-time personal finance application.", + "Stripe Polar Explained: The Magic Behind Instant In-App Transfers": + "Understand how Trackit processes payments and reconciles transaction data using Stripe webhooks for instant, accurate financial records.", +}; + +const POSTS_PER_PAGE = 5; + export default function BlogPage() { + const [page, setPage] = useState(0); + + const heroPost = blog.featured.hero; + const allPosts = [ + ...blog.featuredSidebar.map((p, i) => ({ + image: p.image, + imageAlt: p.imageAlt, + title: p.title, + excerpt: postExcerpts[p.title] ?? "", + href: p.href as string | undefined, + date: postDates[i] ?? "", + category: postCategories[i] ?? "Product", + })), + ...blog.recentPosts.map((p, i) => ({ + image: p.image ?? "/placeholder.svg", + imageAlt: p.imageAlt ?? p.title, + title: p.title, + excerpt: (p.excerpt ?? "").replace(/\*\*/g, ""), + href: p.href, + date: postDates[blog.featuredSidebar.length + i] ?? "", + category: postCategories[blog.featuredSidebar.length + i] ?? "Product", + })), + ]; + + const totalPages = Math.ceil(allPosts.length / POSTS_PER_PAGE); + const paginatedPosts = allPosts.slice( + page * POSTS_PER_PAGE, + (page + 1) * POSTS_PER_PAGE, + ); + return ( -
+
{/* Header */} -
-

- Blog -

- -

- Latest articles & insights +
+

+ + The Trackit + {" "} + Blog

- -

- Practical guides, product updates and design thinking to help you - build better. -

- {/* Featured Section */} -
- {/* Hero Featured Post */} - - {blog.featured.hero.imageAlt} - -
- - {blog.featured.hero.badge} - - -

- {blog.featured.hero.title} + {/* Hero Post + Sidebar */} +
+
+ {/* Hero Post */} + +

+ SEP 19, 2025 +

+

+ {heroPost.title}

-
- - - {/* Sidebar */} -
-

Other featured posts

- -
- {blog.featuredSidebar.map((item) => ( - + {heroPost.imageAlt} +
+ + + {/* Tagline */} +

+ Don't assume that just tracking expenses is enough to build + financial freedom. +

+ + {/* Post List */} +
+ {paginatedPosts.map((post) => ( + +
+ {post.imageAlt +
+
+
+

+ {post.date} +

+ + {post.category} + +
+

+ {post.title} +

+ {post.excerpt && ( +

+ {post.excerpt} +

+ )} +
+ ))}
-
-
- - {/* Recent Posts */} -
-
-

Recent Posts

- + {/* Pagination */} + {totalPages > 1 && ( +
+ + + {page + 1} / {totalPages} + + +
+ )}
-
- {blog.recentPosts.map((p) => ( - - ))} -
+ {/* Sidebar */} +

); diff --git a/src/app/(public)/changelog/page.tsx b/src/app/(public)/changelog/page.tsx index f093bdd..0da5ac6 100644 --- a/src/app/(public)/changelog/page.tsx +++ b/src/app/(public)/changelog/page.tsx @@ -14,7 +14,7 @@ export default function ChangelogPage() { >

- What's new? + What's new?

A rundown of the latest Trackit feature releases, product diff --git a/src/app/(public)/contact/page.tsx b/src/app/(public)/contact/page.tsx index 0a541ed..2f6d875 100644 --- a/src/app/(public)/contact/page.tsx +++ b/src/app/(public)/contact/page.tsx @@ -1,9 +1,7 @@ "use client"; -import { createLogger } from "@/lib/logging"; import { useState, useRef, useEffect } from "react"; -const logger = createLogger("contact-page"); import { motion, useInView } from "framer-motion"; import { Input } from "@ui/input"; import { Textarea } from "@ui/textarea"; @@ -12,8 +10,7 @@ import Earth from "@ui/globe"; import { SparklesCore } from "@ui/sparkles"; import { Label } from "@ui/label"; import { Check, Loader2 } from "lucide-react"; -import { MailIcon, MapPinIcon, PhoneIcon } from "lucide-react"; -import Link from "next/link"; +import { toast } from "sonner"; export default function ContactUsPage() { const [name, setName] = useState(""); @@ -33,7 +30,6 @@ export default function ContactUsPage() { useEffect(() => { function resolveThemeColor() { try { - // Use a canvas to reliably convert any CSS color format (including oklch) to RGB const el = document.createElement("div"); el.style.color = "var(--primary)"; document.body.appendChild(el); @@ -66,7 +62,6 @@ export default function ContactUsPage() { resolveThemeColor(); - // Re-resolve when theme changes (dark/light toggle mutates class on ) const observer = new MutationObserver(() => resolveThemeColor()); observer.observe(document.documentElement, { attributes: true, @@ -80,26 +75,38 @@ export default function ContactUsPage() { setIsSubmitting(true); try { - logger.info("Form submitted", { name, email, message }); - await new Promise((resolve) => setTimeout(resolve, 1000)); + const res = await fetch("/api/contact", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: name.trim(), + email: email.trim(), + message: message.trim(), + }), + }); + + const data = (await res.json()) as { message?: string; error?: string }; + + if (!res.ok) { + toast.error(data.error ?? "Something went wrong. Please try again."); + return; + } + + toast.success(data.message ?? "Message sent!"); setName(""); setEmail(""); setMessage(""); setIsSubmitted(true); - setTimeout(() => { - setIsSubmitted(false); - }, 5000); - } catch (error) { - logger.error("Error submitting form", { - error: error instanceof Error ? error.message : String(error), - }); + setTimeout(() => setIsSubmitted(false), 5000); + } catch { + toast.error("Network error. Please try again."); } finally { setIsSubmitting(false); } }; return ( -

+
-
-
-
-
- -

- Contact -

- - Us - - -
- - -
- - - setName(e.target.value)} - placeholder="Enter your name" - required - /> - +
+
+ {/* Left: Form */} +
+ +

+ Contact +

+ + Us + + +
- - - setEmail(e.target.value)} - placeholder="Enter your email" - required - /> - -
+ + Have a question or feedback? We'd love to hear from you. Fill + out the form and we'll get back to you shortly. + + +
- -