diff --git a/.env.example b/.env.example index 52f75a4..9bcf03d 100644 --- a/.env.example +++ b/.env.example @@ -1,34 +1,55 @@ NODE_ENV=development -# GENERAL -NEXT_PUBLIC_VERCEL_ENV=development -NEXT_PUBLIC_VERCEL_URL=localhost:3000 - -NEXT_PUBLIC_SENTRY_TUNNEL=/monitoring -SENTRY_DSN= - -#SUPABASE +# Required: core platform NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_ANON_KEY= -SUPABASE_SERVICE_ROLE_KEY= - -#CANNY -CANNY_API_KEY= - -NEXT_PUBLIC_HOTJAR_SITE_ID= - -#LIVEBLOCKS +SUPABASE_SERVICE_ROLE_KEY= LIVEBLOCKS_SECRET_KEY= -LIVEBLOCKS_PUCLIC_API_KEY= -KEEPALIVE_ENDPOINT= -KEEPALIVE_API_KEY= - -#ANALYTICS +# Required: app + billing +NEXT_PUBLIC_APP_URL=http://localhost:3000 +NEXT_PUBLIC_VERCEL_ENV=development +NEXT_PUBLIC_VERCEL_URL=localhost:3000 +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... +STRIPE_SECRET_KEY=sk_test_... +STRIPE_WEBHOOK_SECRET=whsec_... +NEXT_PUBLIC_STRIPE_STARTER_MONTHLY_PRICE_ID=price_... +NEXT_PUBLIC_STRIPE_STARTER_YEARLY_PRICE_ID=price_... +NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID=price_... +NEXT_PUBLIC_STRIPE_PRO_YEARLY_PRICE_ID=price_... + +# Optional: observability +NEXT_PUBLIC_SENTRY_TUNNEL=/monitoring +SENTRY_DSN= +SENTRY_ENVIRONMENT= +SENTRY_TRACES_SAMPLE_RATE= +SENTRY_PROFILES_SAMPLE_RATE= +SENTRY_TRACE_PROPAGATION_TARGETS= +SENTRY_RELEASE= +SENTRY_REPLAYS_SESSION_SAMPLE_RATE= +SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE= + +# Optional: analytics + marketing NEXT_PUBLIC_POSTHOG_KEY= NEXT_PUBLIC_POSTHOG_HOST=https://app.posthog.com NEXT_PUBLIC_POSTHOG_ENABLE_LOCAL=false POSTHOG_API_KEY= +NEXT_PUBLIC_HOTJAR_SITE_ID= +NEXT_PUBLIC_SITE_URL= +CANNY_API_KEY= + +# Optional: feature flags + experiments +NEXT_PUBLIC_EXPORT_EXPERIMENT=control +NEXT_PUBLIC_TRANSITION_EXPERIMENT=control -#SECURITY -ARCJET_KEY= \ No newline at end of file +# Optional: collaboration / security +ARCJET_KEY= +NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_API_KEY= +CORS_ALLOWED_ORIGIN= +NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA= +VERCEL_GIT_COMMIT_SHA= + +# Optional: maintenance +KEEPALIVE_ENDPOINT= +KEEPALIVE_API_KEY= +SKIP_ENV_VALIDATION=false diff --git a/.github/workflows/cron-check-usage.yml b/.github/workflows/cron-check-usage.yml new file mode 100644 index 0000000..9349b0a --- /dev/null +++ b/.github/workflows/cron-check-usage.yml @@ -0,0 +1,38 @@ +name: Check Usage Limits + +on: + schedule: + # Runs daily at midnight UTC + - cron: '0 0 * * *' + # Allow manual triggering from the Actions tab + workflow_dispatch: + +jobs: + check-usage: + runs-on: ubuntu-latest + + steps: + - name: Invoke Usage Check API + env: + APP_URL: ${{ secrets.APP_URL || 'https://jollycode.dev' }} + CRON_SECRET: ${{ secrets.CRON_SECRET }} + run: | + if [ -z "$CRON_SECRET" ]; then + echo "Error: CRON_SECRET secret is not set in GitHub Secrets" + exit 1 + fi + + response=$(curl -s -w "\n%{http_code}" -X GET \ + -H "Authorization: Bearer ${CRON_SECRET}" \ + "${APP_URL}/api/cron/check-usage") + http_code=$(echo "$response" | tail -n1) + body=$(echo "$response" | sed '$d') + + if [ "$http_code" -eq 200 ]; then + echo "βœ… Usage check completed successfully" + echo "$body" | jq '.' || echo "$body" + else + echo "❌ Usage check failed with HTTP $http_code" + echo "$body" + exit 1 + fi diff --git a/.github/workflows/keepalive.yml b/.github/workflows/keepalive.yml index c007bde..7616077 100644 --- a/.github/workflows/keepalive.yml +++ b/.github/workflows/keepalive.yml @@ -2,8 +2,8 @@ name: Keepalive on: schedule: - # Runs every Monday at midnight UTC - - cron: '0 0 * * 0' + # Runs every Monday at 00:00 UTC (day 1 = Monday) + - cron: '0 0 * * 1' # Allow manual triggering from the Actions tab workflow_dispatch: @@ -13,6 +13,26 @@ jobs: steps: - name: Ping Keepalive Endpoint + env: + KEEPALIVE_URL: ${{ secrets.KEEPALIVE_ENDPOINT }} run: | - curl -X GET "${{ secrets.KEEPALIVE_ENDPOINT}}" \ - -H "Authorization: Bearer ${{ secrets.KEEPALIVE_API_KEY }}" + if [ -z "$KEEPALIVE_URL" ]; then + echo "::error::KEEPALIVE_ENDPOINT secret is not set" + echo "Please set it in repository settings to your production URL" + echo "Example: https://your-domain.com/api/keepalive" + exit 1 + fi + + response=$(curl -s -w "\n%{http_code}" "$KEEPALIVE_URL") + http_code=$(echo "$response" | tail -n1) + body=$(echo "$response" | sed '$d') + + echo "Response body: $body" + echo "HTTP Status: $http_code" + + if [ "$http_code" -ne 200 ]; then + echo "::error::Keepalive ping failed with status $http_code" + exit 1 + fi + + echo "βœ… Keepalive ping successful!" diff --git a/.github/workflows/plan-validation.yml b/.github/workflows/plan-validation.yml new file mode 100644 index 0000000..3f50db7 --- /dev/null +++ b/.github/workflows/plan-validation.yml @@ -0,0 +1,39 @@ +name: Plan Limits + +on: + pull_request: + paths: + - 'src/lib/config/plans.ts' + - 'supabase/migrations/**' + - '.github/workflows/plan-validation.yml' + push: + branches: + - main + paths: + - 'src/lib/config/plans.ts' + - 'supabase/migrations/**' + - '.github/workflows/plan-validation.yml' + +jobs: + validate-plan-limits: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup pnpm + # Version is automatically read from package.json packageManager field + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Validate plan limits + run: pnpm run validate:plans diff --git a/.gitignore b/.gitignore index 0d9ec1e..99c94aa 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ npm-debug.log* yarn-debug.log* yarn-error.log* +.cursor/debug.log # local env files .env*.local diff --git a/AGENTS.md b/AGENTS.md index d4f97fa..720f50d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,11 +27,39 @@ - PRs should describe intent, note any env vars touched (e.g., `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`, Sentry/PostHog keys), and link related issues. Include screenshots or short clips for UI changes and mention manual checks run. ## Security & Configuration Tips -- Store secrets in `.env.local`; never commit keys. Required keys include Supabase (`NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`) and Sentry/PostHog credentials where applicable. +- Store secrets in `.env.local`; never commit keys. Required keys include Supabase (`NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`), Stripe (`STRIPE_SECRET_KEY`, `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`, `STRIPE_WEBHOOK_SECRET`, price IDs), and Sentry/PostHog credentials where applicable. - When developing analytics/Sentry changes, guard them behind environment checks to keep local runs noise-free. +- Stripe webhook endpoint: `/api/webhooks/stripe` - must be configured in Stripe Dashboard with events: `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted`, `checkout.session.completed`. ## Usage Limits & Plans -- Free plan caps: 10 snippets, 10 animations, 5 slides per animation. Pro plan removes limits. -- Usage counts live in `profiles` and `usage_limits` with helper RPCs (`check_*_limit`, `increment_*`, `decrement_*`). -- Use `src/lib/services/usage-limits.ts` + `src/features/user/queries.ts` for limit checks and usage fetch/invalidation. -- Surface upgrade prompts via `UpgradeDialog` and show current usage with `UsageStatsWidget`. + +### Plan Tiers + +- **Free**: 0 saved snippets/animations, 3 slides per animation, 50 public shares +- **Started** ($5/mo or $3/mo yearly): 50 snippets, 50 animations, 10 slides per animation, 10 folders, 50 video exports, 1,000 public shares +- **Pro** ($9/mo or $7/mo yearly): Unlimited everything + watermark removal + priority support + +### Implementation Details + +- Plan configuration lives in `src/lib/config/plans.ts` with helper functions (`getPlanConfig`, `isLimitReached`, `getUsagePercentage`, etc.) +- Database schema in migration `supabase/migrations/20251207103258_add_usage_limits_and_plans.sql`: + - `profiles` table has plan columns: `plan` (enum), `plan_updated_at`, usage counters, and Stripe fields + - PostgreSQL RPC functions handle atomic limit checks and counter updates +- Usage tracking service: `src/lib/services/usage-limits.ts` provides `checkSnippetLimit`, `checkAnimationLimit`, `incrementUsageCount`, `decrementUsageCount`, `getUserUsage` +- Server actions enforce limits: `src/actions/snippets/create-snippet.ts` and `src/actions/animations/create-animation.ts` check RPCs before saving +- React Query hooks: `src/features/user/queries.ts` exports `useUserUsage()` and `useUserPlan()` for client-side usage display +- UI components: + - `src/components/usage-stats-widget` shows current usage with progress bars and upgrade CTA + - `src/components/ui/upgrade-dialog` displays plan comparison and pricing for upgrades +- Animation store (`src/app/store/animation-store.ts`) enforces slide limits via `addSlide({ maxSlides, onLimit })` parameter + +### Stripe Integration + +- Service layer: `src/lib/services/stripe.ts` handles customer management, checkout sessions, subscriptions, and webhooks +- Client-side: `src/lib/stripe-client.ts` loads Stripe.js; `src/actions/stripe/checkout.ts` provides `createCheckoutSession()` and `createPortalSession()` server actions +- API endpoints: + - `/api/checkout` - Creates Stripe checkout session for plan upgrades + - `/api/webhooks/stripe` - Handles subscription lifecycle events and updates user plans + - `/api/customer-portal` - Creates Stripe customer portal session for subscription management +- Webhooks automatically update `profiles` table when subscriptions are created, updated, or canceled +- Price IDs must be configured in environment variables for both monthly and yearly billing for Started and Pro plans diff --git a/README.md b/README.md index 3283ad0..e1dfd07 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,13 @@ Welcome to the Github repo for JollyCode. An open-source aesthetically appealing We support a wide array of programming languages, including Python, JavaScript, Java, C++, C#, Ruby, PHP, and more. No matter your preferred development language, we've got you covered. +### 🎬 Code Animations + +Create stunning multi-slide code animations with smooth transitions. Perfect for tutorials, demos, and showcasing code evolution. Export as GIF, MP4 or live embeds. + ### 🎨 Share Code Imagery -Fancy showing off your beautiful code? You can share captivating images of your code right from JollyCode. +Share captivating images of your code with beautiful syntax-highlighted Open Graph previews for Twitter, LinkedIn, and other social platforms. Your shared links automatically generate professional preview cards. ### 🌍 Short shared URL @@ -22,15 +26,13 @@ Once you share your URL, it gets shortened for easy sharing via services like Tw On the shared link, visualize the users interacting with your code snippet. -### πŸ’Ύ Code Snippets - -Save your code snippets for future reference directly in the tool. Group your saved snippets into categories for easy retrieval. +### πŸ’Ύ Code Snippets & Collections -- **Saving Snippets**: Use the 'Save Snippet' button after you've added your code. Give your snippet a meaningful name so you can easily find it later. +Save your code snippets and animations for future reference. Organize them into collections (folders) for easy retrieval and management. -- **Organizing Snippets**: Group your snippets into categories or use tags to organize them. This organization method makes it easy to find a relevant piece of code. - -- **Using Snippets**: Click on any snippet in 'My Snippets' to load it into the code editor. You can then modify it as per your current requirements. +- **Saving**: Save snippets and animations with meaningful names for quick access. +- **Organizing**: Group your content into collections to keep everything organized. +- **Using**: Click on any saved item to load it into the editor and continue working. ## πŸš€ Built With @@ -39,6 +41,10 @@ Save your code snippets for future reference directly in the tool. Group your sa - Supabase - TailwindCSS +## Environment Variables + +We validate configuration with `@t3-oss/env-nextjs`; `pnpm env:check` runs automatically before `pnpm dev`, `pnpm build`, and `pnpm start`. Copy `.env.example` to `.env.local` and fill in the required values. + ## Roadmap For any additional feature request πŸ‘‰: [Check it out](https://jollycode.canny.io/feature-requests) diff --git a/emails/account-deleted-email.tsx b/emails/account-deleted-email.tsx new file mode 100644 index 0000000..4ed2407 --- /dev/null +++ b/emails/account-deleted-email.tsx @@ -0,0 +1,74 @@ +import React from "react"; +import { + Body, + Container, + Head, + Heading, + Html, + Preview, + Section, + Text, + Tailwind, +} from "@react-email/components"; +import { Logo } from "../src/components/ui/logo"; + +interface AccountDeletedEmailProps { + name?: string; +} + +export default function AccountDeletedEmail({ name }: AccountDeletedEmailProps) { + return ( + + + Your Jolly Code account has been deleted + + + +
+ +
+ + Your account has been deleted + + + Hey {name || "there"}, + + + This email confirms that your Jolly Code account and all associated data have been permanently deleted from our systems. + + + What was deleted: + + + β€’ Your profile and account information
+ β€’ All your code snippets and animations
+ β€’ All your collections and folders
+ β€’ All shared links and associated data
+ β€’ Your subscription information (if applicable) +
+ + If you had an active subscription, it has been canceled and no further charges will be made. + + + If you didn't request this deletion or have any questions, please contact us immediately at{" "} + + support@jollycode.dev + + . + + + We're sorry to see you go. If you change your mind, you can always create a new account in the future. + + + Best regards, + + + The Jolly Code Team + +
+ +
+ + ); +} + diff --git a/emails/usage-limit-email.tsx b/emails/usage-limit-email.tsx new file mode 100644 index 0000000..561bfd7 --- /dev/null +++ b/emails/usage-limit-email.tsx @@ -0,0 +1,77 @@ +import React from "react"; +import { + Body, + Container, + Head, + Heading, + Html, + Link, + Preview, + Section, + Text, + Tailwind, +} from "@react-email/components"; +import { Logo } from "../src/components/ui/logo"; + +interface UsageLimitWarningEmailProps { + usagePercent?: number; + userName?: string; +} + +export default function UsageLimitWarningEmail({ + usagePercent, + userName, +}: UsageLimitWarningEmailProps) { + return ( + + + + Just a heads-up β€” you're at {usagePercent?.toString() || "90"}% capacity + + + + +
+ +
+ + You’re nearing your storage limit on Jolly Code + + + Hey {userName || "there"}! + + + You've used {usagePercent ?? 90}% of your current + plan's storage. To avoid hitting your limit and ensure + uninterrupted access to your snippets and animations, you may want + to: + + + β€’ Upgrade your plan for more space
+ β€’ Or remove old items to free up room +
+ + Take action now to keep things running smoothly. + +
+ + Manage Subscription + +
+ + Happy coding, + + The Jolly Code Team + +
+ +
+ + ); +} diff --git a/emails/welcome-email.tsx b/emails/welcome-email.tsx new file mode 100644 index 0000000..aaac66e --- /dev/null +++ b/emails/welcome-email.tsx @@ -0,0 +1,67 @@ +import React from "react"; +import { + Body, + Container, + Head, + Heading, + Html, + Link, + Preview, + Section, + Text, + Tailwind, +} from "@react-email/components"; +import { Logo } from "../src/components/ui/logo"; + +interface WelcomeEmailProps { + name?: string; +} + +export default function WelcomeEmail({ name }: WelcomeEmailProps) { + return ( + + + Thanks for subscribing to Jolly Code πŸŽ‰ + + + +
+ +
+ + Welcome to Jolly Code β€” Your subscription is + active! + + + Hey {name || "there"}! + + + We’re thrilled to have you with us! Your subscription is now + active, and you're all set to create stunning code snippets and + animations with ease.

Head over to your dashboard to start + building, sharing, and saving your best work yet.
+
+
+ + Go to Dashboard + +
+ {/* + Need help or have questions? Just reply to this email β€” we’re always happy to help. + */} + + Happy coding, + + The Jolly Code Team + +
+ +
+ + ); +} diff --git a/instrumentation.ts b/instrumentation.ts index 348c4f7..56c9f5d 100644 --- a/instrumentation.ts +++ b/instrumentation.ts @@ -3,9 +3,10 @@ export async function register() { await import('./sentry.server.config'); } - if (process.env.NEXT_RUNTIME === 'edge') { - await import('./sentry.edge.config'); - } + // Temporarily disable Sentry for edge runtime to reduce bundle size + // if (process.env.NEXT_RUNTIME === 'edge') { + // await import('./sentry.edge.config'); + // } } export const onRequestError = async ( diff --git a/liveblocks.config.ts b/liveblocks.config.ts index 6543d7d..0bb156f 100644 --- a/liveblocks.config.ts +++ b/liveblocks.config.ts @@ -7,6 +7,14 @@ const client = createClient({ // throttle: 100, }); +// JSON-serializable type matching Liveblocks' Json type constraint +type Json = JsonScalar | JsonArray | JsonObject; +type JsonScalar = string | number | boolean | null; +type JsonArray = Json[]; +type JsonObject = { + [key: string]: Json | undefined; +}; + // Presence represents the properties that exist on every user in the Room // and that will automatically be kept in sync. Accessible through the // `user.presence` property. Must be JSON-serializable. @@ -27,9 +35,10 @@ type Storage = { // Optionally, UserMeta represents static/readonly metadata on each user, as // provided by your own custom auth back end (if used). Useful for data that // will not change during a session, like a user's name or avatar. +// Note: info must be JSON-serializable per Liveblocks' Json type constraint type UserMeta = { - // id?: string, // Accessible through `user.id` - // info?: Json, // Accessible through `user.info` + id: string; // Accessible through `user.id` - always present (user ID or "anonymous") + info?: JsonObject; // Accessible through `user.info` - must be JSON-serializable }; // Optionally, the type of custom events broadcast and listened to in this diff --git a/next.config.mjs b/next.config.mjs index fc93a88..b9b0642 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,28 +1,122 @@ +import "./src/env.mjs"; import { withSentryConfig } from '@sentry/nextjs'; +const appOrigin = + process.env.CORS_ALLOWED_ORIGIN || + process.env.NEXT_PUBLIC_APP_URL || + "http://localhost:3000"; + /** @type {import('next').NextConfig} */ const nextConfig = { + // Optimize bundle size for Edge Runtime + experimental: { + // Optimize package imports for smaller bundles + optimizePackageImports: ['highlight.js'], + }, + // Enable Next.js Image optimization + images: { + // Enable image optimization (enabled by default, but explicit for clarity) + formats: ['image/avif', 'image/webp'], + // Remote image patterns for external domains + remotePatterns: [ + { + protocol: 'https', + hostname: 'res.cloudinary.com', + pathname: '/**', + }, + // Add other image domains as needed (e.g., for OAuth provider avatars) + // Common OAuth providers: + { + protocol: 'https', + hostname: '**.githubusercontent.com', + pathname: '/**', + }, + { + protocol: 'https', + hostname: '**.googleusercontent.com', + pathname: '/**', + }, + { + protocol: 'https', + hostname: '**.supabase.co', + pathname: '/**', + }, + ], + // Image optimization settings + deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], + imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], + // Minimum cache TTL for optimized images (in seconds) + minimumCacheTTL: 60, + }, async headers() { return [ + // Cache-Control headers for static assets in public folder + { + source: '/:path*.{js,css,woff,woff2,ttf,otf,eot}', + headers: [ + { + key: 'Cache-Control', + value: 'public, max-age=31536000, immutable', + }, + ], + }, + { + source: '/:path*.{png,jpg,jpeg,gif,webp,svg,ico}', + headers: [ + { + key: 'Cache-Control', + value: 'public, max-age=31536000, immutable', + }, + ], + }, + { + source: '/:path*.{json,xml}', + headers: [ + { + key: 'Cache-Control', + value: 'public, max-age=3600, stale-while-revalidate=86400', + }, + ], + }, + // Public oEmbed endpoint remains open for external consumers + // Must be defined before the catch-all /api/:path* pattern { - source: "/:path*", + source: "/api/oembed", headers: [ { key: "Access-Control-Allow-Origin", value: "*" }, { key: "Access-Control-Allow-Methods", - value: "GET,OPTIONS,PATCH,DELETE,POST,PUT", + value: "GET,OPTIONS", + }, + { + key: "Access-Control-Allow-Headers", + value: "Content-Type", + }, + ], + }, + // Public share visit tracking for embeds + // Must be defined before the catch-all /api/:path* pattern + { + source: "/api/save-shared-url-visits", + headers: [ + { key: "Access-Control-Allow-Origin", value: "*" }, + { + key: "Access-Control-Allow-Methods", + value: "POST,OPTIONS", }, { key: "Access-Control-Allow-Headers", - value: "Content-Type, Authorization", + value: "Content-Type", }, ], }, + // Default API CORS: lock to configured origin for authenticated/private APIs + // This catch-all pattern must come after specific routes { source: "/api/:path*", headers: [ { key: "Access-Control-Allow-Credentials", value: "true" }, - { key: "Access-Control-Allow-Origin", value: "*" }, + { key: "Access-Control-Allow-Origin", value: appOrigin }, { key: "Access-Control-Allow-Methods", value: "GET,OPTIONS,PATCH,DELETE,POST,PUT", @@ -30,7 +124,7 @@ const nextConfig = { { key: "Access-Control-Allow-Headers", value: - "X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version", + "X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, Authorization", }, ], }, @@ -47,6 +141,19 @@ const nextConfig = { }, ], }, + { + source: "/animate/shared/:slug", + headers: [ + { + key: "Content-Security-Policy", + value: "frame-ancestors *", + }, + { + key: "X-Frame-Options", + value: "ALLOWALL", + }, + ], + }, ]; }, turbopack: {}, diff --git a/package.json b/package.json index 8562de7..e163a8e 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,17 @@ "private": true, "author": "Dana Rocha", "scripts": { + "env:check": "node --enable-source-maps scripts/check-env.mjs", + "predev": "pnpm env:check", + "prestart": "pnpm env:check", + "prebuild": "pnpm env:check", "dev": "next dev", "build": "next build", "start": "next start", "lint": "eslint . --ext .js,.jsx,.ts,.tsx", - "lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix && pnpm run lint" + "lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix", + "validate:plans": "node scripts/validate-plan-limits.js", + "email:preview": "pnpm exec email dev" }, "dependencies": { "@arcjet/inspect": "1.0.0-beta.15", @@ -23,6 +29,7 @@ "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-aspect-ratio": "^1.0.3", "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-hover-card": "^1.1.15", @@ -38,9 +45,12 @@ "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", + "@react-email/components": "^1.0.1", "@sentry/nextjs": "^8.0.0", + "@stripe/stripe-js": "^8.5.3", "@supabase/ssr": "^0.7.0", "@supabase/supabase-js": "^2.84.0", + "@t3-oss/env-nextjs": "^0.13.8", "@tanstack/react-query": "^5.90.10", "@vercel/analytics": "^1.1.0", "@vercel/og": "^0.5.20", @@ -77,8 +87,10 @@ "react-markdown": "^9.0.0", "react-simple-code-editor": "^0.13.1", "remixicon": "^3.5.0", + "resend": "^6.6.0", "schema-dts": "^1.1.5", "sonner": "^1.0.3", + "stripe": "^20.0.0", "tailwind-merge": "^3.4.0", "tailwindcss-animate": "^1.0.7", "uuid": "^9.0.1", @@ -86,6 +98,7 @@ "zustand": "^5.0.8" }, "devDependencies": { + "@react-email/preview-server": "5.0.8", "@tailwindcss/postcss": "^4.1.17", "@types/diff": "^7.0.2", "@types/gif.js": "^0.2.5", @@ -100,8 +113,10 @@ "eslint-config-next": "16.0.7", "eslint-config-prettier": "^10.1.8", "postcss": "^8.4.31", + "react-email": "^5.0.8", "tailwind-scrollbar": "^4.0.2", "tailwindcss": "^4.1.17", "typescript": "5.9.3" - } + }, + "packageManager": "pnpm@10.26.0+sha512.3b3f6c725ebe712506c0ab1ad4133cf86b1f4b687effce62a9b38b4d72e3954242e643190fc51fa1642949c735f403debd44f5cb0edd657abe63a8b6a7e1e402" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c59c21c..c4211af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: '@radix-ui/react-avatar': specifier: ^1.1.11 version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@radix-ui/react-checkbox': + specifier: ^1.3.3 + version: 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@radix-ui/react-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -89,15 +92,24 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.2.8 version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@react-email/components': + specifier: ^1.0.1 + version: 1.0.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@sentry/nextjs': specifier: ^8.0.0 version: 8.55.0(@opentelemetry/context-async-hooks@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.0.7(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react@19.2.1)(webpack@5.103.0) + '@stripe/stripe-js': + specifier: ^8.5.3 + version: 8.5.3 '@supabase/ssr': specifier: ^0.7.0 version: 0.7.0(@supabase/supabase-js@2.84.0) '@supabase/supabase-js': specifier: ^2.84.0 version: 2.84.0 + '@t3-oss/env-nextjs': + specifier: ^0.13.8 + version: 0.13.8(typescript@5.9.3)(zod@4.1.13) '@tanstack/react-query': specifier: ^5.90.10 version: 5.90.10(react@19.2.1) @@ -206,12 +218,18 @@ importers: remixicon: specifier: ^3.5.0 version: 3.7.0 + resend: + specifier: ^6.6.0 + version: 6.6.0(@react-email/render@2.0.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1)) schema-dts: specifier: ^1.1.5 version: 1.1.5 sonner: specifier: ^1.0.3 version: 1.7.4(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + stripe: + specifier: ^20.0.0 + version: 20.0.0(@types/node@24.10.1) tailwind-merge: specifier: ^3.4.0 version: 3.4.0 @@ -228,6 +246,9 @@ importers: specifier: ^5.0.8 version: 5.0.8(@types/react@19.2.6)(react@19.2.1)(use-sync-external-store@1.6.0(react@19.2.1)) devDependencies: + '@react-email/preview-server': + specifier: 5.0.8 + version: 5.0.8(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@tailwindcss/postcss': specifier: ^4.1.17 version: 4.1.17 @@ -270,6 +291,9 @@ importers: postcss: specifier: ^8.4.31 version: 8.5.6 + react-email: + specifier: ^5.0.8 + version: 5.0.8 tailwind-scrollbar: specifier: ^4.0.2 version: 4.0.2(react@19.2.1)(tailwindcss@4.1.17) @@ -474,6 +498,162 @@ packages: '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.0': resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -713,6 +893,18 @@ packages: cpu: [x64] os: [win32] + '@isaacs/balanced-match@4.0.1': + resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} + engines: {node: 20 || >=22} + + '@isaacs/brace-expansion@5.0.0': + resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} + engines: {node: 20 || >=22} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -752,6 +944,9 @@ packages: '@next/env@16.0.7': resolution: {integrity: sha512-gpaNgUh5nftFKRkRQGnVi5dpcYSKGcZZkQffZ172OrG/XkrnS7UBTQ648YY+8ME92cC4IojpI2LqTC8sTDhAaw==} + '@next/env@16.0.9': + resolution: {integrity: sha512-6284pl8c8n9PQidN63qjPVEu1uXXKjnmbmaLebOzIfTrSXdGiAPsIMRi4pk/+v/ezqweE1/B8bFqiAAfC6lMXg==} + '@next/eslint-plugin-next@16.0.7': resolution: {integrity: sha512-hFrTNZcMEG+k7qxVxZJq3F32Kms130FAhG8lvw2zkKBgAcNOJIxlljNiCjGygvBshvaGBdf88q2CqWtnqezDHA==} @@ -761,48 +956,96 @@ packages: cpu: [arm64] os: [darwin] + '@next/swc-darwin-arm64@16.0.9': + resolution: {integrity: sha512-j06fWg/gPqiWjK+sEpCDsh5gX+Bdy9gnPYjFqMBvBEOIcCFy1/ecF6pY6XAce7WyCJAbBPVb+6GvpmUZKNq0oQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + '@next/swc-darwin-x64@16.0.7': resolution: {integrity: sha512-rtZ7BhnVvO1ICf3QzfW9H3aPz7GhBrnSIMZyr4Qy6boXF0b5E3QLs+cvJmg3PsTCG2M1PBoC+DANUi4wCOKXpA==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] + '@next/swc-darwin-x64@16.0.9': + resolution: {integrity: sha512-FRYYz5GSKUkfvDSjd5hgHME2LgYjfOLBmhRVltbs3oRNQQf9n5UTQMmIu/u5vpkjJFV4L2tqo8duGqDxdQOFwg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + '@next/swc-linux-arm64-gnu@16.0.7': resolution: {integrity: sha512-mloD5WcPIeIeeZqAIP5c2kdaTa6StwP4/2EGy1mUw8HiexSHGK/jcM7lFuS3u3i2zn+xH9+wXJs6njO7VrAqww==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + '@next/swc-linux-arm64-gnu@16.0.9': + resolution: {integrity: sha512-EI2klFVL8tOyEIX5J1gXXpm1YuChmDy4R+tHoNjkCHUmBJqXioYErX/O2go4pEhjxkAxHp2i8y5aJcRz2m5NqQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + '@next/swc-linux-arm64-musl@16.0.7': resolution: {integrity: sha512-+ksWNrZrthisXuo9gd1XnjHRowCbMtl/YgMpbRvFeDEqEBd523YHPWpBuDjomod88U8Xliw5DHhekBC3EOOd9g==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + '@next/swc-linux-arm64-musl@16.0.9': + resolution: {integrity: sha512-vq/5HeGvowhDPMrpp/KP4GjPVhIXnwNeDPF5D6XK6ta96UIt+C0HwJwuHYlwmn0SWyNANqx1Mp6qSVDXwbFKsw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + '@next/swc-linux-x64-gnu@16.0.7': resolution: {integrity: sha512-4WtJU5cRDxpEE44Ana2Xro1284hnyVpBb62lIpU5k85D8xXxatT+rXxBgPkc7C1XwkZMWpK5rXLXTh9PFipWsA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + '@next/swc-linux-x64-gnu@16.0.9': + resolution: {integrity: sha512-GlUdJwy2leA/HnyRYxJ1ZJLCJH+BxZfqV4E0iYLrJipDKxWejWpPtZUdccPmCfIEY9gNBO7bPfbG6IIgkt0qXg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + '@next/swc-linux-x64-musl@16.0.7': resolution: {integrity: sha512-HYlhqIP6kBPXalW2dbMTSuB4+8fe+j9juyxwfMwCe9kQPPeiyFn7NMjNfoFOfJ2eXkeQsoUGXg+O2SE3m4Qg2w==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + '@next/swc-linux-x64-musl@16.0.9': + resolution: {integrity: sha512-UCtOVx4N8AHF434VPwg4L0KkFLAd7pgJShzlX/hhv9+FDrT7/xCuVdlBsCXH7l9yCA/wHl3OqhMbIkgUluriWA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + '@next/swc-win32-arm64-msvc@16.0.7': resolution: {integrity: sha512-EviG+43iOoBRZg9deGauXExjRphhuYmIOJ12b9sAPy0eQ6iwcPxfED2asb/s2/yiLYOdm37kPaiZu8uXSYPs0Q==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] + '@next/swc-win32-arm64-msvc@16.0.9': + resolution: {integrity: sha512-tQjtDGtv63mV3n/cZ4TH8BgUvKTSFlrF06yT5DyRmgQuj5WEjBUDy0W3myIW5kTRYMPrLn42H3VfCNwBH6YYiA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + '@next/swc-win32-x64-msvc@16.0.7': resolution: {integrity: sha512-gniPjy55zp5Eg0896qSrf3yB1dw4F/3s8VK1ephdsZZ129j2n6e1WqCbE2YgcKhW9hPB9TVZENugquWJD5x0ug==} engines: {node: '>= 10'} cpu: [x64] os: [win32] + '@next/swc-win32-x64-msvc@16.0.9': + resolution: {integrity: sha512-y9AGACHTBwnWFLq5B5Fiv3FEbXBusdPb60pgoerB04CV/pwjY1xQNdoTNxAv7eUhU2k1CKnkN4XWVuiK07uOqA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1113,6 +1356,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-checkbox@1.3.3': + resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-collapsible@1.1.12': resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} peerDependencies: @@ -1680,6 +1936,166 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@react-email/body@0.2.0': + resolution: {integrity: sha512-9GCWmVmKUAoRfloboCd+RKm6X17xn7eGL7HnpAZUnjBXBilWCxsKnLMTC/ixSHDKS/A/057M1Tx6ZUXd89sVBw==} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/button@0.2.0': + resolution: {integrity: sha512-8i+v6cMxr2emz4ihCrRiYJPp2/sdYsNNsBzXStlcA+/B9Umpm5Jj3WJKYpgTPM+aeyiqlG/MMI1AucnBm4f1oQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/code-block@0.2.0': + resolution: {integrity: sha512-eIrPW9PIFgDopQU0e/OPpwCW2QWQDtNZDSsiN4sJO8KdMnWWnXJicnRfzrit5rHwFo+Y98i+w/Y5ScnBAFr1dQ==} + engines: {node: '>=22.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/code-inline@0.0.5': + resolution: {integrity: sha512-MmAsOzdJpzsnY2cZoPHFPk6uDO/Ncpb4Kh1hAt9UZc1xOW3fIzpe1Pi9y9p6wwUmpaeeDalJxAxH6/fnTquinA==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/column@0.0.13': + resolution: {integrity: sha512-Lqq17l7ShzJG/d3b1w/+lVO+gp2FM05ZUo/nW0rjxB8xBICXOVv6PqjDnn3FXKssvhO5qAV20lHM6S+spRhEwQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/components@1.0.1': + resolution: {integrity: sha512-HnL0Y/up61sOBQT2cQg9N/kCoW0bP727gDs2MkFWQYELg6+iIHidMDvENXFC0f1ZE6hTB+4t7sszptvTcJWsDA==} + engines: {node: '>=22.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/container@0.0.15': + resolution: {integrity: sha512-Qo2IQo0ru2kZq47REmHW3iXjAQaKu4tpeq/M8m1zHIVwKduL2vYOBQWbC2oDnMtWPmkBjej6XxgtZByxM6cCFg==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/font@0.0.9': + resolution: {integrity: sha512-4zjq23oT9APXkerqeslPH3OZWuh5X4crHK6nx82mVHV2SrLba8+8dPEnWbaACWTNjOCbcLIzaC9unk7Wq2MIXw==} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/head@0.0.12': + resolution: {integrity: sha512-X2Ii6dDFMF+D4niNwMAHbTkeCjlYYnMsd7edXOsi0JByxt9wNyZ9EnhFiBoQdqkE+SMDcu8TlNNttMrf5sJeMA==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/heading@0.0.15': + resolution: {integrity: sha512-xF2GqsvBrp/HbRHWEfOgSfRFX+Q8I5KBEIG5+Lv3Vb2R/NYr0s8A5JhHHGf2pWBMJdbP4B2WHgj/VUrhy8dkIg==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/hr@0.0.11': + resolution: {integrity: sha512-S1gZHVhwOsd1Iad5IFhpfICwNPMGPJidG/Uysy1AwmspyoAP5a4Iw3OWEpINFdgh9MHladbxcLKO2AJO+cA9Lw==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/html@0.0.11': + resolution: {integrity: sha512-qJhbOQy5VW5qzU74AimjAR9FRFQfrMa7dn4gkEXKMB/S9xZN8e1yC1uA9C15jkXI/PzmJ0muDIWmFwatm5/+VA==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/img@0.0.11': + resolution: {integrity: sha512-aGc8Y6U5C3igoMaqAJKsCpkbm1XjguQ09Acd+YcTKwjnC2+0w3yGUJkjWB2vTx4tN8dCqQCXO8FmdJpMfOA9EQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/link@0.0.12': + resolution: {integrity: sha512-vF+xxQk2fGS1CN7UPQDbzvcBGfffr+GjTPNiWM38fhBfsLv6A/YUfaqxWlmL7zLzVmo0K2cvvV9wxlSyNba1aQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/markdown@0.0.17': + resolution: {integrity: sha512-6op3AfsBC9BJKkhG+eoMFRFWlr0/f3FYbtQrK+VhGzJocEAY0WINIFN+W8xzXr//3IL0K/aKtnH3FtpIuescQQ==} + engines: {node: '>=22.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/preview-server@5.0.8': + resolution: {integrity: sha512-TyAxXLFSgMDRwUEkCVvazkRYST9LZmYZMkJVv/K1221cMXa7r00R+S0R65lb4EULx397PRULXVWqJAwxyp/wcA==} + + '@react-email/preview@0.0.13': + resolution: {integrity: sha512-F7j9FJ0JN/A4d7yr+aw28p4uX7VLWs7hTHtLo7WRyw4G+Lit6Zucq4UWKRxJC8lpsUdzVmG7aBJnKOT+urqs/w==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/render@2.0.0': + resolution: {integrity: sha512-rdjNj6iVzv8kRKDPFas+47nnoe6B40+nwukuXwY4FCwM7XBg6tmYr+chQryCuavUj2J65MMf6fztk1bxOUiSVA==} + engines: {node: '>=22.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/row@0.0.12': + resolution: {integrity: sha512-HkCdnEjvK3o+n0y0tZKXYhIXUNPDx+2vq1dJTmqappVHXS5tXS6W5JOPZr5j+eoZ8gY3PShI2LWj5rWF7ZEtIQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/section@0.0.16': + resolution: {integrity: sha512-FjqF9xQ8FoeUZYKSdt8sMIKvoT9XF8BrzhT3xiFKdEMwYNbsDflcjfErJe3jb7Wj/es/lKTbV5QR1dnLzGpL3w==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + + '@react-email/tailwind@2.0.1': + resolution: {integrity: sha512-/xq0IDYVY7863xPY7cdI45Xoz7M6CnIQBJcQvbqN7MNVpopfH9f+mhjayV1JGfKaxlGWuxfLKhgi9T2shsnEFg==} + engines: {node: '>=22.0.0'} + peerDependencies: + '@react-email/body': 0.2.0 + '@react-email/button': 0.2.0 + '@react-email/code-block': 0.2.0 + '@react-email/code-inline': 0.0.5 + '@react-email/container': 0.0.15 + '@react-email/heading': 0.0.15 + '@react-email/hr': 0.0.11 + '@react-email/img': 0.0.11 + '@react-email/link': 0.0.12 + '@react-email/preview': 0.0.13 + '@react-email/text': 0.1.5 + react: ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@react-email/body': + optional: true + '@react-email/button': + optional: true + '@react-email/code-block': + optional: true + '@react-email/code-inline': + optional: true + '@react-email/container': + optional: true + '@react-email/heading': + optional: true + '@react-email/hr': + optional: true + '@react-email/img': + optional: true + '@react-email/link': + optional: true + '@react-email/preview': + optional: true + + '@react-email/text@0.1.5': + resolution: {integrity: sha512-o5PNHFSE085VMXayxH+SJ1LSOtGsTv+RpNKnTiJDrJUwoBu77G3PlKOsZZQHCNyD28WsQpl9v2WcJLbQudqwPg==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^18.0 || ^19.0 || ^19.0.0-rc + '@resvg/resvg-wasm@2.6.0': resolution: {integrity: sha512-iDkBM6Ivex8nULtBu8cX670/lfsGxq8U1cuqE+qS9xFpPQP1enPdVm/33Kq3+B+bAldA+AHNZnCgpmlHo/fZrQ==} engines: {node: '>= 10'} @@ -1705,6 +2121,9 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@selderee/plugin-htmlparser2@0.11.0': + resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} + '@sentry-internal/browser-utils@8.55.0': resolution: {integrity: sha512-ROgqtQfpH/82AQIpESPqPQe0UyWywKJsmVIqi3c5Fh+zkds5LUxnssTj3yNd1x+kxaPDVB023jAP+3ibNgeNDw==} engines: {node: '>=14.18'} @@ -1829,12 +2248,19 @@ packages: resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} engines: {node: '>=10'} + '@socket.io/component-emitter@3.1.2': + resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + '@stablelib/base64@1.0.1': resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@stripe/stripe-js@8.5.3': + resolution: {integrity: sha512-UM0GHAxlTN7v0lCK2P6t0VOlvBIdApIQxhnM3yZ2kupQ4PpSrLsK/n/NyYKtw2NJGMaNRRD1IicWS7fSL2sFtA==} + engines: {node: '>=12.16'} + '@supabase/auth-js@2.84.0': resolution: {integrity: sha512-J6XKbqqg1HQPMfYkAT9BrC8anPpAiifl7qoVLsYhQq5B/dnu/lxab1pabnxtJEsvYG5rwI5HEVEGXMjoQ6Wz2Q==} engines: {node: '>=20.0.0'} @@ -1871,6 +2297,40 @@ packages: resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} engines: {node: '>=10'} + '@t3-oss/env-core@0.13.8': + resolution: {integrity: sha512-L1inmpzLQyYu4+Q1DyrXsGJYCXbtXjC4cICw1uAKv0ppYPQv656lhZPU91Qd1VS6SO/bou1/q5ufVzBGbNsUpw==} + peerDependencies: + arktype: ^2.1.0 + typescript: '>=5.0.0' + valibot: ^1.0.0-beta.7 || ^1.0.0 + zod: ^3.24.0 || ^4.0.0-beta.0 + peerDependenciesMeta: + arktype: + optional: true + typescript: + optional: true + valibot: + optional: true + zod: + optional: true + + '@t3-oss/env-nextjs@0.13.8': + resolution: {integrity: sha512-QmTLnsdQJ8BiQad2W2nvV6oUpH4oMZMqnFEjhVpzU0h3sI9hn8zb8crjWJ1Amq453mGZs6A4v4ihIeBFDOrLeQ==} + peerDependencies: + arktype: ^2.1.0 + typescript: '>=5.0.0' + valibot: ^1.0.0-beta.7 || ^1.0.0 + zod: ^3.24.0 || ^4.0.0-beta.0 + peerDependenciesMeta: + arktype: + optional: true + typescript: + optional: true + valibot: + optional: true + zod: + optional: true + '@tailwindcss/node@4.1.17': resolution: {integrity: sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==} @@ -1976,6 +2436,9 @@ packages: '@types/connect@3.4.36': resolution: {integrity: sha512-P63Zd/JUGq+PdrM1lv0Wv5SBYeA2+CORvbrXbngriYY0jzLUWfQMQQxOhjONEz/wlHOAxOdY7CY65rgQdTjq2w==} + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} @@ -2027,6 +2490,9 @@ packages: '@types/mysql@2.15.26': resolution: {integrity: sha512-DSLCOXhkvfS5WNNPbfn2KdICAmk8lLc+/PNvnPnF7gOdMZCxopXduqv0OQ13y/yA/zXTSikZZqVgybUxOEg6YQ==} + '@types/node@22.19.3': + resolution: {integrity: sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==} + '@types/node@24.10.1': resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} @@ -2383,6 +2849,10 @@ packages: react-dom: optional: true + accepts@1.3.8: + 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: @@ -2416,6 +2886,14 @@ packages: ajv: optional: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv-keywords@5.1.0: resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} peerDependencies: @@ -2427,10 +2905,22 @@ packages: ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -2489,6 +2979,9 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + atomically@2.1.0: + resolution: {integrity: sha512-+gDffFXRW6sl/HCwbta7zK4uNqbPjv4YJEAdz7Vu+FLQHe77eZ4bvbJGi4hE0QPeJlMYMA3piXEr1UL3dAwx7Q==} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -2515,6 +3008,10 @@ packages: resolution: {integrity: sha512-ZXBDPMt/v/8fsIqn+Z5VwrhdR6jVka0bYobHdGia0Nxi7BJ9i/Uvml3AocHIBtIIBhZjBw5MR0aR4ROs/8+SNg==} engines: {node: '>= 0.4'} + base64id@2.0.0: + resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} + engines: {node: ^4.5.0 || >= 5.9} + baseline-browser-mapping@2.8.30: resolution: {integrity: sha512-aTUKW4ptQhS64+v2d6IkPzymEzzhw+G0bA1g3uBRV3+ntkH+svttKseW5IOR4Ed6NUVKqnY7qT3dKvzQ7io4AA==} hasBin: true @@ -2585,6 +3082,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -2601,16 +3102,31 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + chrome-trace-event@1.0.4: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} + citty@0.1.6: + resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + cjs-module-lexer@1.4.3: resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} @@ -2637,6 +3153,10 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -2646,9 +3166,24 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + conf@15.0.2: + resolution: {integrity: sha512-JBSrutapCafTrddF9dH3lc7+T2tBycGF4uPkI4Js+g4vLLEhG6RZcFi3aJd5zntdf5tQxAejJt8dihkoQ/eSJw==} + engines: {node: '>=20'} + + confbox@0.2.2: + resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + cookie@1.0.2: resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} engines: {node: '>=18'} @@ -2656,6 +3191,10 @@ packages: core-js@3.47.0: resolution: {integrity: sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==} + cors@2.8.5: + resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==} + engines: {node: '>= 0.10'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2694,6 +3233,14 @@ packages: dayjs@1.11.19: resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + debounce-fn@6.0.0: + resolution: {integrity: sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==} + engines: {node: '>=18'} + + debounce@2.2.0: + resolution: {integrity: sha512-Xks6RUDLZFdz8LIdR6q0MTH44k7FikOmnh5xkSjMig6ch45afc8sjTjRQf3P6ax8dMgcQrYO/AR2RGWURrruqw==} + engines: {node: '>=18'} + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -2702,6 +3249,15 @@ packages: supports-color: optional: true + debug@4.3.7: + resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -2721,6 +3277,10 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + defer-to-connect@2.0.1: resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} engines: {node: '>=10'} @@ -2758,6 +3318,23 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dot-prop@10.1.0: + resolution: {integrity: sha512-MVUtAugQMOff5RnBy2d9N31iG0lNwg1qAoAOn7pOK5wf94WIaE3My2p3uwTQuvS2AcqchkcR3bHByjaM0mmi7Q==} + engines: {node: '>=20'} + dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -2766,12 +3343,18 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + electron-to-chromium@1.5.259: resolution: {integrity: sha512-I+oLXgpEJzD6Cwuwt1gYjxsDmu/S/Kd41mmLA3O+/uH2pFRO/DvOjUyGozL8j3KeLV6WyZ7ssPwELMsXCcsJAQ==} emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} @@ -2781,10 +3364,26 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + engine.io-parser@5.2.3: + resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} + engines: {node: '>=10.0.0'} + + engine.io@6.6.4: + resolution: {integrity: sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==} + engines: {node: '>=10.2.0'} + enhanced-resolve@5.18.3: resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + env-paths@3.0.0: + resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + es-abstract@1.24.0: resolution: {integrity: sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==} engines: {node: '>= 0.4'} @@ -2820,6 +3419,14 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} + es6-promise@4.2.8: + resolution: {integrity: sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -2971,6 +3578,9 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -3045,6 +3655,10 @@ packages: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + forwarded-parse@2.1.2: resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} @@ -3088,6 +3702,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-east-asian-width@1.4.0: + resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -3125,6 +3743,11 @@ packages: glob-to-regexp@0.4.1: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + glob@11.1.0: + resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} + engines: {node: 20 || >=22} + hasBin: true + glob@9.3.5: resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} engines: {node: '>=16 || 14 >=14.17'} @@ -3208,9 +3831,16 @@ packages: html-to-image@1.11.13: resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==} + html-to-text@9.0.5: + resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==} + engines: {node: '>=14'} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + htmlparser2@8.0.2: + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + http-cache-semantics@4.2.0: resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} @@ -3311,6 +3941,10 @@ packages: resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} engines: {node: '>= 0.4'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-generator-function@1.1.2: resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} engines: {node: '>= 0.4'} @@ -3322,6 +3956,10 @@ packages: is-hexadecimal@2.0.1: resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + is-map@2.0.3: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} @@ -3369,6 +4007,14 @@ packages: resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} engines: {node: '>= 0.4'} + is-unicode-supported@1.3.0: + resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} + engines: {node: '>=12'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + is-weakmap@2.0.2: resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} engines: {node: '>= 0.4'} @@ -3391,10 +4037,18 @@ packages: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} + jackspeak@4.1.1: + resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==} + engines: {node: 20 || >=22} + jest-worker@27.5.1: resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} engines: {node: '>= 10.13.0'} + jiti@2.4.2: + resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} + hasBin: true + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -3423,6 +4077,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -3442,6 +4099,10 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + language-subtag-registry@0.3.23: resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} @@ -3449,6 +4110,9 @@ packages: resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} engines: {node: '>=0.10'} + leac@0.6.0: + resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -3537,6 +4201,14 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + log-symbols@6.0.0: + resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} + engines: {node: '>=18'} + + log-symbols@7.0.1: + resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} + engines: {node: '>=18'} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -3551,6 +4223,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.2.4: + resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -3561,6 +4237,11 @@ packages: resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} engines: {node: '>=12'} + marked@15.0.12: + resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} + engines: {node: '>= 18'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -3667,10 +4348,22 @@ packages: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + mime-types@2.1.35: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + mimic-response@1.0.1: resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} engines: {node: '>=4'} @@ -3679,6 +4372,10 @@ packages: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} + minimatch@10.1.1: + resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} + engines: {node: 20 || >=22} + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -3749,6 +4446,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + negotiator@0.6.4: resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} engines: {node: '>= 0.6'} @@ -3791,6 +4492,28 @@ packages: sass: optional: true + next@16.0.9: + resolution: {integrity: sha512-Xk5x/wEk6ADIAtQECLo1uyE5OagbQCiZ+gW4XEv24FjQ3O2PdSkvgsn22aaseSXC7xg84oONvQjFbSTX5YsMhQ==} + engines: {node: '>=20.9.0'} + deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details. + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -3811,6 +4534,11 @@ packages: resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} engines: {node: '>=10'} + nypm@0.6.0: + resolution: {integrity: sha512-mn8wBFV9G9+UFHIrq+pZ2r2zL4aPau/by3kJb3cM7+5tQHMt6HGQB8FDIeKFYp8o0D2pnH6nVsO88N4AmUxIWg==} + engines: {node: ^14.16.0 || >=16.10.0} + hasBin: true + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -3846,10 +4574,18 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + ora@8.2.0: + resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} + engines: {node: '>=18'} + own-keys@1.0.1: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} @@ -3866,6 +4602,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + pako@0.2.9: resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} @@ -3879,6 +4618,9 @@ packages: parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parseley@0.12.1: + resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -3894,6 +4636,16 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.1: + resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} + engines: {node: 20 || >=22} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + peberminta@0.9.0: + resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} + pg-int8@1.0.1: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} engines: {node: '>=4.0.0'} @@ -3916,6 +4668,9 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + pkg-types@2.3.0: + resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + polished@4.3.1: resolution: {integrity: sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==} engines: {node: '>=10'} @@ -3975,10 +4730,18 @@ packages: peerDependencies: react: '>=16.0.0' + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + progress@2.0.3: resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} engines: {node: '>=0.4.0'} + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -3995,6 +4758,13 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.14.0: + resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + engines: {node: '>=0.6'} + + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -4019,6 +4789,11 @@ packages: peerDependencies: react: ^19.2.1 + react-email@5.0.8: + resolution: {integrity: sha512-JyhnOiFRfO1q1olkZ1lXawPUF01BSsi9Zg7SNpnxUnnlZHVxwVl7WV2U6QP/NPbIJx/VOSjGfNOeQWyqQIZJGA==} + engines: {node: '>=20.0.0'} + hasBin: true + react-hook-form@7.67.0: resolution: {integrity: sha512-E55EOwKJHHIT/I6J9DmQbCWToAYSw9nN5R57MZw9rMtjh+YQreMDxRLfdjfxQbiJ3/qbg3Z02wGzBX4M+5fMtQ==} engines: {node: '>=18.0.0'} @@ -4108,6 +4883,10 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + reflect.getprototypeof@1.0.10: resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} engines: {node: '>= 0.4'} @@ -4133,6 +4912,18 @@ packages: resolution: {integrity: sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==} engines: {node: '>=8.6.0'} + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + + resend@6.6.0: + resolution: {integrity: sha512-d1WoOqSxj5x76JtQMrieNAG1kZkh4NU4f+Je1yq4++JsDpLddhEwnJlNfvkCzvUuZy9ZquWmMMAm2mENd2JvRw==} + engines: {node: '>=20'} + peerDependencies: + '@react-email/render': '*' + peerDependenciesMeta: + '@react-email/render': + optional: true + resolve-alpn@1.2.1: resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} @@ -4159,6 +4950,10 @@ packages: responselike@2.0.1: resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -4206,6 +5001,9 @@ packages: seedrandom@3.0.5: resolution: {integrity: sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==} + selderee@0.11.0: + resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} + semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -4261,6 +5059,24 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + socket.io-adapter@2.5.5: + resolution: {integrity: sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==} + + socket.io-parser@4.2.4: + resolution: {integrity: sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==} + engines: {node: '>=10.0.0'} + + socket.io@4.8.1: + resolution: {integrity: sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==} + engines: {node: '>=10.2.0'} + sonner@1.7.4: resolution: {integrity: sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==} peerDependencies: @@ -4288,10 +5104,26 @@ packages: resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==} engines: {node: '>=6'} + stdin-discarder@0.2.2: + resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} + engines: {node: '>=18'} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + string.prototype.codepointat@0.2.1: resolution: {integrity: sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==} @@ -4321,6 +5153,14 @@ packages: stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -4329,6 +5169,21 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + stripe@20.0.0: + resolution: {integrity: sha512-EaZeWpbJOCcDytdjKSwdrL5BxzbDGNueiCfHjHXlPdBQvLqoxl6AAivC35SPzTmVXJb5duXQlXFGS45H0+e6Gg==} + engines: {node: '>=16'} + peerDependencies: + '@types/node': '>=16' + peerDependenciesMeta: + '@types/node': + optional: true + + stubborn-fs@2.0.0: + resolution: {integrity: sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==} + + stubborn-utils@1.0.2: + resolution: {integrity: sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==} + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -4360,6 +5215,13 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svix@1.76.1: + resolution: {integrity: sha512-CRuDWBTgYfDnBLRaZdKp9VuoPcNUq9An14c/k+4YJ15Qc5Grvf66vp0jvTltd4t7OIRj+8lM1DAgvSgvf7hdLw==} + + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tailwind-merge@3.4.0: resolution: {integrity: sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==} @@ -4405,6 +5267,9 @@ packages: tiny-inflate@1.0.3: resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -4434,6 +5299,10 @@ packages: tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + tsconfig-paths@4.2.0: + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + engines: {node: '>=6'} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -4445,6 +5314,10 @@ packages: resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} engines: {node: '>=8'} + type-fest@5.3.1: + resolution: {integrity: sha512-VCn+LMHbd4t6sF3wfU/+HKT63C9OoyrSIf4b+vtWHpt2U7/4InZG467YDNMFMR70DdHjAdpPWmw2lzRdg0Xqqg==} + engines: {node: '>=20'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -4476,10 +5349,17 @@ packages: engines: {node: '>=14.17'} hasBin: true + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -4523,6 +5403,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -4562,6 +5445,10 @@ packages: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vfile-message@4.0.3: resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} @@ -4598,6 +5485,9 @@ packages: whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + when-exit@2.1.5: + resolution: {integrity: sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -4623,9 +5513,29 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.17.1: + resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + ws@8.18.3: resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} engines: {node: '>=10.0.0'} @@ -4649,6 +5559,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + yoga-wasm-web@0.3.3: resolution: {integrity: sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA==} @@ -4906,6 +5820,84 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + '@eslint-community/eslint-utils@4.9.0(eslint@9.39.1(jiti@2.6.1))': dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -5121,6 +6113,21 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true + '@isaacs/balanced-match@4.0.1': {} + + '@isaacs/brace-expansion@5.0.0': + dependencies: + '@isaacs/balanced-match': 4.0.1 + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.2 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -5177,6 +6184,8 @@ snapshots: '@next/env@16.0.7': {} + '@next/env@16.0.9': {} + '@next/eslint-plugin-next@16.0.7': dependencies: fast-glob: 3.3.1 @@ -5184,27 +6193,51 @@ snapshots: '@next/swc-darwin-arm64@16.0.7': optional: true + '@next/swc-darwin-arm64@16.0.9': + optional: true + '@next/swc-darwin-x64@16.0.7': optional: true + '@next/swc-darwin-x64@16.0.9': + optional: true + '@next/swc-linux-arm64-gnu@16.0.7': optional: true + '@next/swc-linux-arm64-gnu@16.0.9': + optional: true + '@next/swc-linux-arm64-musl@16.0.7': optional: true + '@next/swc-linux-arm64-musl@16.0.9': + optional: true + '@next/swc-linux-x64-gnu@16.0.7': optional: true + '@next/swc-linux-x64-gnu@16.0.9': + optional: true + '@next/swc-linux-x64-musl@16.0.7': optional: true + '@next/swc-linux-x64-musl@16.0.9': + optional: true + '@next/swc-win32-arm64-msvc@16.0.7': optional: true + '@next/swc-win32-arm64-msvc@16.0.9': + optional: true + '@next/swc-win32-x64-msvc@16.0.7': optional: true + '@next/swc-win32-x64-msvc@16.0.9': + optional: true + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -5584,6 +6617,22 @@ snapshots: '@types/react': 19.2.6 '@types/react-dom': 19.2.3(@types/react@19.2.6) + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.6)(react@19.2.1) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.6)(react@19.2.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.6)(react@19.2.1) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.6)(react@19.2.1) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.6)(react@19.2.1) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + optionalDependencies: + '@types/react': 19.2.6 + '@types/react-dom': 19.2.3(@types/react@19.2.6) + '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -6180,33 +7229,170 @@ snapshots: '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.6)(react@19.2.1)': dependencies: react: 19.2.1 - optionalDependencies: - '@types/react': 19.2.6 + optionalDependencies: + '@types/react': 19.2.6 + + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.6)(react@19.2.1)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 19.2.1 + optionalDependencies: + '@types/react': 19.2.6 + + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.6)(react@19.2.1)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.6)(react@19.2.1) + react: 19.2.1 + optionalDependencies: + '@types/react': 19.2.6 + + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + optionalDependencies: + '@types/react': 19.2.6 + '@types/react-dom': 19.2.3(@types/react@19.2.6) + + '@radix-ui/rect@1.1.1': {} + + '@react-email/body@0.2.0(react@19.2.1)': + dependencies: + react: 19.2.1 + + '@react-email/button@0.2.0(react@19.2.1)': + dependencies: + react: 19.2.1 + + '@react-email/code-block@0.2.0(react@19.2.1)': + dependencies: + prismjs: 1.30.0 + react: 19.2.1 + + '@react-email/code-inline@0.0.5(react@19.2.1)': + dependencies: + react: 19.2.1 + + '@react-email/column@0.0.13(react@19.2.1)': + dependencies: + react: 19.2.1 + + '@react-email/components@1.0.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + dependencies: + '@react-email/body': 0.2.0(react@19.2.1) + '@react-email/button': 0.2.0(react@19.2.1) + '@react-email/code-block': 0.2.0(react@19.2.1) + '@react-email/code-inline': 0.0.5(react@19.2.1) + '@react-email/column': 0.0.13(react@19.2.1) + '@react-email/container': 0.0.15(react@19.2.1) + '@react-email/font': 0.0.9(react@19.2.1) + '@react-email/head': 0.0.12(react@19.2.1) + '@react-email/heading': 0.0.15(react@19.2.1) + '@react-email/hr': 0.0.11(react@19.2.1) + '@react-email/html': 0.0.11(react@19.2.1) + '@react-email/img': 0.0.11(react@19.2.1) + '@react-email/link': 0.0.12(react@19.2.1) + '@react-email/markdown': 0.0.17(react@19.2.1) + '@react-email/preview': 0.0.13(react@19.2.1) + '@react-email/render': 2.0.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@react-email/row': 0.0.12(react@19.2.1) + '@react-email/section': 0.0.16(react@19.2.1) + '@react-email/tailwind': 2.0.1(@react-email/body@0.2.0(react@19.2.1))(@react-email/button@0.2.0(react@19.2.1))(@react-email/code-block@0.2.0(react@19.2.1))(@react-email/code-inline@0.0.5(react@19.2.1))(@react-email/container@0.0.15(react@19.2.1))(@react-email/heading@0.0.15(react@19.2.1))(@react-email/hr@0.0.11(react@19.2.1))(@react-email/img@0.0.11(react@19.2.1))(@react-email/link@0.0.12(react@19.2.1))(@react-email/preview@0.0.13(react@19.2.1))(@react-email/text@0.1.5(react@19.2.1))(react@19.2.1) + '@react-email/text': 0.1.5(react@19.2.1) + react: 19.2.1 + transitivePeerDependencies: + - react-dom + + '@react-email/container@0.0.15(react@19.2.1)': + dependencies: + react: 19.2.1 + + '@react-email/font@0.0.9(react@19.2.1)': + dependencies: + react: 19.2.1 + + '@react-email/head@0.0.12(react@19.2.1)': + dependencies: + react: 19.2.1 + + '@react-email/heading@0.0.15(react@19.2.1)': + dependencies: + react: 19.2.1 + + '@react-email/hr@0.0.11(react@19.2.1)': + dependencies: + react: 19.2.1 + + '@react-email/html@0.0.11(react@19.2.1)': + dependencies: + react: 19.2.1 + + '@react-email/img@0.0.11(react@19.2.1)': + dependencies: + react: 19.2.1 + + '@react-email/link@0.0.12(react@19.2.1)': + dependencies: + react: 19.2.1 + + '@react-email/markdown@0.0.17(react@19.2.1)': + dependencies: + marked: 15.0.12 + react: 19.2.1 + + '@react-email/preview-server@5.0.8(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + dependencies: + next: 16.0.9(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + transitivePeerDependencies: + - '@babel/core' + - '@opentelemetry/api' + - '@playwright/test' + - babel-plugin-macros + - babel-plugin-react-compiler + - react + - react-dom + - sass + + '@react-email/preview@0.0.13(react@19.2.1)': + dependencies: + react: 19.2.1 + + '@react-email/render@2.0.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + dependencies: + html-to-text: 9.0.5 + prettier: 3.6.2 + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) - '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.6)(react@19.2.1)': + '@react-email/row@0.0.12(react@19.2.1)': dependencies: - '@radix-ui/rect': 1.1.1 react: 19.2.1 - optionalDependencies: - '@types/react': 19.2.6 - '@radix-ui/react-use-size@1.1.1(@types/react@19.2.6)(react@19.2.1)': + '@react-email/section@0.0.16(react@19.2.1)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.6)(react@19.2.1) react: 19.2.1 - optionalDependencies: - '@types/react': 19.2.6 - '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@react-email/tailwind@2.0.1(@react-email/body@0.2.0(react@19.2.1))(@react-email/button@0.2.0(react@19.2.1))(@react-email/code-block@0.2.0(react@19.2.1))(@react-email/code-inline@0.0.5(react@19.2.1))(@react-email/container@0.0.15(react@19.2.1))(@react-email/heading@0.0.15(react@19.2.1))(@react-email/hr@0.0.11(react@19.2.1))(@react-email/img@0.0.11(react@19.2.1))(@react-email/link@0.0.12(react@19.2.1))(@react-email/preview@0.0.13(react@19.2.1))(@react-email/text@0.1.5(react@19.2.1))(react@19.2.1)': dependencies: - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@react-email/text': 0.1.5(react@19.2.1) react: 19.2.1 - react-dom: 19.2.1(react@19.2.1) + tailwindcss: 4.1.17 optionalDependencies: - '@types/react': 19.2.6 - '@types/react-dom': 19.2.3(@types/react@19.2.6) - - '@radix-ui/rect@1.1.1': {} + '@react-email/body': 0.2.0(react@19.2.1) + '@react-email/button': 0.2.0(react@19.2.1) + '@react-email/code-block': 0.2.0(react@19.2.1) + '@react-email/code-inline': 0.0.5(react@19.2.1) + '@react-email/container': 0.0.15(react@19.2.1) + '@react-email/heading': 0.0.15(react@19.2.1) + '@react-email/hr': 0.0.11(react@19.2.1) + '@react-email/img': 0.0.11(react@19.2.1) + '@react-email/link': 0.0.12(react@19.2.1) + '@react-email/preview': 0.0.13(react@19.2.1) + + '@react-email/text@0.1.5(react@19.2.1)': + dependencies: + react: 19.2.1 '@resvg/resvg-wasm@2.6.0': {} @@ -6232,6 +7418,11 @@ snapshots: '@rtsao/scc@1.1.0': {} + '@selderee/plugin-htmlparser2@0.11.0': + dependencies: + domhandler: 5.0.3 + selderee: 0.11.0 + '@sentry-internal/browser-utils@8.55.0': dependencies: '@sentry/core': 8.55.0 @@ -6422,10 +7613,14 @@ snapshots: '@sindresorhus/is@4.6.0': {} + '@socket.io/component-emitter@3.1.2': {} + '@stablelib/base64@1.0.1': {} '@standard-schema/utils@0.3.0': {} + '@stripe/stripe-js@8.5.3': {} + '@supabase/auth-js@2.84.0': dependencies: tslib: 2.8.1 @@ -6476,6 +7671,18 @@ snapshots: dependencies: defer-to-connect: 2.0.1 + '@t3-oss/env-core@0.13.8(typescript@5.9.3)(zod@4.1.13)': + optionalDependencies: + typescript: 5.9.3 + zod: 4.1.13 + + '@t3-oss/env-nextjs@0.13.8(typescript@5.9.3)(zod@4.1.13)': + dependencies: + '@t3-oss/env-core': 0.13.8(typescript@5.9.3)(zod@4.1.13) + optionalDependencies: + typescript: 5.9.3 + zod: 4.1.13 + '@tailwindcss/node@4.1.17': dependencies: '@jridgewell/remapping': 2.3.5 @@ -6568,6 +7775,10 @@ snapshots: dependencies: '@types/node': 24.10.1 + '@types/cors@2.8.19': + dependencies: + '@types/node': 24.10.1 + '@types/debug@4.1.12': dependencies: '@types/ms': 2.1.0 @@ -6622,6 +7833,10 @@ snapshots: dependencies: '@types/node': 24.10.1 + '@types/node@22.19.3': + dependencies: + undici-types: 6.21.0 + '@types/node@24.10.1': dependencies: undici-types: 7.16.0 @@ -7019,6 +8234,11 @@ snapshots: - bufferutil - utf-8-validate + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + acorn-import-attributes@1.9.5(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -7043,6 +8263,10 @@ snapshots: optionalDependencies: ajv: 8.17.1 + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + ajv-keywords@5.1.0(ajv@8.17.1): dependencies: ajv: 8.17.1 @@ -7062,10 +8286,16 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 + ansi-styles@6.2.3: {} + anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -7160,6 +8390,11 @@ snapshots: async-function@1.0.0: {} + atomically@2.1.0: + dependencies: + stubborn-fs: 2.0.0 + when-exit: 2.1.5 + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 @@ -7176,6 +8411,8 @@ snapshots: base64-js@1.0.2: {} + base64id@2.0.0: {} + baseline-browser-mapping@2.8.30: {} binary-extensions@2.3.0: {} @@ -7255,6 +8492,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chalk@5.6.2: {} + character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -7275,14 +8514,28 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + chrome-trace-event@1.0.4: {} + citty@0.1.6: + dependencies: + consola: 3.4.2 + cjs-module-lexer@1.4.3: {} class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.9.2: {} + client-only@0.0.1: {} clone-response@1.0.3: @@ -7307,18 +8560,43 @@ snapshots: comma-separated-tokens@2.0.3: {} + commander@13.1.0: {} + commander@2.20.3: {} commondir@1.0.1: {} concat-map@0.0.1: {} + conf@15.0.2: + dependencies: + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + atomically: 2.1.0 + debounce-fn: 6.0.0 + dot-prop: 10.1.0 + env-paths: 3.0.0 + json-schema-typed: 8.0.2 + semver: 7.7.3 + uint8array-extras: 1.5.0 + + confbox@0.2.2: {} + + consola@3.4.2: {} + convert-source-map@2.0.0: {} + cookie@0.7.2: {} + cookie@1.0.2: {} core-js@3.47.0: {} + cors@2.8.5: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -7361,10 +8639,20 @@ snapshots: dayjs@1.11.19: {} + debounce-fn@6.0.0: + dependencies: + mimic-function: 5.0.1 + + debounce@2.2.0: {} + debug@3.2.7: dependencies: ms: 2.1.3 + debug@4.3.7: + dependencies: + ms: 2.1.3 + debug@4.4.3: dependencies: ms: 2.1.3 @@ -7379,6 +8667,8 @@ snapshots: deep-is@0.1.4: {} + deepmerge@4.3.1: {} + defer-to-connect@2.0.1: {} define-data-property@1.1.4: @@ -7411,6 +8701,28 @@ snapshots: dependencies: esutils: 2.0.3 + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dot-prop@10.1.0: + dependencies: + type-fest: 5.3.1 + dotenv@16.6.1: {} dunder-proto@1.0.1: @@ -7419,10 +8731,14 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + eastasianwidth@0.2.0: {} + electron-to-chromium@1.5.259: {} emoji-regex@10.6.0: {} + emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} encoding@0.1.13: @@ -7433,11 +8749,33 @@ snapshots: dependencies: once: 1.4.0 + engine.io-parser@5.2.3: {} + + engine.io@6.6.4: + dependencies: + '@types/cors': 2.8.19 + '@types/node': 24.10.1 + accepts: 1.3.8 + base64id: 2.0.0 + cookie: 0.7.2 + cors: 2.8.5 + debug: 4.3.7 + engine.io-parser: 5.2.3 + ws: 8.17.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + enhanced-resolve@5.18.3: dependencies: graceful-fs: 4.2.11 tapable: 2.3.0 + entities@4.5.0: {} + + env-paths@3.0.0: {} + es-abstract@1.24.0: dependencies: array-buffer-byte-length: 1.0.2 @@ -7541,6 +8879,37 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 + es6-promise@4.2.8: {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -7767,6 +9136,8 @@ snapshots: events@3.3.0: {} + exsolve@1.0.8: {} + extend@3.0.2: {} fast-deep-equal@3.1.3: {} @@ -7835,6 +9206,11 @@ snapshots: dependencies: is-callable: 1.2.7 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + forwarded-parse@2.1.2: {} framer-motion@12.23.24(react-dom@19.2.1(react@19.2.1))(react@19.2.1): @@ -7868,6 +9244,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-east-asian-width@1.4.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -7914,6 +9292,15 @@ snapshots: glob-to-regexp@0.4.1: {} + glob@11.1.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.1.1 + minimatch: 10.1.1 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.1 + glob@9.3.5: dependencies: fs.realpath: 1.0.0 @@ -8012,8 +9399,23 @@ snapshots: html-to-image@1.11.13: {} + html-to-text@9.0.5: + dependencies: + '@selderee/plugin-htmlparser2': 0.11.0 + deepmerge: 4.3.1 + dom-serializer: 2.0.0 + htmlparser2: 8.0.2 + selderee: 0.11.0 + html-url-attributes@3.0.1: {} + htmlparser2@8.0.2: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 4.5.0 + http-cache-semantics@4.2.0: {} http2-wrapper@1.0.3: @@ -8128,6 +9530,8 @@ snapshots: dependencies: call-bound: 1.0.4 + is-fullwidth-code-point@3.0.0: {} + is-generator-function@1.1.2: dependencies: call-bound: 1.0.4 @@ -8142,6 +9546,8 @@ snapshots: is-hexadecimal@2.0.1: {} + is-interactive@2.0.0: {} + is-map@2.0.3: {} is-negative-zero@2.0.3: {} @@ -8187,6 +9593,10 @@ snapshots: dependencies: which-typed-array: 1.1.19 + is-unicode-supported@1.3.0: {} + + is-unicode-supported@2.1.0: {} + is-weakmap@2.0.2: {} is-weakref@1.1.1: @@ -8211,12 +9621,18 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 + jackspeak@4.1.1: + dependencies: + '@isaacs/cliui': 8.0.2 + jest-worker@27.5.1: dependencies: '@types/node': 24.10.1 merge-stream: 2.0.0 supports-color: 8.1.1 + jiti@2.4.2: {} + jiti@2.6.1: {} js-tokens@4.0.0: {} @@ -8235,6 +9651,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema-typed@8.0.2: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@1.0.2: @@ -8254,12 +9672,16 @@ snapshots: dependencies: json-buffer: 3.0.1 + kleur@3.0.3: {} + language-subtag-registry@0.3.23: {} language-tags@1.0.9: dependencies: language-subtag-registry: 0.3.23 + leac@0.6.0: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -8327,6 +9749,16 @@ snapshots: lodash.merge@4.6.2: {} + log-symbols@6.0.0: + dependencies: + chalk: 5.6.2 + is-unicode-supported: 1.3.0 + + log-symbols@7.0.1: + dependencies: + is-unicode-supported: 2.1.0 + yoctocolors: 2.1.2 + longest-streak@3.1.0: {} loose-envify@1.4.0: @@ -8337,6 +9769,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.2.4: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -8349,6 +9783,8 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + marked@15.0.12: {} + math-intrinsics@1.1.0: {} mdast-util-from-markdown@2.0.2: @@ -8584,14 +10020,26 @@ snapshots: mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: dependencies: mime-db: 1.52.0 + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mimic-function@5.0.1: {} + mimic-response@1.0.1: {} mimic-response@3.1.0: {} + minimatch@10.1.1: + dependencies: + '@isaacs/brace-expansion': 5.0.0 + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -8641,6 +10089,8 @@ snapshots: natural-compare@1.4.0: {} + negotiator@0.6.3: {} + negotiator@0.6.4: {} neo-async@2.6.2: {} @@ -8683,6 +10133,30 @@ snapshots: - '@babel/core' - babel-plugin-macros + next@16.0.9(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + dependencies: + '@next/env': 16.0.9 + '@swc/helpers': 0.5.15 + caniuse-lite: 1.0.30001756 + postcss: 8.4.31 + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.1) + optionalDependencies: + '@next/swc-darwin-arm64': 16.0.9 + '@next/swc-darwin-x64': 16.0.9 + '@next/swc-linux-arm64-gnu': 16.0.9 + '@next/swc-linux-arm64-musl': 16.0.9 + '@next/swc-linux-x64-gnu': 16.0.9 + '@next/swc-linux-x64-musl': 16.0.9 + '@next/swc-win32-arm64-msvc': 16.0.9 + '@next/swc-win32-x64-msvc': 16.0.9 + '@opentelemetry/api': 1.9.0 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + node-fetch@2.7.0(encoding@0.1.13): dependencies: whatwg-url: 5.0.0 @@ -8695,6 +10169,14 @@ snapshots: normalize-url@6.1.0: {} + nypm@0.6.0: + dependencies: + citty: 0.1.6 + consola: 3.4.2 + pathe: 2.0.3 + pkg-types: 2.3.0 + tinyexec: 0.3.2 + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -8741,6 +10223,10 @@ snapshots: dependencies: wrappy: 1.0.2 + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -8750,6 +10236,18 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + ora@8.2.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 2.9.2 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 6.0.0 + stdin-discarder: 0.2.2 + string-width: 7.2.0 + strip-ansi: 7.1.2 + own-keys@1.0.1: dependencies: get-intrinsic: 1.3.0 @@ -8766,6 +10264,8 @@ snapshots: dependencies: p-limit: 3.1.0 + package-json-from-dist@1.0.1: {} + pako@0.2.9: {} parent-module@1.0.1: @@ -8787,6 +10287,11 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 + parseley@0.12.1: + dependencies: + leac: 0.6.0 + peberminta: 0.9.0 + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -8798,6 +10303,15 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 + path-scurry@2.0.1: + dependencies: + lru-cache: 11.2.4 + minipass: 7.1.2 + + pathe@2.0.3: {} + + peberminta@0.9.0: {} + pg-int8@1.0.1: {} pg-protocol@1.10.3: {} @@ -8816,6 +10330,12 @@ snapshots: picomatch@4.0.3: {} + pkg-types@2.3.0: + dependencies: + confbox: 0.2.2 + exsolve: 1.0.8 + pathe: 2.0.3 + polished@4.3.1: dependencies: '@babel/runtime': 7.28.4 @@ -8870,8 +10390,15 @@ snapshots: clsx: 2.1.1 react: 19.2.1 + prismjs@1.30.0: {} + progress@2.0.3: {} + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -8889,6 +10416,12 @@ snapshots: punycode@2.3.1: {} + qs@6.14.0: + dependencies: + side-channel: 1.1.0 + + querystringify@2.2.0: {} + queue-microtask@1.2.3: {} quick-lru@5.1.1: {} @@ -8911,6 +10444,30 @@ snapshots: react: 19.2.1 scheduler: 0.27.0 + react-email@5.0.8: + dependencies: + '@babel/parser': 7.28.5 + '@babel/traverse': 7.28.5 + chokidar: 4.0.3 + commander: 13.1.0 + conf: 15.0.2 + debounce: 2.2.0 + esbuild: 0.25.12 + glob: 11.1.0 + jiti: 2.4.2 + log-symbols: 7.0.1 + mime-types: 3.0.2 + normalize-path: 3.0.0 + nypm: 0.6.0 + ora: 8.2.0 + prompts: 2.4.2 + socket.io: 4.8.1 + tsconfig-paths: 4.2.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + react-hook-form@7.67.0(react@19.2.1): dependencies: react: 19.2.1 @@ -8999,6 +10556,8 @@ snapshots: dependencies: picomatch: 2.3.1 + readdirp@4.1.2: {} + reflect.getprototypeof@1.0.10: dependencies: call-bind: 1.0.8 @@ -9048,6 +10607,14 @@ snapshots: transitivePeerDependencies: - supports-color + requires-port@1.0.0: {} + + resend@6.6.0(@react-email/render@2.0.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1)): + dependencies: + svix: 1.76.1 + optionalDependencies: + '@react-email/render': 2.0.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + resolve-alpn@1.2.1: {} resolve-from@4.0.0: {} @@ -9076,6 +10643,11 @@ snapshots: dependencies: lowercase-keys: 2.0.0 + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + reusify@1.1.0: {} rollup@3.29.5: @@ -9135,6 +10707,10 @@ snapshots: seedrandom@3.0.5: {} + selderee@0.11.0: + dependencies: + parseley: 0.12.1 + semver@6.3.1: {} semver@7.7.3: {} @@ -9233,6 +10809,40 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + signal-exit@4.1.0: {} + + sisteransi@1.0.5: {} + + socket.io-adapter@2.5.5: + dependencies: + debug: 4.3.7 + ws: 8.17.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-parser@4.2.4: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.3.7 + transitivePeerDependencies: + - supports-color + + socket.io@4.8.1: + dependencies: + accepts: 1.3.8 + base64id: 2.0.0 + cors: 2.8.5 + debug: 4.3.7 + engine.io: 6.6.4 + socket.io-adapter: 2.5.5 + socket.io-parser: 4.2.4 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + sonner@1.7.4(react-dom@19.2.1(react@19.2.1))(react@19.2.1): dependencies: react: 19.2.1 @@ -9255,11 +10865,31 @@ snapshots: dependencies: type-fest: 0.7.1 + stdin-discarder@0.2.2: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 internal-slot: 1.1.0 + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.2 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.4.0 + strip-ansi: 7.1.2 + string.prototype.codepointat@0.2.1: {} string.prototype.includes@2.0.1: @@ -9317,10 +10947,30 @@ snapshots: character-entities-html4: 2.1.0 character-entities-legacy: 3.0.0 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + strip-bom@3.0.0: {} strip-json-comments@3.1.1: {} + stripe@20.0.0(@types/node@24.10.1): + dependencies: + qs: 6.14.0 + optionalDependencies: + '@types/node': 24.10.1 + + stubborn-fs@2.0.0: + dependencies: + stubborn-utils: 1.0.2 + + stubborn-utils@1.0.2: {} + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -9346,6 +10996,17 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svix@1.76.1: + dependencies: + '@stablelib/base64': 1.0.1 + '@types/node': 22.19.3 + es6-promise: 4.2.8 + fast-sha256: 1.3.0 + url-parse: 1.5.10 + uuid: 10.0.0 + + tagged-tag@1.0.0: {} + tailwind-merge@3.4.0: {} tailwind-scrollbar@4.0.2(react@19.2.1)(tailwindcss@4.1.17): @@ -9381,6 +11042,8 @@ snapshots: tiny-inflate@1.0.3: {} + tinyexec@0.3.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) @@ -9409,6 +11072,12 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 + tsconfig-paths@4.2.0: + dependencies: + json5: 2.2.3 + minimist: 1.2.8 + strip-bom: 3.0.0 + tslib@2.8.1: {} type-check@0.4.0: @@ -9417,6 +11086,10 @@ snapshots: type-fest@0.7.1: {} + type-fest@5.3.1: + dependencies: + tagged-tag: 1.0.0 + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -9467,6 +11140,8 @@ snapshots: typescript@5.9.3: {} + uint8array-extras@1.5.0: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -9474,6 +11149,8 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + undici-types@6.21.0: {} + undici-types@7.16.0: {} undici@5.29.0: @@ -9559,6 +11236,11 @@ snapshots: dependencies: punycode: 2.3.1 + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + use-callback-ref@1.3.3(@types/react@19.2.6)(react@19.2.1): dependencies: react: 19.2.1 @@ -9588,6 +11270,8 @@ snapshots: uuid@9.0.1: {} + vary@1.1.2: {} + vfile-message@4.0.3: dependencies: '@types/unist': 3.0.3 @@ -9648,6 +11332,8 @@ snapshots: tr46: 0.0.3 webidl-conversions: 3.0.1 + when-exit@2.1.5: {} + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -9695,8 +11381,22 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.1.2 + wrappy@1.0.2: {} + ws@8.17.1: {} + ws@8.18.3: {} xtend@4.0.2: {} @@ -9705,6 +11405,8 @@ snapshots: yocto-queue@0.1.0: {} + yoctocolors@2.1.2: {} + yoga-wasm-web@0.3.3: {} zod-validation-error@4.0.2(zod@4.1.13): diff --git a/scripts/check-env.mjs b/scripts/check-env.mjs new file mode 100644 index 0000000..5737de2 --- /dev/null +++ b/scripts/check-env.mjs @@ -0,0 +1,47 @@ +// Try to load .env.local and friends before running validation. +// In build environments like Vercel, env vars are already in process.env, +// so @next/env might not be available or needed. +let envLoaded = false; +try { + const nextEnv = await import("@next/env"); + const loadEnvConfig = nextEnv.default?.loadEnvConfig || nextEnv.loadEnvConfig; + if (loadEnvConfig) { + const result = loadEnvConfig(process.cwd()); + envLoaded = true; + if (result?.loadedEnvFiles?.length > 0) { + console.log(`πŸ“ Loaded ${result.loadedEnvFiles.length} env file(s):`, result.loadedEnvFiles.map(f => f.path).join(", ")); + } + } +} catch (error) { + // @next/env not available - this is fine in build environments + // where env vars are already loaded + if (process.env.VERCEL === "1" || process.env.NODE_ENV === "production") { + // Silently continue - env vars are already available in build environment + envLoaded = true; + } else { + console.warn("⚠️ Could not load @next/env:", error.message); + console.warn("πŸ’‘ Make sure you have a .env.local file with required variables, or set SKIP_ENV_VALIDATION=true"); + } +} + +// Skip validation if explicitly requested +if (process.env.SKIP_ENV_VALIDATION === "true") { + console.log("⏭️ Skipping environment variable validation (SKIP_ENV_VALIDATION=true)"); + process.exit(0); +} + +try { + await import("../src/env.mjs"); + console.log("βœ… Environment variables look good."); +} catch (error) { + if (error.message?.includes("Invalid environment variables")) { + console.error("\n❌ Environment validation failed!"); + console.error("\nπŸ’‘ To fix this:"); + console.error(" 1. Create a .env.local file in the project root"); + console.error(" 2. Add all required environment variables (see src/env.mjs for the list)"); + console.error(" 3. Or set SKIP_ENV_VALIDATION=true to skip validation\n"); + process.exit(1); + } + throw error; +} + diff --git a/scripts/validate-plan-limits.js b/scripts/validate-plan-limits.js new file mode 100644 index 0000000..d68efb8 --- /dev/null +++ b/scripts/validate-plan-limits.js @@ -0,0 +1,296 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); + +const projectRoot = path.join(__dirname, '..'); +const plansPath = path.join(projectRoot, 'src/lib/config/plans.ts'); +const migrationsDir = path.join(projectRoot, 'supabase', 'migrations'); + +/** + * Extract plan IDs from the canonical source: src/lib/config/plans.ts + * This reads the planOrder export to automatically stay in sync with plan additions/removals. + * Falls back to parsing the PlanId type if planOrder is not found. + */ +function extractPlanIdsFromSource() { + const content = fs.readFileSync(plansPath, 'utf8'); + + // Try to extract from planOrder array (preferred) + const planOrderMatch = content.match(/export\s+const\s+planOrder:\s*PlanId\[\]\s*=\s*\[(.*?)\]/s); + if (planOrderMatch) { + const planOrderContent = planOrderMatch[1]; + const plans = planOrderContent.match(/'([^']+)'/g) || []; + if (plans.length > 0) { + return plans.map(p => p.replace(/'/g, '')); + } + } + + // Fallback: extract from PlanId type definition + const planIdMatch = content.match(/export\s+type\s+PlanId\s*=\s*([^;]+);/); + if (planIdMatch) { + const typeContent = planIdMatch[1]; + const plans = typeContent.match(/'([^']+)'/g) || []; + if (plans.length > 0) { + return plans.map(p => p.replace(/'/g, '')); + } + } + + throw new Error( + `Could not extract plan IDs from ${plansPath}. ` + + `Expected to find either 'export const planOrder' or 'export type PlanId'.` + ); +} + +const planIds = extractPlanIdsFromSource(); + +// Set USE_AST_EXTRACTION=true to use AST-based extraction instead of regex +const USE_AST_EXTRACTION = process.env.USE_AST_EXTRACTION === 'true'; + +function readFile(filePath) { + return fs.readFileSync(filePath, 'utf8'); +} + +function normalizeLimit(value) { + if (value === undefined || value === null) return null; + if (typeof value === 'number') { + return Number.isFinite(value) ? value : null; + } + + const trimmed = value.toString().trim(); + if (trimmed.toLowerCase() === 'infinity' || trimmed.toLowerCase() === 'null') { + return null; + } + + const numeric = Number(trimmed.replace(/[^0-9.-]/g, '')); + return Number.isNaN(numeric) ? null : numeric; +} + +/** + * AST-based extraction using TypeScript compiler API (more robust than regex) + * Set USE_AST_EXTRACTION=true to use this method + */ +function extractShareLimitsFromTsAST() { + try { + const ts = require('typescript'); + const content = readFile(plansPath); + const sourceFile = ts.createSourceFile( + plansPath, + content, + ts.ScriptTarget.Latest, + true + ); + + const foundPlans = {}; + const foundPlanIds = []; + + function visit(node) { + // Look for object literal expressions (plan definitions) + if (ts.isPropertyAssignment(node)) { + const propertyName = node.name.text; + if (planIds.includes(propertyName) && ts.isObjectLiteralExpression(node.initializer)) { + // Find shareAsPublicURL property within this plan object + const planObject = node.initializer; + for (const prop of planObject.properties) { + if ( + ts.isPropertyAssignment(prop) && + prop.name.text === 'shareAsPublicURL' + ) { + const value = prop.initializer; + let limitValue = null; + + if (ts.isNumericLiteral(value)) { + limitValue = Number(value.text); + } else if (ts.isIdentifier(value) && value.text === 'Infinity') { + limitValue = Infinity; // Pass Infinity to normalizeLimit, which will convert it to null + } + + foundPlans[propertyName] = normalizeLimit(limitValue); + foundPlanIds.push(propertyName); + break; + } + } + } + } + + ts.forEachChild(node, visit); + } + + visit(sourceFile); + + // Validate that all expected plans were found + const missingPlans = planIds.filter((planId) => !foundPlanIds.includes(planId)); + if (foundPlanIds.length !== planIds.length || missingPlans.length > 0) { + const foundCount = foundPlanIds.length; + const expectedCount = planIds.length; + const missingList = missingPlans.join(', '); + throw new Error( + `Only found ${foundCount}/${expectedCount} plans in ${plansPath}. ` + + `Missing plans: ${missingList}. ` + + `Found plans: ${foundPlanIds.join(', ')}` + ); + } + + return foundPlans; + } catch (error) { + if (error.code === 'MODULE_NOT_FOUND' && error.message.includes('typescript')) { + throw new Error( + 'AST extraction requires TypeScript. Install it with: pnpm add -D typescript' + ); + } + throw error; + } +} + +/** + * Regex-based extraction (faster but more fragile) + * Improved to accumulate all found plans before validation + */ +function extractShareLimitsFromTsRegex() { + const content = readFile(plansPath); + const foundPlans = {}; + const foundPlanIds = []; + + // First pass: extract all found plans + planIds.forEach((planId) => { + const planBlock = new RegExp( + `${planId}\\s*:\\s*{[\\s\\S]*?shareAsPublicURL:\\s*([^,\\n]+)`, + 'm' + ); + const match = content.match(planBlock); + if (match) { + foundPlans[planId] = normalizeLimit(match[1]); + foundPlanIds.push(planId); + } + }); + + // Validate that all expected plans were found + const missingPlans = planIds.filter((planId) => !foundPlanIds.includes(planId)); + if (foundPlanIds.length !== planIds.length || missingPlans.length > 0) { + const foundCount = foundPlanIds.length; + const expectedCount = planIds.length; + const missingList = missingPlans.join(', '); + throw new Error( + `Only found ${foundCount}/${expectedCount} plans in ${plansPath}. ` + + `Missing plans: ${missingList}. ` + + `Found plans: ${foundPlanIds.join(', ')}` + ); + } + + return foundPlans; +} + +function extractShareLimitsFromTs() { + return USE_AST_EXTRACTION + ? extractShareLimitsFromTsAST() + : extractShareLimitsFromTsRegex(); +} + +function findLatestMigrationContaining(keyword) { + const files = fs.readdirSync(migrationsDir).filter((file) => file.endsWith('.sql')); + const matching = files + .filter((file) => readFile(path.join(migrationsDir, file)).includes(keyword)) + .sort(); + + if (!matching.length) { + throw new Error(`No migration found containing "${keyword}" in ${migrationsDir}`); + } + + return path.join(migrationsDir, matching[matching.length - 1]); +} + +function getFunctionBody(sqlContent, functionName) { + const fnRegex = new RegExp( + `FUNCTION\\s+${functionName}\\s*\\([\\s\\S]*?\\$\\$(.*?)\\$\\$`, + 's' + ); + const match = sqlContent.match(fnRegex); + if (!match) { + throw new Error(`Function "${functionName}" not found in provided SQL content.`); + } + + return match[1]; +} + +function extractShareLimitsFromGetPlanLimits(sqlContent, migrationPath) { + const body = getFunctionBody(sqlContent, 'get_plan_limits'); + const migrationIdentifier = migrationPath + ? `migration ${path.basename(migrationPath)} (${migrationPath})` + : 'SQL input'; + + return planIds.reduce((acc, planId) => { + const planRegex = new RegExp( + `WHEN\\s+'${planId}'\\s+THEN\\s+'({[\\s\\S]*?})'::json`, + 'i' + ); + const planMatch = body.match(planRegex); + + if (!planMatch) { + throw new Error(`Plan "${planId}" branch missing in get_plan_limits.`); + } + + try { + const planJson = JSON.parse(planMatch[1]); + acc[planId] = normalizeLimit(planJson.shareAsPublicURL); + } catch (error) { + if (error instanceof SyntaxError) { + const jsonSnippet = planMatch[1].substring(0, 200); // First 200 chars of offending JSON + console.error( + `Failed to parse JSON for plan "${planId}" in ${migrationIdentifier}:` + ); + console.error(` Error: ${error.message}`); + console.error(` JSON snippet: ${jsonSnippet}${planMatch[1].length > 200 ? '...' : ''}`); + throw new Error( + `Malformed JSON for plan "${planId}" in ${migrationIdentifier}: ${error.message}` + ); + } + throw error; + } + return acc; + }, {}); +} + +function assertFunctionReferencesGetPlanLimits(sqlContent, functionName) { + const body = getFunctionBody(sqlContent, functionName); + if (!body.includes('get_plan_limits')) { + throw new Error( + `Function "${functionName}" should reference get_plan_limits to stay in sync with plan configuration.` + ); + } +} + +function main() { + const tsLimits = extractShareLimitsFromTs(); + + const planLimitsPath = findLatestMigrationContaining('FUNCTION get_plan_limits'); + const planLimitsSql = readFile(planLimitsPath); + const sqlLimits = extractShareLimitsFromGetPlanLimits(planLimitsSql, planLimitsPath); + + const mismatches = []; + planIds.forEach((planId) => { + if (tsLimits[planId] !== sqlLimits[planId]) { + mismatches.push( + `${planId}: TypeScript=${tsLimits[planId]} vs SQL=${sqlLimits[planId]} (migration ${path.basename( + planLimitsPath + )})` + ); + } + }); + + const checkLimitPath = findLatestMigrationContaining('FUNCTION check_public_share_limit'); + const checkLimitSql = readFile(checkLimitPath); + assertFunctionReferencesGetPlanLimits(checkLimitSql, 'check_public_share_limit'); + + const recordViewPath = findLatestMigrationContaining('FUNCTION record_public_share_view'); + const recordViewSql = readFile(recordViewPath); + assertFunctionReferencesGetPlanLimits(recordViewSql, 'record_public_share_view'); + + if (mismatches.length) { + console.error('Plan limit mismatch detected:'); + mismatches.forEach((line) => console.error(`- ${line}`)); + process.exit(1); + } + + console.log('Plan limits are in sync between plans.ts and SQL functions.'); +} + +main(); diff --git a/scripts/verify-indexes.sql b/scripts/verify-indexes.sql new file mode 100644 index 0000000..a4fbac7 --- /dev/null +++ b/scripts/verify-indexes.sql @@ -0,0 +1,136 @@ +-- Verification script for database indexes +-- Run this script to verify that indexes are being used in query plans +-- Usage: Connect to your Supabase database and run these queries + +-- ============================================================================ +-- CHECK EXISTING INDEXES +-- ============================================================================ +-- List all indexes on tables with foreign keys + +SELECT + schemaname, + tablename, + indexname, + indexdef +FROM pg_indexes +WHERE schemaname = 'public' + AND ( + tablename IN ('snippet', 'collection', 'animation', 'animation_collection', 'links', 'share_view_events', 'stripe_webhook_audit') + OR indexname LIKE '%user_id%' + OR indexname LIKE '%created_at%' + OR indexname LIKE '%snippet_id%' + OR indexname LIKE '%link_id%' + ) +ORDER BY tablename, indexname; + +-- ============================================================================ +-- VERIFY INDEX USAGE WITH EXPLAIN ANALYZE +-- ============================================================================ +-- Replace 'YOUR_USER_ID_HERE' with an actual user_id from your database +-- These queries should show "Index Scan" or "Bitmap Index Scan" in the plan + +-- 1. Snippet queries by user_id +EXPLAIN (ANALYZE, BUFFERS, VERBOSE) +SELECT * FROM public.snippet +WHERE user_id = 'YOUR_USER_ID_HERE' +ORDER BY created_at DESC +LIMIT 20; + +-- 2. Animation queries by user_id +EXPLAIN (ANALYZE, BUFFERS, VERBOSE) +SELECT * FROM public.animation +WHERE user_id = 'YOUR_USER_ID_HERE' +ORDER BY created_at DESC +LIMIT 20; + +-- 3. Collection queries by user_id +EXPLAIN (ANALYZE, BUFFERS, VERBOSE) +SELECT * FROM public.collection +WHERE user_id = 'YOUR_USER_ID_HERE' +ORDER BY created_at DESC +LIMIT 20; + +-- 4. Links lookup by short_url +EXPLAIN (ANALYZE, BUFFERS, VERBOSE) +SELECT * FROM public.links +WHERE short_url = 'YOUR_SHORT_URL_HERE'; + +-- 5. Links by snippet_id +EXPLAIN (ANALYZE, BUFFERS, VERBOSE) +SELECT * FROM public.links +WHERE snippet_id = 'YOUR_SNIPPET_ID_HERE'; + +-- 6. Share view events by owner +EXPLAIN (ANALYZE, BUFFERS, VERBOSE) +SELECT * FROM public.share_view_events +WHERE owner_id = 'YOUR_USER_ID_HERE' +ORDER BY viewed_on DESC +LIMIT 20; + +-- ============================================================================ +-- CHECK INDEX STATISTICS +-- ============================================================================ +-- View index usage statistics (requires pg_stat_statements extension) +-- This shows which indexes are actually being used + +SELECT + schemaname, + tablename, + indexname, + idx_scan as index_scans, + idx_tup_read as tuples_read, + idx_tup_fetch as tuples_fetched +FROM pg_stat_user_indexes +WHERE schemaname = 'public' + AND tablename IN ('snippet', 'collection', 'animation', 'animation_collection', 'links', 'share_view_events', 'stripe_webhook_audit') +ORDER BY idx_scan DESC; + +-- ============================================================================ +-- CHECK FOR MISSING INDEXES ON FOREIGN KEYS +-- ============================================================================ +-- This query identifies foreign key columns that don't have indexes + +SELECT + tc.table_schema, + tc.constraint_name, + tc.table_name, + kcu.column_name, + ccu.table_name AS foreign_table_name, + ccu.column_name AS foreign_column_name, + CASE + WHEN EXISTS ( + SELECT 1 + FROM pg_indexes + WHERE schemaname = tc.table_schema + AND tablename = tc.table_name + AND indexdef LIKE '%' || kcu.column_name || '%' + ) THEN 'HAS INDEX' + ELSE 'MISSING INDEX' + END AS index_status +FROM information_schema.table_constraints AS tc +JOIN information_schema.key_column_usage AS kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema +JOIN information_schema.constraint_column_usage AS ccu + ON ccu.constraint_name = tc.constraint_name + AND ccu.table_schema = tc.table_schema +WHERE tc.constraint_type = 'FOREIGN KEY' + AND tc.table_schema = 'public' +ORDER BY tc.table_name, kcu.column_name; + +-- ============================================================================ +-- CHECK TABLE SIZES AND INDEX SIZES +-- ============================================================================ +-- Monitor index sizes to ensure they're not growing too large + +SELECT + schemaname, + tablename, + pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS total_size, + pg_size_pretty(pg_relation_size(schemaname||'.'||tablename)) AS table_size, + pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename) - pg_relation_size(schemaname||'.'||tablename)) AS indexes_size +FROM pg_tables +WHERE schemaname = 'public' + AND tablename IN ('snippet', 'collection', 'animation', 'animation_collection', 'links', 'share_view_events', 'stripe_webhook_audit') +ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC; + diff --git a/scripts/verify-rpc-function.sql b/scripts/verify-rpc-function.sql new file mode 100644 index 0000000..09403e3 --- /dev/null +++ b/scripts/verify-rpc-function.sql @@ -0,0 +1,21 @@ +-- Verify that get_user_usage_v2 function exists +-- Run this in your Supabase SQL Editor to check if the function was created + +SELECT + p.proname as function_name, + pg_get_function_arguments(p.oid) as arguments, + pg_get_function_result(p.oid) as return_type, + p.proacl as permissions +FROM pg_proc p +JOIN pg_namespace n ON p.pronamespace = n.oid +WHERE n.nspname = 'public' + AND p.proname = 'get_user_usage_v2'; + +-- Also check grants +SELECT + grantee, + privilege_type +FROM information_schema.routine_privileges +WHERE routine_schema = 'public' + AND routine_name = 'get_user_usage_v2'; + diff --git a/src/actions/animations/create-animation.ts b/src/actions/animations/create-animation.ts index d1318dd..03cf577 100644 --- a/src/actions/animations/create-animation.ts +++ b/src/actions/animations/create-animation.ts @@ -2,85 +2,135 @@ import { revalidatePath } from 'next/cache' -import { requireAuth } from '@/actions/utils/auth' import { success, error, type ActionResult } from '@/actions/utils/action-result' import { insertAnimation } from '@/lib/services/database/animations' import type { Animation } from '@/features/animations/dtos' import type { AnimationSettings, AnimationSlide } from '@/types/animation' -import { - checkAnimationLimit, - checkSlideLimit, - incrementUsageCount, - type UsageLimitCheck, -} from '@/lib/services/usage-limits' +import { createAnimationInputSchema, formatZodError } from '@/actions/utils/validation' +import { withAuthAction } from '@/actions/utils/with-auth' +import type { UsageLimitCheck } from '@/lib/services/usage-limits' +import { getPlanConfig, getUpgradeTarget } from '@/lib/config/plans' export type CreateAnimationInput = { - id: string - title: string - slides: AnimationSlide[] - settings: AnimationSettings - url?: string | null -} - -export type CreateAnimationResult = { - animation: Animation - usage: UsageLimitCheck + id: string + title?: string + slides: AnimationSlide[] + settings: AnimationSettings + url?: string | null } export async function createAnimation( - input: CreateAnimationInput -): Promise> { - try { - const { id, title, slides, settings, url } = input - - if (!id || !Array.isArray(slides) || slides.length === 0) { - return error('Missing required fields: id and slides are required') - } - - if (slides.length < 2) { - return error('Add at least two slides to save an animation') - } - - const { user, supabase } = await requireAuth() - - const limit = await checkAnimationLimit(supabase, user.id) - if (!limit.canSave) { - const maxText = limit.max ?? 'unlimited' - return error(`You've reached the free plan limit (${limit.current}/${maxText} animations). Upgrade to Pro for unlimited animations!`) - } + input: CreateAnimationInput +): Promise> { + try { + const parsedInput = createAnimationInputSchema.safeParse(input) - const slideLimit = checkSlideLimit(slides.length, limit.plan) - if (!slideLimit.canSave) { - return error(`Free users can add up to ${slideLimit.max} slides per animation. Upgrade to Pro for unlimited slides!`) - } + if (!parsedInput.success) { + return error(formatZodError(parsedInput.error) ?? 'Invalid animation data') + } - const data = await insertAnimation({ - id, - user_id: user.id, - title: title || 'Untitled', - slides, - settings, - url: url || null, - supabase - }) - - if (!data || data.length === 0) { - return error('Failed to create animation') + const payload = parsedInput.data + + return withAuthAction(payload, async ({ id, title, slides, settings, url }, { user, supabase }) => { + const { data: limitCheckRaw, error: animationLimitError } = await supabase.rpc('check_animation_limit', { + target_user_id: user.id + }) + + if (animationLimitError) { + console.error('Error checking animation limit:', animationLimitError) + return error('Failed to verify save limit. Please try again.') + } + + // Guard against null RPC response + if (!limitCheckRaw || typeof limitCheckRaw !== 'object') { + console.error('Invalid RPC response from check_animation_limit:', limitCheckRaw) + return error('Failed to verify save limit. Please try again.') + } + + // Map RPC response (can be camelCase or snake_case) to camelCase UsageLimitCheck type + const rpcResponse = limitCheckRaw as { + can_save?: boolean + canSave?: boolean + current?: number | null + max?: number | null + plan?: string | null + over_limit?: number | null + overLimit?: number | null + } + + // Normalize plan value from database ("started" -> "starter") + const rawPlan = rpcResponse.plan ?? 'free'; + const normalizedPlan = rawPlan === 'started' ? 'starter' : rawPlan; + + const animationLimitCheck: UsageLimitCheck = { + canSave: Boolean(rpcResponse.canSave ?? rpcResponse.can_save ?? false), + current: rpcResponse.current ?? 0, + max: rpcResponse.max ?? null, + plan: (normalizedPlan as UsageLimitCheck['plan']) ?? 'free', + overLimit: rpcResponse.overLimit ?? rpcResponse.over_limit ?? undefined, + } + + if (!animationLimitCheck.canSave) { + const plan = animationLimitCheck.plan + const current = animationLimitCheck.current ?? 0 + const max = animationLimitCheck.max ?? 0 + const overLimit = animationLimitCheck.overLimit ?? Math.max(current - max, 0) + + if (plan === 'free') { + return error(`You have ${current} animations but the Free plan allows ${max}. Delete items or upgrade to save again. Over limit: ${overLimit}.`) + } else if (plan === 'starter') { + return error(`You\'ve reached your Starter limit (${current}/${max}). Upgrade to Pro for unlimited animations!`) } - - const usageResult = await incrementUsageCount(supabase, user.id, 'animations') - - revalidatePath('/animate') - revalidatePath('/animations') - - return success({ animation: data[0] as Animation, usage: usageResult }) - } catch (err) { - console.error('Error creating animation:', err) - - if (err instanceof Error && err.message.includes('authenticated')) { - return error('User must be authenticated') + return error('Animation limit reached. Please upgrade your plan.') + } + + // Check slide limit using plan config (slide limits are per-animation, not a database count) + const plan = animationLimitCheck.plan + const planConfig = getPlanConfig(plan) + const maxSlides = planConfig.maxSlidesPerAnimation === Infinity ? null : planConfig.maxSlidesPerAnimation + const canAdd = maxSlides === null || slides.length <= maxSlides + + if (!canAdd) { + // Format maxSlides for display (null becomes "unlimited") + const maxSlidesDisplay = maxSlides === null ? 'unlimited' : maxSlides.toString() + + // Get upgrade target and its maxSlides for upgrade messaging + const upgradeTarget = getUpgradeTarget(plan) + let upgradeMessage = '' + + if (upgradeTarget) { + const upgradeConfig = getPlanConfig(upgradeTarget) + const upgradeMaxSlides = upgradeConfig.maxSlidesPerAnimation === Infinity ? null : upgradeConfig.maxSlidesPerAnimation + const upgradeMaxSlidesDisplay = upgradeMaxSlides === null || upgradeMaxSlides === Infinity ? 'unlimited' : upgradeMaxSlides.toString() + const upgradePlanName = upgradeConfig.name + upgradeMessage = ` Upgrade to ${upgradePlanName} for ${upgradeMaxSlidesDisplay} slides per animation!` } - - return error('Failed to create animation. Please try again later.') - } + + return error(`${planConfig.name} users can add up to ${maxSlidesDisplay} slides.${upgradeMessage}`) + } + + const data = await insertAnimation({ + id, + user_id: user.id, + title: title || 'Untitled', + slides, + settings, + url: url || null, + supabase + }) + + if (!data || data.length === 0) { + return error('Failed to create animation') + } + + revalidatePath('/animate') + revalidatePath('/animations') + + return success(data[0] as Animation) + }) + } catch (err) { + console.error('Error creating animation:', err) + + return error('Failed to create animation. Please try again later.') + } } diff --git a/src/actions/animations/create-collection.ts b/src/actions/animations/create-collection.ts index b358f36..04abe17 100644 --- a/src/actions/animations/create-collection.ts +++ b/src/actions/animations/create-collection.ts @@ -2,47 +2,79 @@ import { revalidatePath } from 'next/cache' -import { requireAuth } from '@/actions/utils/auth' +import { requireAuth, AuthError } from '@/actions/utils/auth' import { success, error, type ActionResult } from '@/actions/utils/action-result' import { createAnimationCollection as createAnimationCollectionDb } from '@/lib/services/database/animations' import type { AnimationCollection, Animation } from '@/features/animations/dtos' +import type { PlanId } from '@/lib/config/plans' export type CreateAnimationCollectionInput = { - title: string - animations?: Animation[] + title: string + animations?: Animation[] } export async function createAnimationCollection( - input: CreateAnimationCollectionInput + input: CreateAnimationCollectionInput ): Promise> { - try { - const { title, animations } = input - const sanitizedTitle = title?.trim() || 'Untitled' + try { + const { title, animations } = input + const sanitizedTitle = title?.trim() || 'Untitled' - const { user, supabase } = await requireAuth() + const { user, supabase } = await requireAuth() - const data = await createAnimationCollectionDb({ - user_id: user.id, - title: sanitizedTitle, - animations: animations as any, - supabase - }) + const { data: folderLimit, error: folderLimitError } = await supabase.rpc('check_folder_limit', { + p_user_id: user.id + }) - if (!data || data.length === 0) { - return error('Failed to create collection') - } + if (folderLimitError) { + console.error('Error checking folder limit:', folderLimitError) + return error('Failed to verify folder limit. Please try again.') + } + + const canCreateFolder = Boolean( + folderLimit?.can_create ?? + folderLimit?.canCreate ?? + folderLimit?.can_save ?? + folderLimit?.canSave ?? + false + ) + + if (!canCreateFolder) { + const plan = (folderLimit?.plan as PlanId | undefined) ?? 'free' + if (plan === 'free') { + return error('Free plan does not include folders. Upgrade to Started to organize your animations.') + } + if (plan === 'starter') { + return error('You have reached your 10 folder limit. Upgrade to Pro for unlimited folders.') + } + return error('Folder limit reached. Please upgrade your plan.') + } - revalidatePath('/animations') - revalidatePath('/animate') + // Extract animation IDs from DTO Animation objects + const animationIds = animations?.map((anim) => anim.id) ?? [] - return success(data[0] as AnimationCollection) - } catch (err) { - console.error('Error creating animation collection:', err) + const data = await createAnimationCollectionDb({ + user_id: user.id, + title: sanitizedTitle, + animations: animationIds, + supabase + }) - if (err instanceof Error && err.message.includes('authenticated')) { - return error('User must be authenticated') - } + if (!data || data.length === 0) { + return error('Failed to create collection') + } + + revalidatePath('/animations') + revalidatePath('/animate') - return error('Failed to create collection. Please try again later.') + return success(data[0] as AnimationCollection) + } catch (err) { + console.error('Error creating animation collection:', err) + + if (err instanceof AuthError) { + return error('User must be authenticated') } + + return error('Failed to create collection. Please try again later.') + } } diff --git a/src/actions/animations/delete-animation.ts b/src/actions/animations/delete-animation.ts index 3834e0c..b2a33ee 100644 --- a/src/actions/animations/delete-animation.ts +++ b/src/actions/animations/delete-animation.ts @@ -5,7 +5,6 @@ import { revalidatePath } from 'next/cache' import { requireAuth } from '@/actions/utils/auth' import { success, error, type ActionResult } from '@/actions/utils/action-result' import { deleteAnimation as deleteAnimationDb } from '@/lib/services/database/animations' -import { decrementUsageCount } from '@/lib/services/usage-limits' export async function deleteAnimation( animation_id: string @@ -17,18 +16,12 @@ export async function deleteAnimation( const { user, supabase } = await requireAuth() - const { deletedCount } = await deleteAnimationDb({ + await deleteAnimationDb({ animation_id, user_id: user.id, supabase }) - if (deletedCount > 0) { - await decrementUsageCount(supabase, user.id, 'animations').catch((decrementError) => { - console.error('Failed to decrement animation usage', decrementError) - }) - } - revalidatePath('/animate') revalidatePath('/animations') diff --git a/src/actions/animations/delete-collection.ts b/src/actions/animations/delete-collection.ts index e29e0cb..3283c86 100644 --- a/src/actions/animations/delete-collection.ts +++ b/src/actions/animations/delete-collection.ts @@ -7,32 +7,32 @@ import { success, error, type ActionResult } from '@/actions/utils/action-result import { deleteAnimationCollection as deleteAnimationCollectionDb } from '@/lib/services/database/animations' export async function deleteAnimationCollection( - collection_id: string + collection_id: string ): Promise> { - try { - if (!collection_id) { - return error('Collection id is required') - } + try { + if (!collection_id) { + return error('Collection id is required') + } - const { user, supabase } = await requireAuth() + const { user, supabase } = await requireAuth() - await deleteAnimationCollectionDb({ - collection_id, - user_id: user.id, - supabase - }) + await deleteAnimationCollectionDb({ + collection_id, + user_id: user.id, + supabase + }) - revalidatePath('/animations') - revalidatePath('/animate') + revalidatePath('/animations') + revalidatePath('/animate') - return success(null) - } catch (err) { - console.error('Error deleting animation collection:', err) + return success(null) + } catch (err) { + console.error('Error deleting animation collection:', err) - if (err instanceof Error && err.message.includes('authenticated')) { - return error('User must be authenticated') - } + if (err instanceof Error && err.message.includes('authenticated')) { + return error('User must be authenticated') + } - return error('Failed to delete collection. Please try again later.') - } + return error('Failed to delete collection. Please try again later.') + } } diff --git a/src/actions/animations/get-animations.ts b/src/actions/animations/get-animations.ts index cd003ca..2384a99 100644 --- a/src/actions/animations/get-animations.ts +++ b/src/actions/animations/get-animations.ts @@ -5,27 +5,50 @@ import { success, error, type ActionResult } from '@/actions/utils/action-result import { getUsersAnimationsList } from '@/lib/services/database/animations' import type { Animation } from '@/features/animations/dtos' +/** + * Shared error handler for animation actions + */ +function handleActionError(err: unknown, context: string): ActionResult { + console.error(context, err) + + if (err instanceof Error && err.message.includes('authenticated')) { + return error('User must be authenticated') + } + + return error('Failed to fetch animations. Please try again later.') +} + export async function getAnimations(): Promise> { - try { - const { user, supabase } = await requireAuth() + try { + const { user, supabase } = await requireAuth() - const data = await getUsersAnimationsList({ - user_id: user.id, - supabase - }) + const data = await getUsersAnimationsList({ + user_id: user.id, + supabase + }) - if (!data) { - return error('No animations found') - } + return success(data as Animation[]) + } catch (err) { + return handleActionError(err, 'Error fetching animations:') + } +} - return success(data as Animation[]) - } catch (err) { - console.error('Error fetching animations:', err) +/** + * Server Action: Get animations metadata (id, title, created_at) + * Optimized for list views where content is not needed. + */ +export async function getAnimationsMetadata(): Promise[]>> { + try { + const { user, supabase } = await requireAuth() - if (err instanceof Error && err.message.includes('authenticated')) { - return error('User must be authenticated') - } + const data = await getUsersAnimationsList>({ + user_id: user.id, + supabase, + columns: 'id, title, created_at' + }) - return error('Failed to fetch animations. Please try again later.') - } + return success(data) + } catch (err) { + return handleActionError(err, 'Error fetching animations metadata:') + } } diff --git a/src/actions/animations/update-animation.ts b/src/actions/animations/update-animation.ts index 83d1031..7cfd26d 100644 --- a/src/actions/animations/update-animation.ts +++ b/src/actions/animations/update-animation.ts @@ -1,74 +1,69 @@ 'use server' import { revalidatePath } from 'next/cache' +import { z } from 'zod' -import { requireAuth } from '@/actions/utils/auth' import { success, error, type ActionResult } from '@/actions/utils/action-result' import { updateAnimation as updateAnimationDb } from '@/lib/services/database/animations' import type { Animation } from '@/features/animations/dtos' import type { AnimationSettings, AnimationSlide } from '@/types/animation' import { checkSlideLimit } from '@/lib/services/usage-limits' import type { PlanId } from '@/lib/config/plans' +import { formatZodError, updateAnimationInputSchema } from '@/actions/utils/validation' +import { withAuthAction } from '@/actions/utils/with-auth' -export type UpdateAnimationInput = { - id: string - title?: string - slides?: AnimationSlide[] - settings?: AnimationSettings - url?: string | null -} +export type UpdateAnimationInput = z.infer export async function updateAnimation( - input: UpdateAnimationInput + input: UpdateAnimationInput ): Promise> { - try { - const { id, title, slides, settings, url } = input + try { + const parsedInput = updateAnimationInputSchema.safeParse(input) - if (!id) { - return error('Animation id is required') - } + if (!parsedInput.success) { + return error(formatZodError(parsedInput.error) ?? 'Invalid animation data') + } - const { user, supabase } = await requireAuth() - const { data: profile } = await supabase - .from('profiles') - .select('plan') - .eq('id', user.id) - .single() + const payload = parsedInput.data - const plan = (profile?.plan as PlanId | null) ?? 'free' + return withAuthAction(payload, async ({ id, title, slides, settings, url }, { user, supabase }) => { + const { data: profile } = await supabase + .from('profiles') + .select('plan') + .eq('id', user.id) + .single() - if (slides && slides.length > 0) { - const slideLimit = checkSlideLimit(slides.length, plan) - if (!slideLimit.canSave) { - return error(`Free users can add up to ${slideLimit.max} slides per animation. Upgrade to Pro for unlimited slides!`) - } - } + const plan = (profile?.plan as PlanId | null) ?? 'free' - const data = await updateAnimationDb({ - id, - user_id: user.id, - title: title || 'Untitled', - slides: slides || [], - settings: settings || ({} as AnimationSettings), - url: url || null, - supabase - }) + if (slides && slides.length > 0) { + const slideLimit = checkSlideLimit(slides.length, plan) + if (!slideLimit.canSave) { + return error(`Your plan allows up to ${slideLimit.max} slides per animation. Upgrade to Pro for unlimited slides!`) + } + } - if (!data || data.length === 0) { - return error('Failed to update animation') - } + const data = await updateAnimationDb({ + id, + user_id: user.id, + title, + slides, + settings, + url, + supabase + }) - revalidatePath('/animate') - revalidatePath('/animations') + if (!data || data.length === 0) { + return error('Failed to update animation') + } - return success(data[0] as Animation) - } catch (err) { - console.error('Error updating animation:', err) + revalidatePath('/animate') + revalidatePath('/animations') - if (err instanceof Error && err.message.includes('authenticated')) { - return error('User must be authenticated') - } + return success(data[0] as Animation) + }) + } catch (err) { + console.error('Error updating animation:', err) - return error('Failed to update animation. Please try again later.') - } + return error('Failed to update animation. Please try again later.') + } } diff --git a/src/actions/animations/update-collection.ts b/src/actions/animations/update-collection.ts index a5fcccf..55898b7 100644 --- a/src/actions/animations/update-collection.ts +++ b/src/actions/animations/update-collection.ts @@ -8,46 +8,46 @@ import { updateAnimationCollection as updateAnimationCollectionDb } from '@/lib/ import type { AnimationCollection, Animation } from '@/features/animations/dtos' export type UpdateAnimationCollectionInput = { - id: string - title?: string - animations?: Animation[] + id: string + title?: string + animations?: Animation[] } export async function updateAnimationCollection( - input: UpdateAnimationCollectionInput + input: UpdateAnimationCollectionInput ): Promise> { - try { - const { id, title, animations } = input + try { + const { id, title, animations } = input - if (!id) { - return error('Collection id is required') - } - - const { user, supabase } = await requireAuth() + if (!id) { + return error('Collection id is required') + } - const data = await updateAnimationCollectionDb({ - id, - user_id: user.id, - title: title || 'Untitled', - animations: animations as any, - supabase - }) + const { user, supabase } = await requireAuth() - if (!data || data.length === 0) { - return error('Failed to update collection') - } + const data = await updateAnimationCollectionDb({ + id, + user_id: user.id, + title: title!, + animations: animations as any, + supabase + }) - revalidatePath('/animations') - revalidatePath('/animate') + if (!data || data.length === 0) { + return error('Failed to update collection') + } - return success(data[0] as AnimationCollection) - } catch (err) { - console.error('Error updating animation collection:', err) + revalidatePath('/animations') + revalidatePath('/animate') - if (err instanceof Error && err.message.includes('authenticated')) { - return error('User must be authenticated') - } + return success(data[0] as AnimationCollection) + } catch (err) { + console.error('Error updating animation collection:', err) - return error('Failed to update collection. Please try again later.') + if (err instanceof Error && err.message.includes('authenticated')) { + return error('User must be authenticated') } + + return error('Failed to update collection. Please try again later.') + } } diff --git a/src/actions/collections/create-collection.ts b/src/actions/collections/create-collection.ts index 6dccb20..8880cf5 100644 --- a/src/actions/collections/create-collection.ts +++ b/src/actions/collections/create-collection.ts @@ -5,6 +5,7 @@ import { requireAuth } from '@/actions/utils/auth' import { success, error, type ActionResult } from '@/actions/utils/action-result' import { insertCollection } from '@/lib/services/database/collections' import type { Collection, Snippet } from '@/features/snippets/dtos' +import type { PlanId } from '@/lib/config/plans' export type CreateCollectionInput = { title: string @@ -27,6 +28,34 @@ export async function createCollection( const { user, supabase } = await requireAuth() + const { data: folderLimit, error: folderLimitError } = await supabase.rpc('check_folder_limit', { + p_user_id: user.id + }) + + if (folderLimitError) { + console.error('Error checking folder limit:', folderLimitError) + return error('Failed to verify folder limit. Please try again.') + } + + const canCreateFolder = Boolean( + folderLimit?.can_create ?? + folderLimit?.canCreate ?? + folderLimit?.can_save ?? + folderLimit?.canSave ?? + false + ) + + if (!canCreateFolder) { + const plan = (folderLimit?.plan as PlanId | undefined) ?? 'free' + if (plan === 'free') { + return error('Free plan does not include folders. Upgrade to Starter to organize your snippets.') + } + if (plan === 'starter') { + return error('You have reached your 10 folder limit. Upgrade to Pro for unlimited folders.') + } + return error('Folder limit reached. Please upgrade your plan.') + } + const data = await insertCollection({ user_id: user.id, title: sanitizedTitle, diff --git a/src/actions/collections/delete-collection.ts b/src/actions/collections/delete-collection.ts index cc2a348..849155c 100644 --- a/src/actions/collections/delete-collection.ts +++ b/src/actions/collections/delete-collection.ts @@ -12,33 +12,33 @@ import { deleteCollection as deleteCollectionFromDb } from '@/lib/services/datab * @returns ActionResult with success status or error message */ export async function deleteCollection( - collectionId: string + collectionId: string ): Promise> { - try { - if (!collectionId) { - return error('Collection ID is required') - } + try { + if (!collectionId) { + return error('Collection ID is required') + } - const { user, supabase } = await requireAuth() + const { user, supabase } = await requireAuth() - await deleteCollectionFromDb({ - collection_id: collectionId, - user_id: user.id, - supabase - }) + await deleteCollectionFromDb({ + collection_id: collectionId, + user_id: user.id, + supabase + }) - // Revalidate relevant paths - revalidatePath('/collections') - revalidatePath('/') + // Revalidate relevant paths + revalidatePath('/collections') + revalidatePath('/') - return success({ success: true }) - } catch (err) { - console.error('Error deleting collection:', err) + return success({ success: true }) + } catch (err) { + console.error('Error deleting collection:', err) - if (err instanceof Error && err.message.includes('authenticated')) { - return error('User must be authenticated') - } + if (err instanceof Error && err.message.includes('authenticated')) { + return error('User must be authenticated') + } - return error('Failed to delete collection. Please try again later.') - } + return error('Failed to delete collection. Please try again later.') + } } diff --git a/src/actions/collections/get-collection-by-id.ts b/src/actions/collections/get-collection-by-id.ts index 0952c35..9f34ae9 100644 --- a/src/actions/collections/get-collection-by-id.ts +++ b/src/actions/collections/get-collection-by-id.ts @@ -3,7 +3,6 @@ import { requireAuth } from '@/actions/utils/auth' import { success, error, type ActionResult } from '@/actions/utils/action-result' import { getUserCollectionById as getCollectionByIdFromDb } from '@/lib/services/database/collections' -import { getUsersSnippetsList } from '@/lib/services/database/snippets' import type { Collection, Snippet } from '@/features/snippets/dtos' /** @@ -22,31 +21,21 @@ export async function getCollectionById( const { user, supabase } = await requireAuth() - const [collectionData, snippetsData] = await Promise.all([ - getCollectionByIdFromDb({ - id: collectionId, - user_id: user.id, - supabase - }), - getUsersSnippetsList({ - user_id: user.id, - supabase - }) - ]) + const collectionData = await getCollectionByIdFromDb({ + id: collectionId, + user_id: user.id, + supabase + }) if (!collectionData) { return error('Collection not found') } - // Create a map of snippets for faster lookup - const snippetsMap = new Map(snippetsData?.map(s => [s.id, s]) || []) - - // Populate collection with snippet objects + // Collection already has snippets populated from the service layer + // Just ensure snippets array exists and filter out any null values const populatedCollection = { ...collectionData, - snippets: ((collectionData.snippets as unknown as string[]) || []) - .map((id: string) => snippetsMap.get(id)) - .filter((s: Snippet | undefined): s is Snippet => s !== undefined) + snippets: (collectionData.snippets || []).filter((s: Snippet | null): s is Snippet => s !== null) } return success(populatedCollection as Collection) diff --git a/src/actions/collections/get-collections.ts b/src/actions/collections/get-collections.ts index 30c99aa..5cba064 100644 --- a/src/actions/collections/get-collections.ts +++ b/src/actions/collections/get-collections.ts @@ -3,7 +3,6 @@ import { requireAuth } from '@/actions/utils/auth' import { success, error, type ActionResult } from '@/actions/utils/action-result' import { getUsersCollectionList } from '@/lib/services/database/collections' -import { getUsersSnippetsList } from '@/lib/services/database/snippets' import type { Collection, Snippet } from '@/features/snippets/dtos' /** @@ -12,44 +11,56 @@ import type { Collection, Snippet } from '@/features/snippets/dtos' * @returns ActionResult with array of collections or error message */ export async function getCollections(): Promise> { - try { - const { user, supabase } = await requireAuth() - - const [collectionsData, snippetsData] = await Promise.all([ - getUsersCollectionList({ - user_id: user.id, - supabase - }), - getUsersSnippetsList({ - user_id: user.id, - supabase - }) - ]) - - if (!collectionsData) { - return success([]) - } - - // Create a map of snippets for faster lookup - const snippetsMap = new Map(snippetsData?.map(s => [s.id, s]) || []) - - // Populate collections with snippet objects - const populatedCollections = collectionsData.map((collection: any) => ({ - ...collection, - snippets: (collection.snippets || []) - .map((id: string) => snippetsMap.get(id)) - .filter((s: Snippet | undefined): s is Snippet => s !== undefined) - })) - - return success(populatedCollections as Collection[]) - } catch (err) { - console.error('Error fetching collections:', err) - - if (err instanceof Error && err.message.includes('authenticated')) { - return error('User must be authenticated') - } - - const errorMessage = err instanceof Error ? err.message : 'Failed to fetch collections. Please try again later.'; - return error(errorMessage) - } + try { + const { user, supabase } = await requireAuth() + + const collectionsData = await getUsersCollectionList({ + user_id: user.id, + supabase + }) + + if (!collectionsData) { + return success([]) + } + + // Collections already have snippets populated from the service layer + // Map to DTO format, ensuring id is always present and filtering out any null values + const populatedCollections: Collection[] = collectionsData + .filter((collection): collection is typeof collection & { id: string } => !!collection.id) + .map((collection) => { + // Defensive check: log if title is null/empty (shouldn't happen if database is correct) + if (!collection.title || collection.title.trim() === '') { + console.error('[getCollections] WARNING: Collection has null/empty title:', { id: collection.id, title: collection.title }); + } + return { + id: collection.id, + user_id: collection.user_id, + title: collection.title, // Preserve title as-is from database + snippets: (collection.snippets || []) + .filter((s): boolean => s !== null && typeof s === 'object' && 'id' in s && s.id !== undefined) + .map((s): Snippet => ({ + id: s.id, + user_id: s.user_id, + code: s.code, + language: s.language, + title: s.title, + url: s.url, + created_at: s.created_at, + })), + created_at: collection.created_at, + updated_at: collection.updated_at, + }; + }) + + return success(populatedCollections) + } catch (err) { + console.error('Error fetching collections:', err) + + if (err instanceof Error && err.message.includes('authenticated')) { + return error('User must be authenticated') + } + + const errorMessage = err instanceof Error ? err.message : 'Failed to fetch collections. Please try again later.'; + return error(errorMessage) + } } diff --git a/src/actions/collections/update-collection.ts b/src/actions/collections/update-collection.ts index 7e781ac..e9db23d 100644 --- a/src/actions/collections/update-collection.ts +++ b/src/actions/collections/update-collection.ts @@ -7,9 +7,9 @@ import { updateCollection as updateCollectionInDb } from '@/lib/services/databas import type { Collection, Snippet } from '@/features/snippets/dtos' export type UpdateCollectionInput = { - id: string - title?: string - snippets?: Snippet[] + id: string + title?: string + snippets?: Snippet[] } /** @@ -19,41 +19,49 @@ export type UpdateCollectionInput = { * @returns ActionResult with updated collection or error message */ export async function updateCollection( - input: UpdateCollectionInput + input: UpdateCollectionInput ): Promise> { - try { - const { id, title, snippets } = input + try { + const { id, title, snippets } = input - if (!id) { - return error('Collection ID is required') - } + if (!id) { + return error('Collection ID is required') + } - const { user, supabase } = await requireAuth() + const { user, supabase } = await requireAuth() - const data = await updateCollectionInDb({ - id, - user_id: user.id, - title, - snippets, - supabase - } as any) + const data = await updateCollectionInDb({ + id, + user_id: user.id, + title, + snippets, + supabase + } as any) - if (!data) { - return error('Failed to update collection') - } + if (!data) { + return error('Failed to update collection') + } - // Revalidate relevant paths - revalidatePath('/collections') - revalidatePath('/') + // updateCollectionInDb returns an array, extract the first element + const collection = Array.isArray(data) ? data[0] : data + if (!collection) { + return error('Failed to update collection') + } + // Debug log to verify title preservation - return success(data) - } catch (err) { - console.error('Error updating collection:', err) - if (err instanceof Error && err.message.includes('authenticated')) { - return error('User must be authenticated') - } + // Revalidate relevant paths + revalidatePath('/collections') + revalidatePath('/') - return error('Failed to update collection. Please try again later.') - } + return success(collection) + } catch (err) { + console.error('Error updating collection:', err) + + if (err instanceof Error && err.message.includes('authenticated')) { + return error('User must be authenticated') + } + + return error('Failed to update collection. Please try again later.') + } } diff --git a/src/actions/downgrade/bulk-delete-animation-collections.ts b/src/actions/downgrade/bulk-delete-animation-collections.ts new file mode 100644 index 0000000..b2908f5 --- /dev/null +++ b/src/actions/downgrade/bulk-delete-animation-collections.ts @@ -0,0 +1,71 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { requireAuth, AuthError } from '@/actions/utils/auth' +import { success, error, type ActionResult } from '@/actions/utils/action-result' + +/** + * Server Action: Bulk delete animation collections + */ +export async function bulkDeleteAnimationCollections( + collectionIds: string[] +): Promise> { + try { + if (!collectionIds || collectionIds.length === 0) { + return error('No collection IDs provided') + } + + // Validate input + const validIds = collectionIds.filter((id) => id && typeof id === 'string') + if (validIds.length === 0) { + return error('No valid collection IDs provided') + } + + const { user, supabase } = await requireAuth() + + // Batch delete collections using Supabase's .in() filter + const { data: deletedRows, error: deleteError } = await supabase + .from('animation_collection') + .delete() + .in('id', validIds) + .eq('user_id', user.id) + .select('id') + + if (deleteError) { + console.error('Error bulk deleting animation collections:', deleteError) + return error('Failed to delete collections. Please try again later.') + } + + const deletedCount = deletedRows?.length ?? 0 + + // Try to refresh usage counters explicitly since triggers might be missing for collections + try { + await supabase.rpc('refresh_usage_counters', { p_user_id: user.id }) + } catch (e) { + console.warn('Failed to refresh usage counters:', e) + } + + // Clear usage limits cache to ensure up-to-date counts + try { + const { getUsageLimitsCacheProvider } = await import('@/lib/services/usage-limits-cache') + getUsageLimitsCacheProvider().delete(user.id) + } catch (e) { + console.warn('Failed to clear usage cache:', e) + } + + // Revalidate relevant paths + revalidatePath('/animations') + revalidatePath('/animate') + revalidatePath('/') + + return success({ deletedCount }) + } catch (err) { + console.error('Error bulk deleting animation collections:', err) + + if (err instanceof AuthError) { + return error('User must be authenticated') + } + + return error('Failed to delete collections. Please try again later.') + } +} diff --git a/src/actions/downgrade/bulk-delete-animations.ts b/src/actions/downgrade/bulk-delete-animations.ts new file mode 100644 index 0000000..6c81653 --- /dev/null +++ b/src/actions/downgrade/bulk-delete-animations.ts @@ -0,0 +1,113 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { requireAuth } from '@/actions/utils/auth' +import { success, error, type ActionResult } from '@/actions/utils/action-result' + +/** + * Server Action: Bulk delete animations (optimized batch delete) + */ +export async function bulkDeleteAnimations( + animationIds: string[] +): Promise> { + try { + if (!animationIds || animationIds.length === 0) { + return error('No animation IDs provided') + } + + // Validate input + const validIds = animationIds.filter((id) => id && typeof id === 'string') + if (validIds.length === 0) { + return error('No valid animation IDs provided') + } + + const { user, supabase } = await requireAuth() + + // First, remove animations from collections (batch update) + const { data: collections, error: collectionError } = await supabase + .from('animation_collection') + .select('id, animations') + .eq('user_id', user.id) + + if (collectionError) { + console.error('Error fetching collections for bulk delete:', collectionError) + // Continue with deletion even if collection update fails + } else if (collections && collections.length > 0) { + // Update collections in parallel + const updatePromises = collections.map(async (collection) => { + const currentAnimations = Array.isArray(collection.animations) + ? collection.animations + : [] + const updatedAnimations = currentAnimations.filter( + (id: string) => !validIds.includes(id) + ) + + if (updatedAnimations.length !== currentAnimations.length) { + const { error: updateError } = await supabase + .from('animation_collection') + .update({ + animations: updatedAnimations, + updated_at: new Date().toISOString(), + }) + .eq('id', collection.id) + .eq('user_id', user.id) + + if (updateError) { + console.error(`Error updating collection ${collection.id}:`, updateError) + } + } + }) + + await Promise.all(updatePromises) + } + + // Batch delete animations using Supabase's .in() filter + const { data: deletedRows, error: deleteError } = await supabase + .from('animation') + .delete() + .in('id', validIds) + .eq('user_id', user.id) + .select('id') + + if (deleteError) { + console.error('Error bulk deleting animations:', deleteError) + return error('Failed to delete animations. Please try again later.') + } + + const deletedCount = deletedRows?.length ?? 0 + + if (deletedCount === 0) { + console.warn('No animations deleted β€” none found or already removed') + return success({ deletedCount: 0 }) + } + + if (deletedCount < validIds.length) { + console.warn( + `Only ${deletedCount} of ${validIds.length} animations were deleted. Some may have already been deleted.` + ) + } + + // Clear usage limits cache to ensure up-to-date counts + try { + const { getUsageLimitsCacheProvider } = await import('@/lib/services/usage-limits-cache') + getUsageLimitsCacheProvider().delete(user.id) + } catch (e) { + console.warn('Failed to clear usage cache:', e) + } + + // Revalidate relevant paths + revalidatePath('/animations') + revalidatePath('/animate') + + return success({ deletedCount }) + } catch (err) { + console.error('Error bulk deleting animations:', err) + + if (err instanceof Error && err.message.includes('authenticated')) { + return error('User must be authenticated') + } + + return error('Failed to delete animations. Please try again later.') + } +} + diff --git a/src/actions/downgrade/bulk-delete-snippet-collections.ts b/src/actions/downgrade/bulk-delete-snippet-collections.ts new file mode 100644 index 0000000..d207220 --- /dev/null +++ b/src/actions/downgrade/bulk-delete-snippet-collections.ts @@ -0,0 +1,70 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { requireAuth } from '@/actions/utils/auth' +import { success, error, type ActionResult } from '@/actions/utils/action-result' + +/** + * Server Action: Bulk delete snippet collections + */ +export async function bulkDeleteSnippetCollections( + collectionIds: string[] +): Promise> { + try { + if (!collectionIds || collectionIds.length === 0) { + return error('No collection IDs provided') + } + + // Validate input + const validIds = collectionIds.filter((id) => id && typeof id === 'string') + if (validIds.length === 0) { + return error('No valid collection IDs provided') + } + + const { user, supabase } = await requireAuth() + + // Batch delete collections using Supabase's .in() filter + const { data: deletedRows, error: deleteError } = await supabase + .from('collection') + .delete() + .in('id', validIds) + .eq('user_id', user.id) + .select('id') + + if (deleteError) { + console.error('Error bulk deleting snippet collections:', deleteError) + return error('Failed to delete collections. Please try again later.') + } + + const deletedCount = deletedRows?.length ?? 0 + + // Try to refresh usage counters explicitly since triggers might be missing for collections + try { + await supabase.rpc('refresh_usage_counters', { p_user_id: user.id }) + } catch (e) { + console.warn('Failed to refresh usage counters:', e) + } + + // Clear usage limits cache to ensure up-to-date counts + try { + const { getUsageLimitsCacheProvider } = await import('@/lib/services/usage-limits-cache') + getUsageLimitsCacheProvider().delete(user.id) + } catch (e) { + console.warn('Failed to clear usage cache:', e) + } + + // Revalidate relevant paths + revalidatePath('/collections') + revalidatePath('/') + + return success({ deletedCount }) + } catch (err) { + console.error('Error bulk deleting snippet collections:', err) + + if (err instanceof Error && err.message.includes('authenticated')) { + return error('User must be authenticated') + } + + return error('Failed to delete collections. Please try again later.') + } +} diff --git a/src/actions/downgrade/bulk-delete-snippets.ts b/src/actions/downgrade/bulk-delete-snippets.ts new file mode 100644 index 0000000..0ac0e93 --- /dev/null +++ b/src/actions/downgrade/bulk-delete-snippets.ts @@ -0,0 +1,75 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { requireAuth } from '@/actions/utils/auth' +import { success, error, type ActionResult } from '@/actions/utils/action-result' + +/** + * Server Action: Bulk delete snippets (optimized batch delete) + */ +export async function bulkDeleteSnippets( + snippetIds: string[] +): Promise> { + try { + if (!snippetIds || snippetIds.length === 0) { + return error('No snippet IDs provided') + } + + // Validate input + const validIds = snippetIds.filter((id) => id && typeof id === 'string') + if (validIds.length === 0) { + return error('No valid snippet IDs provided') + } + + const { user, supabase } = await requireAuth() + + // Batch delete using Supabase's .in() filter for better performance + const { data: deletedRows, error: deleteError } = await supabase + .from('snippet') + .delete() + .in('id', validIds) + .eq('user_id', user.id) + .select('id') + + if (deleteError) { + console.error('Error bulk deleting snippets:', deleteError) + return error('Failed to delete snippets. Please try again later.') + } + + const deletedCount = deletedRows?.length ?? 0 + + if (deletedCount === 0) { + console.warn('No snippets deleted β€” none found or already removed') + return success({ deletedCount: 0 }) + } + + if (deletedCount < validIds.length) { + console.warn( + `Only ${deletedCount} of ${validIds.length} snippets were deleted. Some may have already been deleted.` + ) + } + + // Clear usage limits cache to ensure up-to-date counts + try { + const { getUsageLimitsCacheProvider } = await import('@/lib/services/usage-limits-cache') + getUsageLimitsCacheProvider().delete(user.id) + } catch (e) { + console.warn('Failed to clear usage cache:', e) + } + + // Revalidate relevant paths + revalidatePath('/snippets') + revalidatePath('/') + + return success({ deletedCount }) + } catch (err) { + console.error('Error bulk deleting snippets:', err) + + if (err instanceof Error && err.message.includes('authenticated')) { + return error('User must be authenticated') + } + + return error('Failed to delete snippets. Please try again later.') + } +} + diff --git a/src/actions/downgrade/check-downgrade-impact.ts b/src/actions/downgrade/check-downgrade-impact.ts new file mode 100644 index 0000000..758c68b --- /dev/null +++ b/src/actions/downgrade/check-downgrade-impact.ts @@ -0,0 +1,54 @@ +'use server' + +import { requireAuth } from '@/actions/utils/auth' +import { AuthError } from '@/actions/utils/auth-error' +import { success, error, type ActionResult } from '@/actions/utils/action-result' +import { getUserUsage } from '@/lib/services/usage-limits' +import { calculateDowngradeImpact, getDowngradeTarget, type DowngradeImpact } from '@/lib/utils/downgrade-impact' +import { planOrder, type PlanId } from '@/lib/config/plans' + +/** + * Server Action: Check downgrade impact for a target plan + */ +export async function checkDowngradeImpact( + targetPlan?: PlanId +): Promise> { + try { + const { user, supabase } = await requireAuth() + + const usage = await getUserUsage(supabase, user.id) + const currentPlan = usage.plan + + // If no target plan specified, use the next lower tier + const finalTargetPlan = targetPlan || getDowngradeTarget(currentPlan) + + if (!finalTargetPlan) { + return error('No downgrade target available. You are already on the free plan.') + } + + if (finalTargetPlan === currentPlan) { + return error('Target plan is the same as current plan.') + } + + // Check if trying to upgrade instead of downgrade + const currentIndex = planOrder.indexOf(currentPlan) + const targetIndex = planOrder.indexOf(finalTargetPlan) + + if (targetIndex > currentIndex) { + return error('This is an upgrade, not a downgrade. Use the upgrade flow instead.') + } + + const impact = calculateDowngradeImpact(usage, finalTargetPlan) + + return success(impact) + } catch (err) { + console.error('Error checking downgrade impact:', err) + + if (err instanceof AuthError) { + return error('User must be authenticated') + } + + return error('Failed to check downgrade impact. Please try again later.') + } +} + diff --git a/src/actions/index.ts b/src/actions/index.ts index bf49e7d..e0e9ed8 100644 --- a/src/actions/index.ts +++ b/src/actions/index.ts @@ -1,7 +1,7 @@ // Snippets Actions export { getSnippets } from './snippets/get-snippets' export { getSnippetById } from './snippets/get-snippet-by-id' -export { createSnippet, type CreateSnippetInput, type CreateSnippetResult } from './snippets/create-snippet' +export { createSnippet, type CreateSnippetInput } from './snippets/create-snippet' export { updateSnippet, type UpdateSnippetInput } from './snippets/update-snippet' export { deleteSnippet } from './snippets/delete-snippet' @@ -18,7 +18,6 @@ export { getAnimationById } from './animations/get-animation-by-id' export { createAnimation, type CreateAnimationInput, - type CreateAnimationResult, } from './animations/create-animation' export { updateAnimation, type UpdateAnimationInput } from './animations/update-animation' export { deleteAnimation } from './animations/delete-animation' diff --git a/src/actions/snippets/create-snippet.ts b/src/actions/snippets/create-snippet.ts index 467d6e7..930ff2c 100644 --- a/src/actions/snippets/create-snippet.ts +++ b/src/actions/snippets/create-snippet.ts @@ -1,27 +1,18 @@ 'use server' import { revalidatePath } from 'next/cache' -import { requireAuth } from '@/actions/utils/auth' import { success, error, type ActionResult } from '@/actions/utils/action-result' import { insertSnippet } from '@/lib/services/database/snippets' import type { Snippet } from '@/features/snippets/dtos' -import { - checkSnippetLimit, - incrementUsageCount, - type UsageLimitCheck, -} from '@/lib/services/usage-limits' - +import { createSnippetInputSchema, formatZodError } from '@/actions/utils/validation' +import { withAuthAction } from '@/actions/utils/with-auth' +import type { UsageLimitCheck } from '@/lib/services/usage-limits' export type CreateSnippetInput = { - id: string - title?: string - code: string - language: string - url?: string -} - -export type CreateSnippetResult = { - snippet: Snippet - usage: UsageLimitCheck + id: string + title?: string + code: string + language: string + url?: string } /** @@ -31,52 +22,96 @@ export type CreateSnippetResult = { * @returns ActionResult with created snippet or error message */ export async function createSnippet( - input: CreateSnippetInput -): Promise> { - try { - const { id, title, code, language, url } = input - - // Validation - if (!id || !code || !language) { - return error('Missing required fields: id, code, and language are required') - } + input: CreateSnippetInput +): Promise> { + try { + const parsedInput = createSnippetInputSchema.safeParse(input) - const { user, supabase } = await requireAuth() + if (!parsedInput.success) { + return error(formatZodError(parsedInput.error) ?? 'Invalid snippet data') + } - const limit = await checkSnippetLimit(supabase, user.id) - if (!limit.canSave) { - const maxText = limit.max ?? 'unlimited' - return error(`You've reached the free plan limit (${limit.current}/${maxText} snippets). Upgrade to Pro for unlimited snippets!`) - } + const payload = parsedInput.data - const data = await insertSnippet({ - id, - user_id: user.id, - title: title || 'Untitled', - code, - language, - url: url || null, - supabase - }) - - if (!data || data.length === 0) { - return error('Failed to create snippet') - } + return withAuthAction(payload, async ({ id, title, code, language, url }, { user, supabase }) => { + // Check snippet limit before allowing save + const { data: limitCheckRaw, error: limitError } = await supabase.rpc('check_snippet_limit', { + target_user_id: user.id + }) + + if (limitError) { + console.error('Error checking snippet limit:', limitError) + return error('Failed to verify save limit. Please try again.') + } - const updatedUsage = await incrementUsageCount(supabase, user.id, 'snippets') + // Guard against null RPC response + if (!limitCheckRaw || typeof limitCheckRaw !== 'object') { + console.error('Invalid RPC response from check_snippet_limit:', limitCheckRaw) + return error('Failed to verify save limit. Please try again.') + } - // Revalidate the snippets list - revalidatePath('/snippets') - revalidatePath('/') + // Map RPC response (can be camelCase or snake_case) to camelCase UsageLimitCheck type + const rpcResponse = limitCheckRaw as { + can_save?: boolean + canSave?: boolean + current?: number | null + max?: number | null + plan?: string | null + over_limit?: number | null + overLimit?: number | null + } - return success({ snippet: data[0], usage: updatedUsage }) - } catch (err) { - console.error('Error creating snippet:', err) + // Normalize plan value from database ("started" -> "starter") + const rawPlan = rpcResponse.plan ?? 'free'; + const normalizedPlan = rawPlan === 'started' ? 'starter' : rawPlan; - if (err instanceof Error && err.message.includes('authenticated')) { - return error('User must be authenticated') + const limitCheck: UsageLimitCheck = { + canSave: Boolean(rpcResponse.canSave ?? rpcResponse.can_save ?? false), + current: rpcResponse.current ?? 0, + max: rpcResponse.max ?? null, + plan: (normalizedPlan as UsageLimitCheck['plan']) ?? 'free', + overLimit: rpcResponse.overLimit ?? rpcResponse.over_limit ?? undefined, + } + + // Compute overLimit locally if not provided + const current = limitCheck.current ?? 0 + const max = limitCheck.max ?? null + const overLimit = limitCheck.overLimit ?? (max == null ? 0 : Math.max(current - max, 0)) + + if (!limitCheck.canSave) { + const plan = limitCheck.plan + + if (plan === 'free') { + return error(`You have ${current} snippets but the Free plan allows ${max}. Delete items or upgrade to save again. Over limit: ${overLimit}.`) + } else if (plan === 'starter') { + return error(`You've reached your Starter limit (${current}/${max}). Upgrade to Pro for unlimited snippets!`) } + return error('Snippet limit reached. Please upgrade your plan.') + } - return error('Failed to create snippet. Please try again later.') - } + const data = await insertSnippet({ + id, + user_id: user.id, + title: title || 'Untitled', + code, + language, + url: url || null, + supabase + }) + + if (!data || data.length === 0) { + return error('Failed to create snippet') + } + + // Revalidate the snippets list + revalidatePath('/snippets') + revalidatePath('/') + + return success(data[0]) + }) + } catch (err) { + console.error('Error creating snippet:', err) + + return error('Failed to create snippet. Please try again later.') + } } diff --git a/src/actions/snippets/delete-snippet.ts b/src/actions/snippets/delete-snippet.ts index f0496e6..8f27473 100644 --- a/src/actions/snippets/delete-snippet.ts +++ b/src/actions/snippets/delete-snippet.ts @@ -4,7 +4,6 @@ import { revalidatePath } from 'next/cache' import { requireAuth } from '@/actions/utils/auth' import { success, error, type ActionResult } from '@/actions/utils/action-result' import { deleteSnippet as deleteSnippetFromDb } from '@/lib/services/database/snippets' -import { decrementUsageCount } from '@/lib/services/usage-limits' /** * Server Action: Delete a snippet @@ -28,10 +27,6 @@ export async function deleteSnippet( supabase }) - await decrementUsageCount(supabase, user.id, 'snippets').catch((decrementError) => { - console.error('Failed to decrement snippet usage', decrementError) - }) - // Revalidate relevant paths revalidatePath('/snippets') revalidatePath('/') diff --git a/src/actions/snippets/get-snippets.ts b/src/actions/snippets/get-snippets.ts index 8bc371d..4782269 100644 --- a/src/actions/snippets/get-snippets.ts +++ b/src/actions/snippets/get-snippets.ts @@ -1,6 +1,5 @@ 'use server' -import { revalidatePath } from 'next/cache' import { requireAuth } from '@/actions/utils/auth' import { success, error, type ActionResult } from '@/actions/utils/action-result' import { getUsersSnippetsList } from '@/lib/services/database/snippets' @@ -12,26 +11,56 @@ import type { Snippet } from '@/features/snippets/dtos' * @returns ActionResult with array of snippets or error message */ export async function getSnippets(): Promise> { - try { - const { user, supabase } = await requireAuth() + try { + const { user, supabase } = await requireAuth() - const data = await getUsersSnippetsList({ - user_id: user.id, - supabase - }) + const data = await getUsersSnippetsList({ + user_id: user.id, + supabase + }) - if (!data) { - return error('No snippets found') - } + if (!data) { + return error('No snippets found') + } - return success(data) - } catch (err) { - console.error('Error fetching snippets:', err) + return success(data) + } catch (err) { + console.error('Error fetching snippets:', err) - if (err instanceof Error && err.message.includes('authenticated')) { - return error('User must be authenticated') - } + if (err instanceof Error && err.message.includes('authenticated')) { + return error('User must be authenticated') + } - return error('Failed to fetch snippets. Please try again later.') - } + return error('Failed to fetch snippets. Please try again later.') + } +} + +/** + * Server Action: Get snippets metadata (id, title, created_at, language) + * Optimized for list views where code content is not needed. + */ +export async function getSnippetsMetadata(): Promise[]>> { + try { + const { user, supabase } = await requireAuth() + + const data = await getUsersSnippetsList>({ + user_id: user.id, + supabase, + columns: 'id, title, created_at, language' + }) + + if (!data) { + return error('No snippets found') + } + + return success(data) + } catch (err) { + console.error('Error fetching snippets metadata:', err) + + if (err instanceof Error && err.message.includes('authenticated')) { + return error('User must be authenticated') + } + + return error('Failed to fetch snippets. Please try again later.') + } } diff --git a/src/actions/snippets/update-snippet.ts b/src/actions/snippets/update-snippet.ts index 1252d3c..ead4b2b 100644 --- a/src/actions/snippets/update-snippet.ts +++ b/src/actions/snippets/update-snippet.ts @@ -1,10 +1,11 @@ 'use server' import { revalidatePath } from 'next/cache' -import { requireAuth } from '@/actions/utils/auth' import { success, error, type ActionResult } from '@/actions/utils/action-result' -import { updateSnippet as updateSnippetInDb } from '@/lib/services/database/snippets' +import { updateSnippet as updateSnippetInDb, type UpdateSnippetDbInput } from '@/lib/services/database/snippets' import type { Snippet } from '@/features/snippets/dtos' +import { formatZodError, updateSnippetInputSchema } from '@/actions/utils/validation' +import { withAuthAction } from '@/actions/utils/with-auth' export type UpdateSnippetInput = { id: string @@ -24,33 +25,47 @@ export async function updateSnippet( input: UpdateSnippetInput ): Promise> { try { - const { id, title, code, language, url } = input + const parsedInput = updateSnippetInputSchema.safeParse(input) - if (!id) { - return error('Snippet ID is required') + if (!parsedInput.success) { + return error(formatZodError(parsedInput.error) ?? 'Invalid snippet data') } - const { user, supabase } = await requireAuth() + const payload = parsedInput.data - const data = await updateSnippetInDb({ - id, - user_id: user.id, - title, - code, - language, - url, - supabase - } as any) + return withAuthAction(payload, async ({ id, title, code, language, url }, { user, supabase }) => { + const updateInput: UpdateSnippetDbInput = { + id, + user_id: user.id, + supabase, + }; - if (!data || data.length === 0) { - return error('Failed to update snippet') - } + // Only include fields that are provided (not undefined) + if (title !== undefined) { + updateInput.title = title; + } + if (code !== undefined) { + updateInput.code = code; + } + if (language !== undefined) { + updateInput.language = language; + } + if (url !== undefined) { + updateInput.url = url; + } + + const data = await updateSnippetInDb(updateInput) + + if (!data || data.length === 0) { + return error('Failed to update snippet') + } - // Revalidate relevant paths - revalidatePath('/snippets') - revalidatePath('/') + // Revalidate relevant paths + revalidatePath('/snippets') + revalidatePath('/') - return success(data[0]) + return success(data[0]) + }) } catch (err) { console.error('Error updating snippet:', err) diff --git a/src/actions/stripe/checkout.ts b/src/actions/stripe/checkout.ts new file mode 100644 index 0000000..b904b6b --- /dev/null +++ b/src/actions/stripe/checkout.ts @@ -0,0 +1,279 @@ +'use server'; + +import type { PlanId } from '@/lib/config/plans'; +import { createClient } from '@/utils/supabase/server'; +import { + getOrCreateStripeCustomer, + createCheckoutSession as createStripeCheckoutSession, + getStripePriceId, + createCustomerPortalSession, +} from '@/lib/services/stripe'; +import { syncSubscriptionById, syncActiveSubscriptionForCustomer } from '@/lib/services/subscription-sync'; +import { resolveBaseUrl } from '@/lib/utils/resolve-base-url'; +import { hasActiveSubscription, type BillingInfo } from '@/lib/services/billing'; + +type CheckoutResponse = { + url?: string; + error?: string; +}; + +/** + * Create a Stripe checkout session + */ +export async function createCheckoutSession({ + plan, + interval, + headers, +}: { + plan: PlanId; + interval: 'monthly' | 'yearly'; + headers?: { get(name: string): string | null } | null; +}): Promise { + try { + // Validate plan + if (plan === 'free') { + return { error: 'Cannot create checkout session for free plan' }; + } + + if (!['starter', 'pro'].includes(plan)) { + return { error: 'Invalid plan' }; + } + + if (!['monthly', 'yearly'].includes(interval)) { + return { error: 'Invalid billing interval' }; + } + + // Get authenticated user + const supabase = await createClient(); + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + + if (authError || !user) { + return { error: 'Unauthorized' }; + } + + if (!user.email) { + return { error: 'User email not found' }; + } + + // Get user profile + const { data: profile } = await supabase + .from('profiles') + .select('username, stripe_customer_id') + .eq('id', user.id) + .single(); + + // Get or create Stripe customer + const customer = await getOrCreateStripeCustomer({ + userId: user.id, + email: user.email, + name: profile?.username || undefined, + }); + + // Update profile with Stripe customer ID if not already set + const existingCustomerId = profile?.stripe_customer_id; + + if (!existingCustomerId) { + await supabase + .from('profiles') + .update({ stripe_customer_id: customer.id } as any) + .eq('id', user.id); + } + + // Check if user already has an active subscription + const { data: subscriptionData } = await supabase + .from('profiles') + .select('plan, stripe_subscription_status') + .eq('id', user.id) + .single(); + + if ( + hasActiveSubscription( + subscriptionData + ? { + plan: subscriptionData.plan, + stripeCustomerId: null, + stripeSubscriptionId: null, + stripeSubscriptionStatus: subscriptionData.stripe_subscription_status, + subscriptionPeriodEnd: null, + subscriptionCancelAtPeriodEnd: null, + stripePriceId: null, + billingInterval: null, + } + : null + ) + ) { + return { + error: 'You already have an active subscription. Please manage your plan in the customer portal.' + }; + } + + // Get price ID for the plan + let priceId: string; + try { + priceId = getStripePriceId(plan, interval); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + console.error('Failed to get Stripe price ID:', errorMessage); + return { + error: `Checkout configuration error: ${errorMessage}. Please contact support if this issue persists.`, + }; + } + + // Validate price ID before proceeding + if (!priceId || priceId.trim() === '') { + return { + error: + 'Checkout configuration error: Stripe price ID is missing. Please contact support.', + }; + } + + // Create checkout session + const appUrl = resolveBaseUrl(headers); + let session; + try { + session = await createStripeCheckoutSession({ + customerId: customer.id, + priceId, + successUrl: `${appUrl}/checkout/success?session_id={CHECKOUT_SESSION_ID}`, + cancelUrl: `${appUrl}/checkout/canceled?session_id={CHECKOUT_SESSION_ID}`, + metadata: { + userId: user.id, + plan, + interval, + }, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + console.error('Failed to create Stripe checkout session:', errorMessage); + // If it's a validation error from createCheckoutSession, return it directly + if (errorMessage.includes('Invalid Stripe price ID') || errorMessage.includes('price ID')) { + return { + error: `Checkout configuration error: ${errorMessage}. Please contact support.`, + }; + } + return { error: 'Failed to create checkout session. Please try again later.' }; + } + + return { url: session.url || undefined }; + } catch (error) { + console.error('Checkout action error:', error); + return { error: 'Failed to create checkout session' }; + } +} + +/** + * Create a Stripe customer portal session + */ +export async function createPortalSession({ + headers, +}: { + headers?: { get(name: string): string | null } | null; +} = {}): Promise { + try { + // Get authenticated user + const supabase = await createClient(); + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + + if (authError || !user) { + return { error: 'Unauthorized' }; + } + + // Get user's Stripe customer ID + const { data: profile } = await supabase + .from('profiles') + .select('stripe_customer_id') + .eq('id', user.id) + .single(); + + const stripeCustomerId = profile?.stripe_customer_id; + + if (!stripeCustomerId) { + return { error: 'No active subscription found' }; + } + + // Create customer portal session + const appUrl = resolveBaseUrl(headers); + const portalSession = await createCustomerPortalSession({ + customerId: stripeCustomerId, + returnUrl: `${appUrl}/?stripe_action=portal_return`, + }); + + return { url: portalSession.url || undefined }; + } catch (error) { + console.error('Portal action error:', error); + return { error: 'Failed to create portal session' }; + } +} + +/** + * Sync subscription data from Stripe + * If subscriptionId is provided, syncs that specific subscription. + * Otherwise, finds and syncs the active subscription for the user's customer. + */ +export async function syncSubscription({ + subscriptionId, +}: { + subscriptionId?: string; +}): Promise<{ success: boolean; error?: string }> { + try { + // Get authenticated user + const supabase = await createClient(); + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + + if (authError || !user) { + return { success: false, error: 'Unauthorized' }; + } + + // Get user profile with customer ID + const { data: profile } = await supabase + .from('profiles') + .select('stripe_subscription_id, stripe_customer_id') + .eq('id', user.id) + .single(); + + let result: { userId: string; planId: PlanId } | null = null; + + if (subscriptionId) { + // If subscriptionId is provided, verify it belongs to the user + if (!profile?.stripe_subscription_id || profile.stripe_subscription_id !== subscriptionId) { + return { success: false, error: 'Subscription does not belong to user' }; + } + + result = await syncSubscriptionById(subscriptionId); + } else { + // No subscriptionId provided - find and sync active subscription + if (!profile?.stripe_customer_id) { + return { success: false, error: 'No Stripe customer found' }; + } + + result = await syncActiveSubscriptionForCustomer(profile.stripe_customer_id, user.id); + } + + if (!result) { + return { success: false, error: 'Failed to sync subscription' }; + } + + // Invalidate usage cache so the UI reflects the new plan immediately + try { + const { invalidateUserUsageCache } = await import('@/lib/services/usage-limits-cache'); + invalidateUserUsageCache(user.id); + } catch (error) { + console.warn('Failed to invalidate usage cache:', error); + // Don't fail the sync action just because cache invalidation failed + } + + return { success: true }; + } catch (error) { + console.error('Sync subscription action error:', error); + return { success: false, error: 'Failed to sync subscription' }; + } +} diff --git a/src/actions/user/delete-account.ts b/src/actions/user/delete-account.ts new file mode 100644 index 0000000..25e9ba5 --- /dev/null +++ b/src/actions/user/delete-account.ts @@ -0,0 +1,335 @@ +'use server' + +import * as Sentry from '@sentry/nextjs' +import React from 'react' + +import { requireAuth } from '@/actions/utils/auth' +import { success, error, type ActionResult } from '@/actions/utils/action-result' +import { createServiceRoleClient } from '@/utils/supabase/admin' +import { deleteStripeCustomer } from '@/lib/services/stripe' +import { sendEmail } from '@/lib/email/send-email' +import AccountDeletedEmail from '@emails/account-deleted-email' +import { getUsageLimitsCacheProvider } from '@/lib/services/usage-limits-cache' +import { captureServerEvent } from '@/lib/services/tracking/server' +import { ACCOUNT_EVENTS } from '@/lib/services/tracking/events' + +/** + * Masks an email address for privacy-safe logging + * Shows first character of local part, asterisks, and full domain + * Example: "user@example.com" -> "u***@example.com" + */ +function maskEmail(email: string): string { + const [localPart, domain] = email.split('@') + if (!domain || localPart.length === 0) { + return '***@***' + } + const maskedLocal = localPart[0] + '*'.repeat(Math.min(localPart.length - 1, 3)) + return `${maskedLocal}@${domain}` +} + +/** + * Creates an irreversible hash of an email for non-identifying tracking + * Uses a simple hash function (not cryptographically secure, but sufficient for anonymization) + */ +function hashEmail(email: string): string { + let hash = 0 + for (let i = 0; i < email.length; i++) { + const char = email.charCodeAt(i) + hash = ((hash << 5) - hash) + char + hash = hash & hash // Convert to 32-bit integer + } + return `email_hash_${Math.abs(hash).toString(16)}` +} + +/** + * Server Action: Delete user account + * + * This action: + * 1. Authenticates the user + * 2. Sends confirmation email (before deletion so we have the email) + * 3. Deletes Stripe customer (automatically cancels subscriptions) + * 4. Deletes waitlist entries (manual cleanup) + * 5. Deletes user from auth.users (cascades to all related data via foreign keys) + * 6. Clears usage cache + * 7. Logs deletion for audit purposes + * + * @returns ActionResult with success status or error message + */ +export async function deleteAccount(): Promise> { + let user: { id: string; email?: string } | null = null; + try { + // Authenticate user + const authResult = await requireAuth(); + user = authResult.user; + const { supabase } = authResult; + + // Validate user ID + if (!user?.id || typeof user.id !== 'string') { + console.error('[deleteAccount] Invalid user ID:', user?.id) + return error('Invalid user account. Please try signing out and signing back in.') + } + + if (!user.email) { + return error('User email is required for account deletion') + } + + // Get user profile to retrieve Stripe customer ID and username + const { data: profile, error: profileError } = await supabase + .from('profiles') + .select('stripe_customer_id, username, email') + .eq('id', user.id) + .single() + + if (profileError) { + console.error('[deleteAccount] Failed to load profile:', profileError) + Sentry.captureException(profileError, { + tags: { operation: 'delete_account_load_profile' }, + extra: { userId: user.id, errorCode: profileError.code, errorMessage: profileError.message }, + }) + + // If profile doesn't exist, we can still proceed with deletion + // The user might have been partially deleted or profile creation failed + if (profileError.code === 'PGRST116') { + console.warn('[deleteAccount] Profile not found, proceeding with deletion anyway') + // Continue with minimal data + } else { + return error('Failed to load user profile. Please try again later.') + } + } + + // Use profile data if available, otherwise use minimal data from user object + const profileData = profile || null + + const userEmail = profileData?.email || user.email + const userName = profileData?.username || undefined + const stripeCustomerId = profileData?.stripe_customer_id || null + + // Validate email before sending + if (!userEmail || typeof userEmail !== 'string' || !userEmail.includes('@')) { + console.warn('[deleteAccount] Invalid or missing email address, skipping email notification:', { + userId: user.id, + profileEmail: profileData?.email, + userEmail: user.email, + }) + // Continue with deletion even if email is invalid + } else { + // Send confirmation email BEFORE deletion (so we have the email address) + try { + await sendEmail({ + to: userEmail, + subject: 'Your Jolly Code account has been deleted', + react: React.createElement(AccountDeletedEmail, { name: userName }), + idempotencyKey: `account-deleted-${user.id}-${Date.now()}`, + }) + console.log(`[deleteAccount] Sent deletion confirmation email to ${maskEmail(userEmail)}`) + } catch (emailError) { + // Log email error but don't fail deletion (non-critical) + console.error('[deleteAccount] Failed to send confirmation email:', emailError) + Sentry.captureException(emailError, { + level: 'warning', + tags: { operation: 'delete_account_send_email' }, + extra: { userId: user.id }, + }) + } + } + + // Delete Stripe customer (automatically cancels all subscriptions) + if (stripeCustomerId && typeof stripeCustomerId === 'string' && stripeCustomerId.trim() !== '') { + try { + await deleteStripeCustomer(stripeCustomerId) + console.log(`[deleteAccount] Deleted Stripe customer ${stripeCustomerId} for user ${user.id}`) + } catch (stripeError) { + // Log Stripe error but continue with database deletion + // User can contact support if there are billing issues + console.error('[deleteAccount] Failed to delete Stripe customer:', stripeError) + Sentry.captureException(stripeError, { + level: 'error', + tags: { operation: 'delete_account_stripe' }, + extra: { + userId: user.id, + stripeCustomerId, + }, + }) + // Continue with deletion - don't fail the entire operation + } + } else if (stripeCustomerId) { + console.warn('[deleteAccount] Invalid Stripe customer ID format, skipping deletion:', { + userId: user.id, + stripeCustomerId, + }) + } + + // Use service role client for admin operations + const adminSupabase = createServiceRoleClient() + + // Manually delete waitlist entries (user_id is nullable, may not cascade) + try { + const { error: waitlistError } = await adminSupabase + .from('waitlist') + .delete() + .eq('user_id', user.id) + + if (waitlistError) { + console.warn('[deleteAccount] Failed to delete waitlist entries:', waitlistError) + // Non-critical, continue with deletion + } + } catch (waitlistError) { + console.warn('[deleteAccount] Error deleting waitlist entries:', waitlistError) + // Non-critical, continue with deletion + } + + // Manually delete stripe_webhook_audit entries (user_id is nullable, uses ON DELETE SET NULL) + // We delete these for GDPR compliance and complete account cleanup + try { + const { error: auditError } = await adminSupabase + .from('stripe_webhook_audit') + .delete() + .eq('user_id', user.id) + + if (auditError) { + console.warn('[deleteAccount] Failed to delete webhook audit entries:', auditError) + // Non-critical, continue with deletion + } + } catch (auditError) { + console.warn('[deleteAccount] Error deleting webhook audit entries:', auditError) + // Non-critical, continue with deletion + } + + // Clear usage cache before deletion + try { + getUsageLimitsCacheProvider().delete(user.id) + } catch (cacheError) { + console.warn('[deleteAccount] Failed to clear usage cache:', cacheError) + // Non-critical, continue with deletion + } + + // Sign out all user sessions before deletion + // This helps avoid "Database error" issues that can occur if there are active sessions + try { + const { error: signOutError } = await adminSupabase.auth.admin.signOut(user.id, 'global') + if (signOutError) { + console.warn('[deleteAccount] Failed to sign out user sessions:', signOutError) + // Non-critical, continue with deletion + } else { + console.log('[deleteAccount] Signed out all user sessions') + } + } catch (signOutErr) { + console.warn('[deleteAccount] Error signing out user sessions:', signOutErr) + // Non-critical, continue with deletion + } + + // Delete user from auth.users + // This triggers CASCADE deletion of: + // - profiles (which cascades to snippets, collections, animations, links, usage_limits, share_view_events) + // - All related data via foreign key constraints + const { data: deleteData, error: deleteError } = await adminSupabase.auth.admin.deleteUser(user.id) + + if (deleteError) { + console.error('[deleteAccount] Failed to delete user:', deleteError) + console.error('[deleteAccount] Error details:', { + message: deleteError.message, + code: deleteError.code || deleteError.status, + status: deleteError.status, + userId: user.id, + name: deleteError.name, + }) + + // Log the full error object for debugging + console.error('[deleteAccount] Full error object:', JSON.stringify(deleteError, null, 2)) + + Sentry.captureException(deleteError, { + level: 'error', + tags: { operation: 'delete_account_auth' }, + extra: { + userId: user.id, + errorCode: deleteError.code || deleteError.status, + errorStatus: deleteError.status, + errorMessage: deleteError.message, + errorName: deleteError.name, + }, + }) + + // Provide a more helpful error message based on error type + let errorMessage = 'Failed to delete account. Please contact support if this issue persists.' + + if (deleteError.message?.includes('Database error')) { + errorMessage = 'Failed to delete account due to a database constraint. This may be due to active sessions or other dependencies. Please try signing out and signing back in, then try again. If the issue persists, please contact support.' + } else if (deleteError.code === 'user_not_found') { + // User already deleted - this is actually a success case + console.log('[deleteAccount] User already deleted, treating as success') + return success({ success: true }) + } else if (deleteError.message) { + errorMessage = `Failed to delete account: ${deleteError.message}` + } + + // Track account deletion failed (non-blocking) + void captureServerEvent(ACCOUNT_EVENTS.DELETE_ACCOUNT_FAILED, { + userId: user.id, + properties: { + error_code: deleteError.code || deleteError.status, + error_message: deleteError.message, + }, + }) + + return error(errorMessage) + } + + // Verify deletion was successful + // Note: deleteUser may return null user object even on success (user is deleted) + // So we check for the absence of an error rather than the presence of user data + if (deleteData?.user) { + console.log('[deleteAccount] User deletion confirmed:', deleteData.user.id) + } else { + // No user data returned is expected - user has been deleted + console.log('[deleteAccount] User deletion successful (user object removed)') + } + + // Log successful deletion for audit + console.log(`[deleteAccount] Successfully deleted account for user ${user.id}`) + Sentry.captureMessage('User account deleted', { + level: 'info', + tags: { operation: 'delete_account_success' }, + extra: { + userId: user.id, + hadStripeCustomer: !!stripeCustomerId, + }, + }) + + // Track account deletion completed (non-blocking - user is already deleted) + void captureServerEvent(ACCOUNT_EVENTS.DELETE_ACCOUNT_COMPLETED, { + userId: user.id, + properties: { + had_stripe_customer: !!stripeCustomerId, + }, + }) + + return success({ success: true }) + } catch (err) { + console.error('[deleteAccount] Unexpected error:', err) + + const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred' + + Sentry.captureException(err, { + tags: { operation: 'delete_account' }, + }) + + // Track account deletion failed (non-blocking) + // Note: user may not be defined in catch block if error occurred before requireAuth + const userId = user?.id; + if (userId) { + void captureServerEvent(ACCOUNT_EVENTS.DELETE_ACCOUNT_FAILED, { + userId, + properties: { + error_message: errorMessage, + }, + }); + } + + if (err instanceof Error && errorMessage.includes('authenticated')) { + return error('User must be authenticated') + } + + return error('Failed to delete account. Please try again later or contact support.') + } +} + diff --git a/src/actions/user/update-watermark-preference.ts b/src/actions/user/update-watermark-preference.ts new file mode 100644 index 0000000..0cee471 --- /dev/null +++ b/src/actions/user/update-watermark-preference.ts @@ -0,0 +1,54 @@ +'use server'; + +import { createClient } from '@/utils/supabase/server'; +import { updateWatermarkPreference } from '@/lib/services/user-preferences'; + +export type UpdateWatermarkPreferenceResult = { + success: boolean; + error?: string; + requiresUpgrade?: boolean; +}; + +/** + * Server action to update user's watermark visibility preference + * Validates authentication and PRO plan requirement + */ +export async function updateWatermarkPreferenceAction( + hideWatermark: boolean +): Promise { + try { + // Get authenticated user + const supabase = await createClient(); + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + + if (authError || !user) { + return { + success: false, + error: 'You must be signed in to update preferences', + }; + } + + // Update preference + const result = await updateWatermarkPreference(supabase, user.id, hideWatermark); + + if (!result.success) { + return { + success: false, + error: result.error || 'Failed to update preference', + requiresUpgrade: result.error?.includes('PRO'), + }; + } + + return { success: true }; + } catch (error) { + console.error('[updateWatermarkPreferenceAction] Unexpected error:', error); + return { + success: false, + error: 'An unexpected error occurred', + }; + } +} + diff --git a/src/actions/utils/auth-error.ts b/src/actions/utils/auth-error.ts new file mode 100644 index 0000000..3c64200 --- /dev/null +++ b/src/actions/utils/auth-error.ts @@ -0,0 +1,15 @@ +/** + * Custom error class for authentication failures + * Allows robust error detection via instanceof checks + */ +export class AuthError extends Error { + constructor(message: string) { + super(message) + this.name = 'AuthError' + // Maintains proper stack trace for where our error was thrown (only available on V8) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, AuthError) + } + } +} + diff --git a/src/actions/utils/auth.ts b/src/actions/utils/auth.ts index 321faf5..da86227 100644 --- a/src/actions/utils/auth.ts +++ b/src/actions/utils/auth.ts @@ -2,49 +2,73 @@ import { createClient } from '@/utils/supabase/server' import type { SupabaseClient } from '@supabase/supabase-js' +import { AuthError } from './auth-error' -type AuthResult = { - user: { - id: string - email?: string - } - supabase: SupabaseClient +export type AuthResult = { + user: { + id: string + email?: string + } + supabase: SupabaseClient } +// Re-export AuthError for convenience +export { AuthError } + /** * Ensures the user is authenticated and returns user + supabase client * Throws an error if user is not authenticated + * + * Note: If the error is "user_not_found" (user was deleted), we attempt to sign out + * to clear the invalid session, but still throw an error to prevent the action from proceeding. */ export async function requireAuth(): Promise { - const supabase = await createClient() - - const { data: { user }, error } = await supabase.auth.getUser() - - if (error) { - console.error('Auth error:', error) - throw new Error(`Authentication failed: ${error.message}`) - } - - if (!user) { - throw new Error('User must be authenticated') - } - - return { - user: { - id: user.id, - email: user.email - }, - supabase - } + const supabase = await createClient() + + const { data: { user }, error } = await supabase.auth.getUser() + + if (error) { + console.error('Auth error:', error) + + // If user was deleted (user_not_found), attempt to clear the session + // This helps prevent repeated errors from invalid JWTs + if (error.code === 'user_not_found' || + // Best-effort fallback: tolerate false negatives, don't depend on exact message text + error.message?.includes('User from sub claim in JWT does not exist')) { + try { + await supabase.auth.signOut() + } catch (signOutError) { + // Ignore sign out errors - the session is already invalid + } + } + + throw new AuthError(`Authentication failed: ${error.message}`) + } + + if (!user) { + throw new AuthError('User must be authenticated') + } + + return { + user: { + id: user.id, + email: user.email + }, + supabase + } } /** * Gets the current user if authenticated, returns null otherwise */ export async function getAuthUser(): Promise { - try { - return await requireAuth() - } catch { - return null - } + try { + return await requireAuth() + } catch (e) { + // Log errors in non-production environments for debugging + if (process.env.NODE_ENV !== 'production') { + console.debug('[getAuthUser] Authentication check failed:', e) + } + return null + } } diff --git a/src/actions/utils/validation.ts b/src/actions/utils/validation.ts new file mode 100644 index 0000000..67a47f8 --- /dev/null +++ b/src/actions/utils/validation.ts @@ -0,0 +1,112 @@ +import { z, ZodError } from 'zod' + +import { languages } from '@/lib/language-options' + +const MAX_TITLE_LENGTH = 200 +const MAX_CODE_BYTES = 100 * 1024 // 100KB +const scriptTagPattern = /]*>[\s\S]*?<\/script>/gi + +const escapeHtml = (value: string) => + value.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') + +const stripDangerousContent = (value: string) => value.replace(scriptTagPattern, match => escapeHtml(match)) + +export const sanitizeTextInput = (value: string) => + stripDangerousContent(value) + .replace(/<[^>]*>/g, '') + .replace(/\0/g, '') + .trim() + +export const sanitizeCodeInput = (value: string) => + stripDangerousContent(value) + .replace(/\0/g, '') + +export const languageSchema = z + .string() + .min(1, 'Language is required') + .refine(lang => Object.keys(languages).includes(lang), { message: 'Unsupported language' }) + +const codeSizeValidator = (code: string) => new TextEncoder().encode(code).length <= MAX_CODE_BYTES + +export const codeSchema = z + .string() + .min(1, 'One or more sliders are empty. Please add code to all sliders.') + .refine(codeSizeValidator, 'Code exceeds 100KB limit') + .transform(sanitizeCodeInput) + +const baseTitleSchema = z.string().max(MAX_TITLE_LENGTH, `Title must be ${MAX_TITLE_LENGTH} characters or less`) + +export const titleSchema = baseTitleSchema + .min(1, 'Title is required') + .transform(sanitizeTextInput) + +export const optionalTitleSchema = baseTitleSchema + .transform(sanitizeTextInput) + .optional() + +export const optionalTitleToStringSchema = optionalTitleSchema.transform(value => value ?? 'Untitled') + +export const idSchema = z.string().trim().min(1, 'ID is required') + +export const urlSchema = z + .string() + .trim() + .max(2048, 'URL is too long') + .transform(value => value.replace(/\0/g, '')) // Only strip null bytes + .optional() + +export const animationSettingsSchema = z.object({ + fps: z.union([z.literal(24), z.literal(30), z.literal(60)]), + resolution: z.enum(['720p', '1080p']), + transitionType: z.enum(['fade', 'diff']), + exportFormat: z.enum(['mp4', 'webm', 'gif']), + quality: z.enum(['fast', 'balanced', 'high']) +}) + +export const animationSlideSchema = z.object({ + id: idSchema, + code: codeSchema, + title: optionalTitleToStringSchema, + language: languageSchema, + autoDetectLanguage: z.boolean().optional(), + duration: z.number().positive('Duration must be positive') +}) + +export const createSnippetInputSchema = z.object({ + id: idSchema, + title: optionalTitleSchema, + code: codeSchema, + language: languageSchema, + url: urlSchema +}) + +export const updateSnippetInputSchema = z.object({ + id: idSchema, + title: optionalTitleSchema, + code: codeSchema.optional(), + language: languageSchema.optional(), + url: urlSchema +}) + +export const createAnimationInputSchema = z.object({ + id: idSchema, + title: optionalTitleToStringSchema, + slides: z.array(animationSlideSchema).min(2, 'At least two slides are required'), + settings: animationSettingsSchema, + url: urlSchema.nullish() +}) + +export const updateAnimationInputSchema = z.object({ + id: idSchema, + title: optionalTitleSchema, + slides: z.array(animationSlideSchema).optional(), + settings: animationSettingsSchema.optional(), + url: urlSchema.nullish() +}) + +export const formatZodError = (err: unknown) => { + if (err instanceof ZodError) { + return err.issues[0]?.message || 'Invalid input' + } + return null +} diff --git a/src/actions/utils/with-auth.ts b/src/actions/utils/with-auth.ts new file mode 100644 index 0000000..dcd134f --- /dev/null +++ b/src/actions/utils/with-auth.ts @@ -0,0 +1,11 @@ +'use server' + +import { requireAuth, type AuthResult } from '@/actions/utils/auth' + +export async function withAuthAction( + input: Input, + handler: (input: Input, ctx: AuthResult) => Promise +): Promise { + const ctx = await requireAuth() + return handler(input, ctx) +} diff --git a/src/app/[shared_link]/error.tsx b/src/app/[shared_link]/error.tsx new file mode 100644 index 0000000..b5e51f4 --- /dev/null +++ b/src/app/[shared_link]/error.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { useEffect } from "react"; +import * as Sentry from "@sentry/nextjs"; + +import { FriendlyError } from "@/components/errors/friendly-error"; + +export default function SharedLinkError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + // Only report to Sentry in production/non-local environments + if (process.env.NODE_ENV === "production") { + Sentry.captureException(error); + } + }, [error]); + + return ( + + ); +} diff --git a/src/app/[shared_link]/page.tsx b/src/app/[shared_link]/page.tsx index 81bcafb..04af0e3 100644 --- a/src/app/[shared_link]/page.tsx +++ b/src/app/[shared_link]/page.tsx @@ -1,11 +1,14 @@ +import { Metadata } from "next"; +import { cookies, headers } from "next/headers"; +import { createHash } from "crypto"; import { notFound, redirect } from "next/navigation"; + import { getSharedLink, trackSharedLinkVisit } from "@/lib/services/shared-link"; import { captureServerEvent } from "@/lib/services/tracking/server"; import { createClient } from "@/utils/supabase/server"; import { JsonLd } from "@/components/seo/json-ld"; import { siteConfig } from "@/lib/utils/site-config"; -import Link from "next/link"; -import { Metadata } from "next"; +import type { Database } from "@/types/database"; type SharedLinkPageProps = { params: Promise<{ @@ -55,6 +58,45 @@ export default async function SharedLinkPage({ params }: SharedLinkPageProps) { return notFound(); } + const cookieStore = await cookies(); + const viewerCookie = cookieStore.get("jc_viewer_token")?.value; + const headerStore = await headers(); + const fallbackTokenSource = `${headerStore.get("x-forwarded-for") ?? ""}|${headerStore.get("user-agent") ?? ""}`; + const hashedFallback = createHash("sha256").update(fallbackTokenSource || shared_link).digest("hex"); + const viewerToken = viewerCookie ?? hashedFallback; + + const supabase = await createClient(); + let viewResult: Database['public']['Functions']['record_public_share_view']['Returns'] | null = null; + + // Only record view if user_id is present + if (data.user_id) { + const { data: viewData, error: viewError } = await supabase.rpc( + "record_public_share_view", + { p_owner_id: data.user_id, p_link_id: data.id, p_viewer_token: viewerToken } + ); + + if (viewError) { + console.error("Failed to record public share view", viewError); + } else { + viewResult = viewData ?? null; + } + } + + const view = viewResult; + + if (view && view.allowed === false) { + return ( +
+
+

View limit reached

+

+ This shared link has reached its monthly view limit. Please ask the owner to upgrade their plan to enable more views. +

+
+
+ ); + } + // Track visit asynchronously (fire and forget) trackSharedLinkVisit(data.id); diff --git a/src/app/animate/animation-client.tsx b/src/app/animate/animation-client.tsx index 6d99e62..8b03e00 100644 --- a/src/app/animate/animation-client.tsx +++ b/src/app/animate/animation-client.tsx @@ -27,6 +27,7 @@ import { useAnimationLimits } from "@/features/animations/hooks/use-animation-li import { AnimationTabs } from "@/features/animation/components/animation-tabs"; import { AnimationBottomBar } from "@/features/animation/components/animation-bottom-bar"; import { ThemeInjector } from "@/features/animation/components/theme-injector"; +import { ClientErrorBoundary } from "@/components/errors/client-error-boundary"; export default function AnimationClientPage() { const router = useRouter(); @@ -147,7 +148,11 @@ export default function AnimationClientPage() { ); return ( - <> + - + ); } diff --git a/src/app/animate/embed/[slug]/error.tsx b/src/app/animate/embed/[slug]/error.tsx new file mode 100644 index 0000000..022a25e --- /dev/null +++ b/src/app/animate/embed/[slug]/error.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { useEffect } from "react"; +import * as Sentry from "@sentry/nextjs"; + +import { FriendlyError } from "@/components/errors/friendly-error"; + +export default function EmbeddedAnimationError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + // Only report to Sentry in production/non-local environments + if (process.env.NODE_ENV === "production") { + Sentry.captureException(error); + } + }, [error]); + + return ( + + ); +} diff --git a/src/app/animate/error.tsx b/src/app/animate/error.tsx new file mode 100644 index 0000000..14ed933 --- /dev/null +++ b/src/app/animate/error.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useEffect } from "react"; +import * as Sentry from "@sentry/nextjs"; +import { analytics } from "@/lib/services/tracking"; +import { ERROR_EVENTS } from "@/lib/services/tracking/events"; + +import { FriendlyError } from "@/components/errors/friendly-error"; + +export default function AnimateError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + // Only report to Sentry in production/non-local environments + if (process.env.NODE_ENV === "production") { + Sentry.captureException(error); + } + // Track error in analytics + analytics.trackError(ERROR_EVENTS.CLIENT_ERROR_OCCURRED, error, { + page: "animate", + digest: error.digest, + }); + }, [error]); + + return ( + + ); +} diff --git a/src/app/animate/shared/[slug]/error.tsx b/src/app/animate/shared/[slug]/error.tsx new file mode 100644 index 0000000..c09a1be --- /dev/null +++ b/src/app/animate/shared/[slug]/error.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { useEffect } from "react"; +import * as Sentry from "@sentry/nextjs"; + +import { FriendlyError } from "@/components/errors/friendly-error"; + +export default function SharedAnimationError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + // Only report to Sentry in production/non-local environments + if (process.env.NODE_ENV === "production") { + Sentry.captureException(error); + } + }, [error]); + + return ( + + ); +} diff --git a/src/app/animate/shared/[slug]/page.tsx b/src/app/animate/shared/[slug]/page.tsx index 6fafdc6..471692a 100644 --- a/src/app/animate/shared/[slug]/page.tsx +++ b/src/app/animate/shared/[slug]/page.tsx @@ -9,6 +9,10 @@ import { import { AnimateSharedClient } from "@/features/animation/shared-view"; import { JsonLd } from "@/components/seo/json-ld"; import { siteConfig } from "@/lib/utils/site-config"; +import { cookies, headers } from "next/headers"; +import { createClient } from "@/utils/supabase/server"; +import { createHash } from "crypto"; +import { FriendlyError } from "@/components/errors/friendly-error"; type SharedAnimationPageProps = { params: Promise<{ @@ -37,12 +41,19 @@ export async function generateMetadata({ params }: SharedAnimationPageProps): Pr firstSlide?.code?.slice(0, 120)?.replace(/\s+/g, " ") || "View animated code snippets from Jolly Code."; - const ogImage = `/api/og-image?slug=${slug}`; + const headerStoreForMetadata = await headers(); + const host = headerStoreForMetadata.get("host") || ""; + const protocol = headerStoreForMetadata.get("x-forwarded-proto") || "https"; + const baseUrl = `${protocol}://${host}`; + const actualUrl = `${baseUrl}/animate/shared/${slug}`; + const ogImage = `${baseUrl}/api/og-image?slug=${slug}`; + const oembedUrl = `${baseUrl}/api/oembed?url=${encodeURIComponent(actualUrl)}`; return { title, description, openGraph: { + url: actualUrl, images: [ogImage], title, description, @@ -55,17 +66,17 @@ export async function generateMetadata({ params }: SharedAnimationPageProps): Pr images: [ogImage], players: [ { - playerUrl: `${siteConfig.url}/animate/embed/${slug}`, - streamUrl: `${siteConfig.url}/animate/embed/${slug}`, + playerUrl: `${protocol}://${host}/animate/embed/${slug}`, + streamUrl: `${protocol}://${host}/animate/embed/${slug}`, width: 800, height: 450, }, ], }, alternates: { - canonical: `/animate/shared/${slug}`, + canonical: actualUrl, types: { - "application/json+oembed": `/api/oembed?url=${encodeURIComponent(`${siteConfig.url}/animate/shared/${slug}`)}`, + "application/json+oembed": oembedUrl, }, }, }; @@ -79,6 +90,43 @@ export default async function SharedAnimationPage({ params }: SharedAnimationPag return notFound(); } + const cookieStore = await cookies(); + const viewerCookie = cookieStore.get("jc_viewer_token")?.value; + const headerStore = await headers(); + const fallbackTokenSource = `${headerStore.get("x-forwarded-for") ?? ""}|${headerStore.get("user-agent") ?? ""}`; + const hashedFallback = createHash("sha256").update(fallbackTokenSource || slug).digest("hex"); + const viewerToken = viewerCookie ?? hashedFallback; + + const supabase = await createClient(); + // Only record view if user_id is present + let viewResult: any = null; + let viewError: any = null; + + if (data.user_id && data.id) { + const result = await supabase.rpc( + "record_public_share_view", + { p_owner_id: data.user_id, p_link_id: data.id, p_viewer_token: viewerToken } + ); + viewResult = result.data; + viewError = result.error; + + if (viewError) { + console.error("Failed to record public share view", viewError); + } + } + + const view = viewResult; + + if (view && view.allowed === false) { + return ( + + ); + } + const encodedPayload = extractAnimationPayloadFromUrl(data.url); if (!encodedPayload) { return notFound(); diff --git a/src/app/api/arcjet/route.ts b/src/app/api/arcjet/route.ts index f9bb46a..57c3eef 100644 --- a/src/app/api/arcjet/route.ts +++ b/src/app/api/arcjet/route.ts @@ -34,7 +34,7 @@ const aj = arcjet({ export async function GET(req: Request) { const decision = await aj.protect(req, { requested: 5 }); // Deduct 5 tokens from the bucket - console.log("Arcjet decision", decision); + if (decision.isDenied()) { if (decision.reason.isRateLimit()) { diff --git a/src/app/api/auth/callback/route.ts b/src/app/api/auth/callback/route.ts index f115993..b3ee616 100644 --- a/src/app/api/auth/callback/route.ts +++ b/src/app/api/auth/callback/route.ts @@ -2,26 +2,82 @@ import { createClient } from "@/utils/supabase/server"; import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; +import { enforceRateLimit, publicLimiter } from "@/lib/arcjet/limiters"; +import { captureServerEvent } from "@/lib/services/tracking/server"; +import { AUTH_EVENTS } from "@/lib/services/tracking/events"; export async function GET(request: NextRequest) { + const limitResponse = await enforceRateLimit(publicLimiter, request, { + tags: ["auth:callback"], + }); + if (limitResponse) return limitResponse; + const requestUrl = new URL(request.url); const code = requestUrl.searchParams.get("code"); const next = requestUrl.searchParams.get("next") || "/"; if (code) { - console.log('Exchanging code for session...'); const supabase = await createClient(); const { data, error } = await supabase.auth.exchangeCodeForSession(code); - + if (error) { - console.error('OAuth callback error:', error.message); + // Track login failure (non-blocking) + void captureServerEvent(AUTH_EVENTS.LOGIN_FAILED, { + properties: { + error_message: error.message, + provider: 'github', + }, + requestMetadata: { + ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }, + }); + // Redirect to home with error parameter return NextResponse.redirect( new URL(`/?error=${encodeURIComponent(error.message)}`, requestUrl.origin) ); } - - console.log('Session established successfully:', !!data.session); + + if (data.session && data.user) { + // Check if this is a new user (signup) by checking if profile exists + const { data: profile } = await supabase + .from('profiles') + .select('created_at') + .eq('id', data.user.id) + .single(); + + const isNewUser = profile && new Date(profile.created_at).getTime() > Date.now() - 60000; // Created within last minute + + // Track login success (non-blocking to avoid delaying redirect) + void captureServerEvent(AUTH_EVENTS.LOGIN_SUCCESS, { + userId: data.user.id, + properties: { + provider: 'github', + is_new_user: isNewUser, + }, + requestMetadata: { + ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }, + }); + + // Track signup if new user (non-blocking) + if (isNewUser) { + void captureServerEvent(AUTH_EVENTS.SIGNUP_DETECTED, { + userId: data.user.id, + properties: { + provider: 'github', + }, + requestMetadata: { + ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }, + }); + } + } + + // Session established successfully - tracking already done above } // URL to redirect to after sign in process completes diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts index 05aecb7..1c42969 100644 --- a/src/app/api/auth/logout/route.ts +++ b/src/app/api/auth/logout/route.ts @@ -1,12 +1,35 @@ import { createClient } from "@/utils/supabase/server"; import { NextResponse } from "next/server"; +import { enforceRateLimit, publicLimiter } from "@/lib/arcjet/limiters"; +import { captureServerEvent } from "@/lib/services/tracking/server"; +import { AUTH_EVENTS } from "@/lib/services/tracking/events"; +import type { NextRequest } from "next/server"; + +export async function POST(request: NextRequest) { + const limitResponse = await enforceRateLimit(publicLimiter, request, { + tags: ["auth:logout"], + }); + if (limitResponse) return limitResponse; -export async function POST(request: Request) { const requestUrl = new URL(request.url); const supabase = await createClient(); + // Get user before signing out + const { data: { user } } = await supabase.auth.getUser(); + await supabase.auth.signOut(); + // Track logout (non-blocking to avoid delaying redirect) + if (user) { + void captureServerEvent(AUTH_EVENTS.LOGOUT, { + userId: user.id, + requestMetadata: { + ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }, + }); + } + return NextResponse.redirect(`${requestUrl.origin}/`, { status: 301, }); diff --git a/src/app/api/billing/invoices/route.ts b/src/app/api/billing/invoices/route.ts new file mode 100644 index 0000000..71771cd --- /dev/null +++ b/src/app/api/billing/invoices/route.ts @@ -0,0 +1,102 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createClient } from "@/utils/supabase/server"; +import { getInvoices, findActiveSubscriptionId } from "@/lib/services/billing"; +import { enforceRateLimit, publicLimiter, strictLimiter } from "@/lib/arcjet/limiters"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + try { + const limitResponse = await enforceRateLimit(publicLimiter, request, { + tags: ["billing:invoices"], + }); + if (limitResponse) return limitResponse; + + // Get authenticated user + const supabase = await createClient(); + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + + if (authError || !user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const userLimited = await enforceRateLimit(strictLimiter, request, { + userId: user.id, + tags: ["billing:invoices", "user"], + }); + if (userLimited) return userLimited; + + // Get customer ID from query params or database + const { searchParams } = new URL(request.url); + let stripeCustomerId = searchParams.get("customerId"); + + // Fetch profile once + const { data: profile, error: profileError } = await supabase + .from("profiles") + .select("stripe_customer_id, stripe_subscription_id, stripe_subscription_status") + .eq("id", user.id) + .single(); + + // Handle profile query errors + if (profileError || !profile) { + return NextResponse.json( + { error: "Profile not found" }, + { status: 404 } + ); + } + + // If not provided, get from database + if (!stripeCustomerId) { + stripeCustomerId = (profile as any)?.stripe_customer_id; + } else { + // Verify the customer ID belongs to the user + if ((profile as any)?.stripe_customer_id !== stripeCustomerId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + } + } + + if (!stripeCustomerId) { + return NextResponse.json( + { error: "No active subscription found" }, + { status: 400 } + ); + } + + // Fetch invoices from Stripe + try { + // Pass subscription ID if available to filter invoices + let subscriptionId = (profile as any)?.stripe_subscription_id; + const status = (profile as any)?.stripe_subscription_status; + + // If DB says no subscription or canceled, but user might have just upgraded (stale DB), + // try to find the actual active subscription from Stripe to ensure we show the right invoices. + if (!subscriptionId || (status !== 'active' && status !== 'trialing')) { + const activeSubId = await findActiveSubscriptionId(stripeCustomerId); + if (activeSubId) { + subscriptionId = activeSubId; + } + } + + const invoices = await getInvoices(stripeCustomerId, 10, subscriptionId); + return NextResponse.json({ invoices }); + } catch (error: any) { + // getInvoices already handles resource_missing and returns [] + // This catch only handles unexpected errors + console.error("Invoices API error:", error); + return NextResponse.json( + { error: "Failed to fetch invoices" }, + { status: 500 } + ); + } + } catch (error) { + console.error("Invoices API error:", error); + return NextResponse.json( + { error: "Failed to fetch invoices" }, + { status: 500 } + ); + } +} + diff --git a/src/app/api/billing/payment-method/route.ts b/src/app/api/billing/payment-method/route.ts new file mode 100644 index 0000000..3ada02a --- /dev/null +++ b/src/app/api/billing/payment-method/route.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createClient } from "@/utils/supabase/server"; +import { getPaymentMethod } from "@/lib/services/billing"; +import { enforceRateLimit, publicLimiter, strictLimiter } from "@/lib/arcjet/limiters"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + try { + const limitResponse = await enforceRateLimit(publicLimiter, request, { + tags: ["billing:payment-method"], + }); + if (limitResponse) return limitResponse; + + // Get authenticated user + const supabase = await createClient(); + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + + if (authError || !user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const userLimited = await enforceRateLimit(strictLimiter, request, { + userId: user.id, + tags: ["billing:payment-method", "user"], + }); + if (userLimited) return userLimited; + + // Get customer ID from query params or database + const { searchParams } = new URL(request.url); + let customerId = searchParams.get("customerId"); + + // Get user's Stripe customer ID and subscription ID from database + const { data: profile } = await supabase + .from("profiles") + .select("stripe_customer_id, stripe_subscription_id") + .eq("id", user.id) + .single(); + + const userCustomerId = (profile?.stripe_customer_id ?? null) as string | null; + const subscriptionId = (profile?.stripe_subscription_id ?? null) as string | null; + + // If customerId provided in query, verify it matches + if (customerId && customerId !== userCustomerId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); + } + + // Use customer ID from database if not provided + customerId = customerId || userCustomerId; + + if (!customerId) { + return NextResponse.json( + { error: "No active subscription found" }, + { status: 400 } + ); + } + + // Fetch payment method from Stripe (check customer first, then subscription as fallback) + try { + const paymentMethod = await getPaymentMethod(customerId, subscriptionId); + return NextResponse.json({ paymentMethod }); + } catch (error) { + // Handle unexpected errors (getPaymentMethod already handles resource_missing) + console.error("Payment method API error:", error); + return NextResponse.json( + { error: "Failed to fetch payment method" }, + { status: 500 } + ); + } + } catch (error) { + console.error("Payment method API error:", error); + return NextResponse.json( + { error: "Failed to fetch payment method" }, + { status: 500 } + ); + } +} + diff --git a/src/app/api/changelog/route.ts b/src/app/api/changelog/route.ts index 697dbec..1a3f58e 100644 --- a/src/app/api/changelog/route.ts +++ b/src/app/api/changelog/route.ts @@ -1,9 +1,15 @@ import { NextRequest, NextResponse } from "next/server"; +import { enforceRateLimit, publicLimiter } from "@/lib/arcjet/limiters"; const CANNY_ENTRIES_URL = "https://canny.io/api/v1/entries/list"; export async function POST(req: NextRequest | Request) { try { + const limitResponse = await enforceRateLimit(publicLimiter, req as Request, { + tags: ["changelog"], + }); + if (limitResponse) return limitResponse; + const apiKey = process.env.CANNY_API_KEY; if (!apiKey) { diff --git a/src/app/api/checkout/route.ts b/src/app/api/checkout/route.ts new file mode 100644 index 0000000..09a5776 --- /dev/null +++ b/src/app/api/checkout/route.ts @@ -0,0 +1,221 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/utils/supabase/server'; +import { + getOrCreateStripeCustomer, + createCheckoutSession, + getStripePriceId, +} from '@/lib/services/stripe'; +import type { PlanId } from '@/lib/config/plans'; +import { enforceRateLimit, publicLimiter, strictLimiter } from '@/lib/arcjet/limiters'; +import { resolveBaseUrl } from '@/lib/utils/resolve-base-url'; +import type { Database } from '@/types/database'; +import { captureServerEvent } from '@/lib/services/tracking/server'; +import { BILLING_EVENTS } from '@/lib/services/tracking/events'; + +export const dynamic = 'force-dynamic'; + +type CheckoutRequestBody = { + plan: PlanId; + interval: 'monthly' | 'yearly'; +}; + +export async function POST(request: NextRequest) { + try { + const limitResponse = await enforceRateLimit(publicLimiter, request, { + tags: ["checkout:create"], + }); + if (limitResponse) return limitResponse; + + // Get authenticated user + const supabase = await createClient(); + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const userLimited = await enforceRateLimit(strictLimiter, request, { + userId: user.id, + tags: ["checkout:create", "user"], + }); + if (userLimited) return userLimited; + + // Parse request body + let body: CheckoutRequestBody; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + const { plan, interval } = body; + + // Track checkout started (non-blocking) + void captureServerEvent(BILLING_EVENTS.CHECKOUT_STARTED, { + userId: user.id, + properties: { + plan, + interval, + }, + requestMetadata: { + ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }, + }); + + // Validate plan + if (plan === 'free') { + return NextResponse.json( + { error: 'Cannot create checkout session for free plan' }, + { status: 400 } + ); + } + + if (!['starter', 'pro'].includes(plan)) { + return NextResponse.json({ error: 'Invalid plan' }, { status: 400 }); + } + + if (!['monthly', 'yearly'].includes(interval)) { + return NextResponse.json({ error: 'Invalid billing interval' }, { status: 400 }); + } + + // Get user profile + const { data: profile } = await supabase + .from('profiles') + .select('username, stripe_customer_id') + .eq('id', user.id) + .single(); + + const typedProfile: Pick< + Database['public']['Tables']['profiles']['Row'], + 'username' | 'stripe_customer_id' + > | null = profile; + + if (!user.email) { + return NextResponse.json({ error: 'User email not found' }, { status: 400 }); + } + + // Get or create Stripe customer + const customer = await getOrCreateStripeCustomer({ + userId: user.id, + email: user.email, + name: typedProfile?.username ?? undefined, + }); + + // Update profile with Stripe customer ID if not already set + const existingCustomerId = typedProfile?.stripe_customer_id; + if (!existingCustomerId) { + const { error: updateError } = await supabase + .from('profiles') + .update({ stripe_customer_id: customer.id }) + .eq('id', user.id); + if (updateError) { + console.warn('Failed to update stripe_customer_id:', updateError); + } + } + + // Check if user already has an active subscription + // We check the profile first as it's faster than Stripe API + const { data: subscriptionData } = await supabase + .from('profiles') + .select('stripe_subscription_status') + .eq('id', user.id) + .single(); + + const hasActiveSubscription = + subscriptionData?.stripe_subscription_status === 'active' || + subscriptionData?.stripe_subscription_status === 'trialing'; + + if (hasActiveSubscription) { + return NextResponse.json( + { + error: 'You already have an active subscription. Please use the customer portal to manage your plan.', + code: 'ACTIVE_SUBSCRIPTION_EXISTS' + }, + { status: 409 } + ); + } + + // Get price ID for the plan + let priceId: string; + try { + priceId = getStripePriceId(plan, interval); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + console.error('Failed to get Stripe price ID:', errorMessage); + return NextResponse.json( + { + error: `Checkout configuration error: ${errorMessage}. Please contact support if this issue persists.`, + }, + { status: 500 } + ); + } + + // Validate price ID before proceeding + if (!priceId || priceId.trim() === '') { + return NextResponse.json( + { + error: 'Checkout configuration error: Stripe price ID is missing. Please contact support.', + }, + { status: 500 } + ); + } + + // Create checkout session + const appUrl = resolveBaseUrl(request.headers); + let session; + try { + session = await createCheckoutSession({ + customerId: customer.id, + priceId, + successUrl: `${appUrl}/checkout/success?session_id={CHECKOUT_SESSION_ID}`, + cancelUrl: `${appUrl}/checkout/canceled?session_id={CHECKOUT_SESSION_ID}`, + metadata: { + userId: user.id, + plan, + interval, + }, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + console.error('Failed to create Stripe checkout session:', errorMessage); + // If it's a validation error from createCheckoutSession, return it directly + if (errorMessage.includes('Invalid Stripe price ID') || errorMessage.includes('price ID')) { + return NextResponse.json( + { + error: `Checkout configuration error: ${errorMessage}. Please contact support.`, + }, + { status: 500 } + ); + } + return NextResponse.json( + { error: 'Failed to create checkout session. Please try again later.' }, + { status: 500 } + ); + } + + // Track checkout redirected (non-blocking to avoid delaying redirect) + void captureServerEvent(BILLING_EVENTS.CHECKOUT_REDIRECTED, { + userId: user.id, + properties: { + plan, + interval, + session_id: session.id, + }, + requestMetadata: { + ip: request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined, + userAgent: request.headers.get('user-agent') || undefined, + }, + }); + + return NextResponse.json({ sessionId: session.id, url: session.url }); + } catch (error) { + console.error('Checkout error:', error); + return NextResponse.json( + { error: 'Failed to create checkout session' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/cron/check-usage/route.ts b/src/app/api/cron/check-usage/route.ts new file mode 100644 index 0000000..952f1de --- /dev/null +++ b/src/app/api/cron/check-usage/route.ts @@ -0,0 +1,190 @@ +import { NextRequest, NextResponse } from 'next/server'; +import * as Sentry from '@sentry/nextjs'; +import React from 'react'; +import { timingSafeEqual } from 'crypto'; + +import { createServiceRoleClient } from '@/utils/supabase/admin'; +import { getUserUsage } from '@/lib/services/usage-limits'; +import { getMaxUsagePercentage } from '@/lib/utils/usage-helpers'; +import { sendEmail } from '@/lib/email/send-email'; +import UsageLimitWarningEmail from '@emails/usage-limit-email'; +import { env } from '@/env.mjs'; + +export const dynamic = 'force-dynamic'; + +const USAGE_WARNING_THRESHOLD = 90; // Send warning at 90% usage + +interface UserWithUsage { + id: string; + email: string; // Non-null after validation + username: string | null; + usagePercentage: number; +} + +/** + * GET /api/cron/check-usage + * + * Protected cron endpoint that checks all users' usage and sends warning emails + * to users exceeding 90% of their limits. + * + * Secured with CRON_SECRET via Authorization header. + */ +export async function GET(request: NextRequest) { + try { + // Verify authentication via Authorization header only + const authHeader = request.headers.get('authorization'); + const providedSecret = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null; + + // Fast-return if secret is missing + if (!providedSecret) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + // Use timing-safe comparison to prevent timing attacks + const expectedSecret = env.CRON_SECRET ?? ''; + const providedBuffer = Buffer.from(providedSecret, 'utf8'); + const expectedBuffer = Buffer.from(expectedSecret, 'utf8'); + + // Pad buffers to equal length for timing-safe comparison + const maxLength = Math.max(providedBuffer.length, expectedBuffer.length); + const paddedProvided = Buffer.alloc(maxLength); + const paddedExpected = Buffer.alloc(maxLength); + providedBuffer.copy(paddedProvided); + expectedBuffer.copy(paddedExpected); + + if (!timingSafeEqual(paddedProvided, paddedExpected)) { + return NextResponse.json( + { error: 'Unauthorized' }, + { status: 401 } + ); + } + + const supabase = createServiceRoleClient(); + const usersToNotify: UserWithUsage[] = []; + + // Fetch all users with emails (paginate if needed) + // For now, we'll fetch in batches to handle large user bases + const BATCH_SIZE = 100; + let offset = 0; + let totalProcessed = 0; + let hasMore = true; + + while (hasMore) { + const { data: profiles, error: profilesError } = await supabase + .from('profiles') + .select('id, email, username') + .not('email', 'is', null) + .range(offset, offset + BATCH_SIZE - 1); + + if (profilesError) { + console.error('[check-usage] Error fetching profiles:', profilesError); + Sentry.captureException(profilesError, { + tags: { operation: 'check_usage_fetch_profiles' }, + }); + break; + } + + if (!profiles || profiles.length === 0) { + hasMore = false; + break; + } + + // Check usage for each user + for (const profile of profiles) { + if (!profile.email || !profile.id) { + continue; + } + + try { + const usage = await getUserUsage(supabase, profile.id); + const maxUsagePercentage = getMaxUsagePercentage(usage); + + // If user is at or above threshold, add to notification list + // profile.email is guaranteed non-null due to the filter above + if (maxUsagePercentage >= USAGE_WARNING_THRESHOLD && profile.email) { + usersToNotify.push({ + id: profile.id, + email: profile.email, + username: profile.username ?? null, + usagePercentage: Math.round(maxUsagePercentage), + }); + } + } catch (usageError) { + // Log but continue processing other users + console.error(`[check-usage] Error getting usage for user ${profile.id}:`, usageError); + Sentry.captureException(usageError, { + level: 'warning', + tags: { operation: 'check_usage_get_user_usage' }, + extra: { userId: profile.id }, + }); + } + } + + // Track actual number of users processed + totalProcessed += profiles.length; + + // Check if we have more users to process + hasMore = profiles.length === BATCH_SIZE; + offset += BATCH_SIZE; + + // Safety limit to prevent infinite loops + if (totalProcessed > 10000) { + console.warn('[check-usage] Reached safety limit of 10,000 users'); + break; + } + } + + // Send warning emails to users exceeding threshold + const emailResults = { + success: 0, + failed: 0, + }; + + for (const user of usersToNotify) { + try { + await sendEmail({ + to: user.email, + subject: `Usage Alert: You've reached ${user.usagePercentage}% of your limit`, + react: React.createElement(UsageLimitWarningEmail, { + usagePercent: user.usagePercentage, + userName: user.username ?? undefined, + }), + // Use idempotency key to prevent duplicate emails if cron runs multiple times + idempotencyKey: `usage-warning-${user.id}-${Math.floor(Date.now() / (1000 * 60 * 60 * 24))}`, // Daily idempotency + }); + emailResults.success++; + console.log(`[check-usage] Sent usage warning email to user ${user.id}`); + } catch (emailError) { + emailResults.failed++; + console.error(`[check-usage] Failed to send email to user ${user.id}:`, emailError); + Sentry.captureException(emailError, { + level: 'error', + tags: { operation: 'check_usage_send_email' }, + extra: { userId: user.id }, + }); + } + } + + return NextResponse.json({ + success: true, + usersChecked: totalProcessed, + usersNotified: usersToNotify.length, + emailsSent: emailResults.success, + emailsFailed: emailResults.failed, + }); +} catch (error) { + console.error('[check-usage] Unexpected error:', error); + Sentry.captureException(error, { + tags: { operation: 'check_usage' }, + }); + + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 } + ); + } +} + diff --git a/src/app/api/customer-portal/route.ts b/src/app/api/customer-portal/route.ts new file mode 100644 index 0000000..548aa6e --- /dev/null +++ b/src/app/api/customer-portal/route.ts @@ -0,0 +1,114 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { createClient } from '@/utils/supabase/server'; +import type Stripe from "stripe"; + +import { createCustomerPortalSession } from '@/lib/services/stripe'; +import { enforceRateLimit, publicLimiter, strictLimiter } from '@/lib/arcjet/limiters'; +import { env } from '@/env.mjs'; +import type { Database } from '@/types/database'; + +export const dynamic = 'force-dynamic'; + +type Profile = Pick; + +export async function POST(request: NextRequest) { + try { + const limitResponse = await enforceRateLimit(publicLimiter, request, { + tags: ["customer-portal"], + }); + if (limitResponse) return limitResponse; + + // Get authenticated user + const supabase = await createClient(); + const { + data: { user }, + error: authError, + } = await supabase.auth.getUser(); + + if (authError || !user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const userLimited = await enforceRateLimit(strictLimiter, request, { + userId: user.id, + tags: ["customer-portal", "user"], + }); + if (userLimited) return userLimited; + + // Get user's Stripe customer ID + const { data: profile } = await supabase + .from('profiles') + .select('stripe_customer_id') + .eq('id', user.id) + .single(); + + // Get flow intent from request + let flow: "update_subscription" | undefined; + try { + const body = await request.json(); + if (body.flow === "update_subscription") { + flow = "update_subscription"; + } + } catch { + // Body might be empty, that's fine + } + + const stripeCustomerId = profile?.stripe_customer_id; + if (!stripeCustomerId) { + return NextResponse.json( + { error: 'No active subscription found' }, + { status: 400 } + ); + } + + // Prepare flow data if requested + let flow_data: Stripe.BillingPortal.SessionCreateParams.FlowData | undefined; + + if (flow === "update_subscription") { + // We need an active subscription ID for this flow + // Use the robust finder we added to billing.ts to ensure we get the real active one + // even if the DB is stale + const { findActiveSubscriptionId } = await import("@/lib/services/billing"); + const activeSubscriptionId = await findActiveSubscriptionId(stripeCustomerId); + + if (activeSubscriptionId) { + flow_data = { + type: "subscription_update", + subscription_update: { + subscription: activeSubscriptionId, + }, + }; + } + } + + // Create customer portal session + let portalSession; + try { + portalSession = await createCustomerPortalSession({ + customerId: stripeCustomerId, + returnUrl: `${env.NEXT_PUBLIC_APP_URL}/?stripe_action=portal_return`, + flow_data, + }); + } catch (error) { + // If we tried a specific flow and it failed (e.g. feature disabled in Stripe), + // fallback to the generic portal so the user isn't stuck. + if (flow_data) { + console.warn('Portal deep link failed, retrying with generic session:', error); + portalSession = await createCustomerPortalSession({ + customerId: stripeCustomerId, + returnUrl: `${env.NEXT_PUBLIC_APP_URL}/?stripe_action=portal_return`, + }); + } else { + throw error; + } + } + + return NextResponse.json({ url: portalSession.url }); + } catch (error: any) { + console.error('Customer portal error:', error); + return NextResponse.json( + { error: error?.message || 'Failed to create customer portal session' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/keepalive/route.ts b/src/app/api/keepalive/route.ts index 57561c1..d43f473 100644 --- a/src/app/api/keepalive/route.ts +++ b/src/app/api/keepalive/route.ts @@ -3,10 +3,15 @@ import { createClient } from '@supabase/supabase-js' import { NextRequest, NextResponse } from 'next/server' import { applyRequestContextToSentry, applyResponseContextToSentry } from '@/lib/sentry-context' +import { enforceRateLimit, publicLimiter } from '@/lib/arcjet/limiters' export const GET = wrapRouteHandlerWithSentry( async function GET(request: NextRequest) { applyRequestContextToSentry({ request }) + const limitResponse = await enforceRateLimit(publicLimiter, request, { + tags: ["keepalive"], + }); + if (limitResponse) return limitResponse; try { const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL diff --git a/src/app/api/liveblocks-auth/route.ts b/src/app/api/liveblocks-auth/route.ts index 7776413..fd3518f 100644 --- a/src/app/api/liveblocks-auth/route.ts +++ b/src/app/api/liveblocks-auth/route.ts @@ -3,6 +3,7 @@ import { createClient } from "@/utils/supabase/server"; import { Liveblocks } from "@liveblocks/node"; import { wrapRouteHandlerWithSentry } from "@sentry/nextjs"; import { NextRequest, NextResponse } from "next/server"; +import { authedLimiter, enforceRateLimit, publicLimiter } from "@/lib/arcjet/limiters"; const API_KEY = process.env.LIVEBLOCKS_SECRET_KEY!; @@ -11,6 +12,10 @@ const liveblocks = new Liveblocks({ secret: API_KEY }); export const POST = wrapRouteHandlerWithSentry( async function POST(request: NextRequest) { applyRequestContextToSentry({ request }); + const limitResponse = await enforceRateLimit(publicLimiter, request, { + tags: ["liveblocks-auth"], + }); + if (limitResponse) return limitResponse; const supabase = await createClient(); @@ -21,6 +26,12 @@ export const POST = wrapRouteHandlerWithSentry( let userId = "anonymous"; if (data && data.user?.id) { userId = data.user.id; + + const userLimited = await enforceRateLimit(authedLimiter, request, { + userId, + tags: ["liveblocks-auth", "user"], + }); + if (userLimited) return userLimited; } applyRequestContextToSentry({ @@ -45,6 +56,7 @@ export const POST = wrapRouteHandlerWithSentry( } catch (e) { console.error("Error authorizing session:", e); applyResponseContextToSentry(500); + return new NextResponse("Internal Server Error", { status: 500 }); } }, { diff --git a/src/app/api/oembed/route.ts b/src/app/api/oembed/route.ts index 0a7720b..4653527 100644 --- a/src/app/api/oembed/route.ts +++ b/src/app/api/oembed/route.ts @@ -2,8 +2,14 @@ import { NextRequest, NextResponse } from "next/server"; import { siteConfig } from "@/lib/utils/site-config"; import { getSharedLink } from "@/lib/services/shared-link"; import { decodeAnimationSharePayload, extractAnimationPayloadFromUrl } from "@/features/animation/share-utils"; +import { enforceRateLimit, publicLimiter } from "@/lib/arcjet/limiters"; export async function GET(request: NextRequest) { + const limitResponse = await enforceRateLimit(publicLimiter, request, { + tags: ["oembed"], + }); + if (limitResponse) return limitResponse; + const searchParams = request.nextUrl.searchParams; const urlParam = searchParams.get("url"); @@ -49,7 +55,10 @@ export async function GET(request: NextRequest) { } } - const embedUrl = `${siteConfig.url}/animate/embed/${slug}`; + // Use the actual request URL to support Vercel previews and localhost + const requestUrl = new URL(request.url); + const baseUrl = `${requestUrl.protocol}//${requestUrl.host}`; + const embedUrl = `${baseUrl}/animate/embed/${slug}`; // Return oEmbed JSON with CORS headers return NextResponse.json( @@ -58,7 +67,7 @@ export async function GET(request: NextRequest) { version: "1.0", title: title, provider_name: siteConfig.title, - provider_url: siteConfig.url, + provider_url: baseUrl, width: width, height: height, html: ``, diff --git a/src/app/api/og-image/route.tsx b/src/app/api/og-image/route.tsx index 48814c4..f15e2d0 100644 --- a/src/app/api/og-image/route.tsx +++ b/src/app/api/og-image/route.tsx @@ -1,51 +1,21 @@ import { ImageResponse } from "next/og"; +import { highlightCodeForOG, renderStyledSegments, truncateCodeForOG } from "@/lib/utils/og-syntax-highlight"; +import { getThemeColors } from "@/lib/og-theme-colors"; export const runtime = "edge"; export const dynamic = "force-dynamic"; export const contentType = "image/png"; -export const size = { - width: 1200, - height: 630, -}; - -type Slide = { - title?: string; - code?: string; - language?: string; -}; +export const size = { width: 1200, height: 630 }; -type Payload = { - slides?: Slide[]; - editor?: { fontFamily?: string }; -}; +type Slide = { title?: string; code?: string; language?: string }; +type Payload = { slides?: Slide[]; editor?: { fontFamily?: string; backgroundTheme?: string } }; const safeDecodePayload = (raw: string | null): Payload | null => { if (!raw) return null; - - const decode = (value: string) => { - try { - // Next.js automatically URL-decodes searchParams, so we just need to base64 decode - if (typeof atob === "function") { - const decoded = atob(value); - return decodeURIComponent(decoded); - } - if (typeof Buffer !== "undefined") { - const decoded = Buffer.from(value, "base64").toString("utf-8"); - return decodeURIComponent(decoded); - } - return ""; - } catch (error) { - console.error("Failed to decode base64:", error); - return ""; - } - }; - try { - const json = decode(raw); - if (!json) return null; - return JSON.parse(json) as Payload; - } catch (error) { - console.error("Failed to parse JSON from decoded payload:", error); + const decoded = atob(raw); + return JSON.parse(decodeURIComponent(decoded)) as Payload; + } catch { return null; } }; @@ -55,9 +25,8 @@ export async function GET(request: Request) { const { searchParams } = new URL(request.url); const payloadParam = searchParams.get("payload"); const slugParam = searchParams.get("slug"); - const titleOverride = searchParams.get("title") ?? undefined; - const descriptionOverride = searchParams.get("description") ?? undefined; - const mode = searchParams.get("mode") ?? "social"; // "social" or "embed" + const titleOverride = searchParams.get("title"); + const descriptionOverride = searchParams.get("description"); let decodedPayload: Payload | null = null; let titleFromDb: string | undefined; @@ -66,8 +35,13 @@ export async function GET(request: Request) { // If slug is provided, fetch from database if (slugParam) { try { - const { getSharedLink } = await import("@/lib/services/shared-link"); - const { extractAnimationPayloadFromUrl, decodeAnimationSharePayload } = await import("@/features/animation/share-utils"); + const [{ getSharedLink }, { extractAnimationPayloadFromUrl, decodeAnimationSharePayload }] = await Promise.all([ + import("@/lib/services/shared-link").then(m => ({ getSharedLink: m.getSharedLink })), + import("@/features/animation/share-utils").then(m => ({ + extractAnimationPayloadFromUrl: m.extractAnimationPayloadFromUrl, + decodeAnimationSharePayload: m.decodeAnimationSharePayload + })) + ]); const data = await getSharedLink(slugParam); if (data?.url) { @@ -78,152 +52,56 @@ export async function GET(request: Request) { descriptionFromDb = data.description ?? undefined; } } - } catch (error) { - console.error("Failed to fetch shared link:", error); - } + } catch {} } else if (payloadParam) { - // Otherwise use the payload parameter decodedPayload = safeDecodePayload(payloadParam); - - if (!decodedPayload) { - console.error("Failed to decode payload parameter"); - } } - const fallbackSlide: Slide = { + const firstSlide = decodedPayload?.slides?.[0] || { title: titleOverride || "Shared animation", - code: "// No preview available. Generate a link to see your animation here.", - language: "typescript", + code: "// No preview", + language: "javascript", }; - const firstSlide = decodedPayload?.slides?.[0] ?? fallbackSlide; - - const title = titleFromDb || titleOverride || firstSlide?.title || "Shared animation"; - const description = - descriptionFromDb || descriptionOverride || decodedPayload?.editor?.fontFamily || "Bring your code to life with animated snippets."; - - const codeSnippet = - (firstSlide?.code || fallbackSlide.code)?.split("\n").slice(0, 6).join("\n").slice(0, 200) || - fallbackSlide.code; + const title = titleFromDb || titleOverride || firstSlide.title || "Shared animation"; + const rawCode = firstSlide.code || "// No code"; + const language = firstSlide.language || "javascript"; + const backgroundTheme = decodedPayload?.editor?.backgroundTheme || "sublime"; + + // Truncate and highlight code + const codeSnippet = truncateCodeForOG(rawCode, 10, 80); + const styledSegments = highlightCodeForOG(codeSnippet, language, backgroundTheme); + const renderableSegments = renderStyledSegments(styledSegments); + const themeColors = getThemeColors(backgroundTheme); + const background = "linear-gradient(135deg, #f472b6 0%, #a78bfa 50%, #60a5fa 100%)"; - // Get the background theme from payload - const backgroundTheme = (decodedPayload as any)?.editor?.backgroundTheme || "sublime"; - - // Theme background mapping (matching themes-options.ts) - const themeBackgrounds: Record = { - sublime: "linear-gradient(135deg, #f472b6 0%, #a78bfa 50%, #60a5fa 100%)", - hyper: "linear-gradient(135deg, #ec4899 0%, #8b5cf6 50%, #3b82f6 100%)", - dracula: "linear-gradient(135deg, #ff79c6 0%, #bd93f9 50%, #6272a4 100%)", - monokai: "linear-gradient(135deg, #f92672 0%, #66d9ef 50%, #a6e22e 100%)", - nord: "linear-gradient(135deg, #88c0d0 0%, #81a1c1 50%, #5e81ac 100%)", - gotham: "linear-gradient(135deg, #2aa889 0%, #599cab 50%, #4e5165 100%)", - blue: "linear-gradient(135deg, #60a5fa 0%, #3b82f6 50%, #2563eb 100%)", - nightOwl: "linear-gradient(135deg, #c792ea 0%, #7fdbca 50%, #82aaff 100%)", - }; - - // Choose background based on mode - const background = mode === "embed" - ? (themeBackgrounds[backgroundTheme] || themeBackgrounds.sublime) // Use theme gradient for embed - : "linear-gradient(135deg, #f472b6 0%, #a78bfa 50%, #60a5fa 100%)"; // Default gradient for social - - return new ImageResponse( - ( -
-
- {/* Window Header */} -
- {/* Window Controls */} -
-
-
-
-
- - {/* Title Pill */} -
- {title} + try { + return new ImageResponse( +
+
+
+
+
+
+
- - {/* Spacer for centering */} -
+
{title}
+
- - {/* Code Content */} -
-
-                {codeSnippet}
-              
+
+ {renderableSegments.map((line,i)=>
{line.segments.map((seg,j)=>{seg.text})}{line.segments.length===0&& }
)}
-
- ), - { - ...size, - headers: { - "Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400", - }, - } - ); +
jollycode.dev
+
, + {...size,headers:{"Cache-Control":"public, s-maxage=3600, stale-while-revalidate=86400"}} + ); + } catch { + return new ImageResponse(
,size); + } } catch (error) { console.error("OG image generation failed", error); return new ImageResponse(
, size); } } + diff --git a/src/app/api/save-shared-url-visits/route.ts b/src/app/api/save-shared-url-visits/route.ts index 6de0733..5e5bca6 100644 --- a/src/app/api/save-shared-url-visits/route.ts +++ b/src/app/api/save-shared-url-visits/route.ts @@ -3,10 +3,19 @@ import { wrapRouteHandlerWithSentry } from "@sentry/nextjs"; import { NextRequest, NextResponse } from "next/server"; import { SupabaseClient, createClient } from "@supabase/supabase-js"; import { validateContentType } from "@/lib/utils/validate-content-type-request"; +import { enforceRateLimit, publicLimiter } from "@/lib/arcjet/limiters"; + +export const runtime = "nodejs"; const supabase: SupabaseClient = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + process.env.SUPABASE_SERVICE_ROLE_KEY!, + { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + }, ); /** @@ -18,9 +27,18 @@ const supabase: SupabaseClient = createClient( export const POST = wrapRouteHandlerWithSentry( async function POST(request: NextRequest) { applyRequestContextToSentry({ request }); + const limitResponse = await enforceRateLimit(publicLimiter, request, { + tags: ["shared-url:visits"], + }); + if (limitResponse) return limitResponse; try { - const { id } = await await validateContentType(request).json(); + const requestResult = validateContentType(request); + if (requestResult instanceof NextResponse) { + return requestResult; + } + + const { id } = await requestResult.json(); if (!id) { applyResponseContextToSentry(400); diff --git a/src/app/api/shorten-url/route.ts b/src/app/api/shorten-url/route.ts index 4e6874e..19ea170 100644 --- a/src/app/api/shorten-url/route.ts +++ b/src/app/api/shorten-url/route.ts @@ -1,21 +1,32 @@ -import { - applyRequestContextToSentry, - applyResponseContextToSentry, -} from "@/lib/sentry-context"; import { isValidURL } from "@/lib/utils/is-valid-url"; import { validateContentType } from "@/lib/utils/validate-content-type-request"; -import { Database } from "@/types/database"; -import { createClient } from "@/utils/supabase/server"; -import { wrapRouteHandlerWithSentry } from "@sentry/nextjs"; +import { cookies } from "next/headers"; import { NextRequest, NextResponse } from "next/server"; import { nanoid } from "nanoid"; +import { enforceRateLimit, publicLimiter, strictLimiter } from "@/lib/arcjet/limiters"; +import { withAuthRoute } from "@/lib/auth/with-auth-route"; +import { createClient } from "@/utils/supabase/server"; +import type { Database } from "@/types/database"; -export const runtime = "edge"; - +export const runtime = "nodejs"; +const VIEWER_COOKIE = "jc_viewer_token"; -const shortURLs: { [key: string]: string } = {}; -const keySet: Set = new Set(); +/** + * Sets the viewer cookie on a NextResponse with consistent options. + * + * @param {NextResponse} response - The NextResponse to set the cookie on + * @param {string} token - The viewer token to set + */ +function setViewerCookie(response: NextResponse, token: string): void { + response.cookies.set(VIEWER_COOKIE, token, { + path: "/", + maxAge: 60 * 60 * 24 * 365, + httpOnly: true, + sameSite: "lax", + secure: process.env.NODE_ENV === "production", + }); +} /** * Retrieves the URL associated with a given slug. @@ -23,139 +34,195 @@ const keySet: Set = new Set(); * @param {NextRequest} request - an url * @return {Promise} A promise that resolves to a NextResponse object containing the URL associated with the slug, or an error response if the slug is invalid or not found. */ -export const GET = wrapRouteHandlerWithSentry( - async function GET(request: NextRequest) { - applyRequestContextToSentry({ request }); - const supabase = await createClient(); - const url = new URL(request.url); +export async function GET(request: NextRequest) { + const limitResponse = await enforceRateLimit(publicLimiter, request, { + tags: ["shorten-url:get"], + }); + if (limitResponse) return limitResponse; + + const supabase = await createClient(); + const url = new URL(request.url); + + const slug = url.searchParams.get("slug"); + + if (!slug) { + return NextResponse.json( + { error: "No slug was provided." }, + { status: 400 } + ); + } - const slug = url.searchParams.get("slug"); + let data; + try { + const result = await supabase + .from("links") + .select("id, url, title, description, user_id") + .eq("short_url", slug); - if (!slug) { - applyResponseContextToSentry(400); + const dbError = result.error; + if (dbError) { return NextResponse.json( - { error: "No slug was provided." }, - { status: 400 } + { error: "Database error", details: dbError.message }, + { status: 500 } ); } - let data; - let error; + data = result.data; + } catch (err) { + return NextResponse.json( + { error: "Database error", details: err instanceof Error ? err.message : String(err) }, + { status: 500 } + ); + } + + if (!data || !data[0]) { + return NextResponse.json({ error: "URL not found." }, { status: 404 }); + } + + const link = data[0]; + + const cookieStore = await cookies(); + let viewerToken = cookieStore.get(VIEWER_COOKIE)?.value; + + if (!viewerToken) { + viewerToken = nanoid(24); + } + + let viewResult: Database['public']['Functions']['record_public_share_view']['Returns'] | null = null; + + // Only record view if user_id is present + if (link.user_id && link.id) { try { - const result = await supabase - .from("links") - .select("id, url, title, description") - .eq("short_url", slug); + const { data: recordViewData, error: recordViewError } = await supabase.rpc( + "record_public_share_view", + { p_owner_id: link.user_id, p_link_id: link.id, p_viewer_token: viewerToken } + ); - data = result.data; + if (recordViewError) { + throw recordViewError; + } - error = result.error; - } catch (error: Error | any) { - error = error.message; + viewResult = recordViewData ?? null; + } catch (recordError) { + console.error("Failed to record share view", recordError); } + } + + if (viewResult && viewResult.allowed === false) { + const response = NextResponse.json( + { + error: "View limit reached for this shared link.", + current: viewResult.current ?? null, + max: viewResult.max ?? null, + plan: viewResult.plan ?? null, + }, + { status: 429 } + ); - if (!data) { - applyResponseContextToSentry(404); - return NextResponse.json({ error: "URL not found." }, { status: 404 }); + if (!cookieStore.get(VIEWER_COOKIE)?.value && viewerToken) { + setViewerCookie(response, viewerToken); } - applyResponseContextToSentry(200); - return NextResponse.json({ - status: 200, - id: data[0].id, - url: data[0].url, - }); - }, - { - method: "GET", - parameterizedRoute: "/api/shorten-url", - }, -); - -export const POST = wrapRouteHandlerWithSentry( - async function POST(request: NextRequest) { - applyRequestContextToSentry({ request }); - const supabase = await createClient(); - try { - const contentType = await request.headers.get("content-type"); - if (contentType !== "application/json") { - applyResponseContextToSentry(415); - return NextResponse.json({ error: "Invalid request" }, { status: 415 }); - } + return response; + } - const { url, snippet_id, user_id, title, description } = - await validateContentType(request).json(); + const response = NextResponse.json({ + status: 200, + id: link.id, + url: link.url, + }); - applyRequestContextToSentry({ request, userId: user_id }); + if (!cookieStore.get(VIEWER_COOKIE)?.value && viewerToken) { + setViewerCookie(response, viewerToken); + } - const longUrl = url ? url : null; - const validURL = await isValidURL(longUrl); + return response; +} - let data; +export const POST = withAuthRoute(async function POST({ request, supabase, user }) { + const limitResponse = await enforceRateLimit(publicLimiter, request, { + tags: ["shorten-url:create"], + }); + if (limitResponse) return limitResponse; - if (!validURL) { - applyResponseContextToSentry(400); - return NextResponse.json( - { message: `${longUrl} is not a valid url.` }, - { status: 400 } - ); - } + try { + const validated = validateContentType(request); + if (validated instanceof NextResponse) { + return validated; + } - const { data: existingUrl, error } = await supabase - .from("links") - .select("url, short_url, title, description") - .eq("url", longUrl); + const { url, snippet_id, title, description } = await request.json(); - if (error) { - console.error(error); - applyResponseContextToSentry(500); - return NextResponse.json({ error: "Database error" }, { status: 500 }); - } + const longUrl = url ? url : null; + const isInternalPayload = typeof longUrl === "string" && longUrl.startsWith("animation:"); + const validURL = isInternalPayload || await isValidURL(longUrl); - if (existingUrl && existingUrl.length > 0) { - // URL already exists, return the existing short URL and refresh metadata if needed - const existing = existingUrl[0]; - - if (title || description) { - try { - await supabase - .from("links") - .update({ - title: title ?? existing.title ?? null, - description: description ?? existing.description ?? null, - }) - .eq("short_url", existing.short_url); - } catch (updateError) { - console.error("Failed to update link metadata", updateError); - } - } + if (!validURL) { + return NextResponse.json( + { message: `${longUrl} is not a valid url.` }, + { status: 400 } + ); + } - applyResponseContextToSentry(200); - return NextResponse.json({ - status: 200, - short_url: existing.short_url, - }); - } + const userLimited = await enforceRateLimit(strictLimiter, request, { + userId: user.id, + tags: ["shorten-url:create", "user"], + }); + if (userLimited) return userLimited; - const key = nanoid(5); - if (keySet.has(key)) { - applyResponseContextToSentry(400); - return NextResponse.json( - { error: "Key is duplicated." }, - { status: 400 } - ); + const { data: existingUrl, error } = await supabase + .from("links") + .select("url, short_url, title, description, user_id") + .eq("url", longUrl); + + if (error) { + console.error(error); + return NextResponse.json({ error: "Database error" }, { status: 500 }); + } + + const existing = existingUrl?.[0]; + + // If the current user already owns this link, allow reuse without consuming quota + if (existing && existing.user_id === user.id) { + if (title || description) { + try { + await supabase + .from("links") + .update({ + title: title ?? existing.title ?? null, + description: description ?? existing.description ?? null, + }) + .eq("short_url", existing.short_url); + } catch (updateError) { + console.error("Failed to update link metadata", updateError); + } } - keySet.add(key); - shortURLs[key] = longUrl; + return NextResponse.json({ + status: 200, + short_url: existing.short_url, + }); + } - const shortUrl = key; + if (existing) { + return NextResponse.json({ + status: 200, + short_url: existing.short_url, + }); + } + + // Retry loop to handle potential unique constraint violations + const maxRetries = 5; + let shortUrl: string | null = null; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + shortUrl = nanoid(5); const { error: insertError } = await supabase .from("links") .insert([ { - user_id: user_id ? user_id : null, + user_id: user.id, url: longUrl, short_url: shortUrl, snippet_id: snippet_id ? snippet_id : null, @@ -164,25 +231,45 @@ export const POST = wrapRouteHandlerWithSentry( }, ]); - if (insertError) { + if (!insertError) { + // Success - return the short URL + return NextResponse.json({ + status: 200, + short_url: shortUrl, + }); + } + + // Check if it's a unique constraint violation (PostgreSQL error code 23505) + const isUniqueViolation = + insertError.code === "23505" || + insertError.message?.toLowerCase().includes("unique") || + insertError.message?.toLowerCase().includes("duplicate"); + + if (!isUniqueViolation) { + // Not a duplicate key error - return the error console.error("Insert Error:", insertError); - applyResponseContextToSentry(500); return NextResponse.json({ error: insertError.message }, { status: 500 }); } - applyResponseContextToSentry(200); - return NextResponse.json({ - status: 200, - short_url: shortUrl, - }); - } catch (error: any) { - console.error("Shorten URL Error:", error); - applyResponseContextToSentry(500); - return NextResponse.json({ error: error.message }, { status: 500 }); + // If it's the last attempt, return error + if (attempt === maxRetries - 1) { + console.error("Failed to generate unique short URL after retries:", insertError); + return NextResponse.json( + { error: "Failed to generate unique short URL. Please try again." }, + { status: 500 } + ); + } + + // Otherwise, retry with a new key } - }, - { - method: "POST", - parameterizedRoute: "/api/shorten-url", - }, -); + + // This should never be reached, but TypeScript requires it + return NextResponse.json( + { error: "Failed to generate short URL" }, + { status: 500 } + ); + } catch (error: any) { + console.error("Shorten URL Error:", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +}); diff --git a/src/app/api/sync-subscription/route.ts b/src/app/api/sync-subscription/route.ts new file mode 100644 index 0000000..1396a08 --- /dev/null +++ b/src/app/api/sync-subscription/route.ts @@ -0,0 +1,33 @@ +"use server"; + +import { NextRequest, NextResponse } from 'next/server'; +import { syncSubscriptionById } from '@/lib/services/subscription-sync'; +import { createClient } from '@/utils/supabase/server'; + +export async function POST(request: NextRequest) { + try { + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { subscriptionId } = await request.json(); + + if (!subscriptionId) { + return NextResponse.json({ error: 'Missing subscriptionId' }, { status: 400 }); + } + + const result = await syncSubscriptionById(subscriptionId); + + if (!result) { + return NextResponse.json({ error: 'Failed to sync subscription' }, { status: 500 }); + } + + return NextResponse.json({ success: true, result }); + } catch (error) { + console.error('Error syncing subscription:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/src/app/api/waitlist/route.ts b/src/app/api/waitlist/route.ts index 3a61062..431e7a0 100644 --- a/src/app/api/waitlist/route.ts +++ b/src/app/api/waitlist/route.ts @@ -3,10 +3,16 @@ import { NextResponse } from "next/server"; import { createClient } from "@/utils/supabase/server"; import { FEATURE_FLAG_KEYS } from "@/lib/services/tracking/feature-flag-keys"; import type { Json } from "@/types/database"; +import { enforceRateLimit, publicLimiter } from "@/lib/arcjet/limiters"; const allowedFeatures = new Set(Object.values(FEATURE_FLAG_KEYS)); export async function POST(request: Request) { + const limitResponse = await enforceRateLimit(publicLimiter, request, { + tags: ["waitlist"], + }); + if (limitResponse) return limitResponse; + const supabase = await createClient(); const { data: userData } = await supabase.auth.getUser(); const user = userData.user ?? null; diff --git a/src/app/api/webhooks/stripe/route.ts b/src/app/api/webhooks/stripe/route.ts new file mode 100644 index 0000000..7903129 --- /dev/null +++ b/src/app/api/webhooks/stripe/route.ts @@ -0,0 +1,1008 @@ +import * as Sentry from '@sentry/nextjs'; +import { NextRequest, NextResponse } from 'next/server'; +import Stripe from 'stripe'; +import React from 'react'; + +import { createServiceRoleClient } from '@/utils/supabase/admin'; +import { constructWebhookEvent, getStripeClient } from '@/lib/services/stripe'; +import type { PlanId } from '@/lib/config/plans'; +import type { Json } from '@/types/database'; +import { captureServerEvent } from '@/lib/services/tracking/server'; +import { BILLING_EVENTS } from '@/lib/services/tracking/events'; +import { invalidateUserUsageCache } from '@/lib/services/usage-limits-cache'; +import { syncSubscriptionToDatabase } from '@/lib/services/subscription-sync'; +import { getPlanIdFromPriceId, getStripeCustomerId, resolveUserIdFromSubscription } from '@/lib/services/stripe-helpers'; +import { sendEmail } from '@/lib/email/send-email'; +import WelcomeEmail from '@emails/welcome-email'; + +export const dynamic = 'force-dynamic'; +type ServiceRoleClient = ReturnType; + + +function resolvePlan(subscription: Stripe.Subscription): PlanId | null { + const metadataPlan = subscription.metadata?.plan; + + // Handle valid plan IDs + if (metadataPlan === 'starter' || metadataPlan === 'pro') { + return metadataPlan as PlanId; + } + + // Handle legacy typo in metadata where 'started' was used instead of 'starter' + if (metadataPlan === 'started') { + return 'starter'; + } + + const priceId = subscription.items.data[0]?.price.id; + if (!priceId) return null; + + const planFromPrice = getPlanIdFromPriceId(priceId); + return planFromPrice; +} + + + +async function resolveUserIdFromCheckoutSession( + session: Stripe.Checkout.Session, + supabase: ServiceRoleClient +): Promise { + // Try metadata first (most reliable) + if (session.metadata?.userId) { + return session.metadata.userId; + } + + // Try customer lookup + const customerId = getStripeCustomerId(session); + if (customerId) { + const { data, error } = await supabase + .from('profiles') + .select('id') + .eq('stripe_customer_id', customerId) + .maybeSingle(); + + if (error) { + throw new Error(`Failed to resolve user by stripe_customer_id: ${error.message}`); + } + + if (data?.id) { + return data.id; + } + } + + // Try email lookup as fallback + const email = session.customer_email || session.customer_details?.email; + if (email) { + const { data, error } = await supabase + .from('profiles') + .select('id') + .eq('email', email) + .maybeSingle(); + + if (error) { + throw new Error(`Failed to resolve user by email: ${error.message}`); + } + + if (data?.id) { + return data?.id; + } + } + + return null; +} + +async function resolveUserIdFromInvoice( + invoice: Stripe.Invoice, + supabase: ServiceRoleClient +): Promise { + const customerId = getStripeCustomerId(invoice); + if (!customerId) return null; + + const { data, error } = await supabase + .from('profiles') + .select('id') + .eq('stripe_customer_id', customerId) + .maybeSingle(); + + if (error) { + throw new Error(`Failed to resolve user by stripe_customer_id: ${error.message}`); + } + + return data?.id ?? null; +} + +/** + * Log payment failed event for tracking/analytics + * + * Note: Payment failed emails are handled automatically by Stripe. + * Configure in Stripe Dashboard: Settings > Billing > Subscriptions and emails + * Enable "Send emails when card payments fail" + * + * This function is kept for logging/tracking purposes only. + */ +async function sendPaymentFailedEmail( + userId: string, + email: string, + invoiceId: string, + amount: number, + currency: string +): Promise { + // Log payment failure for analytics/tracking + // Stripe automatically sends payment failed emails to customers + console.log(`[Payment Failed] User ${userId} - Invoice ${invoiceId}`, { + email, + amount, + currency, + note: 'Stripe handles payment failed email notifications automatically', + }); +} + +/** + * Attempt to resolve user ID from a Stripe customer ID + */ +async function resolveUserIdFromCustomerId( + customerId: string | null, + supabase: ServiceRoleClient +): Promise { + if (!customerId) return null; + + try { + const { data, error } = await supabase + .from('profiles') + .select('id') + .eq('stripe_customer_id', customerId) + .maybeSingle(); + + if (error) { + console.warn(`[resolveUserIdFromCustomerId] Failed to lookup user for customer ${customerId}:`, error); + return null; + } + + return data?.id ?? null; + } catch (error) { + console.warn(`[resolveUserIdFromCustomerId] Error looking up user for customer ${customerId}:`, error); + return null; + } +} + +async function upsertWebhookAudit( + supabase: ServiceRoleClient, + event: Stripe.Event, + { + status, + errorMessage, + userId, + }: { status: string; errorMessage?: string; userId?: string | null } +) { + // Try to extract customer ID from any Stripe event object + const stripeObject = event.data?.object as unknown as Record; + const stripeCustomerId = stripeObject ? getStripeCustomerId(stripeObject) : null; + + // If we have a customer ID but no user ID, try to resolve it + let resolvedUserId = userId; + if (!resolvedUserId && stripeCustomerId) { + resolvedUserId = await resolveUserIdFromCustomerId(stripeCustomerId, supabase); + } + + // Verify user still exists before inserting (user might have been deleted) + // This prevents foreign key constraint violations when webhooks arrive after account deletion + let finalUserId: string | null = resolvedUserId ?? null; + if (finalUserId) { + try { + const { data: profileCheck } = await supabase + .from('profiles') + .select('id') + .eq('id', finalUserId) + .maybeSingle(); + + if (!profileCheck) { + // User no longer exists (likely deleted), set to null + console.warn(`[upsertWebhookAudit] User ${finalUserId} no longer exists, setting user_id to null for event ${event.id}`); + finalUserId = null; + } + } catch (verifyError) { + // If verification fails, set to null to avoid foreign key constraint violation + console.warn(`[upsertWebhookAudit] Failed to verify user ${finalUserId}, setting user_id to null:`, verifyError); + finalUserId = null; + } + } + + const payload = JSON.parse(JSON.stringify(event)) as Json; + + const { error } = await supabase.from('stripe_webhook_audit').upsert( + { + event_id: event.id, + event_type: event.type, + payload, + status, + error_message: errorMessage ?? null, + stripe_customer_id: stripeCustomerId, + user_id: finalUserId, + }, + { onConflict: 'event_id' } + ); + + if (error) { + console.error('Failed to log Stripe webhook audit event', error); + // If it's a foreign key constraint error, try again with user_id set to null + if (error.code === '23503' && finalUserId) { + console.warn(`[upsertWebhookAudit] Retrying with user_id=null due to foreign key constraint violation for event ${event.id}`); + const { error: retryError } = await supabase.from('stripe_webhook_audit').upsert( + { + event_id: event.id, + event_type: event.type, + payload, + status, + error_message: errorMessage ?? null, + stripe_customer_id: stripeCustomerId, + user_id: null, + }, + { onConflict: 'event_id' } + ); + if (retryError) { + console.error('Failed to log Stripe webhook audit event (retry with null user_id):', retryError); + } + } + } +} + +// Handle subscription created or updated +async function handleSubscriptionChange( + event: Stripe.Event, + supabase: ServiceRoleClient +) { + // Retrieve the latest subscription state from Stripe to ensure we have the most up-to-date data + // This prevents race conditions or stale data issues from the webhook payload + // Expand latest_invoice to get period_start and period_end as fallback if subscription fields are missing + const stripeSubscription = event.data.object as Stripe.Subscription; + const subscription = await getStripeClient().subscriptions.retrieve(stripeSubscription.id, { + expand: ['items.data.price', 'latest_invoice'], + }); + + // Get userId and previous plan BEFORE syncing to detect changes + let userId: string | null = null; + let previousPlan: PlanId | null = null; + let previousStatus: string | null = null; + + try { + userId = await resolveUserIdFromSubscription(subscription, supabase); + if (userId) { + const { data: profile } = await supabase + .from('profiles') + .select('plan, stripe_subscription_status') + .eq('id', userId) + .single(); + + previousPlan = profile?.plan as PlanId | null; + previousStatus = profile?.stripe_subscription_status || null; + } + } catch (error) { + console.error('Error fetching previous plan for tracking:', error); + } + + // For customer.subscription.created, check if user had existing subscription BEFORE syncing + // This determines if we should send welcome email + let shouldSendWelcomeEmail = false; + let userEmail: string | null = null; + let userName: string | undefined = undefined; + + if (event.type === 'customer.subscription.created') { + try { + if (userId) { + // Check if user already had a subscription BEFORE we sync + const { data: profile } = await supabase + .from('profiles') + .select('stripe_subscription_id, email, username') + .eq('id', userId) + .single(); + + // If user had no subscription ID or it's different, this is a new subscription + const hadExistingSubscription = !!profile?.stripe_subscription_id && profile.stripe_subscription_id !== subscription.id; + shouldSendWelcomeEmail = !hadExistingSubscription && !!profile?.email; + userEmail = profile?.email || null; + userName = profile?.username || undefined; + } + } catch (error) { + console.error('Error checking for welcome email in subscription.created:', error); + } + } + + const result = await syncSubscriptionToDatabase(subscription); + + if (!result) { + throw new Error('Failed to sync subscription to database'); + } + + // Send welcome email for NEW subscriptions (customer.subscription.created only) + if (event.type === 'customer.subscription.created' && shouldSendWelcomeEmail && userEmail) { + try { + console.log(`[Welcome Email] Sending to ${userEmail} for user ${result.userId}`); + await sendEmail({ + to: userEmail, + subject: 'Welcome to Jolly Code!', + react: React.createElement(WelcomeEmail, { name: userName }), + idempotencyKey: `welcome-email-${result.userId}-${subscription.id}`, + }); + console.log(`[Welcome Email] Sent successfully to user ${result.userId}`); + } catch (emailError) { + // Don't fail webhook if email fails - log and continue + console.error('[Welcome Email] Failed to send:', emailError); + Sentry.captureException(emailError, { + level: 'warning', + tags: { + operation: 'send_welcome_email_subscription_created', + }, + extra: { + userId: result.userId, + subscriptionId: subscription.id, + userEmail: userEmail, + }, + }); + } + } + + // Track subscription changes + if (userId && result) { + const newPlan = result.planId; + const newStatus = subscription.status; + + // Track subscription changes (non-blocking to avoid delaying webhook response) + if (newStatus === 'canceled' && previousStatus !== 'canceled') { + void captureServerEvent(BILLING_EVENTS.SUBSCRIPTION_CANCELLED, { + userId, + properties: { + plan: previousPlan, + subscription_id: subscription.id, + cancel_at_period_end: subscription.cancel_at_period_end, + }, + }); + } + + // Track subscription reactivation + if (previousStatus === 'canceled' && newStatus === 'active') { + void captureServerEvent(BILLING_EVENTS.SUBSCRIPTION_REACTIVATED, { + userId, + properties: { + plan: newPlan, + subscription_id: subscription.id, + }, + }); + } + + // Track plan upgrades/downgrades + if (previousPlan && newPlan && previousPlan !== newPlan) { + const planOrder: PlanId[] = ['free', 'starter', 'pro']; + const previousIndex = planOrder.indexOf(previousPlan); + const newIndex = planOrder.indexOf(newPlan); + + if (newIndex > previousIndex) { + // Upgrade + void captureServerEvent(BILLING_EVENTS.SUBSCRIPTION_UPGRADED, { + userId, + properties: { + from_plan: previousPlan, + to_plan: newPlan, + subscription_id: subscription.id, + }, + }); + } else if (newIndex < previousIndex) { + // Downgrade + void captureServerEvent(BILLING_EVENTS.SUBSCRIPTION_DOWNGRADED, { + userId, + properties: { + from_plan: previousPlan, + to_plan: newPlan, + subscription_id: subscription.id, + }, + }); + } + } + } + + // Invalidate usage cache + try { + invalidateUserUsageCache(result.userId); + } catch (error) { + console.error('Failed to invalidate usage cache in webhook:', error); + } + + return result.userId; +} + +// Handle subscription deleted (downgrade to free) +async function handleSubscriptionDeleted( + subscription: Stripe.Subscription, + supabase: ServiceRoleClient +): Promise { + const userId = await resolveUserIdFromSubscription(subscription, supabase); + if (!userId) { + // User might have been deleted - this is okay, just log and return null + console.warn('[handleSubscriptionDeleted] No user found for subscription deletion - user may have been deleted'); + return null; + } + + // Verify user still exists before trying to update + const { data: profileCheck } = await supabase + .from('profiles') + .select('id') + .eq('id', userId) + .maybeSingle(); + + if (!profileCheck) { + // User no longer exists (likely deleted) - this is okay, just log and return null + console.warn(`[handleSubscriptionDeleted] User ${userId} no longer exists - account may have been deleted`); + return null; + } + + const { error } = await supabase + .from('profiles') + .update({ + plan: 'free', + plan_updated_at: new Date().toISOString(), + stripe_subscription_status: 'canceled', + subscription_cancel_at_period_end: false, + billing_interval: null, + }) + .eq('id', userId); + + if (error) { + // If user was deleted between check and update, that's okay + if (error.code === 'PGRST116') { + console.warn(`[handleSubscriptionDeleted] User ${userId} was deleted during update - this is expected`); + return null; + } + throw error; + } + + // Track subscription cancellation (non-blocking) + void captureServerEvent(BILLING_EVENTS.SUBSCRIPTION_CANCELLED, { + userId, + properties: { + subscription_id: subscription.id, + plan_before_cancellation: 'free', // Already downgraded to free + }, + }); + + // Only reconcile if user still exists (user might have been deleted) + const { error: reconcileError } = await (supabase as any).rpc('reconcile_over_limit_content', { + p_user_id: userId, + }); + + if (reconcileError) { + // If user was deleted, this is expected - don't log as error + if (reconcileError.message?.includes('User not found') || reconcileError.code === 'P0001') { + console.warn(`[handleSubscriptionDeleted] User ${userId} not found for reconciliation - account may have been deleted`); + } else { + console.error('Failed to reconcile over-limit content after cancellation', reconcileError); + Sentry.captureException(reconcileError, { + level: 'error', + tags: { + action: 'reconcile_over_limit_content', + }, + extra: { userId }, + }); + } + } + + console.log(`Downgraded user ${userId} to free plan`); + + // Invalidate usage cache + try { + invalidateUserUsageCache(userId); + } catch (error) { + console.error('Failed to invalidate usage cache in webhook (downgrade):', error); + } + + return userId; +} + +// Handle checkout session completed +async function handleCheckoutSessionCompleted( + session: Stripe.Checkout.Session, + supabase: ServiceRoleClient +): Promise { + try { + // Fetch full session if subscription is not expanded + let fullSession = session; + if (!session.subscription && session.id) { + fullSession = await getStripeClient().checkout.sessions.retrieve(session.id, { + expand: ['subscription'], + }); + } + + const userId = await resolveUserIdFromCheckoutSession(fullSession, supabase); + if (!userId) { + console.warn('Could not resolve user from checkout session:', session.id); + return null; + } + + // If subscription exists, ensure it's linked to the user profile + const subscriptionId = + typeof fullSession.subscription === 'string' + ? fullSession.subscription + : fullSession.subscription?.id; + + // Get user profile to check email and existing subscription + const { data: profile, error: profileError } = await supabase + .from('profiles') + .select('stripe_subscription_id, stripe_customer_id, email, username') + .eq('id', userId) + .single(); + + if (profileError) { + console.error(`[handleCheckoutSessionCompleted] Failed to load profile for user ${userId}:`, profileError); + throw new Error(`Failed to load user profile: ${profileError.message}`); + } + + if (!profile) { + console.error(`[handleCheckoutSessionCompleted] Profile not found for user ${userId}`); + throw new Error(`User profile not found for userId: ${userId}`); + } + + let hadExistingSubscription = false; + if (subscriptionId) { + // Verify subscription is linked (subscription webhook will handle plan update) + // Profile already loaded above + + // Check if user already had a subscription BEFORE this checkout + // Important: We need to check if the subscription existed BEFORE this checkout session + // The subscription.created webhook might have already run and set stripe_subscription_id, + // so we check if the existing subscription_id matches the new one from checkout. + // If they match, it means this is the NEW subscription (not an existing one). + const existingSubscriptionId = profile?.stripe_subscription_id; + + // If existing subscription ID is different from the new one, user had a previous subscription + // If they match (or existing is null), this is a new subscription + hadExistingSubscription = !!existingSubscriptionId && existingSubscriptionId !== subscriptionId; + + const customerId = getStripeCustomerId(fullSession); + if (customerId && !profile?.stripe_customer_id) { + // Ensure customer ID is set + await supabase + .from('profiles') + .update({ stripe_customer_id: customerId }) + .eq('id', userId); + } + + // NOTE: Welcome email is sent by customer.subscription.created webhook handler + // We don't send it here to avoid duplicates, since both events fire for new subscriptions + } + + // Track checkout completion event + await captureServerEvent('checkout_session_completed', { + distinctId: userId, + properties: { + session_id: fullSession.id, + subscription_id: subscriptionId, + plan: fullSession.metadata?.plan, + interval: fullSession.metadata?.interval, + }, + }); + + console.log(`Checkout session completed for user ${userId}:`, fullSession.id); + return userId; + } catch (error) { + console.error('Error handling checkout session completed:', error); + Sentry.captureException(error, { + extra: { + sessionId: session.id, + operation: 'handleCheckoutSessionCompleted', + }, + }); + throw error; + } +} + +// Handle invoice payment succeeded +async function handleInvoicePaymentSucceeded( + invoice: Stripe.Invoice, + supabase: ServiceRoleClient +): Promise { + try { + const userId = await resolveUserIdFromInvoice(invoice, supabase); + if (!userId) { + console.warn('Could not resolve user from invoice:', invoice.id); + return null; + } + + // Clear any past_due status by updating subscription status to active + const invoiceSubscription = (invoice as any).subscription; + if (typeof invoiceSubscription === 'string') { + const subscriptionId = invoiceSubscription; + const { data: profile } = await supabase + .from('profiles') + .select('stripe_subscription_status') + .eq('id', userId) + .single(); + + // If subscription was past_due, update to active + if (profile?.stripe_subscription_status === 'past_due') { + const { error: updateError } = await supabase + .from('profiles') + .update({ stripe_subscription_status: 'active' }) + .eq('id', userId); + + if (updateError) { + console.error('Failed to update subscription status after payment:', updateError); + Sentry.captureException(updateError, { + extra: { userId, invoiceId: invoice.id }, + }); + } else { + console.log(`Cleared past_due status for user ${userId} after successful payment`); + } + } + } + + // Record payment in audit log (payment details are in webhook audit payload) + // Track payment success event + await captureServerEvent('invoice_payment_succeeded', { + distinctId: userId, + properties: { + invoice_id: invoice.id, + amount: invoice.amount_paid, + currency: invoice.currency, + subscription_id: + typeof invoiceSubscription === 'string' + ? invoiceSubscription + : (invoiceSubscription as Stripe.Subscription)?.id, + }, + }); + + console.log(`Invoice payment succeeded for user ${userId}:`, invoice.id); + return userId; + } catch (error) { + console.error('Error handling invoice payment succeeded:', error); + Sentry.captureException(error, { + extra: { + invoiceId: invoice.id, + operation: 'handleInvoicePaymentSucceeded', + }, + }); + throw error; + } +} + +// Handle invoice payment failed +async function handleInvoicePaymentFailed( + invoice: Stripe.Invoice, + supabase: ServiceRoleClient +): Promise { + try { + const userId = await resolveUserIdFromInvoice(invoice, supabase); + if (!userId) { + console.warn('Could not resolve user from invoice:', invoice.id); + return null; + } + + // Mark subscription as past_due or unpaid + const invoiceSubscription = (invoice as any).subscription; + if (invoiceSubscription && typeof invoiceSubscription === 'string') { + const subscriptionId = invoiceSubscription; + + // Fetch subscription to get current status + let subscription: Stripe.Subscription; + try { + subscription = await getStripeClient().subscriptions.retrieve(subscriptionId); + } catch (error) { + console.error('Failed to fetch subscription:', error); + throw error; + } + + // Update subscription status based on Stripe's status + const statusToSet = + subscription.status === 'past_due' ? 'past_due' : subscription.status || 'past_due'; + + const { error: updateError } = await supabase + .from('profiles') + .update({ stripe_subscription_status: statusToSet }) + .eq('id', userId); + + if (updateError) { + console.error('Failed to update subscription status after payment failure:', updateError); + Sentry.captureException(updateError, { + extra: { userId, invoiceId: invoice.id }, + }); + } else { + console.log(`Updated subscription status to ${statusToSet} for user ${userId}`); + } + } + + // Get user email for notification + const { data: profile } = await supabase + .from('profiles') + .select('email') + .eq('id', userId) + .single(); + + const userEmail = profile?.email || invoice.customer_email || null; + + // Send notification email + if (userEmail) { + try { + await sendPaymentFailedEmail( + userId, + userEmail, + invoice.id, + invoice.amount_due, + invoice.currency + ); + } catch (emailError) { + console.error('Failed to send payment failed email:', emailError); + Sentry.captureException(emailError, { + extra: { userId, invoiceId: invoice.id }, + }); + // Don't throw - email failure shouldn't fail the webhook + } + } + + // Track payment failure event + await captureServerEvent('invoice_payment_failed', { + distinctId: userId, + properties: { + invoice_id: invoice.id, + amount: invoice.amount_due, + currency: invoice.currency, + subscription_id: + typeof invoiceSubscription === 'string' + ? invoiceSubscription + : (invoiceSubscription as Stripe.Subscription)?.id, + attempt_count: invoice.attempt_count, + }, + }); + + console.log(`Invoice payment failed for user ${userId}:`, invoice.id); + return userId; + } catch (error) { + console.error('Error handling invoice payment failed:', error); + Sentry.captureException(error, { + extra: { + invoiceId: invoice.id, + operation: 'handleInvoicePaymentFailed', + }, + }); + throw error; + } +} + +/** + * Determines the appropriate HTTP status code for webhook errors. + * - 400: Client/validation errors (invalid signature, malformed payload) - don't retry + * - 500: Transient/internal errors (DB connectivity/timeouts) - retry + * - 200: Permanent/unrecoverable errors (user not found) - acknowledge, don't retry + */ +function getErrorStatusCode(error: unknown): number { + if (!error) { + return 500; + } + + const errorObj = error as Record; + const errorMessage = typeof errorObj.message === 'string' ? errorObj.message.toLowerCase() : ''; + const errorName = typeof errorObj.name === 'string' ? errorObj.name.toLowerCase() : ''; + const errorType = typeof errorObj.type === 'string' ? errorObj.type.toLowerCase() : ''; + const errorCode = errorObj.code; + + // Stripe signature verification errors (400 - client error, don't retry) + if ( + errorType.includes('signature') || + errorName.includes('signature') || + errorMessage.includes('signature') || + errorMessage.includes('invalid signature') || + errorMessage.includes('no signatures found') + ) { + return 400; + } + + // Malformed payload errors (400 - client error, don't retry) + if ( + errorMessage.includes('malformed') || + errorMessage.includes('invalid json') || + errorMessage.includes('parse error') || + errorMessage.includes('unexpected token') + ) { + return 400; + } + + // Database connectivity/timeout errors (500 - transient, retry) + if ( + errorCode === 'ECONNREFUSED' || + errorCode === 'ETIMEDOUT' || + errorCode === 'ENOTFOUND' || + errorCode === 'ECONNRESET' || + errorMessage.includes('connection') || + errorMessage.includes('timeout') || + errorMessage.includes('network') || + errorMessage.includes('connectivity') || + errorMessage.includes('database unavailable') + ) { + return 500; + } + + // Supabase/PostgreSQL connection errors (500 - transient, retry) + if ( + errorCode === 'PGRST301' || // Connection error + errorMessage.includes('connection to server') || + errorMessage.includes('could not connect') || + errorMessage.includes('connection pool') + ) { + return 500; + } + + // Permanent/unrecoverable errors (200 - acknowledge, don't retry) + if ( + errorMessage.includes('no user found') || + errorMessage.includes('user not found') || + errorMessage.includes('missing metadata and lookup failed') || + errorMessage.includes('could not determine plan') || + errorMessage.includes('subscription has no customer id') + ) { + return 200; + } + + // Supabase errors that are typically permanent (200 - acknowledge, don't retry) + if ( + errorCode === 'PGRST116' || // No rows returned + errorCode === '23505' || // Unique constraint violation (already processed) + errorMessage.includes('duplicate key') || + errorMessage.includes('already exists') + ) { + return 200; + } + + // Default to 500 for unknown errors (transient, retry) + return 500; +} + +export async function POST(request: NextRequest) { + const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; + + if (!webhookSecret) { + console.error('STRIPE_WEBHOOK_SECRET not set'); + return NextResponse.json( + { error: 'Webhook secret not configured' }, + { status: 500 } + ); + } + + const supabase = createServiceRoleClient(); + let event: Stripe.Event | null = null; + let resolvedUserId: string | null = null; + + try { + const body = await request.text(); + const signature = request.headers.get('stripe-signature'); + + if (!signature) { + return NextResponse.json( + { error: 'Missing stripe-signature header' }, + { status: 400 } + ); + } + + // Verify webhook signature + console.log('[Webhook] Verifying webhook signature...'); + event = constructWebhookEvent(body, signature, webhookSecret); + console.log('[Webhook] Event verified:', event.type, event.id); + + + + // Check if we've already processed this event (idempotency check) + console.log('[Webhook] Checking if event already processed:', event.id); + const { data: existingEvent } = await supabase + .from('stripe_webhook_audit') + .select('status') + .eq('event_id', event.id) + .maybeSingle(); + + // If event was already successfully processed, return 200 OK to prevent Stripe retries + if (existingEvent?.status === 'processed') { + console.log(`Event ${event.id} already processed, returning success`); + return NextResponse.json({ received: true, message: 'Event already processed' }); + } + + await upsertWebhookAudit(supabase, event, { status: 'received' }); + + + // Handle different event types + switch (event.type) { + case 'customer.subscription.created': + case 'customer.subscription.updated': + resolvedUserId = await handleSubscriptionChange( + event, + supabase + ); + break; + + case 'customer.subscription.deleted': + resolvedUserId = await handleSubscriptionDeleted( + event.data.object as Stripe.Subscription, + supabase + ); + break; + + case 'checkout.session.completed': + resolvedUserId = await handleCheckoutSessionCompleted( + event.data.object as Stripe.Checkout.Session, + supabase + ); + break; + + case 'invoice.payment_succeeded': + resolvedUserId = await handleInvoicePaymentSucceeded( + event.data.object as Stripe.Invoice, + supabase + ); + break; + + case 'invoice.payment_failed': + resolvedUserId = await handleInvoicePaymentFailed( + event.data.object as Stripe.Invoice, + supabase + ); + break; + + default: + console.log(`Unhandled event type: ${event.type}`); + } + + await upsertWebhookAudit(supabase, event, { status: 'processed', userId: resolvedUserId }); + + return NextResponse.json({ received: true }); + } catch (error) { + const statusCode = getErrorStatusCode(error); + const errorMessage = error instanceof Error ? error.message : 'Unknown webhook error'; + const errorDetails = { + message: errorMessage, + name: error instanceof Error ? error.name : 'Unknown', + code: (error as Record)?.code, + type: (error as Record)?.type, + }; + + console.error('[Webhook] ERROR:', { + ...errorDetails, + statusCode, + eventType: event?.type, + eventId: event?.id, + stack: error instanceof Error ? error.stack : undefined, + }); + + Sentry.captureException(error, { + extra: { + source: 'stripe-webhook', + eventType: event?.type, + eventId: event?.id, + statusCode, + ...errorDetails, + }, + }); + + if (event) { + await upsertWebhookAudit(supabase, event, { + status: 'failed', + errorMessage: errorMessage, + userId: resolvedUserId, + }); + } + + // Return appropriate status code based on error type + // 200 for permanent errors (acknowledge receipt, don't retry) + if (statusCode === 200) { + console.log('Permanent error - acknowledging receipt to prevent retries:', errorMessage); + return NextResponse.json( + { received: true, error: errorMessage }, + { status: 200 } + ); + } + + // 400 for client/validation errors (don't retry) + if (statusCode === 400) { + return NextResponse.json( + { error: errorMessage }, + { status: 400 } + ); + } + + // 500 for transient/internal errors (retry) + return NextResponse.json( + { error: errorMessage || 'Webhook handler failed' }, + { status: 500 } + ); + } +} diff --git a/src/app/checkout/canceled/page.tsx b/src/app/checkout/canceled/page.tsx new file mode 100644 index 0000000..f030af3 --- /dev/null +++ b/src/app/checkout/canceled/page.tsx @@ -0,0 +1,39 @@ +import { createClient } from "@/utils/supabase/server"; +import { captureServerEvent } from "@/lib/services/tracking/server"; +import { BILLING_EVENTS } from "@/lib/services/tracking/events"; + +type Props = { + searchParams: Promise<{ session_id?: string }> +} + +export default async function CheckoutCanceledPage({ searchParams }: Props) { + const params = await searchParams + const sessionId = params?.session_id + + // Track checkout cancellation + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + + if (user) { + await captureServerEvent(BILLING_EVENTS.CHECKOUT_CANCELLED, { + userId: user.id, + properties: { + session_id: sessionId, + }, + }); + } + + return ( +
+
+

Checkout canceled

+

+ Your subscription checkout was canceled. No charges were made. +

+ {sessionId ? ( +

Session: {sessionId}

+ ) : null} +
+
+ ) +} diff --git a/src/app/checkout/success/checkout-success-client.tsx b/src/app/checkout/success/checkout-success-client.tsx new file mode 100644 index 0000000..98d2037 --- /dev/null +++ b/src/app/checkout/success/checkout-success-client.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { useQueryClient } from "@tanstack/react-query"; +import { + USAGE_QUERY_KEY, + USER_PLAN_QUERY_KEY, + BILLING_INFO_QUERY_KEY, +} from "@/features/user/queries"; + +type CheckoutSuccessClientProps = { + plan: string; + sessionId: string; +}; + +export function CheckoutSuccessClient({ + plan, + sessionId, +}: CheckoutSuccessClientProps) { + const router = useRouter(); + const queryClient = useQueryClient(); + + useEffect(() => { + // Invalidate user usage cache to force refetch with new plan + queryClient.invalidateQueries({ queryKey: [USAGE_QUERY_KEY] }); + queryClient.invalidateQueries({ queryKey: [USER_PLAN_QUERY_KEY] }); + queryClient.invalidateQueries({ queryKey: [BILLING_INFO_QUERY_KEY] }); + + // Show success toast + toast.success(`Subscription activated!`, { + description: `Your ${plan} subscription is now active.`, + duration: 5000, + }); + + // Redirect to home after a brief delay + const redirectTimer = setTimeout(() => { + router.push("/"); + }, 1500); + + return () => clearTimeout(redirectTimer); + }, []); + + // Show minimal loading state while redirecting + return ( +
+

Redirecting...

+
+ ); +} diff --git a/src/app/checkout/success/page.tsx b/src/app/checkout/success/page.tsx new file mode 100644 index 0000000..e5a233f --- /dev/null +++ b/src/app/checkout/success/page.tsx @@ -0,0 +1,155 @@ +import { createClient } from "@/utils/supabase/server"; +import { getStripeClient } from "@/lib/services/stripe"; +import { redirect } from "next/navigation"; +import type Stripe from "stripe"; +import { CheckoutSuccessClient } from "./checkout-success-client"; +import { syncSubscriptionToDatabase } from "@/lib/services/subscription-sync"; + +type Props = { + searchParams: Promise<{ session_id?: string }>; +}; + +export default async function CheckoutSuccessPage({ searchParams }: Props) { + const params = await searchParams; + const sessionId = params?.session_id; + + if (!sessionId) { + return ( +
+

Missing checkout session

+

+ We couldn't verify your payment without a session id. +

+
+ ); + } + + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user) { + redirect( + `/login?redirect=/checkout/success&session_id=${encodeURIComponent(sessionId)}` + ); + } + + try { + const session = await getStripeClient().checkout.sessions.retrieve( + sessionId, + { + expand: ["subscription"], + } + ); + + const belongsToUser = + session.metadata?.userId === user.id || + session.customer_email === user.email; + + if (!belongsToUser) { + return ( +
+

Unable to verify payment

+

+ This checkout session doesn't belong to your account. +

+
+ ); + } + + const paymentStatus = session.payment_status; + const subscription = session.subscription as Stripe.Subscription | null; + const subscriptionStatus = subscription?.status; + const plan = session.metadata?.plan ?? "your plan"; + + const isPaid = + paymentStatus === "paid" || paymentStatus === "no_payment_required"; + + // FALLBACK: If subscription exists and webhook hasn't fired yet, sync manually + // This ensures plan is updated even if webhook is delayed or not configured + if (isPaid && subscription) { + try { + const subscriptionId = + typeof subscription === "string" ? subscription : subscription.id; + + // Fetch full subscription details if needed (subscription might be just an ID from session) + let fullSubscription: Stripe.Subscription; + if (typeof subscription === "string") { + fullSubscription = await getStripeClient().subscriptions.retrieve( + subscription, + { + expand: ["items.data.price"], + } + ); + } else if (!subscription.items?.data) { + fullSubscription = await getStripeClient().subscriptions.retrieve( + subscription.id, + { + expand: ["items.data.price"], + } + ); + } else { + fullSubscription = subscription; + } + + const syncResult = await syncSubscriptionToDatabase(fullSubscription); + if (syncResult) { + console.log( + "[CheckoutSuccess] Fallback sync succeeded:", + { userId: syncResult.userId, planId: syncResult.planId } + ); + } + } catch (error) { + // Log but don't fail the page - webhook will handle it eventually + console.error( + "[CheckoutSuccess] Fallback sync failed (webhook will handle it):", + error + ); + } + } + + // Use client component to handle redirect and toast + if (isPaid) { + return ; + } + + // If payment is still processing, show a loading state + return ( +
+

Processing payment

+

+ We're finalizing your {plan} subscription. Current payment + status: {paymentStatus}. +

+ {subscriptionStatus && ( +

+ Subscription status: {subscriptionStatus} +

+ )} +
+ ); + } catch (err) { + const userId = user?.id ?? "unknown"; + const errorMessage = err instanceof Error ? err.message : String(err); + const errorStack = err instanceof Error ? err.stack : undefined; + + console.error("[CheckoutSuccess] Failed to verify checkout session", { + sessionId: sessionId ?? "unknown", + userId, + error: errorMessage, + stack: errorStack, + }); + + return ( +
+

+ Couldn't verify checkout +

+

+ Please refresh or check your email for confirmation. +

+
+ ); + } +} diff --git a/src/app/error.tsx b/src/app/error.tsx new file mode 100644 index 0000000..04cd24b --- /dev/null +++ b/src/app/error.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { useEffect } from "react"; +import * as Sentry from "@sentry/nextjs"; +import { analytics } from "@/lib/services/tracking"; +import { ERROR_EVENTS } from "@/lib/services/tracking/events"; + +import { FriendlyError } from "@/components/errors/friendly-error"; + +export default function RootError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + // Only report to Sentry in production/non-local environments + if (process.env.NODE_ENV === "production") { + Sentry.captureException(error); + } + // Track error in analytics + analytics.trackError(ERROR_EVENTS.CLIENT_ERROR_OCCURRED, error, { + page: "root", + digest: error.digest, + }); + }, [error]); + + return ( + + ); +} diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx index a4a7859..cf2d17b 100644 --- a/src/app/global-error.tsx +++ b/src/app/global-error.tsx @@ -10,7 +10,10 @@ export default function GlobalError({ error: Error & { digest?: string }; }) { useEffect(() => { - Sentry.captureException(error); + // Only report to Sentry in production/non-local environments + if (process.env.NODE_ENV === "production") { + Sentry.captureException(error); + } }, [error]); return ( diff --git a/src/app/globals.css b/src/app/globals.css index 01dd5fb..a56426e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -158,7 +158,7 @@ --success: 129 90% 78%; --success-foreground: 240 10% 8.9%; - --destructive: 0 62.8% 30.6%; + --destructive: 0 62.8% 54.6%; --destructive-foreground: 0 0% 98%; --border: 18 5% 18%; @@ -176,8 +176,8 @@ @apply text-foreground bg-background; } - /* Ensure code elements have normal letter-spacing */ + /* Ensure code elements have zero letter-spacing */ code, pre, [style*="font-family"] { - letter-spacing: normal; + letter-spacing: 0; } } \ No newline at end of file diff --git a/src/app/manifest.ts b/src/app/manifest.ts index eb52f0c..d70dba4 100644 --- a/src/app/manifest.ts +++ b/src/app/manifest.ts @@ -3,20 +3,20 @@ import { MetadataRoute } from "next"; import { siteConfig } from "@/lib/utils/site-config"; export default function manifest(): MetadataRoute.Manifest { - return { - name: siteConfig.title, - short_name: "Jolly Code", - description: siteConfig.description, - start_url: "/", - display: "standalone", - background_color: "#09090b", - theme_color: "#09090b", - icons: [ - { - src: "/favicon.ico", - sizes: "any", - type: "image/x-icon", - }, - ], - }; + return { + name: siteConfig.title, + short_name: "Jolly Code", + description: siteConfig.description, + start_url: "/", + display: "standalone", + background_color: "#09090b", + theme_color: "#09090b", + icons: [ + { + src: "/favicon.ico", + sizes: "any", + type: "image/x-icon", + }, + ], + }; } diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index 83480f4..6010846 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -10,6 +10,8 @@ export default function NotFound() { fill="none" xmlns="http://www.w3.org/2000/svg" className="opacity-50 ml-6" + aria-hidden="true" + role="presentation" > - {children} - + + + {children} + + diff --git a/src/app/store/animation-store.ts b/src/app/store/animation-store.ts index 2c636d2..ad17894 100644 --- a/src/app/store/animation-store.ts +++ b/src/app/store/animation-store.ts @@ -51,6 +51,7 @@ export type AnimationStoreState = { closeTab: (tabId: string) => void; removeAnimationFromTabs: (animationId: string) => void; resetActiveAnimation: () => void; + resetAllAnimationSavedStates: () => void; }; const createSlide = (index: number, code = "", title?: string): AnimationSlide => ({ @@ -66,14 +67,14 @@ const createInitialSlide = (index: number): AnimationSlide => { const sampleCode = [ `// Slide ${index} - JavaScript Example function greet(name) { - console.log(\`Hello, \${name}!\`); + } greet("World");`, `// Slide ${index} - Improved Greeting function greet(name, { excited = false } = {}) { const punctuation = excited ? "!" : "."; - console.log(\`Hello, \${name}\${punctuation}\`); + } greet("World", { excited: true });`, @@ -293,6 +294,7 @@ export const useAnimationStore = create()( setIsAnimationSaved: (saved: boolean) => { const { activeAnimationTabId, tabs } = get(); + const updatedTabs = sanitizeTabs( tabs.map(tab => tab.id === activeAnimationTabId @@ -564,7 +566,26 @@ export const useAnimationStore = create()( ); set({ tabs: updatedTabs }); - } + }, + + resetAllAnimationSavedStates: () => { + const { tabs } = get(); + + // Clear all saved states: remove animation IDs and mark all tabs as unsaved + const updatedTabs = sanitizeTabs( + tabs.map((tab) => ({ + ...tab, + animationId: undefined, + saved: false, + })) + ); + + set({ + animationId: undefined, + isAnimationSaved: false, + tabs: updatedTabs, + }); + }, }), { name: "animation-store", diff --git a/src/app/store/index.ts b/src/app/store/index.ts index 52b4cbc..5cf3173 100644 --- a/src/app/store/index.ts +++ b/src/app/store/index.ts @@ -75,7 +75,7 @@ export const useEditorStore = create()( showBackground: true, fontSize: 15, fontFamily: "robotoMono", - padding: 60, + padding: 64, presentational: false, editor: "default", showLineNumbers: false, diff --git a/src/components/auth-provider/index.tsx b/src/components/auth-provider/index.tsx new file mode 100644 index 0000000..2c1c00e --- /dev/null +++ b/src/components/auth-provider/index.tsx @@ -0,0 +1,110 @@ +'use client' + +import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react' +import { useQueryClient } from '@tanstack/react-query' +import { useRouter } from 'next/navigation' +import { createClient } from '@/utils/supabase/client' +import { useUserStore } from '@/app/store' + +type AuthContextValue = { + isInitialized: boolean +} + +const AuthContext = createContext(undefined) + +/** + * Hook to access auth context including initialization state + */ +export function useAuth(): AuthContextValue { + const context = useContext(AuthContext) + if (context === undefined) { + throw new Error('useAuth must be used within an AuthProvider') + } + return context +} + +/** + * AuthProvider sets up a Supabase auth state listener to automatically + * update the app when the user signs in or out, eliminating the need + * for manual page refreshes. + */ +export function AuthProvider({ children }: { children: React.ReactNode }) { + const queryClient = useQueryClient() + const router = useRouter() + const supabase = useMemo(() => createClient(), []) + const [isInitialized, setIsInitialized] = useState(false) + const hasInitializedRef = useRef(false) + + useEffect(() => { + // Set up auth state listener + // Note: onAuthStateChange fires immediately with current session state when subscribed + const { + data: { subscription }, + } = supabase.auth.onAuthStateChange(async (event, session) => { + // Mark as initialized on the first auth state change event + // This happens immediately when the listener is set up + if (!hasInitializedRef.current) { + hasInitializedRef.current = true + setIsInitialized(true) + } + + // Update user store with new session data (synchronous, always runs) + if (session?.user) { + useUserStore.setState({ user: session.user }) + } else { + useUserStore.setState({ user: null }) + } + + try { + // Invalidate and refetch user query to update UI + await queryClient.invalidateQueries({ queryKey: ['user'] }) + + // Handle different auth events + if (event === 'SIGNED_IN') { + // Refresh server components to get latest data + router.refresh() + + // Refetch collections if user just signed in + await queryClient.invalidateQueries({ queryKey: ['collections'] }) + } else if (event === 'SIGNED_OUT') { + // Invalidate user-specific queries on sign out, preserving public data + await queryClient.invalidateQueries({ queryKey: ['user'] }) + await queryClient.invalidateQueries({ queryKey: ['billing-info'] }) + await queryClient.invalidateQueries({ queryKey: ['user-plan'] }) + await queryClient.invalidateQueries({ queryKey: ['collections'] }) + router.refresh() + } else if (event === 'TOKEN_REFRESHED') { + // Update user data when token is refreshed + await queryClient.refetchQueries({ queryKey: ['user'] }) + } + } catch (error) { + // Log error but don't break auth flow - store state is already updated + console.error('[AuthProvider] Error handling auth state change:', error) + + // Minimal recovery: attempt safe router refresh to keep UI consistent + try { + router.refresh() + } catch (refreshError) { + console.error('[AuthProvider] Error during fallback router refresh:', refreshError) + } + } + }) + + // Cleanup subscription on unmount + return () => { + subscription.unsubscribe() + } + }, [queryClient, router, supabase]) + + const contextValue = useMemo( + () => ({ isInitialized }), + [isInitialized] + ) + + return ( + + {children} + + ) +} + diff --git a/src/components/confirm-delete-dialog.tsx b/src/components/confirm-delete-dialog.tsx new file mode 100644 index 0000000..917ad54 --- /dev/null +++ b/src/components/confirm-delete-dialog.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; + +type ConfirmDeleteDialogProps = { + open: boolean; + title: string; + description?: string; + confirmLabel?: string; + cancelLabel?: string; + isLoading?: boolean; + onConfirm: () => void; + onOpenChange: (open: boolean) => void; +}; + +export function ConfirmDeleteDialog({ + open, + title, + description, + confirmLabel = "Delete", + cancelLabel = "Cancel", + isLoading = false, + onConfirm, + onOpenChange, +}: ConfirmDeleteDialogProps) { + return ( + + + + {title} + + + {description ? ( + {description} + ) : null} + + + +