diff --git a/.env.example b/.env.example index 4ecfe056..7a5adbe8 100644 --- a/.env.example +++ b/.env.example @@ -18,8 +18,13 @@ BETTER_AUTH_SECRET= # OAUTH_SCOPES=openid email profile # OAUTH_PKCE=false -# Email -# Not required, but needed for functions like sending password reset emails. +# Email (optional — needed for password reset and email verification) +# FROM_EMAIL_ADDRESS is required for email sending to work. +# If both provider keys are set, Resend takes priority. +FROM_EMAIL_ADDRESS= +VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS= + +RESEND_API_KEY= SENDGRID_API_KEY= # Integrations @@ -29,3 +34,9 @@ INSTAPAPER_OAUTH_SECRET= # Analytics VITE_PUBLIC_UMAMI_WEBSITE_ID= VITE_PUBLIC_UMAMI_SRC= + +# Error Tracking (Sentry/GlitchTip) +VITE_PUBLIC_SENTRY_DSN_WEB= +VITE_PUBLIC_SENTRY_SECURITY_ENDPOINT_WEB= +SENTRY_DSN_BACKEND= +SENTRY_SECURITY_ENDPOINT_BACKEND= diff --git a/README.md b/README.md index cceba9b9..06115f2b 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,10 @@ Serial takes a model of progressive enhancement for features. The app can run wi ### Email support (for password reset, etc) -- Create an account on [Sendgrid](https://sendgrid.com/en-us) -- Set up a mailing address -- Add your `SENDGRID_API_KEY` to `.env` or your host's environment variables UI. +Serial supports [Resend](https://resend.com) and [SendGrid](https://sendgrid.com/en-us) as email providers. Only one is used at a time — if both keys are set, Resend takes priority. + +- **Resend**: Create an account, add your `RESEND_API_KEY` to `.env` or your host's environment variables UI. +- **SendGrid**: Create an account, set up a mailing address, add your `SENDGRID_API_KEY` to `.env` or your host's environment variables UI. ### Instapaper integration diff --git a/docker-compose.arm.yaml b/docker-compose.arm.yaml index 9745fc35..97043d4d 100644 --- a/docker-compose.arm.yaml +++ b/docker-compose.arm.yaml @@ -21,7 +21,10 @@ services: NODE_ENV: production DATABASE_URL: http://libsql:8080 BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} + FROM_EMAIL_ADDRESS: ${FROM_EMAIL_ADDRESS} + RESEND_API_KEY: ${RESEND_API_KEY} SENDGRID_API_KEY: ${SENDGRID_API_KEY} + VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS: ${VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS} INSTAPAPER_OAUTH_ID: ${INSTAPAPER_OAUTH_ID} INSTAPAPER_OAUTH_SECRET: ${INSTAPAPER_OAUTH_SECRET} depends_on: diff --git a/docker-compose.build-arm.yaml b/docker-compose.build-arm.yaml index 899ab462..5e2dff77 100644 --- a/docker-compose.build-arm.yaml +++ b/docker-compose.build-arm.yaml @@ -25,7 +25,10 @@ services: NODE_ENV: production DATABASE_URL: http://libsql:8080 BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} + FROM_EMAIL_ADDRESS: ${FROM_EMAIL_ADDRESS} + RESEND_API_KEY: ${RESEND_API_KEY} SENDGRID_API_KEY: ${SENDGRID_API_KEY} + VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS: ${VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS} INSTAPAPER_OAUTH_ID: ${INSTAPAPER_OAUTH_ID} INSTAPAPER_OAUTH_SECRET: ${INSTAPAPER_OAUTH_SECRET} depends_on: diff --git a/docker-compose.build-cloud.yaml b/docker-compose.build-cloud.yaml index 7b4c37d0..32b00127 100644 --- a/docker-compose.build-cloud.yaml +++ b/docker-compose.build-cloud.yaml @@ -11,7 +11,10 @@ services: DATABASE_URL: ${DATABASE_URL} DATABASE_AUTH_TOKEN: ${DATABASE_AUTH_TOKEN} BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} + FROM_EMAIL_ADDRESS: ${FROM_EMAIL_ADDRESS} + RESEND_API_KEY: ${RESEND_API_KEY} SENDGRID_API_KEY: ${SENDGRID_API_KEY} + VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS: ${VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS} INSTAPAPER_OAUTH_ID: ${INSTAPAPER_OAUTH_ID} INSTAPAPER_OAUTH_SECRET: ${INSTAPAPER_OAUTH_SECRET} ports: diff --git a/docker-compose.build.yaml b/docker-compose.build.yaml index e8186987..3a115db4 100644 --- a/docker-compose.build.yaml +++ b/docker-compose.build.yaml @@ -25,7 +25,10 @@ services: NODE_ENV: production DATABASE_URL: http://libsql:8080 BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} + FROM_EMAIL_ADDRESS: ${FROM_EMAIL_ADDRESS} + RESEND_API_KEY: ${RESEND_API_KEY} SENDGRID_API_KEY: ${SENDGRID_API_KEY} + VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS: ${VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS} INSTAPAPER_OAUTH_ID: ${INSTAPAPER_OAUTH_ID} INSTAPAPER_OAUTH_SECRET: ${INSTAPAPER_OAUTH_SECRET} depends_on: diff --git a/docker-compose.cloud.yaml b/docker-compose.cloud.yaml index 23fcdde6..4a58027f 100644 --- a/docker-compose.cloud.yaml +++ b/docker-compose.cloud.yaml @@ -6,7 +6,10 @@ services: DATABASE_URL: ${DATABASE_URL} DATABASE_AUTH_TOKEN: ${DATABASE_AUTH_TOKEN} BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} + FROM_EMAIL_ADDRESS: ${FROM_EMAIL_ADDRESS} + RESEND_API_KEY: ${RESEND_API_KEY} SENDGRID_API_KEY: ${SENDGRID_API_KEY} + VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS: ${VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS} INSTAPAPER_OAUTH_ID: ${INSTAPAPER_OAUTH_ID} INSTAPAPER_OAUTH_SECRET: ${INSTAPAPER_OAUTH_SECRET} ports: diff --git a/docker-compose.yaml b/docker-compose.yaml index 413d78b0..850af3f7 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -21,7 +21,10 @@ services: NODE_ENV: production DATABASE_URL: http://libsql:8080 BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET} + FROM_EMAIL_ADDRESS: ${FROM_EMAIL_ADDRESS} + RESEND_API_KEY: ${RESEND_API_KEY} SENDGRID_API_KEY: ${SENDGRID_API_KEY} + VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS: ${VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS} INSTAPAPER_OAUTH_ID: ${INSTAPAPER_OAUTH_ID} INSTAPAPER_OAUTH_SECRET: ${INSTAPAPER_OAUTH_SECRET} depends_on: diff --git a/package.json b/package.json index bfb725ee..2321111a 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,11 @@ "dev:db:test:self-hosted": "turso dev --db-file serial-test-self-hosted.db --port 8082", "dev:migrate:test:main": "DATABASE_URL=http://127.0.0.1:8081 BETTER_AUTH_SECRET=test-secret-key-for-main-tests pnpm schema:migrate", "dev:migrate:test:self-hosted": "DATABASE_URL=http://127.0.0.1:8082 BETTER_AUTH_SECRET=test-secret-key-for-self-hosted-tests pnpm schema:migrate", - "dev:test:main": "concurrently --kill-others \"pnpm dev:db:test:main\" \"pnpm dev:migrate:test:main && DATABASE_URL=http://127.0.0.1:8081 BETTER_AUTH_SECRET=test-secret-key-for-main-tests BETTER_AUTH_BASE_URL=http://localhost:3002 VITE_PUBLIC_IS_MAIN_INSTANCE=true POLAR_ACCESS_TOKEN=test-token-for-e2e POLAR_WEBHOOK_SECRET=test-webhook-secret POLAR_STANDARD_MONTHLY_PRODUCT_ID=test-standard-monthly POLAR_STANDARD_ANNUAL_PRODUCT_ID=test-standard-annual POLAR_PRO_MONTHLY_PRODUCT_ID=test-pro-monthly POLAR_PRO_ANNUAL_PRODUCT_ID=test-pro-annual vite dev --port 3002\"", + "dev:test:main": "concurrently --kill-others \"pnpm dev:db:test:main\" \"pnpm dev:migrate:test:main && DATABASE_URL=http://127.0.0.1:8081 BETTER_AUTH_SECRET=test-secret-key-for-main-tests BETTER_AUTH_BASE_URL=http://localhost:3002 VITE_PUBLIC_IS_MAIN_INSTANCE=true POLAR_ACCESS_TOKEN=test-token-for-e2e POLAR_WEBHOOK_SECRET=test-webhook-secret POLAR_STANDARD_SMALL_QUOTA_MONTHLY_PRODUCT_ID=test-standard-small-monthly POLAR_STANDARD_SMALL_QUOTA_ANNUAL_PRODUCT_ID=test-standard-small-annual POLAR_STANDARD_MEDIUM_QUOTA_MONTHLY_PRODUCT_ID=test-standard-medium-monthly POLAR_STANDARD_MEDIUM_QUOTA_ANNUAL_PRODUCT_ID=test-standard-medium-annual POLAR_STANDARD_LARGE_QUOTA_MONTHLY_PRODUCT_ID=test-standard-large-monthly POLAR_STANDARD_LARGE_QUOTA_ANNUAL_PRODUCT_ID=test-standard-large-annual POLAR_PRO_MONTHLY_PRODUCT_ID=test-pro-monthly POLAR_PRO_ANNUAL_PRODUCT_ID=test-pro-annual vite dev --port 3002\"", "dev:test:self-hosted": "concurrently --kill-others \"pnpm dev:db:test:self-hosted\" \"pnpm dev:migrate:test:self-hosted && DATABASE_URL=http://127.0.0.1:8082 BETTER_AUTH_SECRET=test-secret-key-for-self-hosted-tests BETTER_AUTH_BASE_URL=http://localhost:3001 VITE_PUBLIC_IS_MAIN_INSTANCE=false vite dev --port 3001\"", "dev:migrate": "pnpm schema:migrate", "dev:atomic": "vite dev", + "dev:polar": "polar listen http://localhost:3000/api/auth/polar/webhooks", "dev": "concurrently --kill-others \"pnpm dev:db\" \"pnpm dev:migrate && pnpm dev:atomic\"", "dev:email": "email dev --dir ./src/emails --port 4000", "prebuild": "pnpm typecheck && pnpm format && pnpm lint", @@ -86,6 +87,8 @@ "@radix-ui/react-tooltip": "^1.2.8", "@react-email/components": "^1.0.11", "@sendgrid/mail": "^8.1.6", + "@sentry/browser": "^10.49.0", + "@sentry/node": "^10.49.0", "@t3-oss/env-core": "^0.13.11", "@tanstack/query-async-storage-persister": "^5.96.2", "@tanstack/react-query": "^5.96.2", @@ -94,6 +97,7 @@ "@tanstack/react-start": "1.167.16", "@tanstack/react-table": "^8.21.3", "@tanstack/zod-adapter": "1.166.9", + "@upstash/redis": "^1.37.0", "better-auth": "^1.5.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -124,6 +128,7 @@ "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", + "resend": "^6.11.0", "rss-parser": "^3.13.0", "sonner": "^2.0.7", "superjson": "^2.2.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2a5a49b..99d99127 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,7 +56,7 @@ importers: version: 1.13.13(@opentelemetry/api@1.9.1) '@orpc/experimental-publisher': specifier: ^1.13.13 - version: 1.13.13(@opentelemetry/api@1.9.1) + version: 1.13.13(@opentelemetry/api@1.9.1)(@upstash/redis@1.37.0) '@orpc/server': specifier: ^1.13.13 version: 1.13.13(@opentelemetry/api@1.9.1)(crossws@0.4.4(srvx@0.11.15))(ws@8.19.0) @@ -68,7 +68,7 @@ importers: version: 3.3.0 '@polar-sh/better-auth': specifier: ^1.8.1 - version: 1.8.3(d0c442ce4a9c66fb669f11db7be7f592) + version: 1.8.3(3039f015a40675ed8d512bd4940c91a8) '@polar-sh/sdk': specifier: ^0.47.0 version: 0.47.0 @@ -135,6 +135,12 @@ importers: '@sendgrid/mail': specifier: ^8.1.6 version: 8.1.6 + '@sentry/browser': + specifier: ^10.49.0 + version: 10.49.0 + '@sentry/node': + specifier: ^10.49.0 + version: 10.49.0 '@t3-oss/env-core': specifier: ^0.13.11 version: 0.13.11(typescript@6.0.2)(zod@4.3.6) @@ -159,9 +165,12 @@ importers: '@tanstack/zod-adapter': specifier: 1.166.9 version: 1.166.9(@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(zod@4.3.6) + '@upstash/redis': + specifier: ^1.37.0 + version: 1.37.0 better-auth: specifier: ^1.5.6 - version: 1.5.6(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.167.16(crossws@0.4.4(srvx@0.11.15))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@8.0.3(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(better-sqlite3@12.8.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15))(next@16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.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)(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(vite@8.0.3(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + version: 1.5.6(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.167.16(crossws@0.4.4(srvx@0.11.15))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@8.0.3(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(better-sqlite3@12.8.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15))(next@16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.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)(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(vite@8.0.3(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -176,7 +185,7 @@ importers: version: 1.11.20 drizzle-zod: specifier: ^0.8.3 - version: 0.8.3(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15))(zod@4.3.6) + version: 0.8.3(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15))(zod@4.3.6) embla-carousel-react: specifier: ^8.6.0 version: 8.6.0(react@19.2.4) @@ -249,6 +258,9 @@ importers: remark-rehype: specifier: ^11.1.2 version: 11.1.2 + resend: + specifier: ^6.11.0 + version: 6.11.0(@react-email/render@2.0.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) rss-parser: specifier: ^3.13.0 version: 3.13.0 @@ -336,10 +348,10 @@ importers: version: 0.31.10 drizzle-orm: specifier: ^0.45.2 - version: 0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15) + version: 0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15) drizzle-seed: specifier: ^0.3.1 - version: 0.3.1(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15)) + version: 0.3.1(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15)) esbuild: specifier: ^0.28.0 version: 0.28.0 @@ -357,7 +369,7 @@ importers: version: 7.0.1(eslint@10.2.0(jiti@2.6.1)) nitro: specifier: 3.0.260311-beta - version: 3.0.260311-beta(@libsql/client@0.17.2)(better-sqlite3@12.8.0)(chokidar@4.0.3)(dotenv@17.4.1)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15))(jiti@2.6.1)(lru-cache@11.2.7)(rollup@2.79.2)(vite@8.0.3(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + version: 3.0.260311-beta(@libsql/client@0.17.2)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(chokidar@4.0.3)(dotenv@17.4.1)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15))(jiti@2.6.1)(lru-cache@11.2.7)(rollup@2.79.2)(vite@8.0.3(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) postcss: specifier: ^8.5.8 version: 8.5.8 @@ -1891,6 +1903,11 @@ packages: resolution: {integrity: sha512-ejvBr8MQCbVsWNZnCwDXjUKq40MDmHalq7cJ6e9s/qzTUFIIo/afzt1Vui9T97FM/V/pN4YsFVoed5NIa96RDg==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@fastify/otel@0.18.0': + resolution: {integrity: sha512-3TASCATfw+ctICSb4ymrv7iCm0qJ0N9CarB+CZ7zIJ7KqNbwI5JjyDL1/sxoC0ccTO1Zyd1iQ+oqncPg5FJXaA==} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + '@floating-ui/core@1.7.4': resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} @@ -2344,14 +2361,204 @@ packages: resolution: {integrity: sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA==} engines: {node: '>=20.0'} + '@opentelemetry/api-logs@0.207.0': + resolution: {integrity: sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api-logs@0.212.0': + resolution: {integrity: sha512-TEEVrLbNROUkYY51sBJGk7lO/OLjuepch8+hmpM6ffMJQ2z/KVCjdHuCFX6fJj8OkJP2zckPjrJzQtXU3IAsFg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api-logs@0.214.0': + resolution: {integrity: sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==} + engines: {node: '>=8.0.0'} + '@opentelemetry/api@1.9.1': resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} + '@opentelemetry/core@2.6.1': + resolution: {integrity: sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.7.0': + resolution: {integrity: sha512-DT12SXVwV2eoJrGf4nnsvZojxxeQo+LlNAsoYGRRObPWTeN6APiqZ2+nqDCQDvQX40eLi1AePONS0onoASp3yQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/instrumentation-amqplib@0.61.0': + resolution: {integrity: sha512-mCKoyTGfRNisge4br0NpOFSy2Z1NnEW8hbCJdUDdJFHrPqVzc4IIBPA/vX0U+LUcQqrQvJX+HMIU0dbDRe0i0Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-connect@0.57.0': + resolution: {integrity: sha512-FMEBChnI4FLN5TE9DHwfH7QpNir1JzXno1uz/TAucVdLCyrG0jTrKIcNHt/i30A0M2AunNBCkcd8Ei26dIPKdg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-dataloader@0.31.0': + resolution: {integrity: sha512-f654tZFQXS5YeLDNb9KySrwtg7SnqZN119FauD7acBoTzuLduaiGTNz88ixcVSOOMGZ+EjJu/RFtx5klObC95g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-fs@0.33.0': + resolution: {integrity: sha512-sCZWXGalQ01wr3tAhSR9ucqFJ0phidpAle6/17HVjD6gN8FLmZMK/8sKxdXYHy3PbnlV1P4zeiSVFNKpbFMNLA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-generic-pool@0.57.0': + resolution: {integrity: sha512-orhmlaK+ZIW9hKU+nHTbXrCSXZcH83AescTqmpamHRobRmYSQwRbD0a1odc0yAzuzOtxYiHiXAnpnIpaSSY7Ow==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-graphql@0.62.0': + resolution: {integrity: sha512-3YNuLVPUxafXkH1jBAbGsKNsP3XVzcFDhCDCE3OqBwCwShlqQbLMRMFh1T/d5jaVZiGVmSsfof+ICKD2iOV8xg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-hapi@0.60.0': + resolution: {integrity: sha512-aNljZKYrEa7obLAxd1bCEDxF7kzCLGXTuTJZ8lMR9rIVEjmuKBXN1gfqpm/OB//Zc2zP4iIve1jBp7sr3mQV6w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-http@0.214.0': + resolution: {integrity: sha512-FlkDhZDRjDJDcO2LcSCtjRpkal1NJ8y0fBqBhTvfAR3JSYY2jAIj1kSS5IjmEBt4c3aWv+u/lqLuoCDrrKCSKg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-ioredis@0.62.0': + resolution: {integrity: sha512-ZYt//zcPve8qklaZX+5Z4MkU7UpEkFRrxsf2cnaKYBitqDnsCN69CPAuuMOX6NYdW2rG9sFy7V/QWtBlP5XiNQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-kafkajs@0.23.0': + resolution: {integrity: sha512-4K+nVo+zI+aDz0Z85SObwbdixIbzS9moIuKJaYsdlzcHYnKOPtB7ya8r8Ezivy/GVIBHiKJVq4tv+BEkgOMLaQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-knex@0.58.0': + resolution: {integrity: sha512-Hc/o8fSsaWxZ8r1Yw4rNDLwTpUopTf4X32y4W6UhlHmW8Wizz8wfhgOKIelSeqFVTKBBPIDUOsQWuIMxBmu8Bw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-koa@0.62.0': + resolution: {integrity: sha512-uVip0VuGUQXZ+vFxkKxAUNq8qNl+VFlyHDh/U6IQ8COOEDfbEchdaHnpFrMYF3psZRUuoSIgb7xOeXj00RdwDA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + + '@opentelemetry/instrumentation-lru-memoizer@0.58.0': + resolution: {integrity: sha512-6grM3TdMyHzlGY1cUA+mwoPueB1F3dYKgKtZIH6jOFXqfHAByyLTc+6PFjGM9tKh52CFBJaDwodNlL/Td39z7Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mongodb@0.67.0': + resolution: {integrity: sha512-1WJp5N1lYfHq2IhECOTewFs5Tf2NfUOwQRqs/rZdXKTezArMlucxgzAaqcgp3A3YREXopXTpXHsxZTGHjNhMdQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mongoose@0.60.0': + resolution: {integrity: sha512-8BahAZpKsOoc+lrZGb7Ofn4g3z8qtp5IxDfvAVpKXsEheQN7ONMH5djT5ihy6yf8yyeQJGS0gXFfpEAEeEHqQg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mysql2@0.60.0': + resolution: {integrity: sha512-m/5d3bxQALllCzezYDk/6vajh0tj5OijMMvOZGr+qN1NMXm1dzMNwyJ0gNZW7Fo3YFRyj/jJMxIw+W7d525dlw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mysql@0.60.0': + resolution: {integrity: sha512-08pO8GFPEIz2zquKDGteBZDNmwketdgH8hTe9rVYgW9kCJXq1Psj3wPQGx+VaX4ZJKCfPeoLMYup9+cxHvZyVQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-pg@0.66.0': + resolution: {integrity: sha512-KxfLGXBb7k2ueaPJfq2GXBDXBly8P+SpR/4Mj410hhNgmQF3sCqwXvUBQxZQkDAmsdBAoenM+yV1LhtsMRamcA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-redis@0.62.0': + resolution: {integrity: sha512-y3pPpot7WzR/8JtHcYlTYsyY8g+pbFhAqbwAuG5bLPnR6v6pt1rQc0DpH0OlGP/9CZbWBP+Zhwp9yFoygf/ZXQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-tedious@0.33.0': + resolution: {integrity: sha512-Q6WQwAD01MMTub31GlejoiFACYNw26J426wyjvU7by7fDIr2nZXNW4vhTGs7i7F0TnXBO3xN688g1tdUgYwJ5w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-undici@0.24.0': + resolution: {integrity: sha512-oKzZ3uvqP17sV0EsoQcJgjEfIp0kiZRbYu/eD8p13Cbahumf8lb/xpYeNr/hfAJ4owzEtIDcGIjprfLcYbIKBQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.7.0 + + '@opentelemetry/instrumentation@0.207.0': + resolution: {integrity: sha512-y6eeli9+TLKnznrR8AZlQMSJT7wILpXH+6EYq5Vf/4Ao+huI7EedxQHwRgVUOMLFbe7VFDvHJrX9/f4lcwnJsA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation@0.212.0': + resolution: {integrity: sha512-IyXmpNnifNouMOe0I/gX7ENfv2ZCNdYTF0FpCsoBcpbIHzk81Ww9rQTYTnvghszCg7qGrIhNvWC8dhEifgX9Jg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation@0.214.0': + resolution: {integrity: sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/redis-common@0.38.3': + resolution: {integrity: sha512-VCghU1JYs/4gP6Gqf/xro9MEsZ7LrMv2uONVsaESKL38ZOB9BqnI98FfS23wjMnHlpuE+TTaWSoAVNpTwYXzjw==} + engines: {node: ^18.19.0 || >=20.6.0} + + '@opentelemetry/resources@2.7.0': + resolution: {integrity: sha512-K+oi0hNMv94EpZbnW3eyu2X6SGVpD3O5DhG2NIp65Hc7lhAj9brRXTAVzh3wB82+q3ThakEf7Zd7RsFUqcTc7A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.7.0': + resolution: {integrity: sha512-Yg9zEXJB50DLVLpsKPk7NmNqlPlS+OvqhJGh0A8oawIOTPOwlm4eXs9BMJV7L79lvEwI+dWtAj+YjTyddV336A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@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 + '@orpc/client@1.13.13': resolution: {integrity: sha512-jagx/Sa+9K4HEC5lBrUlMSrmR/06hvZctWh93/sKZc8GBk4zM0+71oT1kXQVw1oRYFV2XAq3xy3m6NdM6gfKYA==} @@ -2459,6 +2666,11 @@ packages: react: ^18 || ^19 react-dom: ^18 || ^19 + '@prisma/instrumentation@7.6.0': + resolution: {integrity: sha512-ZPW2gRiwpPzEfgeZgaekhqXrbW+Y2RJKHVqUmlhZhKzRNCcvR6DykzylDrynpArKKRQtLxoZy36fK7U0p3pdgQ==} + peerDependencies: + '@opentelemetry/api': ^1.8 + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -3368,6 +3580,67 @@ packages: resolution: {integrity: sha512-/ZqxUvKeEztU9drOoPC/8opEPOk+jLlB2q4+xpx6HVLq6aFu3pMpalkTpAQz8XfRfpLp8O25bh6pGPcHDCYpqg==} engines: {node: '>=12.*'} + '@sentry-internal/browser-utils@10.49.0': + resolution: {integrity: sha512-n0QRx0Ysx6mPfIydTkz7VP0FmwM+/EqMZiRqdsU3aTYsngE9GmEDV0OL1bAy6a8N/C1xf9vntkuAtj6N/8Z51w==} + engines: {node: '>=18'} + + '@sentry-internal/feedback@10.49.0': + resolution: {integrity: sha512-JNsUBGv0faCFE7MeZUH99Y9lU9qq3LBALbLxpE1x7ngNrQnVYRlcFgdqaD/btNBKr8awjYL8gmcSkHBWskGqLQ==} + engines: {node: '>=18'} + + '@sentry-internal/replay-canvas@10.49.0': + resolution: {integrity: sha512-7D/NrgH1Qwx5trDYaaTSSJmCb1yVQQLqFG4G/S9x2ltzl9876lSGJL8UeW8ReNQgF3CDAcwbmm/9aXaVSBUNZA==} + engines: {node: '>=18'} + + '@sentry-internal/replay@10.49.0': + resolution: {integrity: sha512-IEy4lwHVMiRE3JAcn+kFKjsTgalDOCSTf20SoFd+nkt6rN/k1RDyr4xpdfF//Kj3UdeTmbuibYjK5H/FLhhnGg==} + engines: {node: '>=18'} + + '@sentry/browser@10.49.0': + resolution: {integrity: sha512-bGCHc+wK2Dx67YoSbmtlt04alqWfQ+dasD/GVipVOq50gvw/BBIDHTEWRJEjACl+LrvszeY54V+24p8z4IgysA==} + engines: {node: '>=18'} + + '@sentry/core@10.49.0': + resolution: {integrity: sha512-UaFeum3LUM1mB0d67jvKnqId1yWQjyqmaDV6kWngG03x+jqXb08tJdGpSoxjXZe13jFBbiBL/wKDDYIK7rCK4g==} + engines: {node: '>=18'} + + '@sentry/node-core@10.49.0': + resolution: {integrity: sha512-7WO0KuCDPSq3G54TVUSI1CKFJwB67LasG+n/gDMBqbrarzs/Yh/s34OOMU5gfVQpncxQAmQsy4nEboQms8iNqA==} + engines: {node: '>=18'} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + '@opentelemetry/core': ^1.30.1 || ^2.1.0 + '@opentelemetry/exporter-trace-otlp-http': '>=0.57.0 <1' + '@opentelemetry/instrumentation': '>=0.57.1 <1' + '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.1.0 + '@opentelemetry/semantic-conventions': ^1.39.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@opentelemetry/core': + optional: true + '@opentelemetry/exporter-trace-otlp-http': + optional: true + '@opentelemetry/instrumentation': + optional: true + '@opentelemetry/sdk-trace-base': + optional: true + '@opentelemetry/semantic-conventions': + optional: true + + '@sentry/node@10.49.0': + resolution: {integrity: sha512-xr+HXABCiO5mgAJRQxsXRdNOLO0+Ee6CvXAAIqovL2A1GlhxNWc5ooPWeIrrLDJ/KGyT8zI91O5scpVXdXs0uQ==} + engines: {node: '>=18'} + + '@sentry/opentelemetry@10.49.0': + resolution: {integrity: sha512-XNLm4dXmtegXQf+EEE2Cs84Ymlo/f5wMx+lg2S2XS4qLbXaPN/HttjhwKftd8D+8iUNfmH+xNMCSshx4s1B/1w==} + engines: {node: '>=18'} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + '@opentelemetry/core': ^1.30.1 || ^2.1.0 + '@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.1.0 + '@opentelemetry/semantic-conventions': ^1.39.0 + '@socket.io/component-emitter@3.1.2': resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} @@ -3702,6 +3975,9 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/cors@2.8.19': resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} @@ -3759,12 +4035,21 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/mysql@2.15.27': + resolution: {integrity: sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==} + '@types/node@16.9.1': resolution: {integrity: sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==} '@types/node@25.5.2': resolution: {integrity: sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==} + '@types/pg-pool@2.0.7': + resolution: {integrity: sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==} + + '@types/pg@8.15.6': + resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -3776,6 +4061,9 @@ packages: '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@types/tedious@4.0.14': + resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -3945,6 +4233,9 @@ packages: cpu: [x64] os: [win32] + '@upstash/redis@1.37.0': + resolution: {integrity: sha512-LqOJ3+XWPLSZ2rGSed5DYG3ixybxb8EhZu3yQqF7MdZX1wLBG/FRcI6xcUZXHy/SS7mmXWyadrud0HJHkOc+uw==} + '@vitejs/plugin-react@6.0.1': resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3995,6 +4286,11 @@ packages: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} + acorn-import-attributes@1.9.5: + resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} + peerDependencies: + acorn: ^8 + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -4370,6 +4666,9 @@ packages: citty@0.1.6: resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + cjs-module-lexer@2.2.0: + resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -5268,6 +5567,9 @@ packages: resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} engines: {node: '>=12.20.0'} + forwarded-parse@2.1.2: + resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} + fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} @@ -5531,6 +5833,13 @@ packages: immer@11.1.4: resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==} + import-in-the-middle@2.0.6: + resolution: {integrity: sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==} + + import-in-the-middle@3.0.1: + resolution: {integrity: sha512-pYkiyXVL2Mf3pozdlDGV6NAObxQx13Ae8knZk1UJRJ6uRW/ZRmTGHlQYtrsSl7ubuE5F8CD1z+s1n4RHNuTtuA==} + engines: {node: '>=18'} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -6186,6 +6495,9 @@ packages: mkdirp-classic@0.5.3: resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + module-details-from-path@1.0.4: + resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} @@ -6457,6 +6769,17 @@ packages: resolution: {integrity: sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==} engines: {node: '>=8'} + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-protocol@1.13.0: + resolution: {integrity: sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -6505,6 +6828,9 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} + postal-mime@2.7.4: + resolution: {integrity: sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==} + postcss-selector-parser@6.0.10: resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} engines: {node: '>=4'} @@ -6517,6 +6843,22 @@ packages: resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} @@ -6829,9 +7171,22 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-in-the-middle@8.0.1: + resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} + engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + reselect@5.1.1: resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + resend@6.11.0: + resolution: {integrity: sha512-S9gxOccfwc+E6Cr3q28Gu8NkiIjYlYPlj9rqk4zkIuzlEoh8sWu/IvJSg7U7t+o3g0Ov2IOCzcneUaCi/M/WdQ==} + engines: {node: '>=20'} + peerDependencies: + '@react-email/render': '*' + peerDependenciesMeta: + '@react-email/render': + optional: true + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -7191,6 +7546,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svix@1.90.0: + resolution: {integrity: sha512-ljkZuyy2+IBEoESkIpn8sLM+sxJHQcPxlZFxU+nVDhltNfUMisMBzWX/UR8SjEnzoI28ZjCzMbmYAPwSTucoMw==} + tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -7370,6 +7728,9 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} @@ -7547,6 +7908,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -7835,6 +8200,10 @@ packages: resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==} engines: {node: '>=4.0'} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -8615,12 +8984,12 @@ snapshots: 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.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15))': + '@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.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15))': dependencies: '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0) '@better-auth/utils': 0.3.1 optionalDependencies: - drizzle-orm: 0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15) + drizzle-orm: 0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15) '@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.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(kysely@0.28.15)': dependencies: @@ -9163,6 +9532,16 @@ snapshots: '@eslint/core': 1.2.0 levn: 0.4.1 + '@fastify/otel@0.18.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.212.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + '@floating-ui/core@1.7.4': dependencies: '@floating-ui/utils': 0.2.10 @@ -9636,10 +10015,261 @@ snapshots: '@oozcitak/util@10.0.0': {} + '@opentelemetry/api-logs@0.207.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api-logs@0.212.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api-logs@0.214.0': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api@1.9.1': {} + '@opentelemetry/core@2.6.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/core@2.7.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/instrumentation-amqplib@0.61.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-connect@0.57.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + '@types/connect': 3.4.38 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-dataloader@0.31.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-fs@0.33.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-generic-pool@0.57.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-graphql@0.62.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-hapi@0.60.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-http@0.214.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + forwarded-parse: 2.1.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-ioredis@0.62.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/redis-common': 0.38.3 + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-kafkajs@0.23.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-knex@0.58.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-koa@0.62.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-lru-memoizer@0.58.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mongodb@0.67.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mongoose@0.60.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mysql2@0.60.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + '@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mysql@0.60.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + '@types/mysql': 2.15.27 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-pg@0.66.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + '@opentelemetry/sql-common': 0.41.2(@opentelemetry/api@1.9.1) + '@types/pg': 8.15.6 + '@types/pg-pool': 2.0.7 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-redis@0.62.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/redis-common': 0.38.3 + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-tedious@0.33.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + '@types/tedious': 4.0.14 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-undici@0.24.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.207.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.207.0 + import-in-the-middle: 2.0.6 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.212.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.212.0 + import-in-the-middle: 2.0.6 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation@0.214.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.214.0 + import-in-the-middle: 3.0.1 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/redis-common@0.38.3': {} + + '@opentelemetry/resources@2.7.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/sdk-trace-base@2.7.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + '@opentelemetry/semantic-conventions@1.40.0': {} + '@opentelemetry/sql-common@0.41.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.0(@opentelemetry/api@1.9.1) + '@orpc/client@1.13.13(@opentelemetry/api@1.9.1)': dependencies: '@orpc/shared': 1.13.13(@opentelemetry/api@1.9.1) @@ -9658,11 +10288,13 @@ snapshots: transitivePeerDependencies: - '@opentelemetry/api' - '@orpc/experimental-publisher@1.13.13(@opentelemetry/api@1.9.1)': + '@orpc/experimental-publisher@1.13.13(@opentelemetry/api@1.9.1)(@upstash/redis@1.37.0)': dependencies: '@orpc/client': 1.13.13(@opentelemetry/api@1.9.1) '@orpc/shared': 1.13.13(@opentelemetry/api@1.9.1) '@orpc/standard-server': 1.13.13(@opentelemetry/api@1.9.1) + optionalDependencies: + '@upstash/redis': 1.37.0 transitivePeerDependencies: - '@opentelemetry/api' @@ -9760,11 +10392,11 @@ snapshots: dependencies: playwright: 1.59.1 - '@polar-sh/better-auth@1.8.3(d0c442ce4a9c66fb669f11db7be7f592)': + '@polar-sh/better-auth@1.8.3(3039f015a40675ed8d512bd4940c91a8)': dependencies: '@polar-sh/checkout': 0.2.0(@stripe/react-stripe-js@4.0.2(@stripe/stripe-js@7.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@stripe/stripe-js@7.9.0)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@16.13.1)(react@19.2.4)(redux@5.0.1) '@polar-sh/sdk': 0.47.0 - better-auth: 1.5.6(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.167.16(crossws@0.4.4(srvx@0.11.15))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@8.0.3(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(better-sqlite3@12.8.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15))(next@16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.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)(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(vite@8.0.3(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + better-auth: 1.5.6(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.167.16(crossws@0.4.4(srvx@0.11.15))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@8.0.3(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(better-sqlite3@12.8.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15))(next@16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.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)(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(vite@8.0.3(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) zod: 4.3.6 transitivePeerDependencies: - '@stripe/react-stripe-js' @@ -9844,6 +10476,13 @@ snapshots: - react-is - redux + '@prisma/instrumentation@7.6.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.207.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -10719,6 +11358,92 @@ snapshots: transitivePeerDependencies: - debug + '@sentry-internal/browser-utils@10.49.0': + dependencies: + '@sentry/core': 10.49.0 + + '@sentry-internal/feedback@10.49.0': + dependencies: + '@sentry/core': 10.49.0 + + '@sentry-internal/replay-canvas@10.49.0': + dependencies: + '@sentry-internal/replay': 10.49.0 + '@sentry/core': 10.49.0 + + '@sentry-internal/replay@10.49.0': + dependencies: + '@sentry-internal/browser-utils': 10.49.0 + '@sentry/core': 10.49.0 + + '@sentry/browser@10.49.0': + dependencies: + '@sentry-internal/browser-utils': 10.49.0 + '@sentry-internal/feedback': 10.49.0 + '@sentry-internal/replay': 10.49.0 + '@sentry-internal/replay-canvas': 10.49.0 + '@sentry/core': 10.49.0 + + '@sentry/core@10.49.0': {} + + '@sentry/node-core@10.49.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.214.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.40.0)': + dependencies: + '@sentry/core': 10.49.0 + '@sentry/opentelemetry': 10.49.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.40.0) + import-in-the-middle: 3.0.1 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@sentry/node@10.49.0': + dependencies: + '@fastify/otel': 0.18.0(@opentelemetry/api@1.9.1) + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-amqplib': 0.61.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-connect': 0.57.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-dataloader': 0.31.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-fs': 0.33.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-generic-pool': 0.57.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-graphql': 0.62.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-hapi': 0.60.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-http': 0.214.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-ioredis': 0.62.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-kafkajs': 0.23.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-knex': 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-koa': 0.62.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-lru-memoizer': 0.58.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mongodb': 0.67.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mongoose': 0.60.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mysql': 0.60.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mysql2': 0.60.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-pg': 0.66.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-redis': 0.62.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-tedious': 0.33.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-undici': 0.24.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + '@prisma/instrumentation': 7.6.0(@opentelemetry/api@1.9.1) + '@sentry/core': 10.49.0 + '@sentry/node-core': 10.49.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.0(@opentelemetry/api@1.9.1))(@opentelemetry/instrumentation@0.214.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.40.0) + '@sentry/opentelemetry': 10.49.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.40.0) + import-in-the-middle: 3.0.1 + transitivePeerDependencies: + - '@opentelemetry/exporter-trace-otlp-http' + - supports-color + + '@sentry/opentelemetry@10.49.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.7.0(@opentelemetry/api@1.9.1))(@opentelemetry/sdk-trace-base@2.7.0(@opentelemetry/api@1.9.1))(@opentelemetry/semantic-conventions@1.40.0)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.7.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + '@sentry/core': 10.49.0 + '@socket.io/component-emitter@3.1.2': {} '@stablelib/base64@1.0.1': {} @@ -11106,6 +11831,10 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/connect@3.4.38': + dependencies: + '@types/node': 25.5.2 + '@types/cors@2.8.19': dependencies: '@types/node': 25.5.2 @@ -11158,12 +11887,26 @@ snapshots: '@types/ms@2.1.0': {} + '@types/mysql@2.15.27': + dependencies: + '@types/node': 25.5.2 + '@types/node@16.9.1': {} '@types/node@25.5.2': dependencies: undici-types: 7.18.2 + '@types/pg-pool@2.0.7': + dependencies: + '@types/pg': 8.15.6 + + '@types/pg@8.15.6': + dependencies: + '@types/node': 25.5.2 + pg-protocol: 1.13.0 + pg-types: 2.2.0 + '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -11174,6 +11917,10 @@ snapshots: '@types/resolve@1.20.2': {} + '@types/tedious@4.0.14': + dependencies: + '@types/node': 25.5.2 + '@types/trusted-types@2.0.7': {} '@types/unist@3.0.3': {} @@ -11336,6 +12083,10 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true + '@upstash/redis@1.37.0': + dependencies: + uncrypto: 0.1.3 + '@vitejs/plugin-react@6.0.1(vite@8.0.3(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 @@ -11391,6 +12142,10 @@ snapshots: mime-types: 2.1.35 negotiator: 0.6.3 + acorn-import-attributes@1.9.5(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -11600,10 +12355,10 @@ snapshots: baseline-browser-mapping@2.9.19: {} - better-auth@1.5.6(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.167.16(crossws@0.4.4(srvx@0.11.15))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@8.0.3(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(better-sqlite3@12.8.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15))(next@16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.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)(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(vite@8.0.3(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))): + better-auth@1.5.6(@opentelemetry/api@1.9.1)(@tanstack/react-start@1.167.16(crossws@0.4.4(srvx@0.11.15))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@8.0.3(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(better-sqlite3@12.8.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15))(next@16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.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)(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.2)(vite@8.0.3(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))): dependencies: '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(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.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15)) + '@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.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15)) '@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.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(kysely@0.28.15) '@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.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(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.1)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.15)(nanostores@1.2.0))(@better-auth/utils@0.3.1) @@ -11623,7 +12378,7 @@ snapshots: '@tanstack/react-start': 1.167.16(crossws@0.4.4(srvx@0.11.15))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@8.0.3(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) better-sqlite3: 12.8.0 drizzle-kit: 0.31.10 - drizzle-orm: 0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15) + drizzle-orm: 0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15) next: 16.1.7(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) @@ -11784,6 +12539,8 @@ snapshots: dependencies: consola: 3.4.2 + cjs-module-lexer@2.2.0: {} + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -11986,11 +12743,11 @@ snapshots: dayjs@1.11.20: {} - db0@0.3.4(@libsql/client@0.17.2)(better-sqlite3@12.8.0)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15)): + db0@0.3.4(@libsql/client@0.17.2)(better-sqlite3@12.8.0)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15)): optionalDependencies: '@libsql/client': 0.17.2 better-sqlite3: 12.8.0 - drizzle-orm: 0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15) + drizzle-orm: 0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15) debounce-fn@6.0.0: dependencies: @@ -12109,22 +12866,24 @@ snapshots: esbuild: 0.25.12 tsx: 4.21.0 - drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15): + drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15): optionalDependencies: '@libsql/client': 0.17.2 '@opentelemetry/api': 1.9.1 + '@types/pg': 8.15.6 + '@upstash/redis': 1.37.0 better-sqlite3: 12.8.0 kysely: 0.28.15 - drizzle-seed@0.3.1(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15)): + drizzle-seed@0.3.1(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15)): dependencies: pure-rand: 6.1.0 optionalDependencies: - drizzle-orm: 0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15) + drizzle-orm: 0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15) - drizzle-zod@0.8.3(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15))(zod@4.3.6): + drizzle-zod@0.8.3(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15))(zod@4.3.6): dependencies: - drizzle-orm: 0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15) + drizzle-orm: 0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15) zod: 4.3.6 dunder-proto@1.0.1: @@ -12773,6 +13532,8 @@ snapshots: dependencies: fetch-blob: 3.2.0 + forwarded-parse@2.1.2: {} + fs-constants@1.0.0: {} fs-extra@9.1.0: @@ -13096,6 +13857,20 @@ snapshots: immer@11.1.4: {} + import-in-the-middle@2.0.6: + dependencies: + acorn: 8.16.0 + acorn-import-attributes: 1.9.5(acorn@8.16.0) + cjs-module-lexer: 2.2.0 + module-details-from-path: 1.0.4 + + import-in-the-middle@3.0.1: + dependencies: + acorn: 8.16.0 + acorn-import-attributes: 1.9.5(acorn@8.16.0) + cjs-module-lexer: 2.2.0 + module-details-from-path: 1.0.4 + imurmurhash@0.1.4: {} inherits@2.0.4: {} @@ -13866,6 +14641,8 @@ snapshots: mkdirp-classic@0.5.3: {} + module-details-from-path@1.0.4: {} + ms@2.0.0: {} ms@2.1.3: {} @@ -13915,11 +14692,11 @@ snapshots: nf3@0.3.16: {} - nitro@3.0.260311-beta(@libsql/client@0.17.2)(better-sqlite3@12.8.0)(chokidar@4.0.3)(dotenv@17.4.1)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15))(jiti@2.6.1)(lru-cache@11.2.7)(rollup@2.79.2)(vite@8.0.3(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): + nitro@3.0.260311-beta(@libsql/client@0.17.2)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(chokidar@4.0.3)(dotenv@17.4.1)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15))(jiti@2.6.1)(lru-cache@11.2.7)(rollup@2.79.2)(vite@8.0.3(@types/node@25.5.2)(esbuild@0.28.0)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): dependencies: consola: 3.4.2 crossws: 0.4.4(srvx@0.11.15) - db0: 0.3.4(@libsql/client@0.17.2)(better-sqlite3@12.8.0)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15)) + db0: 0.3.4(@libsql/client@0.17.2)(better-sqlite3@12.8.0)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15)) env-runner: 0.1.7 h3: 2.0.1-rc.16(crossws@0.4.4(srvx@0.11.15)) hookable: 6.1.0 @@ -13930,7 +14707,7 @@ snapshots: rolldown: 1.0.0-rc.12 srvx: 0.11.15 unenv: 2.0.0-rc.24 - unstorage: 2.0.0-alpha.7(chokidar@4.0.3)(db0@0.3.4(@libsql/client@0.17.2)(better-sqlite3@12.8.0)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15)))(lru-cache@11.2.7)(ofetch@2.0.0-alpha.3) + unstorage: 2.0.0-alpha.7(@upstash/redis@1.37.0)(chokidar@4.0.3)(db0@0.3.4(@libsql/client@0.17.2)(better-sqlite3@12.8.0)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15)))(lru-cache@11.2.7)(ofetch@2.0.0-alpha.3) optionalDependencies: dotenv: 17.4.1 jiti: 2.6.1 @@ -14161,6 +14938,18 @@ snapshots: peek-readable@4.1.0: {} + pg-int8@1.0.1: {} + + pg-protocol@1.13.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -14195,6 +14984,8 @@ snapshots: possible-typed-array-names@1.1.0: {} + postal-mime@2.7.4: {} + postcss-selector-parser@6.0.10: dependencies: cssesc: 3.0.0 @@ -14212,6 +15003,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + prebuild-install@7.1.3: dependencies: detect-libc: 2.1.2 @@ -14554,8 +15355,22 @@ snapshots: require-from-string@2.0.2: {} + require-in-the-middle@8.0.1: + dependencies: + debug: 4.4.3 + module-details-from-path: 1.0.4 + transitivePeerDependencies: + - supports-color + reselect@5.1.1: {} + resend@6.11.0(@react-email/render@2.0.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)): + dependencies: + postal-mime: 2.7.4 + svix: 1.90.0 + optionalDependencies: + '@react-email/render': 2.0.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + resolve-pkg-maps@1.0.0: {} resolve@1.22.11: @@ -14990,6 +15805,11 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svix@1.90.0: + dependencies: + standardwebhooks: 1.0.0 + uuid: 10.0.0 + tagged-tag@1.0.0: {} tailwind-merge@3.5.0: {} @@ -15186,6 +16006,8 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + uncrypto@0.1.3: {} + undici-types@7.18.2: {} undici@7.21.0: {} @@ -15275,10 +16097,11 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 - unstorage@2.0.0-alpha.7(chokidar@4.0.3)(db0@0.3.4(@libsql/client@0.17.2)(better-sqlite3@12.8.0)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15)))(lru-cache@11.2.7)(ofetch@2.0.0-alpha.3): + unstorage@2.0.0-alpha.7(@upstash/redis@1.37.0)(chokidar@4.0.3)(db0@0.3.4(@libsql/client@0.17.2)(better-sqlite3@12.8.0)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15)))(lru-cache@11.2.7)(ofetch@2.0.0-alpha.3): optionalDependencies: + '@upstash/redis': 1.37.0 chokidar: 4.0.3 - db0: 0.3.4(@libsql/client@0.17.2)(better-sqlite3@12.8.0)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(kysely@0.28.15)) + db0: 0.3.4(@libsql/client@0.17.2)(better-sqlite3@12.8.0)(drizzle-orm@0.45.2(@libsql/client@0.17.2)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(@upstash/redis@1.37.0)(better-sqlite3@12.8.0)(kysely@0.28.15)) lru-cache: 11.2.7 ofetch: 2.0.0-alpha.3 @@ -15319,6 +16142,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@10.0.0: {} + v8-compile-cache-lib@3.0.1: {} vary@1.1.2: {} @@ -15657,6 +16482,8 @@ snapshots: xmlbuilder@11.0.1: {} + xtend@4.0.2: {} + y18n@5.0.8: {} yallist@3.1.1: {} diff --git a/server/plugins/sentry.ts b/server/plugins/sentry.ts new file mode 100644 index 00000000..fe1f721e --- /dev/null +++ b/server/plugins/sentry.ts @@ -0,0 +1,26 @@ +import * as Sentry from "@sentry/node"; +import { env } from "../../src/env"; + +const SAMPLE_RATES = { + development: { traces: 1.0 }, + production: { traces: 0.2 }, +} as const; + +let initialized = false; + +export default () => { + if (initialized || !env.SENTRY_DSN_BACKEND) { + return; + } + + initialized = true; + + const isDev = env.NODE_ENV === "development"; + const rates = isDev ? SAMPLE_RATES.development : SAMPLE_RATES.production; + + Sentry.init({ + dsn: env.SENTRY_DSN_BACKEND, + environment: isDev ? "development" : "production", + tracesSampleRate: rates.traces, + }); +}; diff --git a/server/tasks/feeds/background-refresh.ts b/server/tasks/feeds/background-refresh.ts index 248ab7e9..3a74526b 100644 --- a/server/tasks/feeds/background-refresh.ts +++ b/server/tasks/feeds/background-refresh.ts @@ -1,9 +1,10 @@ import { defineTask } from "nitro/task"; -import { and, eq, lte } from "drizzle-orm"; +import { and, eq, isNull, lte, or } from "drizzle-orm"; import { db } from "../../../src/server/db"; import { feeds, user } from "../../../src/server/db/schema"; import { fetchAndInsertFeedData } from "../../../src/server/rss/fetchFeeds"; import { IS_MAIN_INSTANCE } from "../../../src/lib/constants"; +import { env } from "../../../src/env"; export default defineTask({ meta: { @@ -11,8 +12,7 @@ export default defineTask({ description: "Background refresh of active feeds for paid users", }, async run() { - const backgroundRefreshEnabled = - process.env.BACKGROUND_REFRESH_ENABLED !== "false"; + const backgroundRefreshEnabled = env.BACKGROUND_REFRESH_ENABLED !== "false"; if (!backgroundRefreshEnabled) { console.log( @@ -37,11 +37,17 @@ export default defineTask({ adminUserIds = new Set(admins.map((a) => a.id)); } - // Fetch all active feeds that need refreshing in a single query + // Fetch all active feeds that need refreshing in a single query. + // On non-main (self-hosted) instances, also include feeds where nextFetchAt + // is NULL — these have never been scheduled and should still be refreshed. + const fetchAtCondition = IS_MAIN_INSTANCE + ? lte(feeds.nextFetchAt, now) + : or(lte(feeds.nextFetchAt, now), isNull(feeds.nextFetchAt)); + const allFeedsDue = await db .select() .from(feeds) - .where(and(eq(feeds.isActive, true), lte(feeds.nextFetchAt, now))) + .where(and(eq(feeds.isActive, true), fetchAtCondition)) .all(); // Filter to admin users if on main instance @@ -73,6 +79,14 @@ export default defineTask({ let emptyCount = 0; let errorCount = 0; + // Map feed ID → name for error logging + const feedNameMap = new Map(); + for (const userFeeds of feedsByUser.values()) { + for (const feed of userFeeds) { + feedNameMap.set(feed.id, feed.name); + } + } + for (const [userId, userFeeds] of feedsByUser) { try { // Fetch and insert feed data @@ -86,6 +100,14 @@ export default defineTask({ emptyCount++; } else if (result.status === "error") { errorCount++; + const feedName = feedNameMap.get(result.id) ?? "unknown"; + const errMsg = + result.error instanceof Error + ? result.error.message + : String(result.error); + console.error( + `[background-refresh] Error refreshing feed "${feedName}" (id=${result.id}, user=${userId}): ${errMsg}`, + ); } } } catch (e) { diff --git a/src/app/__root.tsx b/src/app/__root.tsx index 6e9ee0a3..cb86cbb1 100644 --- a/src/app/__root.tsx +++ b/src/app/__root.tsx @@ -9,6 +9,7 @@ import { SproutIcon } from "lucide-react"; import { ThemeProvider } from "~/components/ThemeProvider"; import { Toaster } from "~/components/ui/sonner"; import { QueryProvider } from "~/lib/query-provider"; +import { initializeSentry } from "~/lib/sentry"; import { ReloadPrompt } from "~/components/pwa/ReloadPrompt"; import { Button } from "~/components/ui/button"; @@ -20,6 +21,11 @@ import appCss from "~/styles/globals.css?url"; import "@fontsource-variable/outfit"; import "@fontsource-variable/noto-serif"; +// Initialize Sentry as early as possible +if (typeof window !== "undefined") { + initializeSentry(); +} + const title = "Serial"; const description = "A snappy, customizable video feed. Designed to show you exactly the content you want to see and nothing else."; diff --git a/src/app/_app.admin.info.tsx b/src/app/_app.admin.info.tsx index 2bf623e7..3d654654 100644 --- a/src/app/_app.admin.info.tsx +++ b/src/app/_app.admin.info.tsx @@ -5,6 +5,7 @@ import { ArrowLeftIcon } from "lucide-react"; import { UserSignupsChart } from "~/components/admin/UserSignupsChart"; import { UserSigninsChart } from "~/components/admin/UserSigninsChart"; import { UserRetentionChart } from "~/components/admin/UserRetentionChart"; +import { UserFeedCountChart } from "~/components/admin/UserFeedCountChart"; import { adminMiddleware } from "~/server/auth"; import { Button } from "~/components/ui/button"; @@ -29,6 +30,7 @@ function AdminInfoPage() { + ); diff --git a/src/app/_app.feeds.tsx b/src/app/_app.feeds.tsx index 406fcc06..946c5cdd 100644 --- a/src/app/_app.feeds.tsx +++ b/src/app/_app.feeds.tsx @@ -13,6 +13,7 @@ import { FeedManagementTabs } from "~/components/feed/FeedManagementTabs"; import { useFeedManagementShortcuts } from "~/components/feed/useManagementShortcuts"; import { FeedEmptyState } from "~/components/feed/view-lists/EmptyStates"; import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; +import { Progress } from "~/components/ui/progress"; import { Badge } from "~/components/ui/badge"; import { Button } from "~/components/ui/button"; import { Checkbox } from "~/components/ui/checkbox"; @@ -20,6 +21,11 @@ import { ChipCombobox } from "~/components/ui/chip-combobox"; import { Input } from "~/components/ui/input"; import { ControlledResponsiveDialog } from "~/components/ui/responsive-dropdown"; import { Switch } from "~/components/ui/switch"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "~/components/ui/tooltip"; import { useContentCategories } from "~/lib/data/content-categories"; import { useFeedCategories } from "~/lib/data/feed-categories"; import { @@ -29,6 +35,7 @@ import { import { useFeeds } from "~/lib/data/feeds"; import { useBulkDeleteFeedsMutation, + useBulkSetActiveMutation, useSetFeedActiveMutation, } from "~/lib/data/feeds/mutations"; import { useSubscription } from "~/lib/data/subscription"; @@ -40,6 +47,7 @@ import { useBulkAssignViewFeedMutation, useBulkRemoveViewFeedMutation, } from "~/lib/data/view-feeds/mutations"; +import { useShiftSelect } from "~/lib/hooks/useShiftSelect"; export const Route = createFileRoute("/_app/feeds")({ component: ManageFeedsPage, @@ -94,6 +102,7 @@ function ManageFeedsPage() { useSubscription(); const { mutate: setFeedActive, isPending: isTogglingActive } = useSetFeedActiveMutation(); + const { mutateAsync: bulkSetActive } = useBulkSetActiveMutation(); const [selectedFeedIds, setSelectedFeedIds] = useState>( new Set(), @@ -135,6 +144,7 @@ function ManageFeedsPage() { const [showEditDialog, setShowEditDialog] = useState(false); const [selectedCategoryIds, setSelectedCategoryIds] = useState([]); const [selectedViewIds, setSelectedViewIds] = useState([]); + const [bulkActiveState, setBulkActiveState] = useState(false); const { mutateAsync: bulkDeleteFeeds, isPending: isDeletingFeeds } = useBulkDeleteFeedsMutation(); @@ -224,22 +234,16 @@ function ManageFeedsPage() { viewNamesMap, ]); + const filteredFeedIds = useMemo( + () => filteredFeeds.map((f) => f.id), + [filteredFeeds], + ); + const handleFeedSelect = useShiftSelect(filteredFeedIds, setSelectedFeedIds); + const selectedCount = selectedFeedIds.size; const allSelected = filteredFeeds.length > 0 && selectedCount === filteredFeeds.length; - const toggleFeedSelection = (feedId: number) => { - setSelectedFeedIds((prev) => { - const next = new Set(prev); - if (next.has(feedId)) { - next.delete(feedId); - } else { - next.add(feedId); - } - return next; - }); - }; - const selectAll = () => { setSelectedFeedIds(new Set(filteredFeeds.map((f) => f.id))); }; @@ -294,6 +298,11 @@ function ManageFeedsPage() { const openEditDialog = () => { setSelectedCategoryIds(getSharedCategories()); setSelectedViewIds(getSharedViews()); + // If all selected feeds are active, show active; otherwise show deactivated + const allActive = Array.from(selectedFeedIds).every( + (id) => feeds.find((f) => f.id === id)?.isActive, + ); + setBulkActiveState(allActive); setShowEditDialog(true); }; @@ -349,8 +358,40 @@ function ManageFeedsPage() { const sharedCategories = getSharedCategories(); const sharedViews = getSharedViews(); + // Active state + const feedsToToggle = feedIds.filter((id) => { + const feed = feeds.find((f) => f.id === id); + return feed && feed.isActive !== bulkActiveState; + }); + + if (bulkActiveState && feedsToToggle.length > 0 && maxActiveFeeds >= 0) { + const wouldBeActive = activeFeeds + feedsToToggle.length; + + if (wouldBeActive > maxActiveFeeds) { + const overLimit = wouldBeActive - maxActiveFeeds; + toast.warning( + `${overLimit} feed${overLimit > 1 ? "s would" : " would"} exceed your plan limit. To unlock more active feeds, you can switch to a higher plan.`, + { + action: { + label: "Upgrade", + onClick: () => + launchDialog("subscription", { subscriptionView: "picker" }), + }, + }, + ); + return; + } + } + const promises: Array> = []; + // Bulk active state toggle + if (feedsToToggle.length > 0) { + promises.push( + bulkSetActive({ feedIds: feedsToToggle, isActive: bulkActiveState }), + ); + } + // Categories const categoriesToAdd = selectedCategoryIds; const categoriesToRemove = sharedCategories.filter( @@ -414,11 +455,6 @@ function ManageFeedsPage() {
- {billingEnabled && maxActiveFeeds > 0 && ( -

- {activeFeeds} / {maxActiveFeeds} feeds active -

- )}
+ {billingEnabled && + maxActiveFeeds > 0 && + activeFeeds < maxActiveFeeds && ( +
+
+

+ {activeFeeds} / {maxActiveFeeds} feeds active +

+
+ +
+ )} {billingEnabled && maxActiveFeeds > 0 && activeFeeds >= maxActiveFeeds && ( @@ -440,7 +490,9 @@ function ManageFeedsPage() { will receive new content. + + + } >
-
- - -
diff --git a/src/app/_app.import.tsx b/src/app/_app.import.tsx index ed9abdda..b7c72066 100644 --- a/src/app/_app.import.tsx +++ b/src/app/_app.import.tsx @@ -109,6 +109,11 @@ function EditFeedsPage() { const [hasStartedImport, setHasStartedImport] = useState(false); const [isImportComplete, setIsImportComplete] = useState(false); const [importMode, setImportMode] = useState("views"); + // Signal to Playwright tests that React has hydrated and the onChange handler + // is attached to the file input, so file-chooser interactions are reliable. + useEffect(() => { + inputElementRef.current?.setAttribute("data-ready", "true"); + }, []); const channelImportCount = feedsFoundFromFile?.filter( (feed) => feed.shouldImport, @@ -132,7 +137,8 @@ function EditFeedsPage() { { action: { label: "Upgrade", - onClick: () => launchDialog("subscription"), + onClick: () => + launchDialog("subscription", { subscriptionView: "picker" }), }, }, ); @@ -246,7 +252,7 @@ function EditFeedsPage() { @@ -267,10 +273,11 @@ function EditFeedsPage() { )} diff --git a/src/app/_app.index.tsx b/src/app/_app.index.tsx index cb9a5dbc..02c24fd0 100644 --- a/src/app/_app.index.tsx +++ b/src/app/_app.index.tsx @@ -14,6 +14,10 @@ import { ViewFilterChips } from "~/components/feed/ViewFilterChips"; import { useUpdateViewFilter } from "~/lib/data/views"; import { useShortcut } from "~/lib/hooks/useShortcut"; import { SHORTCUT_KEYS } from "~/lib/constants/shortcuts"; +import { useFeeds } from "~/lib/data/feeds"; +import { useHasInitialData } from "~/lib/data/store"; +import FeedLoading from "~/components/loading"; +import { FeedEmptyState } from "~/components/feed/view-lists/EmptyStates"; export const Route = createFileRoute("/_app/")({ component: Home, @@ -90,6 +94,21 @@ function Home() { updateViewFilter(views[nextIndex]!.id); }); + const hasInitialData = useHasInitialData(); + const { feeds, hasFetchedFeeds } = useFeeds(); + + if (!hasInitialData) { + return ; + } + + if (hasFetchedFeeds && !feeds.length) { + return ( +
+ +
+ ); + } + return (
diff --git a/src/app/_app.tags.tsx b/src/app/_app.tags.tsx index a3deee5f..404f675e 100644 --- a/src/app/_app.tags.tsx +++ b/src/app/_app.tags.tsx @@ -26,6 +26,7 @@ import { useFeedCategories } from "~/lib/data/feed-categories"; import { useFeeds } from "~/lib/data/feeds"; import { useViews } from "~/lib/data/views"; import { INBOX_VIEW_ID } from "~/lib/data/views/constants"; +import { useShiftSelect } from "~/lib/hooks/useShiftSelect"; import { useShortcut } from "~/lib/hooks/useShortcut"; export const Route = createFileRoute("/_app/tags")({ @@ -146,19 +147,16 @@ function ManageTagsPage() { viewNamesMap, ]); + const filteredTagIds = useMemo( + () => filteredTags.map((t) => t.id), + [filteredTags], + ); + const handleTagSelect = useShiftSelect(filteredTagIds, setSelectedTagIds); + const selectedCount = selectedTagIds.size; const allSelected = filteredTags.length > 0 && selectedCount === filteredTags.length; - const toggleTagSelection = (tagId: number) => { - setSelectedTagIds((prev) => { - const next = new Set(prev); - if (next.has(tagId)) next.delete(tagId); - else next.add(tagId); - return next; - }); - }; - const selectAll = () => setSelectedTagIds(new Set(filteredTags.map((t) => t.id))); const deselectAll = () => setSelectedTagIds(new Set()); @@ -299,12 +297,12 @@ function ManageTagsPage() { type="button" key={tag.id} className="hover:bg-muted/50 flex w-full cursor-pointer items-center justify-between gap-3 rounded-lg px-3 py-3 text-left transition-colors" - onClick={() => toggleTagSelection(tag.id)} + onClick={(e) => handleTagSelect(tag.id, e)} > toggleTagSelection(tag.id)} + onCheckedChange={() => handleTagSelect(tag.id)} onClick={(e) => e.stopPropagation()} /> {tag.name} diff --git a/src/app/_app.tsx b/src/app/_app.tsx index 7133bb77..da8b5aef 100644 --- a/src/app/_app.tsx +++ b/src/app/_app.tsx @@ -1,19 +1,32 @@ import "~/styles/globals.css"; import { createFileRoute, Outlet } from "@tanstack/react-router"; -import { Suspense } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { CheckIcon } from "lucide-react"; +import { Suspense, useEffect, useRef, useState } from "react"; import { AppDialogs } from "../components/feed/AppDialogs"; import { Header } from "../components/feed/Header"; import type React from "react"; import FeedLoading from "~/components/loading"; import { AppLeftSidebar, AppRightSidebar } from "~/components/app-sidebar"; +import { Button } from "~/components/ui/button"; +import { ControlledResponsiveDialog } from "~/components/ui/responsive-dropdown"; +import { useDialogStore } from "~/components/feed/dialogStore"; import { ImpersonationBanner } from "~/components/ImpersonationBanner"; import { ReleaseNotifier } from "~/components/releases/ReleaseNotifier"; import { SidebarInset, SidebarProvider } from "~/components/ui/sidebar"; import { InitialClientQueries } from "~/lib/data/InitialClientQueries"; +import { usePlanSuccessStore } from "~/lib/data/plan-success"; +import { useSubscription } from "~/lib/data/subscription"; import { useAltKeyHeld } from "~/lib/hooks/useAltKeyHeld"; import { authMiddleware } from "~/server/auth"; import { getMostRecentRelease } from "~/lib/markdown/loaders"; +import { orpc, orpcRouterClient } from "~/lib/orpc"; +import { PLANS } from "~/server/subscriptions/plans"; +import { + getPlanFeatures, + PLAN_ICONS, +} from "~/components/feed/subscription-dialog"; export const Route = createFileRoute("/_app")({ component: RootLayout, @@ -26,9 +39,190 @@ export const Route = createFileRoute("/_app")({ }, }); +const MAX_SYNC_ATTEMPTS = 10; +const SYNC_POLL_INTERVAL_MS = 3_000; + +function useCheckoutSuccess() { + const queryClient = useQueryClient(); + const [awaitingUpgrade, setAwaitingUpgrade] = useState(false); + const { planId, billingEnabled } = useSubscription(); + const openPlanSuccess = usePlanSuccessStore((s) => s.openDialog); + const previousPlanIdRef = useRef(null); + const hasProcessedCheckout = useRef(false); + + // Detect checkout_success query param (waits for billingEnabled to resolve) + useEffect(() => { + if (!billingEnabled) return; + if (hasProcessedCheckout.current) return; + + const params = new URLSearchParams(window.location.search); + if (params.get("checkout_success") !== "true") return; + + hasProcessedCheckout.current = true; + + // Remove the query param from the URL + params.delete("checkout_success"); + const newUrl = + window.location.pathname + + (params.size > 0 ? `?${params.toString()}` : ""); + window.history.replaceState({}, "", newUrl); + + // Snapshot the current plan so we can detect when it changes + previousPlanIdRef.current = planId; + setAwaitingUpgrade(true); + }, [billingEnabled, planId]); + + // Eagerly sync after checkout, then poll if needed + useEffect(() => { + if (!awaitingUpgrade) return; + + const previousPlanId = previousPlanIdRef.current; + + // Check if plan has already changed (e.g. webhook arrived fast) + if (previousPlanId !== null && planId !== previousPlanId) { + setAwaitingUpgrade(false); + openPlanSuccess(); + return; + } + + const controller = new AbortController(); + let attempts = 0; + let isSyncing = false; + + const sync = async (): Promise => { + if (isSyncing) return false; + isSyncing = true; + try { + const result = await orpcRouterClient.subscription.syncAfterCheckout(); + + if (controller.signal.aborted) return false; + + // Update the getStatus query data with the fresh result + queryClient.setQueryData( + orpc.subscription.getStatus.queryOptions().queryKey, + result, + ); + + const planChanged = + previousPlanId !== null && result.planId !== previousPlanId; + if (planChanged) { + setAwaitingUpgrade(false); + openPlanSuccess(); + return true; + } + } catch { + // Ignore errors, will retry + } finally { + isSyncing = false; + } + return false; + }; + + // First attempt immediately, then poll with interval + void sync().then((done) => { + if (done || controller.signal.aborted) return; + + const interval = setInterval(() => { + if (controller.signal.aborted) { + clearInterval(interval); + return; + } + + attempts++; + void sync().then((done) => { + if (done || attempts >= MAX_SYNC_ATTEMPTS) { + clearInterval(interval); + if (!done) { + // Give up gracefully — user will see the upgrade on next load + setAwaitingUpgrade(false); + } + } + }); + }, SYNC_POLL_INTERVAL_MS); + }); + + return () => { + controller.abort(); + }; + }, [awaitingUpgrade, planId, queryClient, openPlanSuccess]); + + return { awaitingUpgrade, billingEnabled }; +} + +/** + * Detect ?subscription=open query param (set by the Polar portal return URL) + * and re-open the subscription dialog. + */ +function usePortalReturn() { + const launchDialog = useDialogStore((s) => s.launchDialog); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + if (params.get("subscription") !== "open") return; + + // Clean the query param from the URL + params.delete("subscription"); + const newUrl = + window.location.pathname + + (params.size > 0 ? `?${params.toString()}` : ""); + window.history.replaceState({}, "", newUrl); + + launchDialog("subscription"); + }, []); // eslint-disable-line react-hooks/exhaustive-deps +} + +function CheckoutSuccessDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { planId } = useSubscription(); + const plan = PLANS[planId]; + const features = getPlanFeatures(plan); + const Icon = PLAN_ICONS[planId] ?? PLAN_ICONS.free; + + return ( + +
+
+ +
+

{plan.name} Plan

+
    + {features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+ +
+
+ ); +} + function RootLayout() { const { mostRecentRelease } = Route.useLoaderData(); useAltKeyHeld(); + usePortalReturn(); + const { awaitingUpgrade, billingEnabled } = useCheckoutSuccess(); + const showPlanSuccess = usePlanSuccessStore((s) => s.showDialog); + const closePlanSuccess = usePlanSuccessStore((s) => s.closeDialog); + + if (awaitingUpgrade) { + return ; + } return ( // @@ -51,6 +245,12 @@ function RootLayout() {
+ {billingEnabled && ( + + )} diff --git a/src/app/_app.views.tsx b/src/app/_app.views.tsx index e81150a7..0ef3da6f 100644 --- a/src/app/_app.views.tsx +++ b/src/app/_app.views.tsx @@ -25,6 +25,7 @@ import { useDeleteViewMutation, useEditViewMutation, } from "~/lib/data/views/mutations"; +import { useShiftSelect } from "~/lib/hooks/useShiftSelect"; import { useShortcut } from "~/lib/hooks/useShortcut"; import { VIEW_READ_STATUS } from "~/server/db/constants"; @@ -114,19 +115,16 @@ function ManageViewsPage() { }); }, [customViews, searchQuery, feedNamesMap, categoryNamesMap]); + const filteredViewIds = useMemo( + () => filteredViews.map((v) => v.id), + [filteredViews], + ); + const handleViewSelect = useShiftSelect(filteredViewIds, setSelectedViewIds); + const selectedCount = selectedViewIds.size; const allSelected = filteredViews.length > 0 && selectedCount === filteredViews.length; - const toggleViewSelection = (viewId: number) => { - setSelectedViewIds((prev) => { - const next = new Set(prev); - if (next.has(viewId)) next.delete(viewId); - else next.add(viewId); - return next; - }); - }; - const selectAll = () => setSelectedViewIds(new Set(filteredViews.map((v) => v.id))); const deselectAll = () => setSelectedViewIds(new Set()); @@ -266,12 +264,12 @@ function ManageViewsPage() { type="button" key={view.id} className="hover:bg-muted/50 flex w-full cursor-pointer items-center justify-between gap-3 rounded-lg px-3 py-3 text-left transition-colors" - onClick={() => toggleViewSelection(view.id)} + onClick={(e) => handleViewSelect(view.id, e)} > toggleViewSelection(view.id)} + onCheckedChange={() => handleViewSelect(view.id)} onClick={(e) => e.stopPropagation()} /> {view.name} diff --git a/src/app/_web.releases.$slug.tsx b/src/app/_web.releases.$slug.tsx index 66691ce8..13f18395 100644 --- a/src/app/_web.releases.$slug.tsx +++ b/src/app/_web.releases.$slug.tsx @@ -14,6 +14,7 @@ export const Route = createFileRoute("/_web/releases/$slug")({ function RouteComponent() { const { release, isAuthed } = Route.useLoaderData(); + const supportEmail = import.meta.env.VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS; return (
@@ -39,12 +40,14 @@ function RouteComponent() { {isAuthed && ( <>

- Thanks for checking out the release log! If you have any questions - or feedback, feel free to send me an email at{" "} - - hey@serial.tube - - . + Thanks for checking out the release log! + {supportEmail && ( + <> + {" "} + If you have any questions or feedback, feel free to send me an + email at {supportEmail}. + + )}

Return to the app → diff --git a/src/app/welcome.tsx b/src/app/welcome.tsx index 9749d25a..86f687d9 100644 --- a/src/app/welcome.tsx +++ b/src/app/welcome.tsx @@ -23,6 +23,7 @@ export const Route = createFileRoute("/welcome")({ function RouteComponent() { const { mostRecentRelease } = Route.useLoaderData(); + const supportEmail = import.meta.env.VITE_PUBLIC_SUPPORT_EMAIL_ADDRESS; return (
@@ -187,17 +188,16 @@ function RouteComponent() {
-
-

- Have a question? Reach us at{" "} - - hey@serial.tube - -

-
+ {supportEmail && ( +
+

+ Have a question? Reach us at{" "} + + {supportEmail} + +

+
+ )} ); } diff --git a/src/components/AddContentCategoryDialog.tsx b/src/components/AddContentCategoryDialog.tsx index 7f32fa54..271e7793 100644 --- a/src/components/AddContentCategoryDialog.tsx +++ b/src/components/AddContentCategoryDialog.tsx @@ -285,13 +285,7 @@ export function EditContentCategoryDialog({ open={selectedContentCategoryId !== null} onOpenChange={onClose} title="Edit Tag" - > -
- - + footer={
+ } + > +
+ +
); diff --git a/src/components/AddFeedDialog.tsx b/src/components/AddFeedDialog.tsx index 87abb84d..33dba884 100644 --- a/src/components/AddFeedDialog.tsx +++ b/src/components/AddFeedDialog.tsx @@ -340,6 +340,64 @@ export function EditFeedDialog({ } + footer={ +
+ + +
+ } >
@@ -422,62 +480,6 @@ export function EditFeedDialog({ openLocation={selectedOpenLocation} setOpenLocation={setSelectedOpenLocation} /> -
- - -
); diff --git a/src/components/AddViewDialog.tsx b/src/components/AddViewDialog.tsx index e85b651e..050c55e9 100644 --- a/src/components/AddViewDialog.tsx +++ b/src/components/AddViewDialog.tsx @@ -517,26 +517,7 @@ export function EditViewDialog({ open={selectedViewId !== null} onOpenChange={onClose} title="Edit View" - > -
- - - - - - + footer={
+ } + > +
+ + + + + +
); diff --git a/src/components/admin/UserFeedCountChart.tsx b/src/components/admin/UserFeedCountChart.tsx new file mode 100644 index 00000000..96ee9fc9 --- /dev/null +++ b/src/components/admin/UserFeedCountChart.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; + +import type { ChartConfig } from "~/components/ui/chart"; +import { orpc } from "~/lib/orpc"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "~/components/ui/chart"; +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; + +const chartConfig = { + allUsers: { + label: "All Feeds", + color: "hsl(var(--chart-1))", + }, + activeUsers: { + label: "Active Feeds", + color: "hsl(var(--chart-2))", + }, +} satisfies ChartConfig; + +export function UserFeedCountChart() { + const { data, isLoading } = useQuery( + orpc.admin.getFeedCountDistribution.queryOptions({ + staleTime: 0, + gcTime: 0, + }), + ); + + return ( + + + + User Feed Count Distribution + + + + {isLoading ? ( +
+ Loading... +
+ ) : !data?.distribution.length ? ( +
+ No data available +
+ ) : ( + + + + + + { + const item = payload[0]; + if (!item) return ""; + return `${item.payload.feedCount} feeds`; + }} + /> + } + /> + + + + + )} +
+
+ ); +} diff --git a/src/components/feed/AppDialogs.tsx b/src/components/feed/AppDialogs.tsx index 0b618853..1cdefdaa 100644 --- a/src/components/feed/AppDialogs.tsx +++ b/src/components/feed/AppDialogs.tsx @@ -1,3 +1,5 @@ +import { useDialogStore } from "./dialogStore"; +import { SubscriptionDialog } from "./subscription-dialog"; import { UserProfileEditDialog } from "./UserProfileEditDialog"; import { AddContentCategoryDialog } from "~/components/AddContentCategoryDialog"; import { AddFeedDialog } from "~/components/AddFeedDialog"; @@ -6,6 +8,8 @@ import { ConnectionsDialog } from "~/components/ConnectionsDialog"; import { CustomVideoDialog } from "~/components/CustomVideoDialog"; export function AppDialogs() { + const { dialog, closeDialog } = useDialogStore(); + return ( <> @@ -14,6 +18,12 @@ export function AppDialogs() { + { + if (!open) closeDialog(); + }} + /> ); } diff --git a/src/components/feed/FeedManagementTabs.tsx b/src/components/feed/FeedManagementTabs.tsx index c32a7c37..eb71512f 100644 --- a/src/components/feed/FeedManagementTabs.tsx +++ b/src/components/feed/FeedManagementTabs.tsx @@ -30,23 +30,35 @@ export function FeedManagementTabs({ value }: { value: FeedManagementTab }) { return ( - + Feeds - + Views - + Tags - + diff --git a/src/components/feed/SubscriptionDialog.tsx b/src/components/feed/SubscriptionDialog.tsx deleted file mode 100644 index 77750dfb..00000000 --- a/src/components/feed/SubscriptionDialog.tsx +++ /dev/null @@ -1,272 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useMutation, useQuery } from "@tanstack/react-query"; -import { CheckIcon } from "lucide-react"; -import { toast } from "sonner"; -import type { PlanConfig } from "~/server/subscriptions/plans"; -import { PLAN_IDS, PLANS } from "~/server/subscriptions/plans"; -import { Button } from "~/components/ui/button"; -import { Input } from "~/components/ui/input"; -import { ControlledResponsiveDialog } from "~/components/ui/responsive-dropdown"; -import { useSubscription } from "~/lib/data/subscription"; -import { orpc } from "~/lib/orpc"; -import { authClient, useSession } from "~/lib/auth-client"; - -function formatPrice(cents: number): string { - const dollars = cents / 100; - return cents % 100 === 0 ? `$${dollars}` : `$${dollars.toFixed(2)}`; -} - -function formatRefreshInterval(ms: number | null): string { - if (ms == null) return "Manual refresh only"; - const minutes = ms / (60 * 1000); - if (minutes < 60) return `Background refresh every ${minutes} min`; - const hours = minutes / 60; - return `Background refresh every ${hours} hr`; -} - -function getPlanFeatures(plan: PlanConfig): string[] { - const features: string[] = []; - features.push(`Up to ${plan.maxActiveFeeds.toLocaleString()} active feeds`); - features.push(formatRefreshInterval(plan.backgroundRefreshIntervalMs)); - - return features; -} - -function EmailVerificationBanner({ onVerified }: { onVerified: () => void }) { - const { data: session } = useSession(); - const [otpSent, setOtpSent] = useState(false); - const [otp, setOtp] = useState(""); - const [sending, setSending] = useState(false); - const [verifying, setVerifying] = useState(false); - - const email = session?.user?.email; - - async function handleSendOtp() { - if (!email) return; - setSending(true); - try { - await authClient.emailOtp.sendVerificationOtp({ - email, - type: "email-verification", - }); - setOtpSent(true); - toast.success("Verification code sent to your email"); - } catch { - toast.error("Failed to send verification code"); - } finally { - setSending(false); - } - } - - async function handleVerify() { - if (!email || !otp) return; - setVerifying(true); - try { - await authClient.emailOtp.verifyEmail({ - email, - otp, - }); - toast.success("Email verified!"); - onVerified(); - } catch { - toast.error("Invalid verification code"); - } finally { - setVerifying(false); - } - } - - return ( -
-

Verify your email to subscribe

-

- You need to verify your email address before upgrading your plan. -

- {!otpSent ? ( - - ) : ( -
- setOtp(e.target.value)} - className="h-8 w-32" - /> - -
- )} -
- ); -} - -export function SubscriptionDialog({ - open, - onOpenChange, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; -}) { - const { planId } = useSubscription(); - const { data: session, refetch: refetchSession } = useSession(); - const [showVerification, setShowVerification] = useState(false); - const [pendingPlanId, setPendingPlanId] = useState<"standard" | "pro" | null>( - null, - ); - - const emailVerified = session?.user?.emailVerified ?? false; - - const { data: products } = useQuery({ - ...orpc.subscription.getProducts.queryOptions(), - enabled: open, - staleTime: 5 * 60 * 1000, - }); - - const checkoutMutation = useMutation( - orpc.subscription.createCheckout.mutationOptions({ - onSuccess: (result) => { - if (result.error === "email-not-verified") { - setShowVerification(true); - toast.error("Please verify your email before subscribing"); - return; - } - if (result.url) { - window.location.assign(result.url); - } - }, - }), - ); - - const portalMutation = useMutation( - orpc.subscription.createPortalSession.mutationOptions({ - onSuccess: (result) => { - if (result.url) { - window.location.assign(result.url); - } - }, - }), - ); - - const isSubscribed = planId !== "free"; - - function handleSubscribeClick(id: "standard" | "pro") { - setPendingPlanId(id); - checkoutMutation.mutate({ planId: id }); - } - - async function handleVerified() { - await refetchSession(); - setShowVerification(false); - if (pendingPlanId) { - checkoutMutation.mutate({ planId: pendingPlanId }); - } - } - - return ( - -
- {showVerification && !emailVerified && ( - - )} - {PLAN_IDS.map((id) => { - const plan = PLANS[id]; - const isCurrent = id === planId; - const isPaid = id !== "free"; - const product = products?.find((p) => p.planId === id); - const monthlyPrice = product?.monthlyPrice ?? null; - const annualPrice = product?.annualPrice ?? null; - const hasPrice = monthlyPrice != null || annualPrice != null; - const features = getPlanFeatures(plan); - - return ( -
-
-

- {plan.name} - {isCurrent && ( - - Current - - )} -

- {hasPrice ? ( -

- {monthlyPrice != null && `${formatPrice(monthlyPrice)}/mo`} - {monthlyPrice != null && annualPrice != null && " · "} - {annualPrice != null && `${formatPrice(annualPrice)}/yr`} -

- ) : ( -

- {isPaid ? "" : "Free"} -

- )} -
-
    - {features.map((feature) => ( -
  • - - {feature} -
  • - ))} -
- {isCurrent && isSubscribed && ( -
- -
- )} - {!isCurrent && id !== "free" && ( -
- -
- )} -
- ); - })} -
-
- ); -} diff --git a/src/components/feed/UserManagementButton.tsx b/src/components/feed/UserManagementButton.tsx index d694ca29..a0bead6d 100644 --- a/src/components/feed/UserManagementButton.tsx +++ b/src/components/feed/UserManagementButton.tsx @@ -10,7 +10,6 @@ import { } from "lucide-react"; import { useState } from "react"; import { useDialogStore } from "./dialogStore"; -import { SubscriptionDialog } from "./SubscriptionDialog"; import { Button } from "~/components/ui/button"; import { DropdownMenuSeparator } from "~/components/ui/dropdown-menu"; import { @@ -33,7 +32,7 @@ export function UserManagementNavItem() { isPending, // loading state } = authClient.useSession(); - const { launchDialog, closeDialog, dialog } = useDialogStore(); + const { launchDialog } = useDialogStore(); const { billingEnabled, planName } = useSubscription(); const router = useRouter(); @@ -43,12 +42,6 @@ export function UserManagementNavItem() { return ( - { - if (!open) closeDialog(); - }} - /> void; + subscriptionView: SubscriptionView; + launchDialog: ( + dialog: DialogType, + options?: { subscriptionView?: SubscriptionView }, + ) => void; closeDialog: () => void; onOpenChange: (open: boolean) => void; }; export const useDialogStore = create((set) => ({ dialog: null, - launchDialog: (dialog: DialogType) => set({ dialog }), - closeDialog: () => set({ dialog: null }), - onOpenChange: () => set({ dialog: null }), + subscriptionView: "overview", + launchDialog: (dialog, options) => + set({ + dialog, + subscriptionView: options?.subscriptionView ?? "overview", + }), + closeDialog: () => set({ dialog: null, subscriptionView: "overview" }), + onOpenChange: () => set({ dialog: null, subscriptionView: "overview" }), })); diff --git a/src/components/feed/import/ImportDropzone.tsx b/src/components/feed/import/ImportDropzone.tsx index c9858e5d..09ee5c08 100644 --- a/src/components/feed/import/ImportDropzone.tsx +++ b/src/components/feed/import/ImportDropzone.tsx @@ -1,33 +1,23 @@ import clsx from "clsx"; -import { useRef, useState } from "react"; -import type { DragEvent, RefObject } from "react"; +import { useState } from "react"; +import type { DragEvent } from "react"; type ImportDropzoneProps = { - inputElementRef: RefObject | null; + inputId: string; onSelectFile: () => void; }; -export function ImportDropzone({ - inputElementRef, - onSelectFile, -}: ImportDropzoneProps) { - const dropzoneRef = useRef(null); - +export function ImportDropzone({ inputId, onSelectFile }: ImportDropzoneProps) { const [isDraggingOverDropzone, setIsDraggingOverDropzone] = useState(false); - const onDragEvent = (e: DragEvent) => { + const onDragEvent = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); }; - const handleActivate = () => { - inputElementRef?.current?.click(); - }; - return ( -
- + ); } diff --git a/src/components/feed/subscription-dialog/BillingCycleSwitchButton.tsx b/src/components/feed/subscription-dialog/BillingCycleSwitchButton.tsx new file mode 100644 index 00000000..3e372389 --- /dev/null +++ b/src/components/feed/subscription-dialog/BillingCycleSwitchButton.tsx @@ -0,0 +1,27 @@ +"use client"; + +import type { BillingInterval } from "./constants"; +import { Button } from "~/components/ui/button"; + +export function BillingCycleSwitchButton({ + currentInterval, + onSwitch, + isPending, +}: { + currentInterval: BillingInterval; + onSwitch: (interval: BillingInterval) => void; + isPending: boolean; +}) { + const alternateInterval: BillingInterval = + currentInterval === "month" ? "year" : "month"; + + return ( + + ); +} diff --git a/src/components/feed/subscription-dialog/CurrentPlanContent.tsx b/src/components/feed/subscription-dialog/CurrentPlanContent.tsx new file mode 100644 index 00000000..836f215c --- /dev/null +++ b/src/components/feed/subscription-dialog/CurrentPlanContent.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { CheckIcon } from "lucide-react"; +import { + BILLING_INTERVAL_DISPLAY, + INTERVAL_LABELS, + PLAN_ICONS, +} from "./constants"; +import { formatDate, formatPrice, getPlanFeatures } from "./utils"; +import { PLANS } from "~/server/subscriptions/plans"; +import { Button } from "~/components/ui/button"; + +export function CurrentPlanContent({ + summary, + pendingSwitch, + onSwitchClick, + onCancelPending, + isCancelPending, +}: { + summary: { + planId: string; + planName: string; + amount: number | null; + currency: string | null; + billingInterval: "month" | "year" | null; + currentPeriodEnd: string | null; + }; + pendingSwitch: + | { + planId: string; + billingInterval: "month" | "year" | null; + appliesAt: string; + } + | null + | undefined; + onSwitchClick: () => void; + onCancelPending: () => void; + isCancelPending: boolean; +}) { + const plan = PLANS[summary.planId as keyof typeof PLANS]; + const features = getPlanFeatures(plan); + const Icon = PLAN_ICONS[summary.planId as keyof typeof PLAN_ICONS]; + const intervalLabel = summary.billingInterval + ? INTERVAL_LABELS[summary.billingInterval] + : null; + + const hasPrice = summary.amount != null && intervalLabel != null; + const hasRenewalDate = summary.currentPeriodEnd != null; + + const hasPendingPlanChange = + pendingSwitch != null && pendingSwitch.planId !== summary.planId; + const hasPendingIntervalChange = + pendingSwitch != null && + pendingSwitch.planId === summary.planId && + pendingSwitch.billingInterval != null && + pendingSwitch.billingInterval !== summary.billingInterval; + const hasPendingSwitch = hasPendingPlanChange || hasPendingIntervalChange; + const pendingPlan = hasPendingSwitch + ? PLANS[pendingSwitch.planId as keyof typeof PLANS] + : null; + const PendingIcon = hasPendingSwitch + ? PLAN_ICONS[pendingSwitch.planId as keyof typeof PLAN_ICONS] + : null; + const pendingFeatures = pendingPlan ? getPlanFeatures(pendingPlan) : []; + const pendingIntervalLabel = + hasPendingIntervalChange && pendingSwitch.billingInterval + ? BILLING_INTERVAL_DISPLAY[pendingSwitch.billingInterval] + : null; + + return ( +
+ {hasPendingSwitch && pendingPlan && PendingIcon && ( + <> +
+ +
+

+ {pendingPlan.name} Plan + {pendingIntervalLabel && ` · ${pendingIntervalLabel}`} +

+

+ Starting {formatDate(pendingSwitch.appliesAt)} +

+
+ +
+
    + {pendingFeatures.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+ + )} +
+ +
+

+ {hasPendingSwitch + ? `${summary.planName} Plan (Current)` + : `${summary.planName} Plan`} +

+

+ {hasPrice && `${formatPrice(summary.amount!)}/${intervalLabel}`} + {hasPrice && hasRenewalDate && " · "} + {hasRenewalDate && + (hasPendingSwitch + ? `Until ${formatDate(summary.currentPeriodEnd!)}` + : `Renews ${formatDate(summary.currentPeriodEnd!)}`)} +

+
+ +
+ {!hasPendingSwitch && ( +
    + {features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/components/feed/subscription-dialog/EmailVerificationBanner.tsx b/src/components/feed/subscription-dialog/EmailVerificationBanner.tsx new file mode 100644 index 00000000..8f4ad89d --- /dev/null +++ b/src/components/feed/subscription-dialog/EmailVerificationBanner.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { authClient, useSession } from "~/lib/auth-client"; + +export function EmailVerificationBanner({ + onVerified, +}: { + onVerified: () => void; +}) { + const { data: session } = useSession(); + const [otpSent, setOtpSent] = useState(false); + const [otp, setOtp] = useState(""); + const [sending, setSending] = useState(false); + const [verifying, setVerifying] = useState(false); + + const email = session?.user?.email; + + async function handleSendOtp() { + if (!email) return; + setSending(true); + try { + await authClient.emailOtp.sendVerificationOtp({ + email, + type: "email-verification", + }); + setOtpSent(true); + toast.success("Verification code sent to your email"); + } catch { + toast.error("Failed to send verification code"); + } finally { + setSending(false); + } + } + + async function handleVerify() { + if (!email || !otp) return; + setVerifying(true); + try { + await authClient.emailOtp.verifyEmail({ + email, + otp, + }); + toast.success("Email verified!"); + onVerified(); + } catch { + toast.error("Invalid verification code"); + } finally { + setVerifying(false); + } + } + + return ( +
+

Verify your email to subscribe

+

+ You need to verify your email address before upgrading your plan. +

+ {!otpSent ? ( + + ) : ( +
+ setOtp(e.target.value)} + className="h-8 w-32" + /> + +
+ )} +
+ ); +} diff --git a/src/components/feed/subscription-dialog/FreePlanCard.tsx b/src/components/feed/subscription-dialog/FreePlanCard.tsx new file mode 100644 index 00000000..2c600af9 --- /dev/null +++ b/src/components/feed/subscription-dialog/FreePlanCard.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { CheckIcon, CircleHelpIcon } from "lucide-react"; +import { PLAN_ICONS, RECOMMENDATION_MESSAGES } from "./constants"; +import { useSubscriptionDialogContext } from "./context"; +import { formatDate, getPlanCardBorderClasses, getPlanFeatures } from "./utils"; +import { Button } from "~/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "~/components/ui/tooltip"; +import { PLANS } from "~/server/subscriptions/plans"; + +export function FreePlanCard() { + const { + planId, + recommendedPlanId, + chosenPlanId, + pendingSwitch, + isSubscribed, + onSwitchToFreeClick, + isSwitchToFreeLoading, + } = useSubscriptionDialogContext(); + + const plan = PLANS.free; + const isCurrent = planId === "free"; + const pendingDate = + pendingSwitch?.planId === "free" ? pendingSwitch.appliesAt : null; + const isPending = pendingDate != null; + const isRecommended = recommendedPlanId === "free"; + const features = getPlanFeatures(plan); + const Icon = PLAN_ICONS.free; + + return ( +
+ {(isCurrent || isRecommended || isPending) && ( +
+ {isCurrent && ( + + Current + + )} + {isPending && ( + + Starting {formatDate(pendingDate)} + + )} + {isRecommended && ( + + + + Recommended + + + + + {chosenPlanId === "free" + ? RECOMMENDATION_MESSAGES.currentFree + : RECOMMENDATION_MESSAGES.upgrade} + + + )} +
+ )} +
+ +

{plan.name}

+
+
    + {features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+ {!isCurrent && isSubscribed && ( +
+ +
+ )} +
+ ); +} diff --git a/src/components/feed/subscription-dialog/PlanSwitchConfirmation.tsx b/src/components/feed/subscription-dialog/PlanSwitchConfirmation.tsx new file mode 100644 index 00000000..42b42781 --- /dev/null +++ b/src/components/feed/subscription-dialog/PlanSwitchConfirmation.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useState } from "react"; +import { CheckIcon } from "lucide-react"; +import { INTERVAL_LABELS, PLAN_ICONS } from "./constants"; +import { formatDate, formatPrice, getPlanFeatures } from "./utils"; +import type { CardRadioOption } from "~/components/ui/card-radio-group"; +import type { BillingInterval } from "./constants"; +import type { SwitchPreview } from "./types"; +import { CardRadioGroup } from "~/components/ui/card-radio-group"; +import { PLANS } from "~/server/subscriptions/plans"; + +export function PlanSwitchConfirmationContent({ + preview, + onIntervalChange, + monthlyPrice, + annualPrice, + currentBillingInterval, +}: { + preview: SwitchPreview; + onIntervalChange: (interval: BillingInterval) => void; + monthlyPrice: number | null; + annualPrice: number | null; + currentBillingInterval: BillingInterval | null; +}) { + const [selectedInterval, setSelectedInterval] = useState( + preview.billingInterval, + ); + + const newPlan = PLANS[preview.newPlanId as keyof typeof PLANS]; + const features = getPlanFeatures(newPlan); + const Icon = PLAN_ICONS[preview.newPlanId as keyof typeof PLAN_ICONS]; + const isFreePlan = preview.newPlanId === "free"; + const intervalLabel = isFreePlan ? null : INTERVAL_LABELS[selectedInterval]; + + const isSamePlanSwitch = preview.currentPlanId === preview.newPlanId; + const hasBothIntervals = + !isFreePlan && monthlyPrice != null && annualPrice != null; + + const intervalOptions: Array> = []; + if (monthlyPrice != null) { + intervalOptions.push({ + value: "month", + title: `Monthly — ${formatPrice(monthlyPrice)}/mo`, + disabled: isSamePlanSwitch && currentBillingInterval === "month", + }); + } + if (annualPrice != null) { + intervalOptions.push({ + value: "year", + title: `Annual — ${formatPrice(annualPrice)}/yr`, + description: `${formatPrice(Math.round(annualPrice / 12))}/mo`, + disabled: isSamePlanSwitch && currentBillingInterval === "year", + }); + } + + function handleIntervalChange(interval: BillingInterval) { + setSelectedInterval(interval); + onIntervalChange(interval); + } + + return ( +
+
+ +
+

{preview.newPlanName} Plan

+

+ {isFreePlan + ? "Free" + : `${formatPrice(preview.newAmount)}/${intervalLabel}`} +

+
+
+ {hasBothIntervals && ( + + )} +
    + {features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+ {preview.isDowngrade ? ( +
+

+ Your plan will change on {formatDate(preview.periodEnd)} +

+

+ You'll keep your current {preview.currentPlanName} plan + features until the end of your billing period. After that, + you'll be switched to the {preview.newPlanName} plan + automatically. +

+
+ ) : preview.proratedAmount > 0 ? ( +
+

+ + Estimated charge today: + {" "} + + {formatPrice(preview.proratedAmount)} + +

+

+ You'll be credited for the unused time on your current plan. + The final amount may differ slightly based on your local tax rates. +

+
+ ) : null} +
+ ); +} diff --git a/src/components/feed/subscription-dialog/ProPlanCard.tsx b/src/components/feed/subscription-dialog/ProPlanCard.tsx new file mode 100644 index 00000000..acf40d2c --- /dev/null +++ b/src/components/feed/subscription-dialog/ProPlanCard.tsx @@ -0,0 +1,135 @@ +"use client"; + +import { CheckIcon, CircleHelpIcon } from "lucide-react"; +import { PLAN_ICONS, RECOMMENDATION_MESSAGES } from "./constants"; +import { useSubscriptionDialogContext } from "./context"; +import { BillingCycleSwitchButton } from "./BillingCycleSwitchButton"; +import { + formatPrice, + getPlanCardBorderClasses, + getPlanFeatures, +} from "./utils"; +import { Button } from "~/components/ui/button"; +import { Skeleton } from "~/components/ui/skeleton"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "~/components/ui/tooltip"; +import { PLANS } from "~/server/subscriptions/plans"; + +export function ProPlanCard() { + const { + planId, + recommendedPlanId, + chosenPlanId, + products, + isLoadingProducts, + isSubscribed, + currentBillingInterval, + onBillingCycleSwitch, + onSubscribeClick, + checkoutMutation, + previewMutation, + } = useSubscriptionDialogContext(); + const plan = PLANS.pro; + const isCurrent = planId === "pro"; + const isRecommended = recommendedPlanId === "pro"; + const features = getPlanFeatures(plan); + const Icon = PLAN_ICONS.pro; + const product = products?.find((p) => p.planId === "pro"); + const monthlyPrice = product?.monthlyPrice ?? null; + const annualPrice = product?.annualPrice ?? null; + const hasPrice = monthlyPrice != null || annualPrice != null; + + const showBillingCycleSwitch = isCurrent && currentBillingInterval != null; + + return ( +
+ {(isCurrent || isRecommended) && ( +
+ {isCurrent && ( + + Current + + )} + {isRecommended && ( + + + + Recommended + + + + + {chosenPlanId === "pro" + ? RECOMMENDATION_MESSAGES.currentPaid + : RECOMMENDATION_MESSAGES.upgrade} + + + )} +
+ )} +
+
+ +

{plan.name}

+
+ {isLoadingProducts ? ( + + ) : hasPrice ? ( +

+ {monthlyPrice != null && `${formatPrice(monthlyPrice)}/mo`} + {monthlyPrice != null && annualPrice != null && " · "} + {annualPrice != null && ( + + + + {formatPrice(annualPrice)}/yr + + + + {formatPrice(Math.round(annualPrice / 12))}/mo + + + )} +

+ ) : null} +
+
    + {features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+ {showBillingCycleSwitch && ( +
+ +
+ )} + {!isCurrent && ( +
+ +
+ )} +
+ ); +} diff --git a/src/components/feed/subscription-dialog/StandardPlanCards.tsx b/src/components/feed/subscription-dialog/StandardPlanCards.tsx new file mode 100644 index 00000000..28daf486 --- /dev/null +++ b/src/components/feed/subscription-dialog/StandardPlanCards.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { CheckIcon, CircleHelpIcon, TreeDeciduousIcon } from "lucide-react"; +import { + QUOTA_DISPLAY_NAMES, + RECOMMENDATION_MESSAGES, + STANDARD_PLAN_IDS, +} from "./constants"; +import { useSubscriptionDialogContext } from "./context"; +import { BillingCycleSwitchButton } from "./BillingCycleSwitchButton"; +import { + formatDate, + formatPrice, + getPlanCardBorderClasses, + getPlanFeatures, +} from "./utils"; +import { Button } from "~/components/ui/button"; +import { Skeleton } from "~/components/ui/skeleton"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "~/components/ui/tooltip"; +import { PLANS } from "~/server/subscriptions/plans"; + +export function StandardPlanCards() { + const { + planId, + recommendedPlanId, + chosenPlanId, + isSubscribed, + products, + isLoadingProducts, + pendingSwitch, + currentBillingInterval, + onBillingCycleSwitch, + onSubscribeClick, + checkoutMutation, + previewMutation, + } = useSubscriptionDialogContext(); + + // Derive shared features from the first standard plan config (all standard + // plans share the same refresh interval and background refresh capability). + const sharedFeatures = getPlanFeatures(PLANS["standard-small"]).filter( + (f) => !f.startsWith("Up to"), + ); + + return ( +
+
+ +

Standard

+
+
    + {sharedFeatures.map((feature) => ( +
  • + + {feature} +
  • + ))} +
+
+ {STANDARD_PLAN_IDS.map((id) => { + const plan = PLANS[id]; + const isCurrent = id === planId; + const isPending = pendingSwitch?.planId === id; + const pendingDate = isPending ? pendingSwitch.appliesAt : null; + const isRecommended = id === recommendedPlanId; + const product = products?.find((p) => p.planId === id); + const monthlyPrice = product?.monthlyPrice ?? null; + const annualPrice = product?.annualPrice ?? null; + const hasPrice = monthlyPrice != null || annualPrice != null; + const feedsLabel = `Up to ${plan.maxActiveFeeds.toLocaleString()} active feeds`; + + return ( +
+ {(isCurrent || isPending || isRecommended) && ( +
+ {isCurrent && ( + + Current + + )} + {pendingDate && ( + + Starting {formatDate(pendingDate)} + + )} + {isRecommended && ( + + + + Recommended + + + + + {chosenPlanId === id + ? RECOMMENDATION_MESSAGES.currentPaid + : RECOMMENDATION_MESSAGES.upgrade} + + + )} +
+ )} +
+ {QUOTA_DISPLAY_NAMES[id]} +
+ {isLoadingProducts ? ( + + ) : hasPrice ? ( + + {monthlyPrice != null && + `${formatPrice(monthlyPrice)}/mo`} + {monthlyPrice != null && annualPrice != null && " · "} + {annualPrice != null && ( + + + + {formatPrice(annualPrice)}/yr + + + + {formatPrice(Math.round(annualPrice / 12))} + /mo + + + )} + + ) : null} + {isCurrent && currentBillingInterval && ( + + )} + {!isCurrent && ( + + )} +
+
+

{feedsLabel}

+
+ ); + })} +
+
+ ); +} diff --git a/src/components/feed/subscription-dialog/SubscriptionDialog.tsx b/src/components/feed/subscription-dialog/SubscriptionDialog.tsx new file mode 100644 index 00000000..0f5cbe91 --- /dev/null +++ b/src/components/feed/subscription-dialog/SubscriptionDialog.tsx @@ -0,0 +1,459 @@ +"use client"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { BILLING_INTERVAL_DISPLAY } from "./constants"; +import { SubscriptionDialogContext } from "./context"; +import { CurrentPlanContent } from "./CurrentPlanContent"; +import { EmailVerificationBanner } from "./EmailVerificationBanner"; +import { FreePlanCard } from "./FreePlanCard"; +import { PlanSwitchConfirmationContent } from "./PlanSwitchConfirmation"; +import { ProPlanCard } from "./ProPlanCard"; +import { StandardPlanCards } from "./StandardPlanCards"; +import { getRecommendedPlanId } from "./utils"; +import type { BillingInterval } from "./constants"; +import type { SubscriptionDialogContextValue } from "./context"; +import type { SwitchPreview } from "./types"; +import type { PaidPlanId } from "~/server/subscriptions/plans"; +import { Button } from "~/components/ui/button"; +import { ControlledResponsiveDialog } from "~/components/ui/responsive-dropdown"; +import { Skeleton } from "~/components/ui/skeleton"; +import { useSession } from "~/lib/auth-client"; +import { useFeeds } from "~/lib/data/feeds/store"; +import { usePlanSuccessStore } from "~/lib/data/plan-success"; +import { useSubscription } from "~/lib/data/subscription"; +import { orpc } from "~/lib/orpc"; +import { PLAN_IDS, PLANS } from "~/server/subscriptions/plans"; +import { useDialogStore } from "~/components/feed/dialogStore"; +import { env } from "~/env"; + +export function SubscriptionDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { planId } = useSubscription(); + const { data: session, refetch: refetchSession } = useSession(); + const queryClient = useQueryClient(); + const [showVerification, setShowVerification] = useState(false); + const [pendingPlanId, setPendingPlanId] = useState(null); + const [switchPreview, setSwitchPreview] = useState( + null, + ); + const [showPlanPicker, setShowPlanPicker] = useState(false); + const subscriptionView = useDialogStore((s) => s.subscriptionView); + const prevOpenRef = useRef(false); + + // Reset dialog state when it opens, respecting the requested view + useEffect(() => { + if (open && !prevOpenRef.current) { + setSwitchPreview(null); + setShowVerification(false); + setShowPlanPicker(subscriptionView === "picker"); + } + prevOpenRef.current = open; + }, [open, subscriptionView]); + + const emailVerified = session?.user?.emailVerified ?? false; + const isSubscribed = planId !== "free"; + + const { data: products, isLoading: isLoadingProducts } = useQuery({ + ...orpc.subscription.getProducts.queryOptions(), + enabled: open, + }); + + const { data: pendingSwitch } = useQuery({ + ...orpc.subscription.getPendingSwitch.queryOptions(), + enabled: open, + }); + + const { data: subscriptionSummary, isLoading: isLoadingSummary } = useQuery({ + ...orpc.subscription.getSubscriptionSummary.queryOptions(), + enabled: open && isSubscribed, + }); + + const checkoutMutation = useMutation( + orpc.subscription.createCheckout.mutationOptions({ + onSuccess: (result) => { + if (result.error === "email-not-verified") { + setShowVerification(true); + toast.error("Please verify your email before subscribing"); + return; + } + if (result.url) { + window.location.assign(result.url); + } + }, + }), + ); + + const previewMutation = useMutation( + orpc.subscription.previewPlanSwitch.mutationOptions({ + onSuccess: (result) => { + if (result) { + setSwitchPreview(result); + } else { + toast.error("Unable to preview plan switch"); + } + }, + }), + ); + + const openPlanSuccess = usePlanSuccessStore((s) => s.openDialog); + + const switchMutation = useMutation( + orpc.subscription.switchPlan.mutationOptions({ + onSuccess: (result) => { + if (result.success) { + setSwitchPreview(null); + onOpenChange(false); + // Invalidate and then show success dialog + void queryClient + .invalidateQueries({ + queryKey: orpc.subscription.getStatus.queryOptions().queryKey, + }) + .then(() => { + openPlanSuccess(); + }); + } + }, + onError: () => { + toast.error("Failed to switch plan. Please try again."); + }, + }), + ); + + const portalMutation = useMutation( + orpc.subscription.createPortalSession.mutationOptions({ + onSuccess: (result) => { + if (result.url) { + window.location.assign(result.url); + } + }, + }), + ); + + const revertPendingMutation = useMutation( + orpc.subscription.revertPendingChange.mutationOptions({ + onSuccess: (result) => { + if (result.success) { + toast.success("Pending change cancelled."); + void queryClient.invalidateQueries({ + queryKey: + orpc.subscription.getPendingSwitch.queryOptions().queryKey, + }); + void queryClient.invalidateQueries({ + queryKey: + orpc.subscription.getSubscriptionSummary.queryOptions().queryKey, + }); + void queryClient.invalidateQueries({ + queryKey: orpc.subscription.getStatus.queryOptions().queryKey, + }); + } + }, + onError: () => { + toast.error("Failed to cancel pending change. Please try again."); + }, + }), + ); + + const downgradePreviewMutation = useMutation( + orpc.subscription.previewDowngrade.mutationOptions({ + onSuccess: (result) => { + if (result) { + setSwitchPreview({ + currentPlanId: result.currentPlanId, + currentPlanName: result.currentPlanName, + currentAmount: 0, + newPlanId: "free", + newPlanName: PLANS.free.name, + newAmount: 0, + proratedAmount: 0, + isDowngrade: true, + periodEnd: result.periodEnd, + currency: "usd", + billingInterval: "month", + subscriptionId: result.subscriptionId, + newProductId: "", + }); + } else { + toast.error("Unable to preview switch"); + } + }, + }), + ); + + const cancelMutation = useMutation( + orpc.subscription.cancelSubscription.mutationOptions({ + onSuccess: (result) => { + if (result.success) { + setSwitchPreview(null); + onOpenChange(false); + toast.success( + "Your plan will switch at the end of your billing period.", + ); + void queryClient.invalidateQueries({ + queryKey: orpc.subscription.getStatus.queryOptions().queryKey, + }); + void queryClient.invalidateQueries({ + queryKey: + orpc.subscription.getPendingSwitch.queryOptions().queryKey, + }); + } + }, + onError: () => { + toast.error("Failed to cancel subscription. Please try again."); + }, + }), + ); + + const feeds = useFeeds(); + + // Recommend the smallest plan that fits the user's feed count, + // but never recommend a lower-tier plan than the user's current plan + const currentPlanIndex = PLAN_IDS.indexOf(planId); + const recommendedPlanId = getRecommendedPlanId( + feeds.length, + currentPlanIndex, + ); + const chosenPlanId = pendingSwitch?.planId ?? planId; + + function handleSubscribeClick(id: PaidPlanId) { + setPendingPlanId(id); + if (isSubscribed) { + // Already subscribed — show switch confirmation + previewMutation.mutate({ planId: id }); + } else { + checkoutMutation.mutate({ + planId: id, + returnPath: window.location.pathname, + }); + } + } + + async function handleVerified() { + await refetchSession(); + setShowVerification(false); + if (pendingPlanId) { + if (isSubscribed) { + previewMutation.mutate({ planId: pendingPlanId }); + } else { + checkoutMutation.mutate({ + planId: pendingPlanId, + returnPath: window.location.pathname, + }); + } + } + } + + const contextValue: SubscriptionDialogContextValue = { + planId, + recommendedPlanId, + chosenPlanId, + isSubscribed, + products, + isLoadingProducts, + pendingSwitch, + onSubscribeClick: handleSubscribeClick, + onSwitchToFreeClick: () => downgradePreviewMutation.mutate({}), + isSwitchToFreeLoading: downgradePreviewMutation.isPending, + currentBillingInterval: + (subscriptionSummary?.billingInterval as BillingInterval | null) ?? null, + onBillingCycleSwitch: (interval: BillingInterval) => { + if (!subscriptionSummary) return; + const paidPlanId = subscriptionSummary.planId; + previewMutation.mutate({ planId: paidPlanId, billingInterval: interval }); + }, + portalMutation, + checkoutMutation, + previewMutation, + }; + + // Determine current view + const showOverview = + isSubscribed && + !showPlanPicker && + !switchPreview && + (isLoadingSummary || subscriptionSummary != null); + const isPlanPickerView = !switchPreview && !showOverview; + const isSwitchToFree = switchPreview?.newPlanId === "free"; + + let dialogTitle: string; + let dialogDescription: string; + let dialogFooter: React.ReactNode; + let dialogOnBack: (() => void) | undefined; + + const isBillingCycleSwitch = + switchPreview != null && + switchPreview.currentPlanId === switchPreview.newPlanId; + + if (switchPreview) { + const billingCycleLabel = + BILLING_INTERVAL_DISPLAY[switchPreview.billingInterval]; + dialogTitle = "Switch Plan"; + dialogDescription = isBillingCycleSwitch + ? `Switch to ${billingCycleLabel} plan` + : `Switch from ${switchPreview.currentPlanName} to ${switchPreview.newPlanName}`; + dialogOnBack = () => setSwitchPreview(null); + dialogFooter = isSwitchToFree ? ( + + ) : ( + + ); + } else if (showOverview) { + dialogTitle = "Your Plan"; + dialogDescription = "Manage your current subscription."; + dialogFooter = ( + + ); + } else { + dialogTitle = "Subscribe to Serial"; + dialogDescription = "All prices are taxes-included."; + dialogOnBack = isSubscribed ? () => setShowPlanPicker(false) : undefined; + dialogFooter = ( +

+ Price too high or need higher limits?{" "} + + + Let us know + {" "} + or{" "} + + learn how to self-host + {" "} + Serial + +

+ ); + } + + function handleOpenChange(nextOpen: boolean) { + if (!nextOpen) { + if (switchPreview) { + setSwitchPreview(null); + return; + } + if (showPlanPicker && isSubscribed) { + setShowPlanPicker(false); + return; + } + setShowPlanPicker(false); + onOpenChange(false); + return; + } + onOpenChange(nextOpen); + } + + const newPlanProduct = switchPreview + ? products?.find((p) => p.planId === switchPreview.newPlanId) + : undefined; + + return ( + + + {switchPreview ? ( + + previewMutation.mutate({ + planId: switchPreview.newPlanId as PaidPlanId, + billingInterval: interval, + }) + } + monthlyPrice={newPlanProduct?.monthlyPrice ?? null} + annualPrice={newPlanProduct?.annualPrice ?? null} + currentBillingInterval={ + (subscriptionSummary?.billingInterval as BillingInterval | null) ?? + null + } + /> + ) : showOverview ? ( + isLoadingSummary ? ( +
+ +
+ + + +
+
+ ) : subscriptionSummary ? ( + setShowPlanPicker(true)} + onCancelPending={() => revertPendingMutation.mutate({})} + isCancelPending={revertPendingMutation.isPending} + /> + ) : null + ) : ( +
+ {showVerification && !emailVerified && ( +
+ +
+ )} + + {/* Free plan */} + + + {/* Paid plans */} + + + {/* Pro plan */} + +
+ )} +
+
+ ); +} diff --git a/src/components/feed/subscription-dialog/constants.ts b/src/components/feed/subscription-dialog/constants.ts new file mode 100644 index 00000000..26b19ecf --- /dev/null +++ b/src/components/feed/subscription-dialog/constants.ts @@ -0,0 +1,48 @@ +import { + ShrubIcon, + SproutIcon, + TreeDeciduousIcon, + TreePineIcon, + TreesIcon, +} from "lucide-react"; + +export const PLAN_ICONS = { + free: SproutIcon, + "standard-small": ShrubIcon, + "standard-medium": TreeDeciduousIcon, + "standard-large": TreePineIcon, + pro: TreesIcon, +} as const; + +export const QUOTA_DISPLAY_NAMES = { + "standard-small": "Small", + "standard-medium": "Medium", + "standard-large": "Large", +} as const; + +export const STANDARD_PLAN_IDS = [ + "standard-small", + "standard-medium", + "standard-large", +] as const; + +export const RECOMMENDATION_MESSAGES = { + currentFree: + "You're just getting started with Serial, so no need to give us money just yet! Consider upgrading later when you have more feeds, or if you want feeds to refresh while you're away.", + currentPaid: + "This plan is just right for the number of feeds you have. Good choice!", + upgrade: + "We think this plan is right for you, as it will allow you to keep all your feeds active.", +} as const; + +export type BillingInterval = "month" | "year"; + +export const INTERVAL_LABELS: Record = { + month: "mo", + year: "yr", +}; + +export const BILLING_INTERVAL_DISPLAY: Record = { + month: "Monthly", + year: "Annual", +}; diff --git a/src/components/feed/subscription-dialog/context.tsx b/src/components/feed/subscription-dialog/context.tsx new file mode 100644 index 00000000..7435a0f5 --- /dev/null +++ b/src/components/feed/subscription-dialog/context.tsx @@ -0,0 +1,48 @@ +import { createContext, useContext } from "react"; +import type { BillingInterval } from "./constants"; +import type { PaidPlanId } from "~/server/subscriptions/plans"; + +export type SubscriptionDialogContextValue = { + planId: string; + recommendedPlanId: string | null; + chosenPlanId: string; + + isSubscribed: boolean; + products: + | Array<{ + planId: string; + monthlyPrice: number | null; + annualPrice: number | null; + }> + | undefined; + isLoadingProducts: boolean; + pendingSwitch: + | { + planId: string; + billingInterval: "month" | "year" | null; + appliesAt: string; + } + | null + | undefined; + onSubscribeClick: (id: PaidPlanId) => void; + onSwitchToFreeClick: () => void; + isSwitchToFreeLoading: boolean; + currentBillingInterval: BillingInterval | null; + onBillingCycleSwitch: (interval: BillingInterval) => void; + portalMutation: { isPending: boolean; mutate: (args: object) => void }; + checkoutMutation: { isPending: boolean }; + previewMutation: { isPending: boolean }; +}; + +export const SubscriptionDialogContext = + createContext(null); + +export function useSubscriptionDialogContext() { + const ctx = useContext(SubscriptionDialogContext); + if (!ctx) { + throw new Error( + "useSubscriptionDialogContext must be used within SubscriptionDialogProvider", + ); + } + return ctx; +} diff --git a/src/components/feed/subscription-dialog/index.ts b/src/components/feed/subscription-dialog/index.ts new file mode 100644 index 00000000..62fafb6c --- /dev/null +++ b/src/components/feed/subscription-dialog/index.ts @@ -0,0 +1,4 @@ +export { SubscriptionDialog } from "./SubscriptionDialog"; +export { PLAN_ICONS } from "./constants"; +export { getPlanFeatures } from "./utils"; +export type { SwitchPreview } from "./types"; diff --git a/src/components/feed/subscription-dialog/types.ts b/src/components/feed/subscription-dialog/types.ts new file mode 100644 index 00000000..48b9ec83 --- /dev/null +++ b/src/components/feed/subscription-dialog/types.ts @@ -0,0 +1,15 @@ +export type SwitchPreview = { + currentPlanId: string; + currentPlanName: string; + currentAmount: number; + newPlanId: string; + newPlanName: string; + newAmount: number; + proratedAmount: number; + isDowngrade: boolean; + periodEnd: string; + currency: string; + billingInterval: "month" | "year"; + subscriptionId: string; + newProductId: string; +}; diff --git a/src/components/feed/subscription-dialog/utils.ts b/src/components/feed/subscription-dialog/utils.ts new file mode 100644 index 00000000..0538d2b6 --- /dev/null +++ b/src/components/feed/subscription-dialog/utils.ts @@ -0,0 +1,71 @@ +import type { PlanConfig } from "~/server/subscriptions/plans"; +import { PLAN_IDS, PLANS } from "~/server/subscriptions/plans"; + +export function formatPrice(cents: number): string { + const dollars = cents / 100; + return cents % 100 === 0 ? `$${dollars}` : `$${dollars.toFixed(2)}`; +} + +export function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString(undefined, { + month: "long", + day: "numeric", + year: "numeric", + }); +} + +function formatRefreshInterval(ms: number): string { + const minutes = Math.round(ms / (60 * 1000)); + if (minutes < 60) { + return minutes === 1 + ? "Refreshes every minute" + : `Refreshes once every ${minutes} min`; + } + const hours = Math.round(minutes / 60); + return hours === 1 + ? "Refresh up to once an hour" + : `Refresh up to once every ${hours} hours`; +} + +export function getPlanFeatures(plan: PlanConfig): string[] { + const features: string[] = []; + + if (plan.maxActiveFeeds === Infinity) { + features.push("Unlimited active feeds"); + } else { + features.push(`Up to ${plan.maxActiveFeeds.toLocaleString()} active feeds`); + } + + features.push(formatRefreshInterval(plan.refreshIntervalMs)); + + if (plan.backgroundRefreshIntervalMs != null) { + features.push("Refresh in background"); + } else { + features.push("Manual refresh only"); + } + + return features; +} + +export function getRecommendedPlanId( + totalFeeds: number, + currentPlanIndex: number, +): string | null { + const bestFit = PLAN_IDS.find((id) => PLANS[id].maxActiveFeeds >= totalFeeds); + if (!bestFit) return null; + const bestFitIndex = PLAN_IDS.indexOf(bestFit); + if (bestFitIndex < currentPlanIndex) return null; + return bestFit; +} + +export function getPlanCardBorderClasses( + isCurrent: boolean, + isRecommended: boolean, +): string { + if (isCurrent || isRecommended) { + return isCurrent && !isRecommended + ? "border-foreground bg-foreground/5" + : "border-sidebar-accent bg-sidebar-accent/5"; + } + return "border-border"; +} diff --git a/src/components/ui/card-radio-group.tsx b/src/components/ui/card-radio-group.tsx index c4e068ae..fdaca3d0 100644 --- a/src/components/ui/card-radio-group.tsx +++ b/src/components/ui/card-radio-group.tsx @@ -7,6 +7,7 @@ export type CardRadioOption = { value: T; title: string; description?: string; + disabled?: boolean; }; type CardRadioGroupProps = { @@ -36,17 +37,29 @@ export function CardRadioGroup({ > {options.map((option) => { const isSelected = option.value === value; + const isDisabled = option.disabled === true; const id = `card-radio-${option.value}`; return (