From ab286c3baf06533718667a6174ca8e4bda4416ca Mon Sep 17 00:00:00 2001 From: John W Date: Wed, 23 Sep 2026 13:22:28 -0700 Subject: [PATCH 1/3] feat(wizard-ci): add feature-flags e2e apps and gateway token minting Adds the feature-flags command to the manifest with Next.js and Django apps, and ensureGatewayToken, which mints and caches a phe_ gateway token so --e2e runs can reach the gateway. --- .../django/django3-saas/.env.example | 21 + .../django/django3-saas/.gitignore | 29 + .../django/django3-saas/README.md | 131 + .../django/django3-saas/accounts/__init__.py | 0 .../django/django3-saas/accounts/admin.py | 11 + .../django/django3-saas/accounts/forms.py | 22 + .../accounts/migrations/0001_initial.py | 43 + ...ail_verified_at_user_stripe_customer_id.py | 23 + .../accounts/migrations/__init__.py | 0 .../django/django3-saas/accounts/models.py | 31 + .../django/django3-saas/accounts/urls.py | 17 + .../django/django3-saas/accounts/views.py | 71 + .../django/django3-saas/billing/__init__.py | 0 .../django/django3-saas/billing/admin.py | 117 + .../billing/management/__init__.py | 0 .../billing/management/commands/__init__.py | 0 .../billing/management/commands/seed_plans.py | 75 + .../billing/migrations/0001_initial.py | 56 + .../billing/migrations/__init__.py | 0 .../django/django3-saas/billing/models.py | 71 + .../django/django3-saas/billing/urls.py | 15 + .../django/django3-saas/billing/views.py | 327 ++ .../django/django3-saas/config/__init__.py | 0 .../django/django3-saas/config/settings.py | 113 + .../django/django3-saas/config/urls.py | 13 + .../django/django3-saas/config/views.py | 9 + .../django/django3-saas/config/wsgi.py | 5 + .../django/django3-saas/dashboard/__init__.py | 0 .../django/django3-saas/dashboard/admin.py | 18 + .../django/django3-saas/dashboard/forms.py | 11 + .../dashboard/migrations/0001_initial.py | 43 + .../dashboard/migrations/__init__.py | 0 .../django/django3-saas/dashboard/models.py | 38 + .../django/django3-saas/dashboard/urls.py | 12 + .../django/django3-saas/dashboard/views.py | 111 + .../django/django3-saas/manage.py | 18 + .../django/django3-saas/marketing/__init__.py | 0 .../django/django3-saas/marketing/urls.py | 9 + .../django/django3-saas/marketing/views.py | 11 + .../django/django3-saas/requirements.txt | 6 + .../django/django3-saas/static/.gitkeep | 0 .../templates/accounts/login.html | 18 + .../templates/accounts/password_reset.html | 16 + .../accounts/password_reset_complete.html | 9 + .../accounts/password_reset_confirm.html | 18 + .../accounts/password_reset_done.html | 11 + .../accounts/password_reset_email.html | 10 + .../accounts/password_reset_subject.txt | 1 + .../templates/accounts/register.html | 15 + .../templates/accounts/settings.html | 23 + .../django/django3-saas/templates/base.html | 87 + .../templates/billing/cancel.html | 23 + .../templates/billing/change_plan.html | 23 + .../templates/billing/manage.html | 46 + .../templates/billing/pricing.html | 53 + .../templates/billing/subscribe.html | 30 + .../templates/dashboard/create_project.html | 16 + .../templates/dashboard/delete_project.html | 18 + .../templates/dashboard/edit_project.html | 16 + .../templates/dashboard/index.html | 114 + .../templates/dashboard/projects.html | 38 + .../django3-saas/templates/errors/404.html | 12 + .../django3-saas/templates/errors/500.html | 12 + .../templates/marketing/features.html | 44 + .../templates/marketing/home.html | 38 + .../next-js/15-app-router-saas/.env.example | 11 + .../next-js/15-app-router-saas/LICENSE | 21 + .../next-js/15-app-router-saas/README.md | 119 + .../dashboard/activity/loading.tsx | 17 + .../(dashboard)/dashboard/activity/page.tsx | 126 + .../(dashboard)/dashboard/general/page.tsx | 121 + .../app/(dashboard)/dashboard/layout.tsx | 73 + .../app/(dashboard)/dashboard/page.tsx | 287 ++ .../(dashboard)/dashboard/security/page.tsx | 167 + .../app/(dashboard)/layout.tsx | 106 + .../app/(dashboard)/page.tsx | 130 + .../app/(dashboard)/pricing/page.tsx | 94 + .../app/(dashboard)/pricing/submit-button.tsx | 30 + .../app/(dashboard)/terminal.tsx | 68 + .../15-app-router-saas/app/(login)/actions.ts | 459 ++ .../15-app-router-saas/app/(login)/login.tsx | 142 + .../app/(login)/sign-in/page.tsx | 10 + .../app/(login)/sign-up/page.tsx | 10 + .../app/api/stripe/checkout/route.ts | 97 + .../app/api/stripe/webhook/route.ts | 35 + .../15-app-router-saas/app/api/team/route.ts | 6 + .../15-app-router-saas/app/api/user/route.ts | 6 + .../15-app-router-saas/app/favicon.ico | Bin 0 -> 25931 bytes .../15-app-router-saas/app/globals.css | 275 ++ .../next-js/15-app-router-saas/app/layout.tsx | 44 + .../15-app-router-saas/app/not-found.tsx | 27 + .../15-app-router-saas/components.json | 21 + .../components/ui/avatar.tsx | 53 + .../components/ui/button.tsx | 59 + .../15-app-router-saas/components/ui/card.tsx | 92 + .../components/ui/dropdown-menu.tsx | 257 ++ .../components/ui/input.tsx | 21 + .../components/ui/label.tsx | 24 + .../components/ui/radio-group.tsx | 45 + .../15-app-router-saas/docker-compose.yml | 16 + .../15-app-router-saas/drizzle.config.ts | 10 + .../15-app-router-saas/lib/auth/middleware.ts | 75 + .../15-app-router-saas/lib/auth/session.ts | 59 + .../15-app-router-saas/lib/db/drizzle.ts | 13 + .../db/migrations/0000_soft_the_anarchist.sql | 88 + .../lib/db/migrations/meta/0000_snapshot.json | 389 ++ .../lib/db/migrations/meta/_journal.json | 13 + .../15-app-router-saas/lib/db/queries.ts | 130 + .../15-app-router-saas/lib/db/schema.ts | 142 + .../next-js/15-app-router-saas/lib/db/seed.ts | 118 + .../15-app-router-saas/lib/db/setup.ts | 216 + .../lib/payments/STRIPE_STUB_README.md | 212 + .../lib/payments/TESTING_GUIDE.md | 223 + .../lib/payments/actions.ts | 15 + .../lib/payments/stripe-stub.ts | 354 ++ .../15-app-router-saas/lib/payments/stripe.ts | 195 + .../next-js/15-app-router-saas/lib/utils.ts | 6 + .../next-js/15-app-router-saas/middleware.ts | 49 + .../next-js/15-app-router-saas/next.config.ts | 9 + .../next-js/15-app-router-saas/package.json | 42 + .../next-js/15-app-router-saas/pnpm-lock.yaml | 3794 +++++++++++++++++ .../15-app-router-saas/postcss.config.mjs | 5 + .../next-js/15-app-router-saas/tsconfig.json | 41 + apps/manifest.json | 7 + package.json | 4 +- services/gateway-token/gateway-token.test.ts | 98 + services/gateway-token/index.ts | 138 + services/wizard-ci/e2e.ts | 15 + 128 files changed, 11696 insertions(+), 1 deletion(-) create mode 100644 apps/feature-flags/django/django3-saas/.env.example create mode 100644 apps/feature-flags/django/django3-saas/.gitignore create mode 100644 apps/feature-flags/django/django3-saas/README.md create mode 100644 apps/feature-flags/django/django3-saas/accounts/__init__.py create mode 100644 apps/feature-flags/django/django3-saas/accounts/admin.py create mode 100644 apps/feature-flags/django/django3-saas/accounts/forms.py create mode 100644 apps/feature-flags/django/django3-saas/accounts/migrations/0001_initial.py create mode 100644 apps/feature-flags/django/django3-saas/accounts/migrations/0002_user_email_verified_at_user_stripe_customer_id.py create mode 100644 apps/feature-flags/django/django3-saas/accounts/migrations/__init__.py create mode 100644 apps/feature-flags/django/django3-saas/accounts/models.py create mode 100644 apps/feature-flags/django/django3-saas/accounts/urls.py create mode 100644 apps/feature-flags/django/django3-saas/accounts/views.py create mode 100644 apps/feature-flags/django/django3-saas/billing/__init__.py create mode 100644 apps/feature-flags/django/django3-saas/billing/admin.py create mode 100644 apps/feature-flags/django/django3-saas/billing/management/__init__.py create mode 100644 apps/feature-flags/django/django3-saas/billing/management/commands/__init__.py create mode 100644 apps/feature-flags/django/django3-saas/billing/management/commands/seed_plans.py create mode 100644 apps/feature-flags/django/django3-saas/billing/migrations/0001_initial.py create mode 100644 apps/feature-flags/django/django3-saas/billing/migrations/__init__.py create mode 100644 apps/feature-flags/django/django3-saas/billing/models.py create mode 100644 apps/feature-flags/django/django3-saas/billing/urls.py create mode 100644 apps/feature-flags/django/django3-saas/billing/views.py create mode 100644 apps/feature-flags/django/django3-saas/config/__init__.py create mode 100644 apps/feature-flags/django/django3-saas/config/settings.py create mode 100644 apps/feature-flags/django/django3-saas/config/urls.py create mode 100644 apps/feature-flags/django/django3-saas/config/views.py create mode 100644 apps/feature-flags/django/django3-saas/config/wsgi.py create mode 100644 apps/feature-flags/django/django3-saas/dashboard/__init__.py create mode 100644 apps/feature-flags/django/django3-saas/dashboard/admin.py create mode 100644 apps/feature-flags/django/django3-saas/dashboard/forms.py create mode 100644 apps/feature-flags/django/django3-saas/dashboard/migrations/0001_initial.py create mode 100644 apps/feature-flags/django/django3-saas/dashboard/migrations/__init__.py create mode 100644 apps/feature-flags/django/django3-saas/dashboard/models.py create mode 100644 apps/feature-flags/django/django3-saas/dashboard/urls.py create mode 100644 apps/feature-flags/django/django3-saas/dashboard/views.py create mode 100644 apps/feature-flags/django/django3-saas/manage.py create mode 100644 apps/feature-flags/django/django3-saas/marketing/__init__.py create mode 100644 apps/feature-flags/django/django3-saas/marketing/urls.py create mode 100644 apps/feature-flags/django/django3-saas/marketing/views.py create mode 100644 apps/feature-flags/django/django3-saas/requirements.txt create mode 100644 apps/feature-flags/django/django3-saas/static/.gitkeep create mode 100644 apps/feature-flags/django/django3-saas/templates/accounts/login.html create mode 100644 apps/feature-flags/django/django3-saas/templates/accounts/password_reset.html create mode 100644 apps/feature-flags/django/django3-saas/templates/accounts/password_reset_complete.html create mode 100644 apps/feature-flags/django/django3-saas/templates/accounts/password_reset_confirm.html create mode 100644 apps/feature-flags/django/django3-saas/templates/accounts/password_reset_done.html create mode 100644 apps/feature-flags/django/django3-saas/templates/accounts/password_reset_email.html create mode 100644 apps/feature-flags/django/django3-saas/templates/accounts/password_reset_subject.txt create mode 100644 apps/feature-flags/django/django3-saas/templates/accounts/register.html create mode 100644 apps/feature-flags/django/django3-saas/templates/accounts/settings.html create mode 100644 apps/feature-flags/django/django3-saas/templates/base.html create mode 100644 apps/feature-flags/django/django3-saas/templates/billing/cancel.html create mode 100644 apps/feature-flags/django/django3-saas/templates/billing/change_plan.html create mode 100644 apps/feature-flags/django/django3-saas/templates/billing/manage.html create mode 100644 apps/feature-flags/django/django3-saas/templates/billing/pricing.html create mode 100644 apps/feature-flags/django/django3-saas/templates/billing/subscribe.html create mode 100644 apps/feature-flags/django/django3-saas/templates/dashboard/create_project.html create mode 100644 apps/feature-flags/django/django3-saas/templates/dashboard/delete_project.html create mode 100644 apps/feature-flags/django/django3-saas/templates/dashboard/edit_project.html create mode 100644 apps/feature-flags/django/django3-saas/templates/dashboard/index.html create mode 100644 apps/feature-flags/django/django3-saas/templates/dashboard/projects.html create mode 100644 apps/feature-flags/django/django3-saas/templates/errors/404.html create mode 100644 apps/feature-flags/django/django3-saas/templates/errors/500.html create mode 100644 apps/feature-flags/django/django3-saas/templates/marketing/features.html create mode 100644 apps/feature-flags/django/django3-saas/templates/marketing/home.html create mode 100644 apps/feature-flags/next-js/15-app-router-saas/.env.example create mode 100644 apps/feature-flags/next-js/15-app-router-saas/LICENSE create mode 100644 apps/feature-flags/next-js/15-app-router-saas/README.md create mode 100644 apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/dashboard/activity/loading.tsx create mode 100644 apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/dashboard/activity/page.tsx create mode 100644 apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/dashboard/general/page.tsx create mode 100644 apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/dashboard/layout.tsx create mode 100644 apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/dashboard/page.tsx create mode 100644 apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/dashboard/security/page.tsx create mode 100644 apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/layout.tsx create mode 100644 apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/page.tsx create mode 100644 apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/pricing/page.tsx create mode 100644 apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/pricing/submit-button.tsx create mode 100644 apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/terminal.tsx create mode 100644 apps/feature-flags/next-js/15-app-router-saas/app/(login)/actions.ts create mode 100644 apps/feature-flags/next-js/15-app-router-saas/app/(login)/login.tsx create mode 100644 apps/feature-flags/next-js/15-app-router-saas/app/(login)/sign-in/page.tsx create mode 100644 apps/feature-flags/next-js/15-app-router-saas/app/(login)/sign-up/page.tsx create mode 100644 apps/feature-flags/next-js/15-app-router-saas/app/api/stripe/checkout/route.ts create mode 100644 apps/feature-flags/next-js/15-app-router-saas/app/api/stripe/webhook/route.ts create mode 100644 apps/feature-flags/next-js/15-app-router-saas/app/api/team/route.ts create mode 100644 apps/feature-flags/next-js/15-app-router-saas/app/api/user/route.ts create mode 100644 apps/feature-flags/next-js/15-app-router-saas/app/favicon.ico create mode 100644 apps/feature-flags/next-js/15-app-router-saas/app/globals.css create mode 100644 apps/feature-flags/next-js/15-app-router-saas/app/layout.tsx create mode 100644 apps/feature-flags/next-js/15-app-router-saas/app/not-found.tsx create mode 100644 apps/feature-flags/next-js/15-app-router-saas/components.json create mode 100644 apps/feature-flags/next-js/15-app-router-saas/components/ui/avatar.tsx create mode 100644 apps/feature-flags/next-js/15-app-router-saas/components/ui/button.tsx create mode 100644 apps/feature-flags/next-js/15-app-router-saas/components/ui/card.tsx create mode 100644 apps/feature-flags/next-js/15-app-router-saas/components/ui/dropdown-menu.tsx create mode 100644 apps/feature-flags/next-js/15-app-router-saas/components/ui/input.tsx create mode 100644 apps/feature-flags/next-js/15-app-router-saas/components/ui/label.tsx create mode 100644 apps/feature-flags/next-js/15-app-router-saas/components/ui/radio-group.tsx create mode 100644 apps/feature-flags/next-js/15-app-router-saas/docker-compose.yml create mode 100644 apps/feature-flags/next-js/15-app-router-saas/drizzle.config.ts create mode 100644 apps/feature-flags/next-js/15-app-router-saas/lib/auth/middleware.ts create mode 100644 apps/feature-flags/next-js/15-app-router-saas/lib/auth/session.ts create mode 100644 apps/feature-flags/next-js/15-app-router-saas/lib/db/drizzle.ts create mode 100644 apps/feature-flags/next-js/15-app-router-saas/lib/db/migrations/0000_soft_the_anarchist.sql create mode 100644 apps/feature-flags/next-js/15-app-router-saas/lib/db/migrations/meta/0000_snapshot.json create mode 100644 apps/feature-flags/next-js/15-app-router-saas/lib/db/migrations/meta/_journal.json create mode 100644 apps/feature-flags/next-js/15-app-router-saas/lib/db/queries.ts create mode 100644 apps/feature-flags/next-js/15-app-router-saas/lib/db/schema.ts create mode 100644 apps/feature-flags/next-js/15-app-router-saas/lib/db/seed.ts create mode 100644 apps/feature-flags/next-js/15-app-router-saas/lib/db/setup.ts create mode 100644 apps/feature-flags/next-js/15-app-router-saas/lib/payments/STRIPE_STUB_README.md create mode 100644 apps/feature-flags/next-js/15-app-router-saas/lib/payments/TESTING_GUIDE.md create mode 100644 apps/feature-flags/next-js/15-app-router-saas/lib/payments/actions.ts create mode 100644 apps/feature-flags/next-js/15-app-router-saas/lib/payments/stripe-stub.ts create mode 100644 apps/feature-flags/next-js/15-app-router-saas/lib/payments/stripe.ts create mode 100644 apps/feature-flags/next-js/15-app-router-saas/lib/utils.ts create mode 100644 apps/feature-flags/next-js/15-app-router-saas/middleware.ts create mode 100644 apps/feature-flags/next-js/15-app-router-saas/next.config.ts create mode 100644 apps/feature-flags/next-js/15-app-router-saas/package.json create mode 100644 apps/feature-flags/next-js/15-app-router-saas/pnpm-lock.yaml create mode 100644 apps/feature-flags/next-js/15-app-router-saas/postcss.config.mjs create mode 100644 apps/feature-flags/next-js/15-app-router-saas/tsconfig.json create mode 100644 services/gateway-token/gateway-token.test.ts create mode 100644 services/gateway-token/index.ts diff --git a/apps/feature-flags/django/django3-saas/.env.example b/apps/feature-flags/django/django3-saas/.env.example new file mode 100644 index 000000000..b8e3ce5c6 --- /dev/null +++ b/apps/feature-flags/django/django3-saas/.env.example @@ -0,0 +1,21 @@ +# Django settings +SECRET_KEY=your-secret-key-here +DEBUG=True +ALLOWED_HOSTS=localhost,127.0.0.1 + +# Database (defaults to SQLite if not set) +DATABASE_URL= + +# Email settings (defaults to console backend for development) +EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend +EMAIL_HOST= +EMAIL_PORT=587 +EMAIL_HOST_USER= +EMAIL_HOST_PASSWORD= +EMAIL_USE_TLS=True +DEFAULT_FROM_EMAIL=noreply@example.com + +# Stripe settings (optional - for payment processing) +STRIPE_PUBLIC_KEY= +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= diff --git a/apps/feature-flags/django/django3-saas/.gitignore b/apps/feature-flags/django/django3-saas/.gitignore new file mode 100644 index 000000000..be9c7d9b8 --- /dev/null +++ b/apps/feature-flags/django/django3-saas/.gitignore @@ -0,0 +1,29 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +.venv/ +venv/ +ENV/ + +# Django +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal +*.pot +*.pyc + +# Static files +staticfiles/ + +# Environment +.env + +# IDE +.idea/ +.vscode/ +*.swp +*.swo diff --git a/apps/feature-flags/django/django3-saas/README.md b/apps/feature-flags/django/django3-saas/README.md new file mode 100644 index 000000000..6e117bfca --- /dev/null +++ b/apps/feature-flags/django/django3-saas/README.md @@ -0,0 +1,131 @@ +# Django SaaS example app + +A Django 3.0+ SaaS application for testing PostHog wizard integration. This app provides subscription billing, user authentication, and project management features. + +## Running the app + +### Prerequisites + +- Python 3.10+ +- SQLite (included with Python, used by default) +- Stripe account (optional, app runs in demo mode without it) + +### Installation + +1. Create and activate a virtual environment: + +```bash +python -m venv venv +source venv/bin/activate # On Windows: venv\Scripts\activate +``` + +1. Install dependencies: + +```bash +pip install -r requirements.txt +``` + +1. Set up environment variables (create a `.env` file): + +```bash +SECRET_KEY=your-secret-key +# DATABASE_URL=postgresql://... # Optional, defaults to SQLite +STRIPE_PUBLIC_KEY=pk_test_... # Optional, enables Stripe +STRIPE_SECRET_KEY=sk_test_... # Optional, enables Stripe +STRIPE_WEBHOOK_SECRET=whsec_... # Optional, for webhooks +``` + +> **Note:** By default, the app uses SQLite (`db.sqlite3`) and runs in demo mode without Stripe. No additional setup is required for local development. + +1. Initialize the database: + +```bash +python manage.py migrate +python manage.py seed_plans # Optional: seed pricing plans +``` + +1. Run the development server: + +```bash +python manage.py runserver +``` + +The app will be available at `http://127.0.0.1:8000`. + +--- + +## Application structure + +``` +├── manage.py # Django management script +├── requirements.txt # Python dependencies +├── accounts/ # User authentication & profiles +│ ├── models.py # Custom User model +│ ├── views.py # Login, register, password reset +│ └── forms.py # Auth forms +├── billing/ # Subscription & payment handling +│ ├── models.py # Plan, Subscription models +│ ├── views.py # Stripe checkout, webhooks, billing portal +│ ├── admin.py # Django admin customization +│ └── management/ # seed_plans command +├── config/ # Django settings +│ ├── settings.py +│ ├── urls.py +│ └── wsgi.py +├── dashboard/ # Main app functionality +│ ├── models.py # Project, ActivityLog models +│ ├── views.py # Dashboard, project CRUD +│ └── forms.py +├── marketing/ # Public pages +│ └── views.py # Home, features pages +├── static/ # CSS, JS, images +└── templates/ # HTML templates +``` + +## Features + +### Authentication + +- User registration with email +- Login/logout with session management +- Password reset via email +- User profiles and settings + +### Billing & subscriptions + +- Pricing page with plan tiers +- Stripe Checkout integration +- Subscription management (upgrade/downgrade/cancel) +- Stripe webhook handling +- Demo mode when Stripe is not configured + +### Dashboard + +- Project CRUD (create, read, update, delete) +- Activity logging +- Usage metrics display +- Subscription status + +### Admin panel + +- Django admin at `/admin/` +- Plan management with subscriber counts +- Subscription management with status badges + +## Database models + +| Model | Description | +|-------|-------------| +| `User` | Custom user model with authentication and Stripe customer ID | +| `Plan` | Subscription plans with pricing and Stripe price IDs | +| `Subscription` | User subscriptions with status tracking | +| `Project` | User projects with activity logging | +| `ActivityLog` | Audit trail for user actions | + +## Key dependencies + +- **Django** - Web framework +- **Stripe** - Payment processing +- **Whitenoise** - Static file serving +- **dj-database-url** - Database configuration from URL +- **python-dotenv** - Environment variable management diff --git a/apps/feature-flags/django/django3-saas/accounts/__init__.py b/apps/feature-flags/django/django3-saas/accounts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/feature-flags/django/django3-saas/accounts/admin.py b/apps/feature-flags/django/django3-saas/accounts/admin.py new file mode 100644 index 000000000..6b043c786 --- /dev/null +++ b/apps/feature-flags/django/django3-saas/accounts/admin.py @@ -0,0 +1,11 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin +from .models import User + + +@admin.register(User) +class CustomUserAdmin(UserAdmin): + list_display = ['username', 'email', 'is_staff', 'date_joined'] + fieldsets = UserAdmin.fieldsets + ( + ('Profile', {'fields': ('bio',)}), + ) diff --git a/apps/feature-flags/django/django3-saas/accounts/forms.py b/apps/feature-flags/django/django3-saas/accounts/forms.py new file mode 100644 index 000000000..3edb36845 --- /dev/null +++ b/apps/feature-flags/django/django3-saas/accounts/forms.py @@ -0,0 +1,22 @@ +from django import forms +from django.contrib.auth.forms import UserCreationForm, AuthenticationForm +from .models import User + + +class RegisterForm(UserCreationForm): + email = forms.EmailField(required=True) + company_name = forms.CharField(max_length=200, required=False) + + class Meta: + model = User + fields = ['username', 'email', 'company_name', 'password1', 'password2'] + + +class LoginForm(AuthenticationForm): + username = forms.CharField(widget=forms.TextInput(attrs={'autofocus': True})) + + +class ProfileForm(forms.ModelForm): + class Meta: + model = User + fields = ['first_name', 'last_name', 'email', 'company_name'] diff --git a/apps/feature-flags/django/django3-saas/accounts/migrations/0001_initial.py b/apps/feature-flags/django/django3-saas/accounts/migrations/0001_initial.py new file mode 100644 index 000000000..c4e96d1fa --- /dev/null +++ b/apps/feature-flags/django/django3-saas/accounts/migrations/0001_initial.py @@ -0,0 +1,43 @@ +from django.db import migrations, models +import django.contrib.auth.models +import django.contrib.auth.validators +import django.utils.timezone + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('company_name', models.CharField(blank=True, max_length=200)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/apps/feature-flags/django/django3-saas/accounts/migrations/0002_user_email_verified_at_user_stripe_customer_id.py b/apps/feature-flags/django/django3-saas/accounts/migrations/0002_user_email_verified_at_user_stripe_customer_id.py new file mode 100644 index 000000000..cc6746b56 --- /dev/null +++ b/apps/feature-flags/django/django3-saas/accounts/migrations/0002_user_email_verified_at_user_stripe_customer_id.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.27 on 2026-01-21 22:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='email_verified_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='user', + name='stripe_customer_id', + field=models.CharField(blank=True, max_length=100), + ), + ] diff --git a/apps/feature-flags/django/django3-saas/accounts/migrations/__init__.py b/apps/feature-flags/django/django3-saas/accounts/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/feature-flags/django/django3-saas/accounts/models.py b/apps/feature-flags/django/django3-saas/accounts/models.py new file mode 100644 index 000000000..747a354d4 --- /dev/null +++ b/apps/feature-flags/django/django3-saas/accounts/models.py @@ -0,0 +1,31 @@ +from hashlib import md5 +from django.contrib.auth.models import AbstractUser +from django.db import models + + +class User(AbstractUser): + company_name = models.CharField(max_length=200, blank=True) + email_verified_at = models.DateTimeField(null=True, blank=True) + + # Stripe customer ID for billing + stripe_customer_id = models.CharField(max_length=100, blank=True) + + def __str__(self): + return self.username + + def avatar_url(self, size=128): + digest = md5(self.email.lower().encode('utf-8')).hexdigest() + return f'https://www.gravatar.com/avatar/{digest}?d=identicon&s={size}' + + def get_active_subscription(self): + return self.subscriptions.filter(status='active').first() + + def is_subscribed(self): + return self.subscriptions.filter(status='active').exists() + + def get_plan(self): + sub = self.get_active_subscription() + return sub.plan if sub else None + + def is_email_verified(self): + return self.email_verified_at is not None diff --git a/apps/feature-flags/django/django3-saas/accounts/urls.py b/apps/feature-flags/django/django3-saas/accounts/urls.py new file mode 100644 index 000000000..52720159b --- /dev/null +++ b/apps/feature-flags/django/django3-saas/accounts/urls.py @@ -0,0 +1,17 @@ +from django.urls import path +from . import views + +app_name = 'accounts' + +urlpatterns = [ + path('login/', views.CustomLoginView.as_view(), name='login'), + path('logout/', views.CustomLogoutView.as_view(), name='logout'), + path('register/', views.register, name='register'), + path('settings/', views.settings, name='settings'), + + # Password reset + path('password-reset/', views.CustomPasswordResetView.as_view(), name='password_reset'), + path('password-reset/done/', views.CustomPasswordResetDoneView.as_view(), name='password_reset_done'), + path('password-reset///', views.CustomPasswordResetConfirmView.as_view(), name='password_reset_confirm'), + path('password-reset/complete/', views.CustomPasswordResetCompleteView.as_view(), name='password_reset_complete'), +] diff --git a/apps/feature-flags/django/django3-saas/accounts/views.py b/apps/feature-flags/django/django3-saas/accounts/views.py new file mode 100644 index 000000000..03b8ea066 --- /dev/null +++ b/apps/feature-flags/django/django3-saas/accounts/views.py @@ -0,0 +1,71 @@ +from django.shortcuts import render, redirect +from django.contrib.auth import login +from django.contrib.auth.decorators import login_required +from django.contrib.auth.views import ( + LoginView, LogoutView, + PasswordResetView, PasswordResetDoneView, + PasswordResetConfirmView, PasswordResetCompleteView +) +from django.contrib import messages +from django.urls import reverse_lazy +from .forms import RegisterForm, LoginForm, ProfileForm + + +class CustomLoginView(LoginView): + form_class = LoginForm + template_name = 'accounts/login.html' + + +class CustomLogoutView(LogoutView): + next_page = reverse_lazy('accounts:login') + + +class CustomPasswordResetView(PasswordResetView): + template_name = 'accounts/password_reset.html' + email_template_name = 'accounts/password_reset_email.html' + subject_template_name = 'accounts/password_reset_subject.txt' + success_url = reverse_lazy('accounts:password_reset_done') + + +class CustomPasswordResetDoneView(PasswordResetDoneView): + template_name = 'accounts/password_reset_done.html' + + +class CustomPasswordResetConfirmView(PasswordResetConfirmView): + template_name = 'accounts/password_reset_confirm.html' + success_url = reverse_lazy('accounts:password_reset_complete') + + +class CustomPasswordResetCompleteView(PasswordResetCompleteView): + template_name = 'accounts/password_reset_complete.html' + + +def register(request): + if request.user.is_authenticated: + return redirect('dashboard:index') + + if request.method == 'POST': + form = RegisterForm(request.POST) + if form.is_valid(): + user = form.save() + login(request, user) + messages.success(request, 'Registration successful. Welcome!') + return redirect('dashboard:index') + else: + form = RegisterForm() + + return render(request, 'accounts/register.html', {'form': form}) + + +@login_required +def settings(request): + if request.method == 'POST': + form = ProfileForm(request.POST, instance=request.user) + if form.is_valid(): + form.save() + messages.success(request, 'Settings updated.') + return redirect('accounts:settings') + else: + form = ProfileForm(instance=request.user) + + return render(request, 'accounts/settings.html', {'form': form}) diff --git a/apps/feature-flags/django/django3-saas/billing/__init__.py b/apps/feature-flags/django/django3-saas/billing/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/feature-flags/django/django3-saas/billing/admin.py b/apps/feature-flags/django/django3-saas/billing/admin.py new file mode 100644 index 000000000..489b3f002 --- /dev/null +++ b/apps/feature-flags/django/django3-saas/billing/admin.py @@ -0,0 +1,117 @@ +from django.contrib import admin +from django.utils.html import format_html +from django.db.models import Count, Sum +from .models import Plan, Subscription + + +@admin.register(Plan) +class PlanAdmin(admin.ModelAdmin): + list_display = ['name', 'price_display', 'interval', 'subscriber_count', 'is_active', 'is_featured', 'sort_order'] + list_filter = ['is_active', 'is_featured', 'interval'] + prepopulated_fields = {'slug': ('name',)} + ordering = ['sort_order', 'price'] + search_fields = ['name', 'slug'] + readonly_fields = ['created_at', 'updated_at'] + + fieldsets = ( + (None, { + 'fields': ('name', 'slug', 'description') + }), + ('Pricing', { + 'fields': ('price', 'currency', 'interval') + }), + ('Features', { + 'fields': ('features',), + 'description': 'Enter features as a JSON array: ["Feature 1", "Feature 2"]' + }), + ('Display', { + 'fields': ('is_active', 'is_featured', 'sort_order') + }), + ('Stripe', { + 'fields': ('stripe_price_id',), + 'classes': ('collapse',) + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + def price_display(self, obj): + return obj.get_price_display() + price_display.short_description = 'Price' + + def subscriber_count(self, obj): + count = obj.subscriptions.filter(status='active').count() + return format_html('{}', count) + subscriber_count.short_description = 'Active Subscribers' + + def get_queryset(self, request): + return super().get_queryset(request).annotate( + active_subs=Count('subscriptions', filter=models.Q(subscriptions__status='active')) + ) + + +@admin.register(Subscription) +class SubscriptionAdmin(admin.ModelAdmin): + list_display = ['user', 'plan', 'status_badge', 'current_period_start', 'current_period_end', 'is_demo'] + list_filter = ['status', 'plan', 'created_at'] + search_fields = ['user__username', 'user__email', 'stripe_subscription_id'] + raw_id_fields = ['user'] + readonly_fields = ['created_at', 'updated_at'] + date_hierarchy = 'created_at' + + fieldsets = ( + (None, { + 'fields': ('user', 'plan', 'status') + }), + ('Billing Period', { + 'fields': ('current_period_start', 'current_period_end', 'canceled_at') + }), + ('Stripe', { + 'fields': ('stripe_subscription_id', 'stripe_customer_id'), + 'classes': ('collapse',) + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + def status_badge(self, obj): + colors = { + 'active': '#28a745', + 'canceled': '#dc3545', + 'past_due': '#ffc107', + 'trialing': '#17a2b8', + 'paused': '#6c757d', + } + color = colors.get(obj.status, '#6c757d') + return format_html( + '{}', + color, obj.get_status_display() + ) + status_badge.short_description = 'Status' + + def is_demo(self, obj): + if obj.stripe_subscription_id and obj.stripe_subscription_id.startswith('sub_demo_'): + return format_html('Demo') + return format_html('Live') + is_demo.short_description = 'Mode' + + actions = ['mark_as_canceled', 'mark_as_active'] + + def mark_as_canceled(self, request, queryset): + from django.utils import timezone + updated = queryset.update(status='canceled', canceled_at=timezone.now()) + self.message_user(request, f'{updated} subscription(s) marked as canceled.') + mark_as_canceled.short_description = 'Mark selected as canceled' + + def mark_as_active(self, request, queryset): + updated = queryset.update(status='active', canceled_at=None) + self.message_user(request, f'{updated} subscription(s) marked as active.') + mark_as_active.short_description = 'Mark selected as active' + + +# Import models for use in admin queryset +from django.db import models diff --git a/apps/feature-flags/django/django3-saas/billing/management/__init__.py b/apps/feature-flags/django/django3-saas/billing/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/feature-flags/django/django3-saas/billing/management/commands/__init__.py b/apps/feature-flags/django/django3-saas/billing/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/feature-flags/django/django3-saas/billing/management/commands/seed_plans.py b/apps/feature-flags/django/django3-saas/billing/management/commands/seed_plans.py new file mode 100644 index 000000000..425ff16be --- /dev/null +++ b/apps/feature-flags/django/django3-saas/billing/management/commands/seed_plans.py @@ -0,0 +1,75 @@ +from django.core.management.base import BaseCommand +from billing.models import Plan + + +class Command(BaseCommand): + help = 'Seed the database with demo pricing plans' + + def handle(self, *args, **options): + plans_data = [ + { + 'name': 'Starter', + 'slug': 'starter', + 'description': 'Perfect for individuals and small projects.', + 'price': 0, + 'interval': 'month', + 'features': [ + '1 project', + '1,000 API requests/month', + 'Community support', + 'Basic analytics', + ], + 'is_active': True, + 'is_featured': False, + 'sort_order': 1, + 'stripe_price_id': '', + }, + { + 'name': 'Growth', + 'slug': 'growth', + 'description': 'For growing teams that need more power.', + 'price': 29, + 'interval': 'month', + 'features': [ + '10 projects', + '50,000 API requests/month', + 'Email support', + 'Advanced analytics', + 'Team collaboration', + ], + 'is_active': True, + 'is_featured': True, + 'sort_order': 2, + 'stripe_price_id': 'price_growth_monthly', + }, + { + 'name': 'Scale', + 'slug': 'scale', + 'description': 'For businesses that need unlimited scale.', + 'price': 99, + 'interval': 'month', + 'features': [ + 'Unlimited projects', + 'Unlimited API requests', + 'Priority support', + 'Custom analytics', + 'Team collaboration', + 'SSO integration', + 'Dedicated account manager', + ], + 'is_active': True, + 'is_featured': False, + 'sort_order': 3, + 'stripe_price_id': 'price_scale_monthly', + }, + ] + + for plan_data in plans_data: + plan, created = Plan.objects.update_or_create( + slug=plan_data['slug'], + defaults=plan_data + ) + status = 'Created' if created else 'Updated' + self.stdout.write(f'{status}: {plan.name}') + + self.stdout.write(self.style.SUCCESS('Successfully seeded plans')) diff --git a/apps/feature-flags/django/django3-saas/billing/migrations/0001_initial.py b/apps/feature-flags/django/django3-saas/billing/migrations/0001_initial.py new file mode 100644 index 000000000..a7b81374e --- /dev/null +++ b/apps/feature-flags/django/django3-saas/billing/migrations/0001_initial.py @@ -0,0 +1,56 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Plan', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('slug', models.SlugField(unique=True)), + ('description', models.TextField(blank=True)), + ('price', models.DecimalField(decimal_places=2, max_digits=10)), + ('currency', models.CharField(default='USD', max_length=3)), + ('interval', models.CharField(choices=[('month', 'Monthly'), ('year', 'Yearly')], default='month', max_length=20)), + ('features', models.JSONField(default=list)), + ('is_active', models.BooleanField(default=True)), + ('is_featured', models.BooleanField(default=False)), + ('sort_order', models.IntegerField(default=0)), + ('stripe_price_id', models.CharField(blank=True, max_length=100)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'ordering': ['sort_order', 'price'], + }, + ), + migrations.CreateModel( + name='Subscription', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('active', 'Active'), ('canceled', 'Canceled'), ('past_due', 'Past Due'), ('trialing', 'Trialing'), ('paused', 'Paused')], default='active', max_length=20)), + ('current_period_start', models.DateTimeField()), + ('current_period_end', models.DateTimeField()), + ('canceled_at', models.DateTimeField(blank=True, null=True)), + ('stripe_subscription_id', models.CharField(blank=True, max_length=100)), + ('stripe_customer_id', models.CharField(blank=True, max_length=100)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('plan', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='subscriptions', to='billing.plan')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscriptions', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/apps/feature-flags/django/django3-saas/billing/migrations/__init__.py b/apps/feature-flags/django/django3-saas/billing/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/feature-flags/django/django3-saas/billing/models.py b/apps/feature-flags/django/django3-saas/billing/models.py new file mode 100644 index 000000000..282e2207f --- /dev/null +++ b/apps/feature-flags/django/django3-saas/billing/models.py @@ -0,0 +1,71 @@ +from django.db import models +from django.conf import settings + + +class Plan(models.Model): + name = models.CharField(max_length=100) + slug = models.SlugField(unique=True) + description = models.TextField(blank=True) + price = models.DecimalField(max_digits=10, decimal_places=2) + currency = models.CharField(max_length=3, default='USD') + interval = models.CharField( + max_length=20, + choices=[('month', 'Monthly'), ('year', 'Yearly')], + default='month' + ) + features = models.JSONField(default=list) + is_active = models.BooleanField(default=True) + is_featured = models.BooleanField(default=False) + sort_order = models.IntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + # Stripe integration (optional) + stripe_price_id = models.CharField(max_length=100, blank=True) + + class Meta: + ordering = ['sort_order', 'price'] + + def __str__(self): + return self.name + + def get_price_display(self): + if self.price == 0: + return 'Free' + return f'${self.price}/{self.interval}' + + +class Subscription(models.Model): + STATUS_CHOICES = [ + ('active', 'Active'), + ('canceled', 'Canceled'), + ('past_due', 'Past Due'), + ('trialing', 'Trialing'), + ('paused', 'Paused'), + ] + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='subscriptions' + ) + plan = models.ForeignKey(Plan, on_delete=models.PROTECT, related_name='subscriptions') + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='active') + current_period_start = models.DateTimeField() + current_period_end = models.DateTimeField() + canceled_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + # Stripe integration (optional) + stripe_subscription_id = models.CharField(max_length=100, blank=True) + stripe_customer_id = models.CharField(max_length=100, blank=True) + + class Meta: + ordering = ['-created_at'] + + def __str__(self): + return f'{self.user.username} - {self.plan.name}' + + def is_active(self): + return self.status == 'active' diff --git a/apps/feature-flags/django/django3-saas/billing/urls.py b/apps/feature-flags/django/django3-saas/billing/urls.py new file mode 100644 index 000000000..6aa1342da --- /dev/null +++ b/apps/feature-flags/django/django3-saas/billing/urls.py @@ -0,0 +1,15 @@ +from django.urls import path +from . import views + +app_name = 'billing' + +urlpatterns = [ + path('pricing/', views.pricing, name='pricing'), + path('subscribe//', views.subscribe, name='subscribe'), + path('success/', views.success, name='success'), + path('manage/', views.manage, name='manage'), + path('change-plan//', views.change_plan, name='change_plan'), + path('cancel/', views.cancel, name='cancel'), + path('portal/', views.billing_portal, name='billing_portal'), + path('webhook/', views.webhook, name='webhook'), +] diff --git a/apps/feature-flags/django/django3-saas/billing/views.py b/apps/feature-flags/django/django3-saas/billing/views.py new file mode 100644 index 000000000..075923e23 --- /dev/null +++ b/apps/feature-flags/django/django3-saas/billing/views.py @@ -0,0 +1,327 @@ +import uuid +from django.shortcuts import render, redirect, get_object_or_404 +from django.contrib.auth.decorators import login_required +from django.contrib import messages +from django.conf import settings +from django.http import HttpResponse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST +from django.utils import timezone +from datetime import timedelta +from .models import Plan, Subscription + +# Check if Stripe is configured +STRIPE_CONFIGURED = bool(getattr(settings, 'STRIPE_SECRET_KEY', '')) + +if STRIPE_CONFIGURED: + import stripe + stripe.api_key = settings.STRIPE_SECRET_KEY + + +def pricing(request): + """Display pricing plans.""" + plans = Plan.objects.filter(is_active=True) + return render(request, 'billing/pricing.html', {'plans': plans}) + + +@login_required +def subscribe(request, plan_slug): + """Subscribe to a plan - redirects to Stripe Checkout or creates demo subscription.""" + plan = get_object_or_404(Plan, slug=plan_slug, is_active=True) + + # Check if user already has an active subscription + existing = request.user.subscriptions.filter(status='active').first() + if existing: + messages.warning(request, 'You already have an active subscription.') + return redirect('billing:manage') + + if request.method == 'POST': + if STRIPE_CONFIGURED and plan.stripe_price_id: + # Create Stripe Checkout Session + try: + checkout_session = stripe.checkout.Session.create( + customer_email=request.user.email, + payment_method_types=['card'], + line_items=[{ + 'price': plan.stripe_price_id, + 'quantity': 1, + }], + mode='subscription', + success_url=request.build_absolute_uri('/billing/success/') + '?session_id={CHECKOUT_SESSION_ID}', + cancel_url=request.build_absolute_uri('/billing/pricing/'), + metadata={ + 'user_id': request.user.id, + 'plan_id': plan.id, + }, + allow_promotion_codes=True, + ) + return redirect(checkout_session.url) + except Exception as e: + messages.error(request, f'Payment error: {str(e)}') + return redirect('billing:pricing') + else: + # Demo mode - create subscription directly + now = timezone.now() + Subscription.objects.create( + user=request.user, + plan=plan, + status='active', + current_period_start=now, + current_period_end=now + timedelta(days=30 if plan.interval == 'month' else 365), + stripe_subscription_id=f'sub_demo_{uuid.uuid4().hex[:12]}', + ) + messages.success(request, f'Successfully subscribed to {plan.name}! (Demo mode)') + return redirect('dashboard:index') + + return render(request, 'billing/subscribe.html', { + 'plan': plan, + 'stripe_configured': STRIPE_CONFIGURED, + }) + + +@login_required +def success(request): + """Handle successful Stripe checkout.""" + session_id = request.GET.get('session_id') + + if STRIPE_CONFIGURED and session_id: + try: + session = stripe.checkout.Session.retrieve(session_id) + messages.success(request, 'Subscription successful! Welcome aboard.') + except Exception: + messages.warning(request, 'Could not verify payment. Please check your subscription status.') + + return redirect('dashboard:index') + + +@login_required +def manage(request): + """Manage subscription - view current plan, upgrade/downgrade options.""" + subscription = request.user.get_active_subscription() + plans = Plan.objects.filter(is_active=True) + return render(request, 'billing/manage.html', { + 'subscription': subscription, + 'plans': plans, + 'stripe_configured': STRIPE_CONFIGURED, + }) + + +@login_required +def change_plan(request, plan_slug): + """Change subscription plan.""" + plan = get_object_or_404(Plan, slug=plan_slug, is_active=True) + subscription = request.user.get_active_subscription() + + if not subscription: + return redirect('billing:subscribe', plan_slug=plan_slug) + + if request.method == 'POST': + if STRIPE_CONFIGURED and subscription.stripe_subscription_id and not subscription.stripe_subscription_id.startswith('sub_demo_'): + # Update Stripe subscription + try: + stripe_sub = stripe.Subscription.retrieve(subscription.stripe_subscription_id) + stripe.Subscription.modify( + subscription.stripe_subscription_id, + items=[{ + 'id': stripe_sub['items']['data'][0].id, + 'price': plan.stripe_price_id, + }], + proration_behavior='create_prorations', + ) + subscription.plan = plan + subscription.save() + messages.success(request, f'Plan changed to {plan.name}.') + except Exception as e: + messages.error(request, f'Error changing plan: {str(e)}') + else: + # Demo mode + subscription.plan = plan + subscription.save() + messages.success(request, f'Plan changed to {plan.name}. (Demo mode)') + + return redirect('billing:manage') + + return render(request, 'billing/change_plan.html', { + 'plan': plan, + 'subscription': subscription, + }) + + +@login_required +def cancel(request): + """Cancel subscription.""" + subscription = request.user.get_active_subscription() + + if not subscription: + messages.warning(request, 'No active subscription to cancel.') + return redirect('billing:manage') + + if request.method == 'POST': + if STRIPE_CONFIGURED and subscription.stripe_subscription_id and not subscription.stripe_subscription_id.startswith('sub_demo_'): + # Cancel at period end in Stripe + try: + stripe.Subscription.modify( + subscription.stripe_subscription_id, + cancel_at_period_end=True, + ) + except Exception as e: + messages.error(request, f'Error canceling: {str(e)}') + return redirect('billing:manage') + + subscription.status = 'canceled' + subscription.canceled_at = timezone.now() + subscription.save() + messages.success(request, 'Subscription canceled. You will have access until the end of your billing period.') + return redirect('billing:manage') + + return render(request, 'billing/cancel.html', {'subscription': subscription}) + + +@login_required +def billing_portal(request): + """Redirect to Stripe Billing Portal for self-service management.""" + if not STRIPE_CONFIGURED: + messages.info(request, 'Billing portal is not available in demo mode.') + return redirect('billing:manage') + + subscription = request.user.get_active_subscription() + if not subscription or not subscription.stripe_customer_id: + messages.warning(request, 'No billing information found.') + return redirect('billing:manage') + + try: + portal_session = stripe.billing_portal.Session.create( + customer=subscription.stripe_customer_id, + return_url=request.build_absolute_uri('/billing/manage/'), + ) + return redirect(portal_session.url) + except Exception as e: + messages.error(request, f'Error accessing billing portal: {str(e)}') + return redirect('billing:manage') + + +@csrf_exempt +@require_POST +def webhook(request): + """Handle Stripe webhooks.""" + payload = request.body + sig_header = request.META.get('HTTP_STRIPE_SIGNATURE') + webhook_secret = getattr(settings, 'STRIPE_WEBHOOK_SECRET', '') + + if not STRIPE_CONFIGURED or not webhook_secret: + return HttpResponse(status=400) + + try: + event = stripe.Webhook.construct_event( + payload, sig_header, webhook_secret + ) + except ValueError: + return HttpResponse(status=400) + except Exception: + return HttpResponse(status=400) + + # Handle the event + if event['type'] == 'checkout.session.completed': + session = event['data']['object'] + _handle_checkout_completed(session) + elif event['type'] == 'customer.subscription.updated': + subscription_data = event['data']['object'] + _handle_subscription_updated(subscription_data) + elif event['type'] == 'customer.subscription.deleted': + subscription_data = event['data']['object'] + _handle_subscription_deleted(subscription_data) + elif event['type'] == 'invoice.payment_failed': + invoice = event['data']['object'] + _handle_payment_failed(invoice) + + return HttpResponse(status=200) + + +def _handle_checkout_completed(session): + """Create subscription after successful checkout.""" + from accounts.models import User + + user_id = session.get('metadata', {}).get('user_id') + plan_id = session.get('metadata', {}).get('plan_id') + + if not user_id or not plan_id: + return + + try: + user = User.objects.get(id=user_id) + plan = Plan.objects.get(id=plan_id) + except (User.DoesNotExist, Plan.DoesNotExist): + return + + # Get subscription details from Stripe + stripe_sub = stripe.Subscription.retrieve(session['subscription']) + + Subscription.objects.create( + user=user, + plan=plan, + status='active', + current_period_start=timezone.datetime.fromtimestamp( + stripe_sub['current_period_start'], tz=timezone.utc + ), + current_period_end=timezone.datetime.fromtimestamp( + stripe_sub['current_period_end'], tz=timezone.utc + ), + stripe_subscription_id=stripe_sub['id'], + stripe_customer_id=stripe_sub['customer'], + ) + + +def _handle_subscription_updated(subscription_data): + """Update subscription status.""" + try: + subscription = Subscription.objects.get( + stripe_subscription_id=subscription_data['id'] + ) + except Subscription.DoesNotExist: + return + + status_map = { + 'active': 'active', + 'past_due': 'past_due', + 'canceled': 'canceled', + 'trialing': 'trialing', + 'paused': 'paused', + } + + subscription.status = status_map.get(subscription_data['status'], 'active') + subscription.current_period_start = timezone.datetime.fromtimestamp( + subscription_data['current_period_start'], tz=timezone.utc + ) + subscription.current_period_end = timezone.datetime.fromtimestamp( + subscription_data['current_period_end'], tz=timezone.utc + ) + subscription.save() + + +def _handle_subscription_deleted(subscription_data): + """Handle subscription cancellation.""" + try: + subscription = Subscription.objects.get( + stripe_subscription_id=subscription_data['id'] + ) + subscription.status = 'canceled' + subscription.canceled_at = timezone.now() + subscription.save() + except Subscription.DoesNotExist: + pass + + +def _handle_payment_failed(invoice): + """Handle failed payment.""" + subscription_id = invoice.get('subscription') + if not subscription_id: + return + + try: + subscription = Subscription.objects.get( + stripe_subscription_id=subscription_id + ) + subscription.status = 'past_due' + subscription.save() + except Subscription.DoesNotExist: + pass diff --git a/apps/feature-flags/django/django3-saas/config/__init__.py b/apps/feature-flags/django/django3-saas/config/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/feature-flags/django/django3-saas/config/settings.py b/apps/feature-flags/django/django3-saas/config/settings.py new file mode 100644 index 000000000..3c02dea1b --- /dev/null +++ b/apps/feature-flags/django/django3-saas/config/settings.py @@ -0,0 +1,113 @@ +import os +from pathlib import Path +from dotenv import load_dotenv + +load_dotenv() + +BASE_DIR = Path(__file__).resolve().parent.parent + +SECRET_KEY = os.environ.get('SECRET_KEY', 'django-insecure-dev-key-change-in-production') + +DEBUG = os.environ.get('DEBUG', 'True').lower() in ('true', '1', 'yes') + +ALLOWED_HOSTS = [h.strip() for h in os.environ.get('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',')] + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'accounts', + 'billing', + 'dashboard', + 'marketing', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'config.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [BASE_DIR / 'templates'], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'config.wsgi.application' + +DATABASE_URL = os.environ.get('DATABASE_URL', '') +if DATABASE_URL: + import dj_database_url + DATABASES = { + 'default': dj_database_url.parse(DATABASE_URL) + } +else: + DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } + } + +AUTH_PASSWORD_VALIDATORS = [ + {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, + {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'}, + {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, + {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}, +] + +AUTH_USER_MODEL = 'accounts.User' + +LANGUAGE_CODE = 'en-us' +TIME_ZONE = 'UTC' +USE_I18N = True +USE_TZ = True + +STATIC_URL = '/static/' +STATIC_ROOT = BASE_DIR / 'staticfiles' +STATICFILES_DIRS = [BASE_DIR / 'static'] +STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +LOGIN_URL = 'accounts:login' +LOGIN_REDIRECT_URL = 'dashboard:index' +LOGOUT_REDIRECT_URL = 'marketing:home' + +# Email settings (console backend for development) +EMAIL_BACKEND = os.environ.get( + 'EMAIL_BACKEND', + 'django.core.mail.backends.console.EmailBackend' +) +EMAIL_HOST = os.environ.get('EMAIL_HOST', 'localhost') +EMAIL_PORT = int(os.environ.get('EMAIL_PORT', 25)) +EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER', '') +EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD', '') +EMAIL_USE_TLS = os.environ.get('EMAIL_USE_TLS', 'False').lower() in ('true', '1', 'yes') +DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', 'noreply@example.com') + +# Stripe settings (optional - for billing integration) +STRIPE_PUBLIC_KEY = os.environ.get('STRIPE_PUBLIC_KEY', '') +STRIPE_SECRET_KEY = os.environ.get('STRIPE_SECRET_KEY', '') +STRIPE_WEBHOOK_SECRET = os.environ.get('STRIPE_WEBHOOK_SECRET', '') diff --git a/apps/feature-flags/django/django3-saas/config/urls.py b/apps/feature-flags/django/django3-saas/config/urls.py new file mode 100644 index 000000000..a3d56f6d2 --- /dev/null +++ b/apps/feature-flags/django/django3-saas/config/urls.py @@ -0,0 +1,13 @@ +from django.contrib import admin +from django.urls import path, include + +urlpatterns = [ + path('admin/', admin.site.urls), + path('accounts/', include('accounts.urls')), + path('billing/', include('billing.urls')), + path('dashboard/', include('dashboard.urls')), + path('', include('marketing.urls')), +] + +handler404 = 'config.views.handler404' +handler500 = 'config.views.handler500' diff --git a/apps/feature-flags/django/django3-saas/config/views.py b/apps/feature-flags/django/django3-saas/config/views.py new file mode 100644 index 000000000..43a79d154 --- /dev/null +++ b/apps/feature-flags/django/django3-saas/config/views.py @@ -0,0 +1,9 @@ +from django.shortcuts import render + + +def handler404(request, exception): + return render(request, 'errors/404.html', status=404) + + +def handler500(request): + return render(request, 'errors/500.html', status=500) diff --git a/apps/feature-flags/django/django3-saas/config/wsgi.py b/apps/feature-flags/django/django3-saas/config/wsgi.py new file mode 100644 index 000000000..885c6e5b7 --- /dev/null +++ b/apps/feature-flags/django/django3-saas/config/wsgi.py @@ -0,0 +1,5 @@ +import os +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') +application = get_wsgi_application() diff --git a/apps/feature-flags/django/django3-saas/dashboard/__init__.py b/apps/feature-flags/django/django3-saas/dashboard/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/feature-flags/django/django3-saas/dashboard/admin.py b/apps/feature-flags/django/django3-saas/dashboard/admin.py new file mode 100644 index 000000000..abd0a00c9 --- /dev/null +++ b/apps/feature-flags/django/django3-saas/dashboard/admin.py @@ -0,0 +1,18 @@ +from django.contrib import admin +from .models import Project, ActivityLog + + +@admin.register(Project) +class ProjectAdmin(admin.ModelAdmin): + list_display = ['name', 'owner', 'is_active', 'created_at'] + list_filter = ['is_active', 'created_at'] + search_fields = ['name', 'description', 'owner__username'] + raw_id_fields = ['owner'] + + +@admin.register(ActivityLog) +class ActivityLogAdmin(admin.ModelAdmin): + list_display = ['user', 'action', 'created_at'] + list_filter = ['action', 'created_at'] + search_fields = ['user__username', 'action', 'description'] + raw_id_fields = ['user'] diff --git a/apps/feature-flags/django/django3-saas/dashboard/forms.py b/apps/feature-flags/django/django3-saas/dashboard/forms.py new file mode 100644 index 000000000..547886cb7 --- /dev/null +++ b/apps/feature-flags/django/django3-saas/dashboard/forms.py @@ -0,0 +1,11 @@ +from django import forms +from .models import Project + + +class ProjectForm(forms.ModelForm): + class Meta: + model = Project + fields = ['name', 'description', 'is_active'] + widgets = { + 'description': forms.Textarea(attrs={'rows': 4}), + } diff --git a/apps/feature-flags/django/django3-saas/dashboard/migrations/0001_initial.py b/apps/feature-flags/django/django3-saas/dashboard/migrations/0001_initial.py new file mode 100644 index 000000000..606d09e60 --- /dev/null +++ b/apps/feature-flags/django/django3-saas/dashboard/migrations/0001_initial.py @@ -0,0 +1,43 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Project', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200)), + ('description', models.TextField(blank=True)), + ('is_active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='projects', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='ActivityLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('action', models.CharField(max_length=100)), + ('description', models.TextField(blank=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activities', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/apps/feature-flags/django/django3-saas/dashboard/migrations/__init__.py b/apps/feature-flags/django/django3-saas/dashboard/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/feature-flags/django/django3-saas/dashboard/models.py b/apps/feature-flags/django/django3-saas/dashboard/models.py new file mode 100644 index 000000000..ce9aeed42 --- /dev/null +++ b/apps/feature-flags/django/django3-saas/dashboard/models.py @@ -0,0 +1,38 @@ +from django.db import models +from django.conf import settings + + +class Project(models.Model): + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='projects' + ) + name = models.CharField(max_length=200) + description = models.TextField(blank=True) + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ['-created_at'] + + def __str__(self): + return self.name + + +class ActivityLog(models.Model): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='activities' + ) + action = models.CharField(max_length=100) + description = models.TextField(blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-created_at'] + + def __str__(self): + return f'{self.user.username} - {self.action}' diff --git a/apps/feature-flags/django/django3-saas/dashboard/urls.py b/apps/feature-flags/django/django3-saas/dashboard/urls.py new file mode 100644 index 000000000..fd79a18f8 --- /dev/null +++ b/apps/feature-flags/django/django3-saas/dashboard/urls.py @@ -0,0 +1,12 @@ +from django.urls import path +from . import views + +app_name = 'dashboard' + +urlpatterns = [ + path('', views.index, name='index'), + path('projects/', views.projects, name='projects'), + path('projects/create/', views.create_project, name='create_project'), + path('projects//edit/', views.edit_project, name='edit_project'), + path('projects//delete/', views.delete_project, name='delete_project'), +] diff --git a/apps/feature-flags/django/django3-saas/dashboard/views.py b/apps/feature-flags/django/django3-saas/dashboard/views.py new file mode 100644 index 000000000..be99138b9 --- /dev/null +++ b/apps/feature-flags/django/django3-saas/dashboard/views.py @@ -0,0 +1,111 @@ +from django.shortcuts import render, redirect, get_object_or_404 +from django.contrib.auth.decorators import login_required +from django.contrib import messages +from django.utils import timezone +from datetime import timedelta +from .models import Project, ActivityLog +from .forms import ProjectForm + + +@login_required +def index(request): + projects = request.user.projects.all()[:5] + activities = request.user.activities.all()[:10] + subscription = request.user.get_active_subscription() + + # Calculate usage metrics for the user + now = timezone.now() + thirty_days_ago = now - timedelta(days=30) + + metrics = { + 'project_count': request.user.projects.count(), + 'active_projects': request.user.projects.filter(is_active=True).count(), + 'activities_this_month': request.user.activities.filter( + created_at__gte=thirty_days_ago + ).count(), + } + + # Add subscription info + if subscription: + days_remaining = (subscription.current_period_end - now).days + metrics['days_remaining'] = max(0, days_remaining) + metrics['plan_name'] = subscription.plan.name + + return render(request, 'dashboard/index.html', { + 'projects': projects, + 'activities': activities, + 'subscription': subscription, + 'metrics': metrics, + }) + + +@login_required +def projects(request): + projects = request.user.projects.all() + return render(request, 'dashboard/projects.html', {'projects': projects}) + + +@login_required +def create_project(request): + if request.method == 'POST': + form = ProjectForm(request.POST) + if form.is_valid(): + project = form.save(commit=False) + project.owner = request.user + project.save() + + ActivityLog.objects.create( + user=request.user, + action='project_created', + description=f'Created project: {project.name}' + ) + + messages.success(request, 'Project created.') + return redirect('dashboard:projects') + else: + form = ProjectForm() + + return render(request, 'dashboard/create_project.html', {'form': form}) + + +@login_required +def edit_project(request, pk): + project = get_object_or_404(Project, pk=pk, owner=request.user) + + if request.method == 'POST': + form = ProjectForm(request.POST, instance=project) + if form.is_valid(): + form.save() + + ActivityLog.objects.create( + user=request.user, + action='project_updated', + description=f'Updated project: {project.name}' + ) + + messages.success(request, 'Project updated.') + return redirect('dashboard:projects') + else: + form = ProjectForm(instance=project) + + return render(request, 'dashboard/edit_project.html', {'form': form, 'project': project}) + + +@login_required +def delete_project(request, pk): + project = get_object_or_404(Project, pk=pk, owner=request.user) + + if request.method == 'POST': + name = project.name + project.delete() + + ActivityLog.objects.create( + user=request.user, + action='project_deleted', + description=f'Deleted project: {name}' + ) + + messages.success(request, 'Project deleted.') + return redirect('dashboard:projects') + + return render(request, 'dashboard/delete_project.html', {'project': project}) diff --git a/apps/feature-flags/django/django3-saas/manage.py b/apps/feature-flags/django/django3-saas/manage.py new file mode 100644 index 000000000..fcc53d76a --- /dev/null +++ b/apps/feature-flags/django/django3-saas/manage.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python +import os +import sys + +def main(): + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + +if __name__ == '__main__': + main() diff --git a/apps/feature-flags/django/django3-saas/marketing/__init__.py b/apps/feature-flags/django/django3-saas/marketing/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/feature-flags/django/django3-saas/marketing/urls.py b/apps/feature-flags/django/django3-saas/marketing/urls.py new file mode 100644 index 000000000..5425d47ba --- /dev/null +++ b/apps/feature-flags/django/django3-saas/marketing/urls.py @@ -0,0 +1,9 @@ +from django.urls import path +from . import views + +app_name = 'marketing' + +urlpatterns = [ + path('', views.home, name='home'), + path('features/', views.features, name='features'), +] diff --git a/apps/feature-flags/django/django3-saas/marketing/views.py b/apps/feature-flags/django/django3-saas/marketing/views.py new file mode 100644 index 000000000..eebb4b294 --- /dev/null +++ b/apps/feature-flags/django/django3-saas/marketing/views.py @@ -0,0 +1,11 @@ +from django.shortcuts import render + + +def home(request): + """Landing page for the SaaS application.""" + return render(request, 'marketing/home.html') + + +def features(request): + """Features page showcasing product capabilities.""" + return render(request, 'marketing/features.html') diff --git a/apps/feature-flags/django/django3-saas/requirements.txt b/apps/feature-flags/django/django3-saas/requirements.txt new file mode 100644 index 000000000..e10e2f8dc --- /dev/null +++ b/apps/feature-flags/django/django3-saas/requirements.txt @@ -0,0 +1,6 @@ +Django>=3.2,<5.0 +python-dotenv>=1.0.0 +gunicorn>=21.0.0 +whitenoise>=6.6.0 +dj-database-url>=2.0.0 +stripe>=7.0.0 diff --git a/apps/feature-flags/django/django3-saas/static/.gitkeep b/apps/feature-flags/django/django3-saas/static/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/apps/feature-flags/django/django3-saas/templates/accounts/login.html b/apps/feature-flags/django/django3-saas/templates/accounts/login.html new file mode 100644 index 000000000..9c6880b7d --- /dev/null +++ b/apps/feature-flags/django/django3-saas/templates/accounts/login.html @@ -0,0 +1,18 @@ +{% extends 'base.html' %} + +{% block title %}Login{% endblock %} + +{% block content %} +

Login

+ +
+ {% csrf_token %} + {{ form.as_p }} + +
+ +

+ Forgot your password? +

+

Don't have an account? Register

+{% endblock %} diff --git a/apps/feature-flags/django/django3-saas/templates/accounts/password_reset.html b/apps/feature-flags/django/django3-saas/templates/accounts/password_reset.html new file mode 100644 index 000000000..4170b3d9a --- /dev/null +++ b/apps/feature-flags/django/django3-saas/templates/accounts/password_reset.html @@ -0,0 +1,16 @@ +{% extends 'base.html' %} + +{% block title %}Reset Password{% endblock %} + +{% block content %} +

Reset Password

+

Enter your email address and we'll send you a link to reset your password.

+ +
+ {% csrf_token %} + {{ form.as_p }} + +
+ +

Back to Login

+{% endblock %} diff --git a/apps/feature-flags/django/django3-saas/templates/accounts/password_reset_complete.html b/apps/feature-flags/django/django3-saas/templates/accounts/password_reset_complete.html new file mode 100644 index 000000000..aa5a58577 --- /dev/null +++ b/apps/feature-flags/django/django3-saas/templates/accounts/password_reset_complete.html @@ -0,0 +1,9 @@ +{% extends 'base.html' %} + +{% block title %}Password Reset Complete{% endblock %} + +{% block content %} +

Password Reset Complete

+

Your password has been reset successfully.

+

Login Now

+{% endblock %} diff --git a/apps/feature-flags/django/django3-saas/templates/accounts/password_reset_confirm.html b/apps/feature-flags/django/django3-saas/templates/accounts/password_reset_confirm.html new file mode 100644 index 000000000..fb0d2a4c6 --- /dev/null +++ b/apps/feature-flags/django/django3-saas/templates/accounts/password_reset_confirm.html @@ -0,0 +1,18 @@ +{% extends 'base.html' %} + +{% block title %}Set New Password{% endblock %} + +{% block content %} +

Set New Password

+ +{% if validlink %} +
+ {% csrf_token %} + {{ form.as_p }} + +
+{% else %} +

This password reset link is invalid or has expired.

+

Request a new link

+{% endif %} +{% endblock %} diff --git a/apps/feature-flags/django/django3-saas/templates/accounts/password_reset_done.html b/apps/feature-flags/django/django3-saas/templates/accounts/password_reset_done.html new file mode 100644 index 000000000..832749f1a --- /dev/null +++ b/apps/feature-flags/django/django3-saas/templates/accounts/password_reset_done.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} + +{% block title %}Password Reset Sent{% endblock %} + +{% block content %} +

Check Your Email

+

We've sent you an email with instructions to reset your password.

+

If you don't receive an email, make sure you entered the address you registered with.

+ +

Back to Login

+{% endblock %} diff --git a/apps/feature-flags/django/django3-saas/templates/accounts/password_reset_email.html b/apps/feature-flags/django/django3-saas/templates/accounts/password_reset_email.html new file mode 100644 index 000000000..ced6109aa --- /dev/null +++ b/apps/feature-flags/django/django3-saas/templates/accounts/password_reset_email.html @@ -0,0 +1,10 @@ +You're receiving this email because you requested a password reset for your account. + +Please click the link below to reset your password: + +{{ protocol }}://{{ domain }}{% url 'accounts:password_reset_confirm' uidb64=uid token=token %} + +If you didn't request this, you can ignore this email. + +Thanks, +The Django SaaS Team diff --git a/apps/feature-flags/django/django3-saas/templates/accounts/password_reset_subject.txt b/apps/feature-flags/django/django3-saas/templates/accounts/password_reset_subject.txt new file mode 100644 index 000000000..7a3a81292 --- /dev/null +++ b/apps/feature-flags/django/django3-saas/templates/accounts/password_reset_subject.txt @@ -0,0 +1 @@ +Password Reset - Django SaaS \ No newline at end of file diff --git a/apps/feature-flags/django/django3-saas/templates/accounts/register.html b/apps/feature-flags/django/django3-saas/templates/accounts/register.html new file mode 100644 index 000000000..e6b09da53 --- /dev/null +++ b/apps/feature-flags/django/django3-saas/templates/accounts/register.html @@ -0,0 +1,15 @@ +{% extends 'base.html' %} + +{% block title %}Register{% endblock %} + +{% block content %} +

Register

+ +
+ {% csrf_token %} + {{ form.as_p }} + +
+ +

Already have an account? Login

+{% endblock %} diff --git a/apps/feature-flags/django/django3-saas/templates/accounts/settings.html b/apps/feature-flags/django/django3-saas/templates/accounts/settings.html new file mode 100644 index 000000000..14edba6ce --- /dev/null +++ b/apps/feature-flags/django/django3-saas/templates/accounts/settings.html @@ -0,0 +1,23 @@ +{% extends 'base.html' %} + +{% block title %}Settings{% endblock %} + +{% block content %} +

Settings

+ +
+

Profile Information

+
+ {% csrf_token %} + {{ form.as_p }} + +
+
+ +
+

Security

+

+ Change Password +

+
+{% endblock %} diff --git a/apps/feature-flags/django/django3-saas/templates/base.html b/apps/feature-flags/django/django3-saas/templates/base.html new file mode 100644 index 000000000..6da2962b1 --- /dev/null +++ b/apps/feature-flags/django/django3-saas/templates/base.html @@ -0,0 +1,87 @@ + + + + + + {% block title %}Django SaaS{% endblock %} + + + {% block extra_css %}{% endblock %} + + +
+ +
+ +
+ {% if messages %} + {% for message in messages %} +

{{ message }}

+ {% endfor %} + {% endif %} + + {% block content %}{% endblock %} +
+ +
+

Django SaaS App

+
+ + {% block extra_js %}{% endblock %} + + diff --git a/apps/feature-flags/django/django3-saas/templates/billing/cancel.html b/apps/feature-flags/django/django3-saas/templates/billing/cancel.html new file mode 100644 index 000000000..9e11d38a2 --- /dev/null +++ b/apps/feature-flags/django/django3-saas/templates/billing/cancel.html @@ -0,0 +1,23 @@ +{% extends 'base.html' %} + +{% block title %}Cancel Subscription{% endblock %} + +{% block content %} +

Cancel Subscription

+ +
+

Are you sure?

+

+ You're about to cancel your {{ subscription.plan.name }} subscription. +

+

+ You will continue to have access until {{ subscription.current_period_end|date:"M d, Y" }}. +

+ +
+ {% csrf_token %} + + Keep Subscription +
+
+{% endblock %} diff --git a/apps/feature-flags/django/django3-saas/templates/billing/change_plan.html b/apps/feature-flags/django/django3-saas/templates/billing/change_plan.html new file mode 100644 index 000000000..5df476a59 --- /dev/null +++ b/apps/feature-flags/django/django3-saas/templates/billing/change_plan.html @@ -0,0 +1,23 @@ +{% extends 'base.html' %} + +{% block title %}Change Plan{% endblock %} + +{% block content %} +

Change Plan

+ +
+

+ Current Plan: {{ subscription.plan.name }} ({{ subscription.plan.get_price_display }})
+ New Plan: {{ plan.name }} ({{ plan.get_price_display }}) +

+ +
+ {% csrf_token %} +

+ Your plan will be changed immediately. +

+ + Cancel +
+
+{% endblock %} diff --git a/apps/feature-flags/django/django3-saas/templates/billing/manage.html b/apps/feature-flags/django/django3-saas/templates/billing/manage.html new file mode 100644 index 000000000..d94a59989 --- /dev/null +++ b/apps/feature-flags/django/django3-saas/templates/billing/manage.html @@ -0,0 +1,46 @@ +{% extends 'base.html' %} + +{% block title %}Manage Subscription{% endblock %} + +{% block content %} +

Billing

+ +{% if subscription %} +
+

Current Subscription

+

+ Plan: {{ subscription.plan.name }}
+ Status: + + {{ subscription.get_status_display }} +
+ Price: {{ subscription.plan.get_price_display }}
+ Current Period: {{ subscription.current_period_start|date:"M d, Y" }} - {{ subscription.current_period_end|date:"M d, Y" }} +

+ + +
+ +

Change Plan

+
+ {% for plan in plans %} + {% if plan != subscription.plan %} +
+

{{ plan.name }}

+

{{ plan.get_price_display }}

+ Switch +
+ {% endif %} + {% endfor %} +
+ +{% else %} +
+

No Active Subscription

+

Choose a plan to get started.

+ View Plans +
+{% endif %} +{% endblock %} diff --git a/apps/feature-flags/django/django3-saas/templates/billing/pricing.html b/apps/feature-flags/django/django3-saas/templates/billing/pricing.html new file mode 100644 index 000000000..d8045564c --- /dev/null +++ b/apps/feature-flags/django/django3-saas/templates/billing/pricing.html @@ -0,0 +1,53 @@ +{% extends 'base.html' %} + +{% block title %}Pricing - Django SaaS{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +

Simple, Transparent Pricing

+

Choose the plan that fits your needs.

+ +
+ {% for plan in plans %} + + {% empty %} +

No plans available.

+ {% endfor %} +
+{% endblock %} diff --git a/apps/feature-flags/django/django3-saas/templates/billing/subscribe.html b/apps/feature-flags/django/django3-saas/templates/billing/subscribe.html new file mode 100644 index 000000000..2b40bb727 --- /dev/null +++ b/apps/feature-flags/django/django3-saas/templates/billing/subscribe.html @@ -0,0 +1,30 @@ +{% extends 'base.html' %} + +{% block title %}Subscribe to {{ plan.name }}{% endblock %} + +{% block content %} +

Subscribe to {{ plan.name }}

+ +
+

{{ plan.name }} - {{ plan.get_price_display }}

+ {% if plan.description %} +

{{ plan.description }}

+ {% endif %} + +

Features

+
    + {% for feature in plan.features %} +
  • {{ feature }}
  • + {% endfor %} +
+ +
+ {% csrf_token %} +

+ By subscribing, you agree to our terms of service. +

+ + Cancel +
+
+{% endblock %} diff --git a/apps/feature-flags/django/django3-saas/templates/dashboard/create_project.html b/apps/feature-flags/django/django3-saas/templates/dashboard/create_project.html new file mode 100644 index 000000000..38d627f6a --- /dev/null +++ b/apps/feature-flags/django/django3-saas/templates/dashboard/create_project.html @@ -0,0 +1,16 @@ +{% extends 'base.html' %} + +{% block title %}Create Project{% endblock %} + +{% block content %} +

Create Project

+ +
+
+ {% csrf_token %} + {{ form.as_p }} + + Cancel +
+
+{% endblock %} diff --git a/apps/feature-flags/django/django3-saas/templates/dashboard/delete_project.html b/apps/feature-flags/django/django3-saas/templates/dashboard/delete_project.html new file mode 100644 index 000000000..06a4c12b3 --- /dev/null +++ b/apps/feature-flags/django/django3-saas/templates/dashboard/delete_project.html @@ -0,0 +1,18 @@ +{% extends 'base.html' %} + +{% block title %}Delete Project{% endblock %} + +{% block content %} +

Delete Project

+ +
+

Are you sure you want to delete {{ project.name }}?

+

This action cannot be undone.

+ +
+ {% csrf_token %} + + Cancel +
+
+{% endblock %} diff --git a/apps/feature-flags/django/django3-saas/templates/dashboard/edit_project.html b/apps/feature-flags/django/django3-saas/templates/dashboard/edit_project.html new file mode 100644 index 000000000..5ac1bd361 --- /dev/null +++ b/apps/feature-flags/django/django3-saas/templates/dashboard/edit_project.html @@ -0,0 +1,16 @@ +{% extends 'base.html' %} + +{% block title %}Edit Project{% endblock %} + +{% block content %} +

Edit Project

+ +
+
+ {% csrf_token %} + {{ form.as_p }} + + Cancel +
+
+{% endblock %} diff --git a/apps/feature-flags/django/django3-saas/templates/dashboard/index.html b/apps/feature-flags/django/django3-saas/templates/dashboard/index.html new file mode 100644 index 000000000..3162eef2e --- /dev/null +++ b/apps/feature-flags/django/django3-saas/templates/dashboard/index.html @@ -0,0 +1,114 @@ +{% extends 'base.html' %} + +{% block title %}Dashboard{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +

Dashboard

+ +
+
+

{{ metrics.project_count }}

+

Total Projects

+
+
+

{{ metrics.active_projects }}

+

Active Projects

+
+
+

{% if subscription %}{{ subscription.plan.name }}{% else %}Free{% endif %}

+

Current Plan

+
+ {% if subscription %} +
+

{{ metrics.days_remaining }}

+

Days Remaining

+
+ {% endif %} +
+

{{ metrics.activities_this_month }}

+

Actions This Month

+
+
+ +{% if not subscription %} +
+
+
+

Upgrade Your Plan

+

Get more projects and features with a paid plan.

+
+ View Plans +
+
+{% endif %} + +
+
+
+

Recent Projects

+ View All +
+ + {% if projects %} + {% for project in projects %} +
+
+

{{ project.name }}

+ + {% if project.is_active %}Active{% else %}Inactive{% endif %} + +
+ {% if project.description %} +

{{ project.description|truncatewords:20 }}

+ {% endif %} + Created {{ project.created_at|date:"M d, Y" }} +
+ {% endfor %} + {% else %} +
+

No projects yet.

+ Create your first project +
+ {% endif %} +
+ +
+

Recent Activity

+ {% if activities %} + {% for activity in activities %} +
+

{{ activity.description }}

+ {{ activity.created_at|date:"M d, Y H:i" }} +
+ {% endfor %} + {% else %} +

No recent activity.

+ {% endif %} + + {% if subscription %} +

Subscription

+
+

Plan: {{ subscription.plan.name }}

+

Status: + + {{ subscription.get_status_display }} + +

+

Renews: {{ subscription.current_period_end|date:"M d, Y" }}

+ Manage +
+ {% endif %} +
+
+{% endblock %} diff --git a/apps/feature-flags/django/django3-saas/templates/dashboard/projects.html b/apps/feature-flags/django/django3-saas/templates/dashboard/projects.html new file mode 100644 index 000000000..e9752a485 --- /dev/null +++ b/apps/feature-flags/django/django3-saas/templates/dashboard/projects.html @@ -0,0 +1,38 @@ +{% extends 'base.html' %} + +{% block title %}Projects{% endblock %} + +{% block content %} +
+

Projects

+ New Project +
+ +{% if projects %} + {% for project in projects %} +
+
+
+

{{ project.name }}

+ {% if project.description %} +

{{ project.description|truncatewords:30 }}

+ {% endif %} + Created {{ project.created_at|date:"M d, Y" }} +
+
+ + {% if project.is_active %}Active{% else %}Inactive{% endif %} + + Edit + Delete +
+
+
+ {% endfor %} +{% else %} +
+

No projects yet.

+ Create your first project +
+{% endif %} +{% endblock %} diff --git a/apps/feature-flags/django/django3-saas/templates/errors/404.html b/apps/feature-flags/django/django3-saas/templates/errors/404.html new file mode 100644 index 000000000..04a38f32c --- /dev/null +++ b/apps/feature-flags/django/django3-saas/templates/errors/404.html @@ -0,0 +1,12 @@ +{% extends 'base.html' %} + +{% block title %}Page Not Found - Django SaaS{% endblock %} + +{% block content %} +
+

404

+

Page Not Found

+

The page you're looking for doesn't exist or has been moved.

+ Go Home +
+{% endblock %} diff --git a/apps/feature-flags/django/django3-saas/templates/errors/500.html b/apps/feature-flags/django/django3-saas/templates/errors/500.html new file mode 100644 index 000000000..9e5dd1db1 --- /dev/null +++ b/apps/feature-flags/django/django3-saas/templates/errors/500.html @@ -0,0 +1,12 @@ +{% extends 'base.html' %} + +{% block title %}Server Error - Django SaaS{% endblock %} + +{% block content %} +
+

500

+

Server Error

+

Something went wrong on our end. Please try again later.

+ Go Home +
+{% endblock %} diff --git a/apps/feature-flags/django/django3-saas/templates/marketing/features.html b/apps/feature-flags/django/django3-saas/templates/marketing/features.html new file mode 100644 index 000000000..148aeb69b --- /dev/null +++ b/apps/feature-flags/django/django3-saas/templates/marketing/features.html @@ -0,0 +1,44 @@ +{% extends 'base.html' %} + +{% block title %}Features - Django SaaS{% endblock %} + +{% block content %} +

Features

+

Everything you need to manage and scale your business.

+ +
+
+

Project Management

+

Create, organize, and manage all your projects from a central dashboard.

+
+ +
+

Activity Tracking

+

Keep track of all changes with detailed activity logs and history.

+
+ +
+

Subscription Billing

+

Flexible subscription plans with easy upgrade and downgrade options.

+
+ +
+

User Management

+

Manage your account settings, profile, and security preferences.

+
+ +
+

Admin Dashboard

+

Full admin panel to manage users, plans, and subscriptions.

+
+ +
+

Secure & Reliable

+

Built with security best practices. Your data is safe with us.

+
+
+ + +{% endblock %} diff --git a/apps/feature-flags/django/django3-saas/templates/marketing/home.html b/apps/feature-flags/django/django3-saas/templates/marketing/home.html new file mode 100644 index 000000000..e5ad8aadf --- /dev/null +++ b/apps/feature-flags/django/django3-saas/templates/marketing/home.html @@ -0,0 +1,38 @@ +{% extends 'base.html' %} + +{% block title %}Django SaaS - Home{% endblock %} + +{% block content %} +
+

Build Better Products Faster

+

+ A modern SaaS platform to manage your projects, track progress, and scale your business. +

+
+ {% if user.is_authenticated %} + Go to Dashboard + {% else %} + Start Free Trial + Login + {% endif %} +
+
+ +
+

Why Choose Us?

+
+
+

Project Management

+

Organize and track all your projects in one place with our intuitive dashboard.

+
+
+

Flexible Plans

+

Choose from multiple pricing tiers that grow with your business needs.

+
+
+

Activity Tracking

+

Monitor your team's progress with detailed activity logs and metrics.

+
+
+
+{% endblock %} diff --git a/apps/feature-flags/next-js/15-app-router-saas/.env.example b/apps/feature-flags/next-js/15-app-router-saas/.env.example new file mode 100644 index 000000000..9f67031a4 --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/.env.example @@ -0,0 +1,11 @@ +# This is an example of your .env file format, which pnpm db:setup will create. +# Note: this must be .env, not .env.local, without further configuration changes. +POSTGRES_URL=postgresql://*** +STRIPE_SECRET_KEY=sk_test_*** +STRIPE_WEBHOOK_SECRET=whsec_*** +BASE_URL=http://localhost:3000 +AUTH_SECRET=*** + +# Stripe Stub Mode (optional) +# Set to 'stub' to use mock Stripe implementation without real API keys +# STRIPE_MODE=stub \ No newline at end of file diff --git a/apps/feature-flags/next-js/15-app-router-saas/LICENSE b/apps/feature-flags/next-js/15-app-router-saas/LICENSE new file mode 100644 index 000000000..b3ddd8d1d --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Vercel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/feature-flags/next-js/15-app-router-saas/README.md b/apps/feature-flags/next-js/15-app-router-saas/README.md new file mode 100644 index 000000000..093d91d2e --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/README.md @@ -0,0 +1,119 @@ +# Next.js SaaS Starter + +This is a starter template for building a SaaS application using **Next.js** with support for authentication, Stripe integration for payments, and a dashboard for logged-in users. + +**Demo: [https://next-saas-start.vercel.app/](https://next-saas-start.vercel.app/)** + +## Features + +- Marketing landing page (`/`) with animated Terminal element +- Pricing page (`/pricing`) which connects to Stripe Checkout +- Dashboard pages with CRUD operations on users/teams +- Basic RBAC with Owner and Member roles +- Subscription management with Stripe Customer Portal +- Email/password authentication with JWTs stored to cookies +- Global middleware to protect logged-in routes +- Local middleware to protect Server Actions or validate Zod schemas +- Activity logging system for any user events + +## Tech Stack + +- **Framework**: [Next.js](https://nextjs.org/) +- **Database**: [Postgres](https://www.postgresql.org/) +- **ORM**: [Drizzle](https://orm.drizzle.team/) +- **Payments**: [Stripe](https://stripe.com/) +- **UI Library**: [shadcn/ui](https://ui.shadcn.com/) + +## Getting Started + +```bash +git clone https://github.com/nextjs/saas-starter +cd saas-starter +pnpm install +``` + +## Running Locally + +[Install](https://docs.stripe.com/stripe-cli) and log in to your Stripe account: + +```bash +stripe login +``` + +Use the included setup script to create your `.env` file: + +```bash +pnpm db:setup +``` + +Run the database migrations and seed the database with a default user and team: + +```bash +pnpm db:migrate +pnpm db:seed +``` + +This will create the following user and team: + +- User: `test@test.com` +- Password: `admin123` + +You can also create new users through the `/sign-up` route. + +Finally, run the Next.js development server: + +```bash +pnpm dev +``` + +Open [http://localhost:3000](http://localhost:3000) in your browser to see the app in action. + +You can listen for Stripe webhooks locally through their CLI to handle subscription change events: + +```bash +stripe listen --forward-to localhost:3000/api/stripe/webhook +``` + +## Testing Payments + +To test Stripe payments, use the following test card details: + +- Card Number: `4242 4242 4242 4242` +- Expiration: Any future date +- CVC: Any 3-digit number + +## Going to Production + +When you're ready to deploy your SaaS application to production, follow these steps: + +### Set up a production Stripe webhook + +1. Go to the Stripe Dashboard and create a new webhook for your production environment. +2. Set the endpoint URL to your production API route (e.g., `https://yourdomain.com/api/stripe/webhook`). +3. Select the events you want to listen for (e.g., `checkout.session.completed`, `customer.subscription.updated`). + +### Deploy to Vercel + +1. Push your code to a GitHub repository. +2. Connect your repository to [Vercel](https://vercel.com/) and deploy it. +3. Follow the Vercel deployment process, which will guide you through setting up your project. + +### Add environment variables + +In your Vercel project settings (or during deployment), add all the necessary environment variables. Make sure to update the values for the production environment, including: + +1. `BASE_URL`: Set this to your production domain. +2. `STRIPE_SECRET_KEY`: Use your Stripe secret key for the production environment. +3. `STRIPE_WEBHOOK_SECRET`: Use the webhook secret from the production webhook you created in step 1. +4. `POSTGRES_URL`: Set this to your production database URL. +5. `AUTH_SECRET`: Set this to a random string. `openssl rand -base64 32` will generate one. + +## Other Templates + +While this template is intentionally minimal and to be used as a learning resource, there are other paid versions in the community which are more full-featured: + +- https://achromatic.dev +- https://shipfa.st +- https://makerkit.dev +- https://zerotoshipped.com +- https://turbostarter.dev diff --git a/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/dashboard/activity/loading.tsx b/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/dashboard/activity/loading.tsx new file mode 100644 index 000000000..965f34cb5 --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/dashboard/activity/loading.tsx @@ -0,0 +1,17 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; + +export default function ActivityPageSkeleton() { + return ( +
+

+ Activity Log +

+ + + Recent Activity + + + +
+ ); +} diff --git a/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/dashboard/activity/page.tsx b/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/dashboard/activity/page.tsx new file mode 100644 index 000000000..68ada9699 --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/dashboard/activity/page.tsx @@ -0,0 +1,126 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Settings, + LogOut, + UserPlus, + Lock, + UserCog, + AlertCircle, + UserMinus, + Mail, + CheckCircle, + type LucideIcon, +} from 'lucide-react'; +import { ActivityType } from '@/lib/db/schema'; +import { getActivityLogs } from '@/lib/db/queries'; + +const iconMap: Record = { + [ActivityType.SIGN_UP]: UserPlus, + [ActivityType.SIGN_IN]: UserCog, + [ActivityType.SIGN_OUT]: LogOut, + [ActivityType.UPDATE_PASSWORD]: Lock, + [ActivityType.DELETE_ACCOUNT]: UserMinus, + [ActivityType.UPDATE_ACCOUNT]: Settings, + [ActivityType.CREATE_TEAM]: UserPlus, + [ActivityType.REMOVE_TEAM_MEMBER]: UserMinus, + [ActivityType.INVITE_TEAM_MEMBER]: Mail, + [ActivityType.ACCEPT_INVITATION]: CheckCircle, +}; + +function getRelativeTime(date: Date) { + const now = new Date(); + const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); + + if (diffInSeconds < 60) return 'just now'; + if (diffInSeconds < 3600) + return `${Math.floor(diffInSeconds / 60)} minutes ago`; + if (diffInSeconds < 86400) + return `${Math.floor(diffInSeconds / 3600)} hours ago`; + if (diffInSeconds < 604800) + return `${Math.floor(diffInSeconds / 86400)} days ago`; + return date.toLocaleDateString(); +} + +function formatAction(action: ActivityType): string { + switch (action) { + case ActivityType.SIGN_UP: + return 'You signed up'; + case ActivityType.SIGN_IN: + return 'You signed in'; + case ActivityType.SIGN_OUT: + return 'You signed out'; + case ActivityType.UPDATE_PASSWORD: + return 'You changed your password'; + case ActivityType.DELETE_ACCOUNT: + return 'You deleted your account'; + case ActivityType.UPDATE_ACCOUNT: + return 'You updated your account'; + case ActivityType.CREATE_TEAM: + return 'You created a new team'; + case ActivityType.REMOVE_TEAM_MEMBER: + return 'You removed a team member'; + case ActivityType.INVITE_TEAM_MEMBER: + return 'You invited a team member'; + case ActivityType.ACCEPT_INVITATION: + return 'You accepted an invitation'; + default: + return 'Unknown action occurred'; + } +} + +export default async function ActivityPage() { + const logs = await getActivityLogs(); + + return ( +
+

+ Activity Log +

+ + + Recent Activity + + + {logs.length > 0 ? ( +
    + {logs.map((log) => { + const Icon = iconMap[log.action as ActivityType] || Settings; + const formattedAction = formatAction( + log.action as ActivityType + ); + + return ( +
  • +
    + +
    +
    +

    + {formattedAction} + {log.ipAddress && ` from IP ${log.ipAddress}`} +

    +

    + {getRelativeTime(new Date(log.timestamp))} +

    +
    +
  • + ); + })} +
+ ) : ( +
+ +

+ No activity yet +

+

+ When you perform actions like signing in or updating your + account, they'll appear here. +

+
+ )} +
+
+
+ ); +} diff --git a/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/dashboard/general/page.tsx b/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/dashboard/general/page.tsx new file mode 100644 index 000000000..e3e0165aa --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/dashboard/general/page.tsx @@ -0,0 +1,121 @@ +'use client'; + +import { useActionState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; +import { Loader2 } from 'lucide-react'; +import { updateAccount } from '@/app/(login)/actions'; +import { User } from '@/lib/db/schema'; +import useSWR from 'swr'; +import { Suspense } from 'react'; + +const fetcher = (url: string) => fetch(url).then((res) => res.json()); + +type ActionState = { + name?: string; + error?: string; + success?: string; +}; + +type AccountFormProps = { + state: ActionState; + nameValue?: string; + emailValue?: string; +}; + +function AccountForm({ + state, + nameValue = '', + emailValue = '' +}: AccountFormProps) { + return ( + <> +
+ + +
+
+ + +
+ + ); +} + +function AccountFormWithData({ state }: { state: ActionState }) { + const { data: user } = useSWR('/api/user', fetcher); + return ( + + ); +} + +export default function GeneralPage() { + const [state, formAction, isPending] = useActionState( + updateAccount, + {} + ); + + return ( +
+

+ General Settings +

+ + + + Account Information + + +
+ }> + + + {state.error && ( +

{state.error}

+ )} + {state.success && ( +

{state.success}

+ )} + +
+
+
+
+ ); +} diff --git a/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/dashboard/layout.tsx b/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/dashboard/layout.tsx new file mode 100644 index 000000000..be0396866 --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/dashboard/layout.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { Button } from '@/components/ui/button'; +import { Users, Settings, Shield, Activity, Menu } from 'lucide-react'; + +export default function DashboardLayout({ + children +}: { + children: React.ReactNode; +}) { + const pathname = usePathname(); + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + + const navItems = [ + { href: '/dashboard', icon: Users, label: 'Team' }, + { href: '/dashboard/general', icon: Settings, label: 'General' }, + { href: '/dashboard/activity', icon: Activity, label: 'Activity' }, + { href: '/dashboard/security', icon: Shield, label: 'Security' } + ]; + + return ( +
+ {/* Mobile header */} +
+
+ Settings +
+ +
+ +
+ {/* Sidebar */} + + + {/* Main content */} +
{children}
+
+
+ ); +} diff --git a/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/dashboard/page.tsx b/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/dashboard/page.tsx new file mode 100644 index 000000000..60475a858 --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/dashboard/page.tsx @@ -0,0 +1,287 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardFooter +} from '@/components/ui/card'; +import { customerPortalAction } from '@/lib/payments/actions'; +import { useActionState } from 'react'; +import { TeamDataWithMembers, User } from '@/lib/db/schema'; +import { removeTeamMember, inviteTeamMember } from '@/app/(login)/actions'; +import useSWR from 'swr'; +import { Suspense } from 'react'; +import { Input } from '@/components/ui/input'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { Label } from '@/components/ui/label'; +import { Loader2, PlusCircle } from 'lucide-react'; + +type ActionState = { + error?: string; + success?: string; +}; + +const fetcher = (url: string) => fetch(url).then((res) => res.json()); + +function SubscriptionSkeleton() { + return ( + + + Team Subscription + + + ); +} + +function ManageSubscription() { + const { data: teamData } = useSWR('/api/team', fetcher); + + return ( + + + Team Subscription + + +
+
+
+

+ Current Plan: {teamData?.planName || 'Free'} +

+

+ {teamData?.subscriptionStatus === 'active' + ? 'Billed monthly' + : teamData?.subscriptionStatus === 'trialing' + ? 'Trial period' + : 'No active subscription'} +

+
+
+ +
+
+
+
+
+ ); +} + +function TeamMembersSkeleton() { + return ( + + + Team Members + + +
+
+
+
+
+
+
+
+
+
+
+ ); +} + +function TeamMembers() { + const { data: teamData } = useSWR('/api/team', fetcher); + const [removeState, removeAction, isRemovePending] = useActionState< + ActionState, + FormData + >(removeTeamMember, {}); + + const getUserDisplayName = (user: Pick) => { + return user.name || user.email || 'Unknown User'; + }; + + if (!teamData?.teamMembers?.length) { + return ( + + + Team Members + + +

No team members yet.

+
+
+ ); + } + + return ( + + + Team Members + + +
    + {teamData.teamMembers.map((member, index) => ( +
  • +
    + + {/* + This app doesn't save profile images, but here + is how you'd show them: + + + */} + + {getUserDisplayName(member.user) + .split(' ') + .map((n) => n[0]) + .join('')} + + +
    +

    + {getUserDisplayName(member.user)} +

    +

    + {member.role} +

    +
    +
    + {index > 1 ? ( +
    + + +
    + ) : null} +
  • + ))} +
+ {removeState?.error && ( +

{removeState.error}

+ )} +
+
+ ); +} + +function InviteTeamMemberSkeleton() { + return ( + + + Invite Team Member + + + ); +} + +function InviteTeamMember() { + const { data: user } = useSWR('/api/user', fetcher); + const isOwner = user?.role === 'owner'; + const [inviteState, inviteAction, isInvitePending] = useActionState< + ActionState, + FormData + >(inviteTeamMember, {}); + + return ( + + + Invite Team Member + + +
+
+ + +
+
+ + +
+ + +
+
+ + +
+
+
+ {inviteState?.error && ( +

{inviteState.error}

+ )} + {inviteState?.success && ( +

{inviteState.success}

+ )} + +
+
+ {!isOwner && ( + +

+ You must be a team owner to invite new members. +

+
+ )} +
+ ); +} + +export default function SettingsPage() { + return ( +
+

Team Settings

+ }> + + + }> + + + }> + + +
+ ); +} diff --git a/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/dashboard/security/page.tsx b/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/dashboard/security/page.tsx new file mode 100644 index 000000000..642b072da --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/dashboard/security/page.tsx @@ -0,0 +1,167 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; +import { Lock, Trash2, Loader2 } from 'lucide-react'; +import { useActionState } from 'react'; +import { updatePassword, deleteAccount } from '@/app/(login)/actions'; + +type PasswordState = { + currentPassword?: string; + newPassword?: string; + confirmPassword?: string; + error?: string; + success?: string; +}; + +type DeleteState = { + password?: string; + error?: string; + success?: string; +}; + +export default function SecurityPage() { + const [passwordState, passwordAction, isPasswordPending] = useActionState< + PasswordState, + FormData + >(updatePassword, {}); + + const [deleteState, deleteAction, isDeletePending] = useActionState< + DeleteState, + FormData + >(deleteAccount, {}); + + return ( +
+

+ Security Settings +

+ + + Password + + +
+
+ + +
+
+ + +
+
+ + +
+ {passwordState.error && ( +

{passwordState.error}

+ )} + {passwordState.success && ( +

{passwordState.success}

+ )} + +
+
+
+ + + + Delete Account + + +

+ Account deletion is non-reversable. Please proceed with caution. +

+
+
+ + +
+ {deleteState.error && ( +

{deleteState.error}

+ )} + +
+
+
+
+ ); +} diff --git a/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/layout.tsx b/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/layout.tsx new file mode 100644 index 000000000..5e1d0b1b2 --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/layout.tsx @@ -0,0 +1,106 @@ +'use client'; + +import Link from 'next/link'; +import { use, useState, Suspense } from 'react'; +import { Button } from '@/components/ui/button'; +import { CircleIcon, Home, LogOut } from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from '@/components/ui/dropdown-menu'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { signOut } from '@/app/(login)/actions'; +import { useRouter } from 'next/navigation'; +import { User } from '@/lib/db/schema'; +import useSWR, { mutate } from 'swr'; + +const fetcher = (url: string) => fetch(url).then((res) => res.json()); + +function UserMenu() { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const { data: user } = useSWR('/api/user', fetcher); + const router = useRouter(); + + async function handleSignOut() { + await signOut(); + mutate('/api/user'); + router.push('/'); + } + + if (!user) { + return ( + <> + + Pricing + + + + ); + } + + return ( + + + + + + {user.email + .split(' ') + .map((n) => n[0]) + .join('')} + + + + + + + + Dashboard + + +
+ +
+
+
+ ); +} + +function Header() { + return ( +
+
+ + + ACME + +
+ }> + + +
+
+
+ ); +} + +export default function Layout({ children }: { children: React.ReactNode }) { + return ( +
+
+ {children} +
+ ); +} diff --git a/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/page.tsx b/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/page.tsx new file mode 100644 index 000000000..07e3b487f --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/page.tsx @@ -0,0 +1,130 @@ +import { Button } from '@/components/ui/button'; +import { ArrowRight, CreditCard, Database } from 'lucide-react'; +import { Terminal } from './terminal'; + +export default function HomePage() { + return ( +
+
+
+
+
+

+ Build Your SaaS + Faster Than Ever +

+

+ Launch your SaaS product in record time with our powerful, + ready-to-use template. Packed with modern technologies and + essential integrations. +

+ +
+
+ +
+
+
+
+ +
+
+
+
+
+ + + +
+
+

+ Next.js and React +

+

+ Leverage the power of modern web technologies for optimal + performance and developer experience. +

+
+
+ +
+
+ +
+
+

+ Postgres and Drizzle ORM +

+

+ Robust database solution with an intuitive ORM for efficient + data management and scalability. +

+
+
+ +
+
+ +
+
+

+ Stripe Integration +

+

+ Seamless payment processing and subscription management with + industry-leading Stripe integration. +

+
+
+
+
+
+ +
+
+
+
+

+ Ready to launch your SaaS? +

+

+ Our template provides everything you need to get your SaaS up + and running quickly. Don't waste time on boilerplate - focus on + what makes your product unique. +

+
+ +
+
+
+
+ ); +} diff --git a/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/pricing/page.tsx b/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/pricing/page.tsx new file mode 100644 index 000000000..234cbf5ed --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/pricing/page.tsx @@ -0,0 +1,94 @@ +import { checkoutAction } from '@/lib/payments/actions'; +import { Check } from 'lucide-react'; +import { getStripePrices, getStripeProducts } from '@/lib/payments/stripe'; +import { SubmitButton } from './submit-button'; + +// Prices are fresh for one hour max +export const revalidate = 3600; + +export default async function PricingPage() { + const [prices, products] = await Promise.all([ + getStripePrices(), + getStripeProducts(), + ]); + + const basePlan = products.find((product) => product.name === 'Base'); + const plusPlan = products.find((product) => product.name === 'Plus'); + + const basePrice = prices.find((price) => price.productId === basePlan?.id); + const plusPrice = prices.find((price) => price.productId === plusPlan?.id); + + return ( +
+
+ + +
+
+ ); +} + +function PricingCard({ + name, + price, + interval, + trialDays, + features, + priceId, +}: { + name: string; + price: number; + interval: string; + trialDays: number; + features: string[]; + priceId?: string; +}) { + return ( +
+

{name}

+

+ with {trialDays} day free trial +

+

+ ${price / 100}{' '} + + per user / {interval} + +

+
    + {features.map((feature, index) => ( +
  • + + {feature} +
  • + ))} +
+
+ + + +
+ ); +} diff --git a/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/pricing/submit-button.tsx b/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/pricing/submit-button.tsx new file mode 100644 index 000000000..aa04ea9db --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/pricing/submit-button.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { Button } from '@/components/ui/button'; +import { ArrowRight, Loader2 } from 'lucide-react'; +import { useFormStatus } from 'react-dom'; + +export function SubmitButton() { + const { pending } = useFormStatus(); + + return ( + + ); +} diff --git a/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/terminal.tsx b/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/terminal.tsx new file mode 100644 index 000000000..a80112317 --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/terminal.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Copy, Check } from 'lucide-react'; + +export function Terminal() { + const [terminalStep, setTerminalStep] = useState(0); + const [copied, setCopied] = useState(false); + const terminalSteps = [ + 'git clone https://github.com/nextjs/saas-starter', + 'pnpm install', + 'pnpm db:setup', + 'pnpm db:migrate', + 'pnpm db:seed', + 'pnpm dev 🎉', + ]; + + useEffect(() => { + const timer = setTimeout(() => { + setTerminalStep((prev) => + prev < terminalSteps.length - 1 ? prev + 1 : prev + ); + }, 500); + + return () => clearTimeout(timer); + }, [terminalStep]); + + const copyToClipboard = () => { + navigator.clipboard.writeText(terminalSteps.join('\n')); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+
+
+
+
+
+
+
+ +
+
+ {terminalSteps.map((step, index) => ( +
terminalStep ? 'opacity-0' : 'opacity-100'} transition-opacity duration-300`} + > + $ {step} +
+ ))} +
+
+
+ ); +} diff --git a/apps/feature-flags/next-js/15-app-router-saas/app/(login)/actions.ts b/apps/feature-flags/next-js/15-app-router-saas/app/(login)/actions.ts new file mode 100644 index 000000000..532adc0ef --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/app/(login)/actions.ts @@ -0,0 +1,459 @@ +'use server'; + +import { z } from 'zod'; +import { and, eq, sql } from 'drizzle-orm'; +import { db } from '@/lib/db/drizzle'; +import { + User, + users, + teams, + teamMembers, + activityLogs, + type NewUser, + type NewTeam, + type NewTeamMember, + type NewActivityLog, + ActivityType, + invitations +} from '@/lib/db/schema'; +import { comparePasswords, hashPassword, setSession } from '@/lib/auth/session'; +import { redirect } from 'next/navigation'; +import { cookies } from 'next/headers'; +import { createCheckoutSession } from '@/lib/payments/stripe'; +import { getUser, getUserWithTeam } from '@/lib/db/queries'; +import { + validatedAction, + validatedActionWithUser +} from '@/lib/auth/middleware'; + +async function logActivity( + teamId: number | null | undefined, + userId: number, + type: ActivityType, + ipAddress?: string +) { + if (teamId === null || teamId === undefined) { + return; + } + const newActivity: NewActivityLog = { + teamId, + userId, + action: type, + ipAddress: ipAddress || '' + }; + await db.insert(activityLogs).values(newActivity); +} + +const signInSchema = z.object({ + email: z.string().email().min(3).max(255), + password: z.string().min(8).max(100) +}); + +export const signIn = validatedAction(signInSchema, async (data, formData) => { + const { email, password } = data; + + const userWithTeam = await db + .select({ + user: users, + team: teams + }) + .from(users) + .leftJoin(teamMembers, eq(users.id, teamMembers.userId)) + .leftJoin(teams, eq(teamMembers.teamId, teams.id)) + .where(eq(users.email, email)) + .limit(1); + + if (userWithTeam.length === 0) { + return { + error: 'Invalid email or password. Please try again.', + email, + password + }; + } + + const { user: foundUser, team: foundTeam } = userWithTeam[0]; + + const isPasswordValid = await comparePasswords( + password, + foundUser.passwordHash + ); + + if (!isPasswordValid) { + return { + error: 'Invalid email or password. Please try again.', + email, + password + }; + } + + await Promise.all([ + setSession(foundUser), + logActivity(foundTeam?.id, foundUser.id, ActivityType.SIGN_IN) + ]); + + const redirectTo = formData.get('redirect') as string | null; + if (redirectTo === 'checkout') { + const priceId = formData.get('priceId') as string; + return createCheckoutSession({ team: foundTeam, priceId }); + } + + redirect('/dashboard'); +}); + +const signUpSchema = z.object({ + email: z.string().email(), + password: z.string().min(8), + inviteId: z.string().optional() +}); + +export const signUp = validatedAction(signUpSchema, async (data, formData) => { + const { email, password, inviteId } = data; + + const existingUser = await db + .select() + .from(users) + .where(eq(users.email, email)) + .limit(1); + + if (existingUser.length > 0) { + return { + error: 'Failed to create user. Please try again.', + email, + password + }; + } + + const passwordHash = await hashPassword(password); + + const newUser: NewUser = { + email, + passwordHash, + role: 'owner' // Default role, will be overridden if there's an invitation + }; + + const [createdUser] = await db.insert(users).values(newUser).returning(); + + if (!createdUser) { + return { + error: 'Failed to create user. Please try again.', + email, + password + }; + } + + let teamId: number; + let userRole: string; + let createdTeam: typeof teams.$inferSelect | null = null; + + if (inviteId) { + // Check if there's a valid invitation + const [invitation] = await db + .select() + .from(invitations) + .where( + and( + eq(invitations.id, parseInt(inviteId)), + eq(invitations.email, email), + eq(invitations.status, 'pending') + ) + ) + .limit(1); + + if (invitation) { + teamId = invitation.teamId; + userRole = invitation.role; + + await db + .update(invitations) + .set({ status: 'accepted' }) + .where(eq(invitations.id, invitation.id)); + + await logActivity(teamId, createdUser.id, ActivityType.ACCEPT_INVITATION); + + [createdTeam] = await db + .select() + .from(teams) + .where(eq(teams.id, teamId)) + .limit(1); + } else { + return { error: 'Invalid or expired invitation.', email, password }; + } + } else { + // Create a new team if there's no invitation + const newTeam: NewTeam = { + name: `${email}'s Team` + }; + + [createdTeam] = await db.insert(teams).values(newTeam).returning(); + + if (!createdTeam) { + return { + error: 'Failed to create team. Please try again.', + email, + password + }; + } + + teamId = createdTeam.id; + userRole = 'owner'; + + await logActivity(teamId, createdUser.id, ActivityType.CREATE_TEAM); + } + + const newTeamMember: NewTeamMember = { + userId: createdUser.id, + teamId: teamId, + role: userRole + }; + + await Promise.all([ + db.insert(teamMembers).values(newTeamMember), + logActivity(teamId, createdUser.id, ActivityType.SIGN_UP), + setSession(createdUser) + ]); + + const redirectTo = formData.get('redirect') as string | null; + if (redirectTo === 'checkout') { + const priceId = formData.get('priceId') as string; + return createCheckoutSession({ team: createdTeam, priceId }); + } + + redirect('/dashboard'); +}); + +export async function signOut() { + const user = (await getUser()) as User; + const userWithTeam = await getUserWithTeam(user.id); + await logActivity(userWithTeam?.teamId, user.id, ActivityType.SIGN_OUT); + (await cookies()).delete('session'); +} + +const updatePasswordSchema = z.object({ + currentPassword: z.string().min(8).max(100), + newPassword: z.string().min(8).max(100), + confirmPassword: z.string().min(8).max(100) +}); + +export const updatePassword = validatedActionWithUser( + updatePasswordSchema, + async (data, _, user) => { + const { currentPassword, newPassword, confirmPassword } = data; + + const isPasswordValid = await comparePasswords( + currentPassword, + user.passwordHash + ); + + if (!isPasswordValid) { + return { + currentPassword, + newPassword, + confirmPassword, + error: 'Current password is incorrect.' + }; + } + + if (currentPassword === newPassword) { + return { + currentPassword, + newPassword, + confirmPassword, + error: 'New password must be different from the current password.' + }; + } + + if (confirmPassword !== newPassword) { + return { + currentPassword, + newPassword, + confirmPassword, + error: 'New password and confirmation password do not match.' + }; + } + + const newPasswordHash = await hashPassword(newPassword); + const userWithTeam = await getUserWithTeam(user.id); + + await Promise.all([ + db + .update(users) + .set({ passwordHash: newPasswordHash }) + .where(eq(users.id, user.id)), + logActivity(userWithTeam?.teamId, user.id, ActivityType.UPDATE_PASSWORD) + ]); + + return { + success: 'Password updated successfully.' + }; + } +); + +const deleteAccountSchema = z.object({ + password: z.string().min(8).max(100) +}); + +export const deleteAccount = validatedActionWithUser( + deleteAccountSchema, + async (data, _, user) => { + const { password } = data; + + const isPasswordValid = await comparePasswords(password, user.passwordHash); + if (!isPasswordValid) { + return { + password, + error: 'Incorrect password. Account deletion failed.' + }; + } + + const userWithTeam = await getUserWithTeam(user.id); + + await logActivity( + userWithTeam?.teamId, + user.id, + ActivityType.DELETE_ACCOUNT + ); + + // Soft delete + await db + .update(users) + .set({ + deletedAt: sql`CURRENT_TIMESTAMP`, + email: sql`CONCAT(email, '-', id, '-deleted')` // Ensure email uniqueness + }) + .where(eq(users.id, user.id)); + + if (userWithTeam?.teamId) { + await db + .delete(teamMembers) + .where( + and( + eq(teamMembers.userId, user.id), + eq(teamMembers.teamId, userWithTeam.teamId) + ) + ); + } + + (await cookies()).delete('session'); + redirect('/sign-in'); + } +); + +const updateAccountSchema = z.object({ + name: z.string().min(1, 'Name is required').max(100), + email: z.string().email('Invalid email address') +}); + +export const updateAccount = validatedActionWithUser( + updateAccountSchema, + async (data, _, user) => { + const { name, email } = data; + const userWithTeam = await getUserWithTeam(user.id); + + await Promise.all([ + db.update(users).set({ name, email }).where(eq(users.id, user.id)), + logActivity(userWithTeam?.teamId, user.id, ActivityType.UPDATE_ACCOUNT) + ]); + + return { name, success: 'Account updated successfully.' }; + } +); + +const removeTeamMemberSchema = z.object({ + memberId: z.number() +}); + +export const removeTeamMember = validatedActionWithUser( + removeTeamMemberSchema, + async (data, _, user) => { + const { memberId } = data; + const userWithTeam = await getUserWithTeam(user.id); + + if (!userWithTeam?.teamId) { + return { error: 'User is not part of a team' }; + } + + await db + .delete(teamMembers) + .where( + and( + eq(teamMembers.id, memberId), + eq(teamMembers.teamId, userWithTeam.teamId) + ) + ); + + await logActivity( + userWithTeam.teamId, + user.id, + ActivityType.REMOVE_TEAM_MEMBER + ); + + return { success: 'Team member removed successfully' }; + } +); + +const inviteTeamMemberSchema = z.object({ + email: z.string().email('Invalid email address'), + role: z.enum(['member', 'owner']) +}); + +export const inviteTeamMember = validatedActionWithUser( + inviteTeamMemberSchema, + async (data, _, user) => { + const { email, role } = data; + const userWithTeam = await getUserWithTeam(user.id); + + if (!userWithTeam?.teamId) { + return { error: 'User is not part of a team' }; + } + + const existingMember = await db + .select() + .from(users) + .leftJoin(teamMembers, eq(users.id, teamMembers.userId)) + .where( + and(eq(users.email, email), eq(teamMembers.teamId, userWithTeam.teamId)) + ) + .limit(1); + + if (existingMember.length > 0) { + return { error: 'User is already a member of this team' }; + } + + // Check if there's an existing invitation + const existingInvitation = await db + .select() + .from(invitations) + .where( + and( + eq(invitations.email, email), + eq(invitations.teamId, userWithTeam.teamId), + eq(invitations.status, 'pending') + ) + ) + .limit(1); + + if (existingInvitation.length > 0) { + return { error: 'An invitation has already been sent to this email' }; + } + + // Create a new invitation + await db.insert(invitations).values({ + teamId: userWithTeam.teamId, + email, + role, + invitedBy: user.id, + status: 'pending' + }); + + await logActivity( + userWithTeam.teamId, + user.id, + ActivityType.INVITE_TEAM_MEMBER + ); + + // TODO: Send invitation email and include ?inviteId={id} to sign-up URL + // await sendInvitationEmail(email, userWithTeam.team.name, role) + + return { success: 'Invitation sent successfully' }; + } +); diff --git a/apps/feature-flags/next-js/15-app-router-saas/app/(login)/login.tsx b/apps/feature-flags/next-js/15-app-router-saas/app/(login)/login.tsx new file mode 100644 index 000000000..86c7e280d --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/app/(login)/login.tsx @@ -0,0 +1,142 @@ +'use client'; + +import Link from 'next/link'; +import { useActionState } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { CircleIcon, Loader2 } from 'lucide-react'; +import { signIn, signUp } from './actions'; +import { ActionState } from '@/lib/auth/middleware'; + +export function Login({ mode = 'signin' }: { mode?: 'signin' | 'signup' }) { + const searchParams = useSearchParams(); + const redirect = searchParams.get('redirect'); + const priceId = searchParams.get('priceId'); + const inviteId = searchParams.get('inviteId'); + const [state, formAction, pending] = useActionState( + mode === 'signin' ? signIn : signUp, + { error: '' } + ); + + return ( +
+
+
+ +
+

+ {mode === 'signin' + ? 'Sign in to your account' + : 'Create your account'} +

+
+ +
+
+ + + +
+ +
+ +
+
+ +
+ +
+ +
+
+ + {state?.error && ( +
{state.error}
+ )} + +
+ +
+
+ +
+
+
+
+
+
+ + {mode === 'signin' + ? 'New to our platform?' + : 'Already have an account?'} + +
+
+ +
+ + {mode === 'signin' + ? 'Create an account' + : 'Sign in to existing account'} + +
+
+
+
+ ); +} diff --git a/apps/feature-flags/next-js/15-app-router-saas/app/(login)/sign-in/page.tsx b/apps/feature-flags/next-js/15-app-router-saas/app/(login)/sign-in/page.tsx new file mode 100644 index 000000000..ed47e387e --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/app/(login)/sign-in/page.tsx @@ -0,0 +1,10 @@ +import { Suspense } from 'react'; +import { Login } from '../login'; + +export default function SignInPage() { + return ( + + + + ); +} diff --git a/apps/feature-flags/next-js/15-app-router-saas/app/(login)/sign-up/page.tsx b/apps/feature-flags/next-js/15-app-router-saas/app/(login)/sign-up/page.tsx new file mode 100644 index 000000000..10ff615db --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/app/(login)/sign-up/page.tsx @@ -0,0 +1,10 @@ +import { Suspense } from 'react'; +import { Login } from '../login'; + +export default function SignUpPage() { + return ( + + + + ); +} diff --git a/apps/feature-flags/next-js/15-app-router-saas/app/api/stripe/checkout/route.ts b/apps/feature-flags/next-js/15-app-router-saas/app/api/stripe/checkout/route.ts new file mode 100644 index 000000000..d1eba26fd --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/app/api/stripe/checkout/route.ts @@ -0,0 +1,97 @@ +import { eq } from 'drizzle-orm'; +import { db } from '@/lib/db/drizzle'; +import { users, teams, teamMembers } from '@/lib/db/schema'; +import { setSession } from '@/lib/auth/session'; +import { NextRequest, NextResponse } from 'next/server'; +import { stripe } from '@/lib/payments/stripe'; +import Stripe from 'stripe'; + +export async function GET(request: NextRequest) { + const searchParams = request.nextUrl.searchParams; + const sessionId = searchParams.get('session_id'); + + if (!sessionId) { + return NextResponse.redirect(new URL('/pricing', request.url)); + } + + try { + const session = await stripe.checkout.sessions.retrieve(sessionId, { + expand: ['customer', 'subscription'], + }); + + if (!session.customer || typeof session.customer === 'string') { + throw new Error('Invalid customer data from Stripe.'); + } + + const customerId = session.customer.id; + const subscriptionId = + typeof session.subscription === 'string' + ? session.subscription + : session.subscription?.id; + + if (!subscriptionId) { + throw new Error('No subscription found for this session.'); + } + + const subscription = await stripe.subscriptions.retrieve(subscriptionId, { + expand: ['items.data.price.product'], + }); + + const plan = subscription.items.data[0]?.price; + + if (!plan) { + throw new Error('No plan found for this subscription.'); + } + + const productId = (plan.product as Stripe.Product).id; + + if (!productId) { + throw new Error('No product ID found for this subscription.'); + } + + const userId = session.client_reference_id; + if (!userId) { + throw new Error("No user ID found in session's client_reference_id."); + } + + const user = await db + .select() + .from(users) + .where(eq(users.id, Number(userId))) + .limit(1); + + if (user.length === 0) { + throw new Error('User not found in database.'); + } + + const userTeam = await db + .select({ + teamId: teamMembers.teamId, + }) + .from(teamMembers) + .where(eq(teamMembers.userId, user[0].id)) + .limit(1); + + if (userTeam.length === 0) { + throw new Error('User is not associated with any team.'); + } + + await db + .update(teams) + .set({ + stripeCustomerId: customerId, + stripeSubscriptionId: subscriptionId, + stripeProductId: productId, + planName: (plan.product as Stripe.Product).name, + subscriptionStatus: subscription.status, + updatedAt: new Date(), + }) + .where(eq(teams.id, userTeam[0].teamId)); + + await setSession(user[0]); + return NextResponse.redirect(new URL('/dashboard', request.url)); + } catch (error) { + console.error('Error handling successful checkout:', error); + return NextResponse.redirect(new URL('/error', request.url)); + } +} diff --git a/apps/feature-flags/next-js/15-app-router-saas/app/api/stripe/webhook/route.ts b/apps/feature-flags/next-js/15-app-router-saas/app/api/stripe/webhook/route.ts new file mode 100644 index 000000000..446066dc9 --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/app/api/stripe/webhook/route.ts @@ -0,0 +1,35 @@ +import Stripe from 'stripe'; +import { handleSubscriptionChange, stripe } from '@/lib/payments/stripe'; +import { NextRequest, NextResponse } from 'next/server'; + +// Use a dummy webhook secret for stub mode +const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET || 'whsec_stub_secret'; + +export async function POST(request: NextRequest) { + const payload = await request.text(); + const signature = request.headers.get('stripe-signature') as string; + + let event: Stripe.Event; + + try { + event = stripe.webhooks.constructEvent(payload, signature, webhookSecret); + } catch (err) { + console.error('Webhook signature verification failed.', err); + return NextResponse.json( + { error: 'Webhook signature verification failed.' }, + { status: 400 } + ); + } + + switch (event.type) { + case 'customer.subscription.updated': + case 'customer.subscription.deleted': + const subscription = event.data.object as Stripe.Subscription; + await handleSubscriptionChange(subscription); + break; + default: + console.log(`Unhandled event type ${event.type}`); + } + + return NextResponse.json({ received: true }); +} diff --git a/apps/feature-flags/next-js/15-app-router-saas/app/api/team/route.ts b/apps/feature-flags/next-js/15-app-router-saas/app/api/team/route.ts new file mode 100644 index 000000000..0659e7f8b --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/app/api/team/route.ts @@ -0,0 +1,6 @@ +import { getTeamForUser } from '@/lib/db/queries'; + +export async function GET() { + const team = await getTeamForUser(); + return Response.json(team); +} diff --git a/apps/feature-flags/next-js/15-app-router-saas/app/api/user/route.ts b/apps/feature-flags/next-js/15-app-router-saas/app/api/user/route.ts new file mode 100644 index 000000000..6c3dc014c --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/app/api/user/route.ts @@ -0,0 +1,6 @@ +import { getUser } from '@/lib/db/queries'; + +export async function GET() { + const user = await getUser(); + return Response.json(user); +} diff --git a/apps/feature-flags/next-js/15-app-router-saas/app/favicon.ico b/apps/feature-flags/next-js/15-app-router-saas/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 zcmeHv30#a{`}aL_*G&7qml|y<+KVaDM2m#dVr!KsA!#An?kSQM(q<_dDNCpjEux83 zLb9Z^XxbDl(w>%i@8hT6>)&Gu{h#Oeyszu?xtw#Zb1mO{pgX9699l+Qppw7jXaYf~-84xW z)w4x8?=youko|}Vr~(D$UXIbiXABHh`p1?nn8Po~fxRJv}|0e(BPs|G`(TT%kKVJAdg5*Z|x0leQq0 zkdUBvb#>9F()jo|T~kx@OM8$9wzs~t2l;K=woNssA3l6|sx2r3+kdfVW@e^8e*E}v zA1y5{bRi+3Z`uD3{F7LgFJDdvm;nJilkzDku>BwXH(8ItVCXk*-lSJnR?-2UN%hJ){&rlvg`CDTj z)Bzo!3v7Ou#83zEDEFcKt(f1E0~=rqeEbTnMvWR#{+9pg%7G8y>u1OVRUSoox-ovF z2Ydma(;=YuBY(eI|04{hXzZD6_f(v~H;C~y5=DhAC{MMS>2fm~1H_t2$56pc$NH8( z5bH|<)71dV-_oCHIrzrT`2s-5w_+2CM0$95I6X8p^r!gHp+j_gd;9O<1~CEQQGS8) zS9Qh3#p&JM-G8rHekNmKVewU;pJRcTAog68KYo^dRo}(M>36U4Us zfgYWSiHZL3;lpWT=zNAW>Dh#mB!_@Lg%$ms8N-;aPqMn+C2HqZgz&9~Eu z4|Kp<`$q)Uw1R?y(~S>ePdonHxpV1#eSP1B;Ogo+-Pk}6#0GsZZ5!||ev2MGdh}_m z{DeR7?0-1^zVs&`AV6Vt;r3`I`OI_wgs*w=eO%_#7Kepl{B@xiyCANc(l zzIyd4y|c6PXWq9-|KM8(zIk8LPk(>a)zyFWjhT!$HJ$qX1vo@d25W<fvZQ2zUz5WRc(UnFMKHwe1| zWmlB1qdbiA(C0jmnV<}GfbKtmcu^2*P^O?MBLZKt|As~ge8&AAO~2K@zbXelK|4T<{|y4`raF{=72kC2Kn(L4YyenWgrPiv z@^mr$t{#X5VuIMeL!7Ab6_kG$&#&5p*Z{+?5U|TZ`B!7llpVmp@skYz&n^8QfPJzL z0G6K_OJM9x+Wu2gfN45phANGt{7=C>i34CV{Xqlx(fWpeAoj^N0Biu`w+MVcCUyU* zDZuzO0>4Z6fbu^T_arWW5n!E45vX8N=bxTVeFoep_G#VmNlQzAI_KTIc{6>c+04vr zx@W}zE5JNSU>!THJ{J=cqjz+4{L4A{Ob9$ZJ*S1?Ggg3klFp!+Y1@K+pK1DqI|_gq z5ZDXVpge8-cs!o|;K73#YXZ3AShj50wBvuq3NTOZ`M&qtjj#GOFfgExjg8Gn8>Vq5 z`85n+9|!iLCZF5$HJ$Iu($dm?8~-ofu}tEc+-pyke=3!im#6pk_Wo8IA|fJwD&~~F zc16osQ)EBo58U7XDuMexaPRjU@h8tXe%S{fA0NH3vGJFhuyyO!Uyl2^&EOpX{9As0 zWj+P>{@}jxH)8|r;2HdupP!vie{sJ28b&bo!8`D^x}TE$%zXNb^X1p@0PJ86`dZyj z%ce7*{^oo+6%&~I!8hQy-vQ7E)0t0ybH4l%KltWOo~8cO`T=157JqL(oq_rC%ea&4 z2NcTJe-HgFjNg-gZ$6!Y`SMHrlj}Etf7?r!zQTPPSv}{so2e>Fjs1{gzk~LGeesX%r(Lh6rbhSo_n)@@G-FTQy93;l#E)hgP@d_SGvyCp0~o(Y;Ee8{ zdVUDbHm5`2taPUOY^MAGOw*>=s7=Gst=D+p+2yON!0%Hk` zz5mAhyT4lS*T3LS^WSxUy86q&GnoHxzQ6vm8)VS}_zuqG?+3td68_x;etQAdu@sc6 zQJ&5|4(I?~3d-QOAODHpZ=hlSg(lBZ!JZWCtHHSj`0Wh93-Uk)_S%zsJ~aD>{`A0~ z9{AG(e|q3g5B%wYKRxiL2Y$8(4w6bzchKuloQW#e&S3n+P- z8!ds-%f;TJ1>)v)##>gd{PdS2Oc3VaR`fr=`O8QIO(6(N!A?pr5C#6fc~Ge@N%Vvu zaoAX2&(a6eWy_q&UwOhU)|P3J0Qc%OdhzW=F4D|pt0E4osw;%<%Dn58hAWD^XnZD= z>9~H(3bmLtxpF?a7su6J7M*x1By7YSUbxGi)Ot0P77`}P3{)&5Un{KD?`-e?r21!4vTTnN(4Y6Lin?UkSM z`MXCTC1@4A4~mvz%Rh2&EwY))LeoT=*`tMoqcEXI>TZU9WTP#l?uFv+@Dn~b(>xh2 z;>B?;Tz2SR&KVb>vGiBSB`@U7VIWFSo=LDSb9F{GF^DbmWAfpms8Sx9OX4CnBJca3 zlj9(x!dIjN?OG1X4l*imJNvRCk}F%!?SOfiOq5y^mZW)jFL@a|r-@d#f7 z2gmU8L3IZq0ynIws=}~m^#@&C%J6QFo~Mo4V`>v7MI-_!EBMMtb%_M&kvAaN)@ZVw z+`toz&WG#HkWDjnZE!6nk{e-oFdL^$YnbOCN}JC&{$#$O27@|Tn-skXr)2ml2~O!5 zX+gYoxhoc7qoU?C^3~&!U?kRFtnSEecWuH0B0OvLodgUAi}8p1 zrO6RSXHH}DMc$&|?D004DiOVMHV8kXCP@7NKB zgaZq^^O<7PoKEp72kby@W0Z!Y*Ay{&vfg#C&gG@YVR9g?FEocMUi1gSN$+V+ayF45{a zuDZDTN}mS|;BO%gEf}pjBfN2-gIrU#G5~cucA;dokXW89%>AyXJJI z9X4UlIWA|ZYHgbI z5?oFk@A=Ik7lrEQPDH!H+b`7_Y~aDb_qa=B2^Y&Ow41cU=4WDd40dp5(QS-WMN-=Y z9g;6_-JdNU;|6cPwf$ak*aJIcwL@1n$#l~zi{c{EW?T;DaW*E8DYq?Umtz{nJ&w-M zEMyTDrC&9K$d|kZe2#ws6)L=7K+{ zQw{XnV6UC$6-rW0emqm8wJoeZK)wJIcV?dST}Z;G0Arq{dVDu0&4kd%N!3F1*;*pW zR&qUiFzK=@44#QGw7k1`3t_d8&*kBV->O##t|tonFc2YWrL7_eqg+=+k;!F-`^b8> z#KWCE8%u4k@EprxqiV$VmmtiWxDLgnGu$Vs<8rppV5EajBXL4nyyZM$SWVm!wnCj-B!Wjqj5-5dNXukI2$$|Bu3Lrw}z65Lc=1G z^-#WuQOj$hwNGG?*CM_TO8Bg-1+qc>J7k5c51U8g?ZU5n?HYor;~JIjoWH-G>AoUP ztrWWLbRNqIjW#RT*WqZgPJXU7C)VaW5}MiijYbABmzoru6EmQ*N8cVK7a3|aOB#O& zBl8JY2WKfmj;h#Q!pN%9o@VNLv{OUL?rixHwOZuvX7{IJ{(EdPpuVFoQqIOa7giLVkBOKL@^smUA!tZ1CKRK}#SSM)iQHk)*R~?M!qkCruaS!#oIL1c z?J;U~&FfH#*98^G?i}pA{ z9Jg36t4=%6mhY(quYq*vSxptes9qy|7xSlH?G=S@>u>Ebe;|LVhs~@+06N<4CViBk zUiY$thvX;>Tby6z9Y1edAMQaiH zm^r3v#$Q#2T=X>bsY#D%s!bhs^M9PMAcHbCc0FMHV{u-dwlL;a1eJ63v5U*?Q_8JO zT#50!RD619#j_Uf))0ooADz~*9&lN!bBDRUgE>Vud-i5ck%vT=r^yD*^?Mp@Q^v+V zG#-?gKlr}Eeqifb{|So?HM&g91P8|av8hQoCmQXkd?7wIJwb z_^v8bbg`SAn{I*4bH$u(RZ6*xUhuA~hc=8czK8SHEKTzSxgbwi~9(OqJB&gwb^l4+m`k*Q;_?>Y-APi1{k zAHQ)P)G)f|AyjSgcCFps)Fh6Bca*Xznq36!pV6Az&m{O8$wGFD? zY&O*3*J0;_EqM#jh6^gMQKpXV?#1?>$ml1xvh8nSN>-?H=V;nJIwB07YX$e6vLxH( zqYwQ>qxwR(i4f)DLd)-$P>T-no_c!LsN@)8`e;W@)-Hj0>nJ-}Kla4-ZdPJzI&Mce zv)V_j;(3ERN3_@I$N<^|4Lf`B;8n+bX@bHbcZTopEmDI*Jfl)-pFDvo6svPRoo@(x z);_{lY<;);XzT`dBFpRmGrr}z5u1=pC^S-{ce6iXQlLGcItwJ^mZx{m$&DA_oEZ)B{_bYPq-HA zcH8WGoBG(aBU_j)vEy+_71T34@4dmSg!|M8Vf92Zj6WH7Q7t#OHQqWgFE3ARt+%!T z?oLovLVlnf?2c7pTc)~cc^($_8nyKwsN`RA-23ed3sdj(ys%pjjM+9JrctL;dy8a( z@en&CQmnV(()bu|Y%G1-4a(6x{aLytn$T-;(&{QIJB9vMox11U-1HpD@d(QkaJdEb zG{)+6Dos_L+O3NpWo^=gR?evp|CqEG?L&Ut#D*KLaRFOgOEK(Kq1@!EGcTfo+%A&I z=dLbB+d$u{sh?u)xP{PF8L%;YPPW53+@{>5W=Jt#wQpN;0_HYdw1{ksf_XhO4#2F= zyPx6Lx2<92L-;L5PD`zn6zwIH`Jk($?Qw({erA$^bC;q33hv!d!>%wRhj# zal^hk+WGNg;rJtb-EB(?czvOM=H7dl=vblBwAv>}%1@{}mnpUznfq1cE^sgsL0*4I zJ##!*B?=vI_OEVis5o+_IwMIRrpQyT_Sq~ZU%oY7c5JMIADzpD!Upz9h@iWg_>>~j zOLS;wp^i$-E?4<_cp?RiS%Rd?i;f*mOz=~(&3lo<=@(nR!_Rqiprh@weZlL!t#NCc zO!QTcInq|%#>OVgobj{~ixEUec`E25zJ~*DofsQdzIa@5^nOXj2T;8O`l--(QyU^$t?TGY^7#&FQ+2SS3B#qK*k3`ye?8jUYSajE5iBbJls75CCc(m3dk{t?- zopcER9{Z?TC)mk~gpi^kbbu>b-+a{m#8-y2^p$ka4n60w;Sc2}HMf<8JUvhCL0B&Btk)T`ctE$*qNW8L$`7!r^9T+>=<=2qaq-;ll2{`{Rg zc5a0ZUI$oG&j-qVOuKa=*v4aY#IsoM+1|c4Z)<}lEDvy;5huB@1RJPquU2U*U-;gu z=En2m+qjBzR#DEJDO`WU)hdd{Vj%^0V*KoyZ|5lzV87&g_j~NCjwv0uQVqXOb*QrQ zy|Qn`hxx(58c70$E;L(X0uZZ72M1!6oeg)(cdKO ze0gDaTz+ohR-#d)NbAH4x{I(21yjwvBQfmpLu$)|m{XolbgF!pmsqJ#D}(ylp6uC> z{bqtcI#hT#HW=wl7>p!38sKsJ`r8}lt-q%Keqy%u(xk=yiIJiUw6|5IvkS+#?JTBl z8H5(Q?l#wzazujH!8o>1xtn8#_w+397*_cy8!pQGP%K(Ga3pAjsaTbbXJlQF_+m+-UpUUent@xM zg%jqLUExj~o^vQ3Gl*>wh=_gOr2*|U64_iXb+-111aH}$TjeajM+I20xw(((>fej-@CIz4S1pi$(#}P7`4({6QS2CaQS4NPENDp>sAqD z$bH4KGzXGffkJ7R>V>)>tC)uax{UsN*dbeNC*v}#8Y#OWYwL4t$ePR?VTyIs!wea+ z5Urmc)X|^`MG~*dS6pGSbU+gPJoq*^a=_>$n4|P^w$sMBBy@f*Z^Jg6?n5?oId6f{ z$LW4M|4m502z0t7g<#Bx%X;9<=)smFolV&(V^(7Cv2-sxbxopQ!)*#ZRhTBpx1)Fc zNm1T%bONzv6@#|dz(w02AH8OXe>kQ#1FMCzO}2J_mST)+ExmBr9cva-@?;wnmWMOk z{3_~EX_xadgJGv&H@zK_8{(x84`}+c?oSBX*Ge3VdfTt&F}yCpFP?CpW+BE^cWY0^ zb&uBN!Ja3UzYHK-CTyA5=L zEMW{l3Usky#ly=7px648W31UNV@K)&Ub&zP1c7%)`{);I4b0Q<)B}3;NMG2JH=X$U zfIW4)4n9ZM`-yRj67I)YSLDK)qfUJ_ij}a#aZN~9EXrh8eZY2&=uY%2N0UFF7<~%M zsB8=erOWZ>Ct_#^tHZ|*q`H;A)5;ycw*IcmVxi8_0Xk}aJA^ath+E;xg!x+As(M#0=)3!NJR6H&9+zd#iP(m0PIW8$ z1Y^VX`>jm`W!=WpF*{ioM?C9`yOR>@0q=u7o>BP-eSHqCgMDj!2anwH?s%i2p+Q7D zzszIf5XJpE)IG4;d_(La-xenmF(tgAxK`Y4sQ}BSJEPs6N_U2vI{8=0C_F?@7<(G; zo$~G=8p+076G;`}>{MQ>t>7cm=zGtfbdDXm6||jUU|?X?CaE?(<6bKDYKeHlz}DA8 zXT={X=yp_R;HfJ9h%?eWvQ!dRgz&Su*JfNt!Wu>|XfU&68iRikRrHRW|ZxzRR^`eIGt zIeiDgVS>IeExKVRWW8-=A=yA`}`)ZkWBrZD`hpWIxBGkh&f#ijr449~m`j6{4jiJ*C!oVA8ZC?$1RM#K(_b zL9TW)kN*Y4%^-qPpMP7d4)o?Nk#>aoYHT(*g)qmRUb?**F@pnNiy6Fv9rEiUqD(^O zzyS?nBrX63BTRYduaG(0VVG2yJRe%o&rVrLjbxTaAFTd8s;<<@Qs>u(<193R8>}2_ zuwp{7;H2a*X7_jryzriZXMg?bTuegABb^87@SsKkr2)0Gyiax8KQWstw^v#ix45EVrcEhr>!NMhprl$InQMzjSFH54x5k9qHc`@9uKQzvL4ihcq{^B zPrVR=o_ic%Y>6&rMN)hTZsI7I<3&`#(nl+3y3ys9A~&^=4?PL&nd8)`OfG#n zwAMN$1&>K++c{^|7<4P=2y(B{jJsQ0a#U;HTo4ZmWZYvI{+s;Td{Yzem%0*k#)vjpB zia;J&>}ICate44SFYY3vEelqStQWFihx%^vQ@Do(sOy7yR2@WNv7Y9I^yL=nZr3mb zXKV5t@=?-Sk|b{XMhA7ZGB@2hqsx}4xwCW!in#C zI@}scZlr3-NFJ@NFaJlhyfcw{k^vvtGl`N9xSo**rDW4S}i zM9{fMPWo%4wYDG~BZ18BD+}h|GQKc-g^{++3MY>}W_uq7jGHx{mwE9fZiPCoxN$+7 zrODGGJrOkcPQUB(FD5aoS4g~7#6NR^ma7-!>mHuJfY5kTe6PpNNKC9GGRiu^L31uG z$7v`*JknQHsYB!Tm_W{a32TM099djW%5e+j0Ve_ct}IM>XLF1Ap+YvcrLV=|CKo6S zb+9Nl3_YdKP6%Cxy@6TxZ>;4&nTneadr z_ES90ydCev)LV!dN=#(*f}|ZORFdvkYBni^aLbUk>BajeWIOcmHP#8S)*2U~QKI%S zyrLmtPqb&TphJ;>yAxri#;{uyk`JJqODDw%(Z=2`1uc}br^V%>j!gS)D*q*f_-qf8&D;W1dJgQMlaH5er zN2U<%Smb7==vE}dDI8K7cKz!vs^73o9f>2sgiTzWcwY|BMYHH5%Vn7#kiw&eItCqa zIkR2~Q}>X=Ar8W|^Ms41Fm8o6IB2_j60eOeBB1Br!boW7JnoeX6Gs)?7rW0^5psc- zjS16yb>dFn>KPOF;imD}e!enuIniFzv}n$m2#gCCv4jM#ArwlzZ$7@9&XkFxZ4n!V zj3dyiwW4Ki2QG{@i>yuZXQizw_OkZI^-3otXC{!(lUpJF33gI60ak;Uqitp74|B6I zgg{b=Iz}WkhCGj1M=hu4#Aw173YxIVbISaoc z-nLZC*6Tgivd5V`K%GxhBsp@SUU60-rfc$=wb>zdJzXS&-5(NRRodFk;Kxk!S(O(a0e7oY=E( zAyS;Ow?6Q&XA+cnkCb{28_1N8H#?J!*$MmIwLq^*T_9-z^&UE@A(z9oGYtFy6EZef LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/apps/feature-flags/next-js/15-app-router-saas/app/globals.css b/apps/feature-flags/next-js/15-app-router-saas/app/globals.css new file mode 100644 index 000000000..794a380a2 --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/app/globals.css @@ -0,0 +1,275 @@ +@import "tailwindcss"; +/* + ---break--- +*/ +@custom-variant dark (&:is(.dark *)); + +@import "tw-animate-css"; + +@variant dark (&:is(.dark *)); + +@theme { + --color-background: hsl(var(--background)); + --color-foreground: hsl(var(--foreground)); + + --color-card: hsl(var(--card)); + --color-card-foreground: hsl(var(--card-foreground)); + + --color-popover: hsl(var(--popover)); + --color-popover-foreground: hsl(var(--popover-foreground)); + + --color-primary: hsl(var(--primary)); + --color-primary-foreground: hsl(var(--primary-foreground)); + + --color-secondary: hsl(var(--secondary)); + --color-secondary-foreground: hsl(var(--secondary-foreground)); + + --color-muted: hsl(var(--muted)); + --color-muted-foreground: hsl(var(--muted-foreground)); + + --color-accent: hsl(var(--accent)); + --color-accent-foreground: hsl(var(--accent-foreground)); + + --color-destructive: hsl(var(--destructive)); + --color-destructive-foreground: hsl(var(--destructive-foreground)); + + --color-border: hsl(var(--border)); + --color-input: hsl(var(--input)); + --color-ring: hsl(var(--ring)); + + --color-chart-1: hsl(var(--chart-1)); + --color-chart-2: hsl(var(--chart-2)); + --color-chart-3: hsl(var(--chart-3)); + --color-chart-4: hsl(var(--chart-4)); + --color-chart-5: hsl(var(--chart-5)); + + --color-sidebar: hsl(var(--sidebar-background)); + --color-sidebar-foreground: hsl(var(--sidebar-foreground)); + --color-sidebar-primary: hsl(var(--sidebar-primary)); + --color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground)); + --color-sidebar-accent: hsl(var(--sidebar-accent)); + --color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground)); + --color-sidebar-border: hsl(var(--sidebar-border)); + --color-sidebar-ring: hsl(var(--sidebar-ring)); + + --radius-lg: var(--radius); + --radius-md: calc(var(--radius) - 2px); + --radius-sm: calc(var(--radius) - 4px); +} + +/* + The default border color has changed to `currentColor` in Tailwind CSS v4, + so we've added these compatibility styles to make sure everything still + looks the same as it did with Tailwind CSS v3. + + If we ever want to remove these styles, we need to add an explicit border + color utility to any element that depends on these defaults. +*/ +@layer base { + *, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + border-color: var(--color-gray-200, currentColor); + } +} + +@layer utilities { + body { + font-family: "Manrope", Arial, Helvetica, sans-serif; + } +} + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --radius: 0.5rem; + --sidebar-background: 0 0% 98%; + --sidebar-foreground: 240 5.3% 26.1%; + --sidebar-primary: 240 5.9% 10%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 240 4.8% 95.9%; + --sidebar-accent-foreground: 240 5.9% 10%; + --sidebar-border: 220 13% 91%; + --sidebar-ring: 217.2 91.2% 59.8%; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + --sidebar-background: 240 5.9% 10%; + --sidebar-foreground: 240 4.8% 95.9%; + --sidebar-primary: 224.3 76.3% 48%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-accent-foreground: 240 4.8% 95.9%; + --sidebar-border: 240 3.7% 15.9%; + --sidebar-ring: 217.2 91.2% 59.8%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} + +/* + ---break--- +*/ + +:root { + --background: hsl(0 0% 100%); + --foreground: hsl(240 10% 3.9%); + --card: hsl(0 0% 100%); + --card-foreground: hsl(240 10% 3.9%); + --popover: hsl(0 0% 100%); + --popover-foreground: hsl(240 10% 3.9%); + --primary: hsl(240 5.9% 10%); + --primary-foreground: hsl(0 0% 98%); + --secondary: hsl(240 4.8% 95.9%); + --secondary-foreground: hsl(240 5.9% 10%); + --muted: hsl(240 4.8% 95.9%); + --muted-foreground: hsl(240 3.8% 46.1%); + --accent: hsl(240 4.8% 95.9%); + --accent-foreground: hsl(240 5.9% 10%); + --destructive: hsl(0 84.2% 60.2%); + --destructive-foreground: hsl(0 0% 98%); + --border: hsl(240 5.9% 90%); + --input: hsl(240 5.9% 90%); + --ring: hsl(240 10% 3.9%); + --chart-1: hsl(12 76% 61%); + --chart-2: hsl(173 58% 39%); + --chart-3: hsl(197 37% 24%); + --chart-4: hsl(43 74% 66%); + --chart-5: hsl(27 87% 67%); + --radius: 0.6rem; +} + +/* + ---break--- +*/ + +.dark { + --background: hsl(240 10% 3.9%); + --foreground: hsl(0 0% 98%); + --card: hsl(240 10% 3.9%); + --card-foreground: hsl(0 0% 98%); + --popover: hsl(240 10% 3.9%); + --popover-foreground: hsl(0 0% 98%); + --primary: hsl(0 0% 98%); + --primary-foreground: hsl(240 5.9% 10%); + --secondary: hsl(240 3.7% 15.9%); + --secondary-foreground: hsl(0 0% 98%); + --muted: hsl(240 3.7% 15.9%); + --muted-foreground: hsl(240 5% 64.9%); + --accent: hsl(240 3.7% 15.9%); + --accent-foreground: hsl(0 0% 98%); + --destructive: hsl(0 62.8% 30.6%); + --destructive-foreground: hsl(0 0% 98%); + --border: hsl(240 3.7% 15.9%); + --input: hsl(240 3.7% 15.9%); + --ring: hsl(240 4.9% 83.9%); + --chart-1: hsl(220 70% 50%); + --chart-2: hsl(160 60% 45%); + --chart-3: hsl(30 80% 55%); + --chart-4: hsl(280 65% 60%); + --chart-5: hsl(340 75% 55%); +} + +/* + ---break--- +*/ + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); +} + +/* + ---break--- +*/ + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/apps/feature-flags/next-js/15-app-router-saas/app/layout.tsx b/apps/feature-flags/next-js/15-app-router-saas/app/layout.tsx new file mode 100644 index 000000000..9c2596780 --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/app/layout.tsx @@ -0,0 +1,44 @@ +import './globals.css'; +import type { Metadata, Viewport } from 'next'; +import { Manrope } from 'next/font/google'; +import { getUser, getTeamForUser } from '@/lib/db/queries'; +import { SWRConfig } from 'swr'; + +export const metadata: Metadata = { + title: 'Next.js SaaS Starter', + description: 'Get started quickly with Next.js, Postgres, and Stripe.' +}; + +export const viewport: Viewport = { + maximumScale: 1 +}; + +const manrope = Manrope({ subsets: ['latin'] }); + +export default function RootLayout({ + children +}: { + children: React.ReactNode; +}) { + return ( + + + + {children} + + + + ); +} diff --git a/apps/feature-flags/next-js/15-app-router-saas/app/not-found.tsx b/apps/feature-flags/next-js/15-app-router-saas/app/not-found.tsx new file mode 100644 index 000000000..7aad74b69 --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/app/not-found.tsx @@ -0,0 +1,27 @@ +import Link from 'next/link'; +import { CircleIcon } from 'lucide-react'; + +export default function NotFound() { + return ( +
+
+
+ +
+

+ Page Not Found +

+

+ The page you are looking for might have been removed, had its name + changed, or is temporarily unavailable. +

+ + Back to Home + +
+
+ ); +} diff --git a/apps/feature-flags/next-js/15-app-router-saas/components.json b/apps/feature-flags/next-js/15-app-router-saas/components.json new file mode 100644 index 000000000..5a3c7506d --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/apps/feature-flags/next-js/15-app-router-saas/components/ui/avatar.tsx b/apps/feature-flags/next-js/15-app-router-saas/components/ui/avatar.tsx new file mode 100644 index 000000000..852cb27a6 --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/components/ui/avatar.tsx @@ -0,0 +1,53 @@ +"use client"; + +import * as React from "react"; +import { Avatar as AvatarPrimitive } from "radix-ui";; + +import { cn } from "@/lib/utils"; + +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/apps/feature-flags/next-js/15-app-router-saas/components/ui/button.tsx b/apps/feature-flags/next-js/15-app-router-saas/components/ui/button.tsx new file mode 100644 index 000000000..82cad4b47 --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/components/ui/button.tsx @@ -0,0 +1,59 @@ +import * as React from "react"; +import { Slot as SlotPrimitive } from "radix-ui";; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", + destructive: + "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline" + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9" + } + }, + defaultVariants: { + variant: "default", + size: "default" + } + } +); + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean; + }) { + const Comp = asChild ? SlotPrimitive.Slot : "button"; + + return ( + + ); +} + +export { Button, buttonVariants }; diff --git a/apps/feature-flags/next-js/15-app-router-saas/components/ui/card.tsx b/apps/feature-flags/next-js/15-app-router-saas/components/ui/card.tsx new file mode 100644 index 000000000..4733d5844 --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent +}; diff --git a/apps/feature-flags/next-js/15-app-router-saas/components/ui/dropdown-menu.tsx b/apps/feature-flags/next-js/15-app-router-saas/components/ui/dropdown-menu.tsx new file mode 100644 index 000000000..f84f00586 --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/components/ui/dropdown-menu.tsx @@ -0,0 +1,257 @@ +"use client"; + +import * as React from "react"; +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";; +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuContent({ + className, + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean; + variant?: "default" | "destructive"; +}) { + return ( + + ); +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ); +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return ; +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent +}; diff --git a/apps/feature-flags/next-js/15-app-router-saas/components/ui/input.tsx b/apps/feature-flags/next-js/15-app-router-saas/components/ui/input.tsx new file mode 100644 index 000000000..0316cc455 --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/components/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ); +} + +export { Input }; diff --git a/apps/feature-flags/next-js/15-app-router-saas/components/ui/label.tsx b/apps/feature-flags/next-js/15-app-router-saas/components/ui/label.tsx new file mode 100644 index 000000000..387e860dd --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/components/ui/label.tsx @@ -0,0 +1,24 @@ +"use client"; + +import * as React from "react"; +import { Label as LabelPrimitive } from "radix-ui";; + +import { cn } from "@/lib/utils"; + +function Label({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Label }; diff --git a/apps/feature-flags/next-js/15-app-router-saas/components/ui/radio-group.tsx b/apps/feature-flags/next-js/15-app-router-saas/components/ui/radio-group.tsx new file mode 100644 index 000000000..cdf5e9dd5 --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/components/ui/radio-group.tsx @@ -0,0 +1,45 @@ +"use client"; + +import * as React from "react"; +import { RadioGroup as RadioGroupPrimitive } from "radix-ui";; +import { CircleIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function RadioGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function RadioGroupItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ); +} + +export { RadioGroup, RadioGroupItem }; diff --git a/apps/feature-flags/next-js/15-app-router-saas/docker-compose.yml b/apps/feature-flags/next-js/15-app-router-saas/docker-compose.yml new file mode 100644 index 000000000..017d66652 --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/docker-compose.yml @@ -0,0 +1,16 @@ + +services: + postgres: + image: postgres:16.4-alpine + container_name: next15_app_router_saas_starter_postgres + environment: + POSTGRES_DB: postgres + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "54322:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + +volumes: + postgres_data: diff --git a/apps/feature-flags/next-js/15-app-router-saas/drizzle.config.ts b/apps/feature-flags/next-js/15-app-router-saas/drizzle.config.ts new file mode 100644 index 000000000..6f6cc8873 --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/drizzle.config.ts @@ -0,0 +1,10 @@ +import type { Config } from 'drizzle-kit'; + +export default { + schema: './lib/db/schema.ts', + out: './lib/db/migrations', + dialect: 'postgresql', + dbCredentials: { + url: process.env.POSTGRES_URL!, + }, +} satisfies Config; diff --git a/apps/feature-flags/next-js/15-app-router-saas/lib/auth/middleware.ts b/apps/feature-flags/next-js/15-app-router-saas/lib/auth/middleware.ts new file mode 100644 index 000000000..a17f25e0d --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/lib/auth/middleware.ts @@ -0,0 +1,75 @@ +import { z } from 'zod'; +import { TeamDataWithMembers, User } from '@/lib/db/schema'; +import { getTeamForUser, getUser } from '@/lib/db/queries'; +import { redirect } from 'next/navigation'; + +export type ActionState = { + error?: string; + success?: string; + [key: string]: any; // This allows for additional properties +}; + +type ValidatedActionFunction, T> = ( + data: z.infer, + formData: FormData +) => Promise; + +export function validatedAction, T>( + schema: S, + action: ValidatedActionFunction +) { + return async (prevState: ActionState, formData: FormData) => { + const result = schema.safeParse(Object.fromEntries(formData)); + if (!result.success) { + return { error: result.error.errors[0].message }; + } + + return action(result.data, formData); + }; +} + +type ValidatedActionWithUserFunction, T> = ( + data: z.infer, + formData: FormData, + user: User +) => Promise; + +export function validatedActionWithUser, T>( + schema: S, + action: ValidatedActionWithUserFunction +) { + return async (prevState: ActionState, formData: FormData) => { + const user = await getUser(); + if (!user) { + throw new Error('User is not authenticated'); + } + + const result = schema.safeParse(Object.fromEntries(formData)); + if (!result.success) { + return { error: result.error.errors[0].message }; + } + + return action(result.data, formData, user); + }; +} + +type ActionWithTeamFunction = ( + formData: FormData, + team: TeamDataWithMembers +) => Promise; + +export function withTeam(action: ActionWithTeamFunction) { + return async (formData: FormData): Promise => { + const user = await getUser(); + if (!user) { + redirect('/sign-in'); + } + + const team = await getTeamForUser(); + if (!team) { + throw new Error('Team not found'); + } + + return action(formData, team); + }; +} diff --git a/apps/feature-flags/next-js/15-app-router-saas/lib/auth/session.ts b/apps/feature-flags/next-js/15-app-router-saas/lib/auth/session.ts new file mode 100644 index 000000000..0c474c70a --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/lib/auth/session.ts @@ -0,0 +1,59 @@ +import { compare, hash } from 'bcryptjs'; +import { SignJWT, jwtVerify } from 'jose'; +import { cookies } from 'next/headers'; +import { NewUser } from '@/lib/db/schema'; + +const key = new TextEncoder().encode(process.env.AUTH_SECRET); +const SALT_ROUNDS = 10; + +export async function hashPassword(password: string) { + return hash(password, SALT_ROUNDS); +} + +export async function comparePasswords( + plainTextPassword: string, + hashedPassword: string +) { + return compare(plainTextPassword, hashedPassword); +} + +type SessionData = { + user: { id: number }; + expires: string; +}; + +export async function signToken(payload: SessionData) { + return await new SignJWT(payload) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('1 day from now') + .sign(key); +} + +export async function verifyToken(input: string) { + const { payload } = await jwtVerify(input, key, { + algorithms: ['HS256'], + }); + return payload as SessionData; +} + +export async function getSession() { + const session = (await cookies()).get('session')?.value; + if (!session) return null; + return await verifyToken(session); +} + +export async function setSession(user: NewUser) { + const expiresInOneDay = new Date(Date.now() + 24 * 60 * 60 * 1000); + const session: SessionData = { + user: { id: user.id! }, + expires: expiresInOneDay.toISOString(), + }; + const encryptedSession = await signToken(session); + (await cookies()).set('session', encryptedSession, { + expires: expiresInOneDay, + httpOnly: true, + secure: true, + sameSite: 'lax', + }); +} diff --git a/apps/feature-flags/next-js/15-app-router-saas/lib/db/drizzle.ts b/apps/feature-flags/next-js/15-app-router-saas/lib/db/drizzle.ts new file mode 100644 index 000000000..7dc4a1b4f --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/lib/db/drizzle.ts @@ -0,0 +1,13 @@ +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import * as schema from './schema'; +import dotenv from 'dotenv'; + +dotenv.config(); + +if (!process.env.POSTGRES_URL) { + throw new Error('POSTGRES_URL environment variable is not set'); +} + +export const client = postgres(process.env.POSTGRES_URL); +export const db = drizzle(client, { schema }); diff --git a/apps/feature-flags/next-js/15-app-router-saas/lib/db/migrations/0000_soft_the_anarchist.sql b/apps/feature-flags/next-js/15-app-router-saas/lib/db/migrations/0000_soft_the_anarchist.sql new file mode 100644 index 000000000..b3dae0509 --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/lib/db/migrations/0000_soft_the_anarchist.sql @@ -0,0 +1,88 @@ +CREATE TABLE IF NOT EXISTS "activity_logs" ( + "id" serial PRIMARY KEY NOT NULL, + "team_id" integer NOT NULL, + "user_id" integer, + "action" text NOT NULL, + "timestamp" timestamp DEFAULT now() NOT NULL, + "ip_address" varchar(45) +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "invitations" ( + "id" serial PRIMARY KEY NOT NULL, + "team_id" integer NOT NULL, + "email" varchar(255) NOT NULL, + "role" varchar(50) NOT NULL, + "invited_by" integer NOT NULL, + "invited_at" timestamp DEFAULT now() NOT NULL, + "status" varchar(20) DEFAULT 'pending' NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "team_members" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" integer NOT NULL, + "team_id" integer NOT NULL, + "role" varchar(50) NOT NULL, + "joined_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "teams" ( + "id" serial PRIMARY KEY NOT NULL, + "name" varchar(100) NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + "stripe_customer_id" text, + "stripe_subscription_id" text, + "stripe_product_id" text, + "plan_name" varchar(50), + "subscription_status" varchar(20), + CONSTRAINT "teams_stripe_customer_id_unique" UNIQUE("stripe_customer_id"), + CONSTRAINT "teams_stripe_subscription_id_unique" UNIQUE("stripe_subscription_id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "users" ( + "id" serial PRIMARY KEY NOT NULL, + "name" varchar(100), + "email" varchar(255) NOT NULL, + "password_hash" text NOT NULL, + "role" varchar(20) DEFAULT 'member' NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + "deleted_at" timestamp, + CONSTRAINT "users_email_unique" UNIQUE("email") +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "activity_logs" ADD CONSTRAINT "activity_logs_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "activity_logs" ADD CONSTRAINT "activity_logs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "invitations" ADD CONSTRAINT "invitations_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "invitations" ADD CONSTRAINT "invitations_invited_by_users_id_fk" FOREIGN KEY ("invited_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "team_members" ADD CONSTRAINT "team_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "team_members" ADD CONSTRAINT "team_members_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/apps/feature-flags/next-js/15-app-router-saas/lib/db/migrations/meta/0000_snapshot.json b/apps/feature-flags/next-js/15-app-router-saas/lib/db/migrations/meta/0000_snapshot.json new file mode 100644 index 000000000..622eb9779 --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/lib/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,389 @@ +{ + "id": "261fd993-fb2c-43e7-89d6-cd58786c5f58", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.activity_logs": { + "name": "activity_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "activity_logs_team_id_teams_id_fk": { + "name": "activity_logs_team_id_teams_id_fk", + "tableFrom": "activity_logs", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "activity_logs_user_id_users_id_fk": { + "name": "activity_logs_user_id_users_id_fk", + "tableFrom": "activity_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.invitations": { + "name": "invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "invited_at": { + "name": "invited_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + } + }, + "indexes": {}, + "foreignKeys": { + "invitations_team_id_teams_id_fk": { + "name": "invitations_team_id_teams_id_fk", + "tableFrom": "invitations", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invitations_invited_by_users_id_fk": { + "name": "invitations_invited_by_users_id_fk", + "tableFrom": "invitations", + "tableTo": "users", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.team_members": { + "name": "team_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_product_id": { + "name": "stripe_product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_name": { + "name": "plan_name", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "subscription_status": { + "name": "subscription_status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "teams_stripe_customer_id_unique": { + "name": "teams_stripe_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_customer_id" + ] + }, + "teams_stripe_subscription_id_unique": { + "name": "teams_stripe_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_subscription_id" + ] + } + } + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/feature-flags/next-js/15-app-router-saas/lib/db/migrations/meta/_journal.json b/apps/feature-flags/next-js/15-app-router-saas/lib/db/migrations/meta/_journal.json new file mode 100644 index 000000000..fd4447417 --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/lib/db/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1726443359662, + "tag": "0000_soft_the_anarchist", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/apps/feature-flags/next-js/15-app-router-saas/lib/db/queries.ts b/apps/feature-flags/next-js/15-app-router-saas/lib/db/queries.ts new file mode 100644 index 000000000..1bcdf644b --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/lib/db/queries.ts @@ -0,0 +1,130 @@ +import { desc, and, eq, isNull } from 'drizzle-orm'; +import { db } from './drizzle'; +import { activityLogs, teamMembers, teams, users } from './schema'; +import { cookies } from 'next/headers'; +import { verifyToken } from '@/lib/auth/session'; + +export async function getUser() { + const sessionCookie = (await cookies()).get('session'); + if (!sessionCookie || !sessionCookie.value) { + return null; + } + + const sessionData = await verifyToken(sessionCookie.value); + if ( + !sessionData || + !sessionData.user || + typeof sessionData.user.id !== 'number' + ) { + return null; + } + + if (new Date(sessionData.expires) < new Date()) { + return null; + } + + const user = await db + .select() + .from(users) + .where(and(eq(users.id, sessionData.user.id), isNull(users.deletedAt))) + .limit(1); + + if (user.length === 0) { + return null; + } + + return user[0]; +} + +export async function getTeamByStripeCustomerId(customerId: string) { + const result = await db + .select() + .from(teams) + .where(eq(teams.stripeCustomerId, customerId)) + .limit(1); + + return result.length > 0 ? result[0] : null; +} + +export async function updateTeamSubscription( + teamId: number, + subscriptionData: { + stripeSubscriptionId: string | null; + stripeProductId: string | null; + planName: string | null; + subscriptionStatus: string; + } +) { + await db + .update(teams) + .set({ + ...subscriptionData, + updatedAt: new Date() + }) + .where(eq(teams.id, teamId)); +} + +export async function getUserWithTeam(userId: number) { + const result = await db + .select({ + user: users, + teamId: teamMembers.teamId + }) + .from(users) + .leftJoin(teamMembers, eq(users.id, teamMembers.userId)) + .where(eq(users.id, userId)) + .limit(1); + + return result[0]; +} + +export async function getActivityLogs() { + const user = await getUser(); + if (!user) { + throw new Error('User not authenticated'); + } + + return await db + .select({ + id: activityLogs.id, + action: activityLogs.action, + timestamp: activityLogs.timestamp, + ipAddress: activityLogs.ipAddress, + userName: users.name + }) + .from(activityLogs) + .leftJoin(users, eq(activityLogs.userId, users.id)) + .where(eq(activityLogs.userId, user.id)) + .orderBy(desc(activityLogs.timestamp)) + .limit(10); +} + +export async function getTeamForUser() { + const user = await getUser(); + if (!user) { + return null; + } + + const result = await db.query.teamMembers.findFirst({ + where: eq(teamMembers.userId, user.id), + with: { + team: { + with: { + teamMembers: { + with: { + user: { + columns: { + id: true, + name: true, + email: true + } + } + } + } + } + } + } + }); + + return result?.team || null; +} diff --git a/apps/feature-flags/next-js/15-app-router-saas/lib/db/schema.ts b/apps/feature-flags/next-js/15-app-router-saas/lib/db/schema.ts new file mode 100644 index 000000000..1d047ce66 --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/lib/db/schema.ts @@ -0,0 +1,142 @@ +import { + pgTable, + serial, + varchar, + text, + timestamp, + integer, +} from 'drizzle-orm/pg-core'; +import { relations } from 'drizzle-orm'; + +export const users = pgTable('users', { + id: serial('id').primaryKey(), + name: varchar('name', { length: 100 }), + email: varchar('email', { length: 255 }).notNull().unique(), + passwordHash: text('password_hash').notNull(), + role: varchar('role', { length: 20 }).notNull().default('member'), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + deletedAt: timestamp('deleted_at'), +}); + +export const teams = pgTable('teams', { + id: serial('id').primaryKey(), + name: varchar('name', { length: 100 }).notNull(), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + stripeCustomerId: text('stripe_customer_id').unique(), + stripeSubscriptionId: text('stripe_subscription_id').unique(), + stripeProductId: text('stripe_product_id'), + planName: varchar('plan_name', { length: 50 }), + subscriptionStatus: varchar('subscription_status', { length: 20 }), +}); + +export const teamMembers = pgTable('team_members', { + id: serial('id').primaryKey(), + userId: integer('user_id') + .notNull() + .references(() => users.id), + teamId: integer('team_id') + .notNull() + .references(() => teams.id), + role: varchar('role', { length: 50 }).notNull(), + joinedAt: timestamp('joined_at').notNull().defaultNow(), +}); + +export const activityLogs = pgTable('activity_logs', { + id: serial('id').primaryKey(), + teamId: integer('team_id') + .notNull() + .references(() => teams.id), + userId: integer('user_id').references(() => users.id), + action: text('action').notNull(), + timestamp: timestamp('timestamp').notNull().defaultNow(), + ipAddress: varchar('ip_address', { length: 45 }), +}); + +export const invitations = pgTable('invitations', { + id: serial('id').primaryKey(), + teamId: integer('team_id') + .notNull() + .references(() => teams.id), + email: varchar('email', { length: 255 }).notNull(), + role: varchar('role', { length: 50 }).notNull(), + invitedBy: integer('invited_by') + .notNull() + .references(() => users.id), + invitedAt: timestamp('invited_at').notNull().defaultNow(), + status: varchar('status', { length: 20 }).notNull().default('pending'), +}); + +export const teamsRelations = relations(teams, ({ many }) => ({ + teamMembers: many(teamMembers), + activityLogs: many(activityLogs), + invitations: many(invitations), +})); + +export const usersRelations = relations(users, ({ many }) => ({ + teamMembers: many(teamMembers), + invitationsSent: many(invitations), +})); + +export const invitationsRelations = relations(invitations, ({ one }) => ({ + team: one(teams, { + fields: [invitations.teamId], + references: [teams.id], + }), + invitedBy: one(users, { + fields: [invitations.invitedBy], + references: [users.id], + }), +})); + +export const teamMembersRelations = relations(teamMembers, ({ one }) => ({ + user: one(users, { + fields: [teamMembers.userId], + references: [users.id], + }), + team: one(teams, { + fields: [teamMembers.teamId], + references: [teams.id], + }), +})); + +export const activityLogsRelations = relations(activityLogs, ({ one }) => ({ + team: one(teams, { + fields: [activityLogs.teamId], + references: [teams.id], + }), + user: one(users, { + fields: [activityLogs.userId], + references: [users.id], + }), +})); + +export type User = typeof users.$inferSelect; +export type NewUser = typeof users.$inferInsert; +export type Team = typeof teams.$inferSelect; +export type NewTeam = typeof teams.$inferInsert; +export type TeamMember = typeof teamMembers.$inferSelect; +export type NewTeamMember = typeof teamMembers.$inferInsert; +export type ActivityLog = typeof activityLogs.$inferSelect; +export type NewActivityLog = typeof activityLogs.$inferInsert; +export type Invitation = typeof invitations.$inferSelect; +export type NewInvitation = typeof invitations.$inferInsert; +export type TeamDataWithMembers = Team & { + teamMembers: (TeamMember & { + user: Pick; + })[]; +}; + +export enum ActivityType { + SIGN_UP = 'SIGN_UP', + SIGN_IN = 'SIGN_IN', + SIGN_OUT = 'SIGN_OUT', + UPDATE_PASSWORD = 'UPDATE_PASSWORD', + DELETE_ACCOUNT = 'DELETE_ACCOUNT', + UPDATE_ACCOUNT = 'UPDATE_ACCOUNT', + CREATE_TEAM = 'CREATE_TEAM', + REMOVE_TEAM_MEMBER = 'REMOVE_TEAM_MEMBER', + INVITE_TEAM_MEMBER = 'INVITE_TEAM_MEMBER', + ACCEPT_INVITATION = 'ACCEPT_INVITATION', +} diff --git a/apps/feature-flags/next-js/15-app-router-saas/lib/db/seed.ts b/apps/feature-flags/next-js/15-app-router-saas/lib/db/seed.ts new file mode 100644 index 000000000..66b60513e --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/lib/db/seed.ts @@ -0,0 +1,118 @@ +import { stripe } from '../payments/stripe'; +import { db } from './drizzle'; +import { users, teams, teamMembers } from './schema'; +import { hashPassword } from '@/lib/auth/session'; +import { eq } from 'drizzle-orm'; + +async function createStripeProducts() { + console.log('Creating Stripe products and prices...'); + + const baseProduct = await stripe.products.create({ + name: 'Base', + description: 'Base subscription plan', + }); + + await stripe.prices.create({ + product: baseProduct.id, + unit_amount: 800, // $8 in cents + currency: 'usd', + recurring: { + interval: 'month', + trial_period_days: 7, + }, + }); + + const plusProduct = await stripe.products.create({ + name: 'Plus', + description: 'Plus subscription plan', + }); + + await stripe.prices.create({ + product: plusProduct.id, + unit_amount: 1200, // $12 in cents + currency: 'usd', + recurring: { + interval: 'month', + trial_period_days: 7, + }, + }); + + console.log('Stripe products and prices created successfully.'); +} + +async function seed() { + const email = 'test@test.com'; + const password = 'admin123'; + const passwordHash = await hashPassword(password); + + // Check if user already exists + const existingUsers = await db + .select() + .from(users) + .where(eq(users.email, email)) + .limit(1); + + let user; + if (existingUsers.length > 0) { + console.log('User already exists, skipping user creation.'); + user = existingUsers[0]; + } else { + const [newUser] = await db + .insert(users) + .values([ + { + email: email, + passwordHash: passwordHash, + role: "owner", + }, + ]) + .returning(); + user = newUser; + console.log('Initial user created.'); + } + + // Check if team already exists for this user + const existingTeamMembers = await db + .select() + .from(teamMembers) + .where(eq(teamMembers.userId, user.id)) + .limit(1); + + let team; + if (existingTeamMembers.length > 0) { + console.log('Team membership already exists, skipping team creation.'); + const existingTeams = await db + .select() + .from(teams) + .where(eq(teams.id, existingTeamMembers[0].teamId)) + .limit(1); + team = existingTeams[0]; + } else { + const [newTeam] = await db + .insert(teams) + .values({ + name: 'Test Team', + }) + .returning(); + team = newTeam; + + await db.insert(teamMembers).values({ + teamId: team.id, + userId: user.id, + role: 'owner', + }); + console.log('Team and team membership created.'); + } + + await createStripeProducts(); +} + +seed() + .catch((error) => { + console.error('Seed process failed:', error); + process.exit(1); + }) + .finally(() => { + console.log('Seed process finished. Exiting...'); + process.exit(0); + }); diff --git a/apps/feature-flags/next-js/15-app-router-saas/lib/db/setup.ts b/apps/feature-flags/next-js/15-app-router-saas/lib/db/setup.ts new file mode 100644 index 000000000..c23d7d4fa --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/lib/db/setup.ts @@ -0,0 +1,216 @@ +import { exec } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import { promisify } from 'node:util'; +import readline from 'node:readline'; +import crypto from 'node:crypto'; +import path from 'node:path'; +import os from 'node:os'; + +const execAsync = promisify(exec); + +function question(query: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => + rl.question(query, (ans) => { + rl.close(); + resolve(ans); + }) + ); +} + +async function checkStripeCLI() { + console.log( + 'Step 1: Checking if Stripe CLI is installed and authenticated...' + ); + try { + await execAsync('stripe --version'); + console.log('Stripe CLI is installed.'); + + // Check if Stripe CLI is authenticated + try { + await execAsync('stripe config --list'); + console.log('Stripe CLI is authenticated.'); + } catch (error) { + console.log( + 'Stripe CLI is not authenticated or the authentication has expired.' + ); + console.log('Please run: stripe login'); + const answer = await question( + 'Have you completed the authentication? (y/n): ' + ); + if (answer.toLowerCase() !== 'y') { + console.log( + 'Please authenticate with Stripe CLI and run this script again.' + ); + process.exit(1); + } + + // Verify authentication after user confirms login + try { + await execAsync('stripe config --list'); + console.log('Stripe CLI authentication confirmed.'); + } catch (error) { + console.error( + 'Failed to verify Stripe CLI authentication. Please try again.' + ); + process.exit(1); + } + } + } catch (error) { + console.error( + 'Stripe CLI is not installed. Please install it and try again.' + ); + console.log('To install Stripe CLI, follow these steps:'); + console.log('1. Visit: https://docs.stripe.com/stripe-cli'); + console.log( + '2. Download and install the Stripe CLI for your operating system' + ); + console.log('3. After installation, run: stripe login'); + console.log( + 'After installation and authentication, please run this setup script again.' + ); + process.exit(1); + } +} + +async function getPostgresURL(): Promise { + console.log('Step 2: Setting up Postgres'); + const dbChoice = await question( + 'Do you want to use a local Postgres instance with Docker (L) or a remote Postgres instance (R)? (L/R): ' + ); + + if (dbChoice.toLowerCase() === 'l') { + console.log('Setting up local Postgres instance with Docker...'); + await setupLocalPostgres(); + return 'postgres://postgres:postgres@localhost:54322/postgres'; + } else { + console.log( + 'You can find Postgres databases at: https://vercel.com/marketplace?category=databases' + ); + return await question('Enter your POSTGRES_URL: '); + } +} + +async function setupLocalPostgres() { + console.log('Checking if Docker is installed...'); + try { + await execAsync('docker --version'); + console.log('Docker is installed.'); + } catch (error) { + console.error( + 'Docker is not installed. Please install Docker and try again.' + ); + console.log( + 'To install Docker, visit: https://docs.docker.com/get-docker/' + ); + process.exit(1); + } + + console.log('Creating docker-compose.yml file...'); + const dockerComposeContent = ` +services: + postgres: + image: postgres:16.4-alpine + container_name: next_saas_starter_postgres + environment: + POSTGRES_DB: postgres + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "54322:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + +volumes: + postgres_data: +`; + + await fs.writeFile( + path.join(process.cwd(), 'docker-compose.yml'), + dockerComposeContent + ); + console.log('docker-compose.yml file created.'); + + console.log('Starting Docker container with `docker compose up -d`...'); + try { + await execAsync('docker compose up -d'); + console.log('Docker container started successfully.'); + } catch (error) { + console.error( + 'Failed to start Docker container. Please check your Docker installation and try again.' + ); + process.exit(1); + } +} + +async function getStripeSecretKey(): Promise { + console.log('Step 3: Getting Stripe Secret Key'); + console.log( + 'You can find your Stripe Secret Key at: https://dashboard.stripe.com/test/apikeys' + ); + return await question('Enter your Stripe Secret Key: '); +} + +async function createStripeWebhook(): Promise { + console.log('Step 4: Creating Stripe webhook...'); + try { + const { stdout } = await execAsync('stripe listen --print-secret'); + const match = stdout.match(/whsec_[a-zA-Z0-9]+/); + if (!match) { + throw new Error('Failed to extract Stripe webhook secret'); + } + console.log('Stripe webhook created.'); + return match[0]; + } catch (error) { + console.error( + 'Failed to create Stripe webhook. Check your Stripe CLI installation and permissions.' + ); + if (os.platform() === 'win32') { + console.log( + 'Note: On Windows, you may need to run this script as an administrator.' + ); + } + throw error; + } +} + +function generateAuthSecret(): string { + console.log('Step 5: Generating AUTH_SECRET...'); + return crypto.randomBytes(32).toString('hex'); +} + +async function writeEnvFile(envVars: Record) { + console.log('Step 6: Writing environment variables to .env'); + const envContent = Object.entries(envVars) + .map(([key, value]) => `${key}=${value}`) + .join('\n'); + + await fs.writeFile(path.join(process.cwd(), '.env'), envContent); + console.log('.env file created with the necessary variables.'); +} + +async function main() { + await checkStripeCLI(); + + const POSTGRES_URL = await getPostgresURL(); + const STRIPE_SECRET_KEY = await getStripeSecretKey(); + const STRIPE_WEBHOOK_SECRET = await createStripeWebhook(); + const BASE_URL = 'http://localhost:3000'; + const AUTH_SECRET = generateAuthSecret(); + + await writeEnvFile({ + POSTGRES_URL, + STRIPE_SECRET_KEY, + STRIPE_WEBHOOK_SECRET, + BASE_URL, + AUTH_SECRET, + }); + + console.log('🎉 Setup completed successfully!'); +} + +main().catch(console.error); diff --git a/apps/feature-flags/next-js/15-app-router-saas/lib/payments/STRIPE_STUB_README.md b/apps/feature-flags/next-js/15-app-router-saas/lib/payments/STRIPE_STUB_README.md new file mode 100644 index 000000000..80111f5ca --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/lib/payments/STRIPE_STUB_README.md @@ -0,0 +1,212 @@ +# Stripe Stub Mode + +This directory contains a stub implementation of the Stripe API that allows you to develop and test the SaaS application without real Stripe API keys. + +## Why Use Stub Mode? + +- **No Stripe Account Required**: Develop without signing up for Stripe +- **Faster Development**: No network latency from API calls +- **Offline Development**: Work without internet connection +- **Predictable Testing**: Consistent mock data for testing +- **No API Limits**: No rate limiting or quotas +- **Free**: No charges or test mode transactions + +## How to Enable Stub Mode + +### Option 1: Environment Variable (Recommended) + +Add to your [.env](.env) file: + +```bash +STRIPE_MODE=stub +``` + +### Option 2: Invalid/Missing API Keys + +The stub mode automatically activates if: +- `STRIPE_SECRET_KEY` is not set +- `STRIPE_SECRET_KEY` is set to `sk_test_***` (example value) + +## What Gets Mocked + +### Products & Prices + +The stub provides three pre-configured products: + +1. **Basic Plan** + - Product ID: `prod_stub_basic` + - Price ID: `price_stub_basic_monthly` + - Price: $10.00/month + - Trial: 14 days + +2. **Pro Plan** + - Product ID: `prod_stub_pro` + - Price ID: `price_stub_pro_monthly` + - Price: $25.00/month + - Trial: 14 days + +3. **Enterprise Plan** + - Product ID: `prod_stub_enterprise` + - Price ID: `price_stub_enterprise_monthly` + - Price: $100.00/month + - Trial: 14 days + +### Checkout Sessions + +When you create a checkout session in stub mode: +- A mock session ID is generated (`cs_stub_*`) +- A mock customer ID is created (`cus_stub_*`) +- A mock subscription ID is created (`sub_stub_*`) +- The user is redirected to the success URL +- All subscription data is stored in your local database + +### Customer Portal + +The billing portal session creates: +- A mock portal session ID (`bps_stub_*`) +- A redirect to your dashboard with a query param +- All configuration options are accepted + +### Webhooks + +Webhook events can be simulated by: +1. Calling the webhook endpoint directly +2. Sending mock event data in the request body +3. The stub will parse and process the event + +Example webhook event: +```json +{ + "type": "customer.subscription.updated", + "data": { + "object": { + "id": "sub_stub_123", + "customer": "cus_stub_123", + "status": "active", + "items": { + "data": [{ + "plan": { + "product": { + "id": "prod_stub_basic", + "name": "Basic Plan" + } + } + }] + } + } + } +} +``` + +## Testing the Checkout Flow + +1. **Navigate to Pricing Page**: `/pricing` +2. **Click on a Plan**: Choose any plan +3. **Get Redirected**: You'll see a "checkout" URL with session ID +4. **Auto-Complete**: The checkout automatically succeeds +5. **Return to Dashboard**: You're logged in with active subscription + +The entire flow works without leaving your application! + +## Console Logging + +When stub mode is active, you'll see: +``` +🎭 Running in Stripe STUB mode - no real API calls will be made +``` + +All stub operations log to console with `[Stripe Stub]` prefix: +``` +[Stripe Stub] Creating checkout session: {...} +[Stripe Stub] Retrieving subscription: sub_stub_123 +[Stripe Stub] Listing products: {...} +``` + +## Subscription Statuses + +The stub supports all subscription statuses: +- `trialing` (default for new subscriptions) +- `active` +- `canceled` +- `unpaid` +- `past_due` + +## Limitations + +The stub implementation: +- ✅ Supports all core SaaS functionality +- ✅ Works with the pricing page +- ✅ Processes checkout sessions +- ✅ Handles webhooks +- ✅ Manages customer portal +- ❌ Does NOT process real payments +- ❌ Does NOT send Stripe emails +- ❌ Does NOT create actual Stripe resources +- ❌ Does NOT validate real payment methods + +## Switching to Real Stripe + +When you're ready to use real Stripe: + +1. Sign up at [stripe.com](https://stripe.com) +2. Get your API keys from the Stripe Dashboard +3. Update your `.env` file: + ```bash + STRIPE_SECRET_KEY=sk_test_your_real_key + STRIPE_WEBHOOK_SECRET=whsec_your_real_secret + # Remove or comment out: + # STRIPE_MODE=stub + ``` +4. Restart your development server +5. Set up real products and prices in Stripe Dashboard +6. Configure webhook endpoints in Stripe + +## Files + +- [stripe-stub.ts](stripe-stub.ts) - Stub implementation +- [stripe.ts](stripe.ts) - Main Stripe client (auto-detects stub mode) +- `STRIPE_STUB_README.md` - This file + +## Development Tips + +1. **Use stub mode for UI development** - Build out forms, buttons, and flows +2. **Use real Stripe for payment testing** - Verify actual payment processing +3. **Keep stub data simple** - Extend the stub as needed for your use case +4. **Log everything** - The stub logs all operations for debugging + +## Extending the Stub + +To add more mock products or customize behavior, edit [stripe-stub.ts](stripe-stub.ts): + +```typescript +// Add a new product +{ + id: 'prod_stub_custom', + name: 'Custom Plan', + description: 'A custom plan', + active: true, +} +``` + +## Troubleshooting + +**Q: My checkout isn't working** +A: Check the console for `[Stripe Stub]` logs to see what's happening + +**Q: I'm not seeing stub mode** +A: Verify `STRIPE_MODE=stub` in your `.env` and restart the dev server + +**Q: Can I use this in production?** +A: No! Stub mode is for development only. Always use real Stripe in production. + +**Q: How do I test webhooks?** +A: Send POST requests to `/api/stripe/webhook` with mock event data + +## Support + +For issues with the stub implementation, check: +1. Console logs for `[Stripe Stub]` messages +2. Your `.env` configuration +3. The stub source code in [stripe-stub.ts](stripe-stub.ts) + +For real Stripe issues, see [Stripe Documentation](https://stripe.com/docs). diff --git a/apps/feature-flags/next-js/15-app-router-saas/lib/payments/TESTING_GUIDE.md b/apps/feature-flags/next-js/15-app-router-saas/lib/payments/TESTING_GUIDE.md new file mode 100644 index 000000000..c991dde04 --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/lib/payments/TESTING_GUIDE.md @@ -0,0 +1,223 @@ +# Testing Stripe Stub Mode + +This guide shows you how to test the application using Stripe stub mode. + +## Quick Start + +1. **Enable stub mode** (already done in your `.env`): + ```bash + STRIPE_MODE=stub + ``` + +2. **Start your development server**: + ```bash + pnpm dev + ``` + +3. **Look for the stub mode indicator** in your console: + ``` + 🎭 Running in Stripe STUB mode - no real API calls will be made + ``` + +## Testing Scenarios + +### Scenario 1: View Pricing + +1. Navigate to `http://localhost:3000/pricing` +2. You should see pricing plans displayed +3. The plans come from the stub (not real Stripe) + +**Expected Stub Data:** +- Basic Plan: $10/month +- Pro Plan: $25/month +- Enterprise Plan: $100/month + +### Scenario 2: Checkout Flow + +1. Go to `/pricing` +2. Click "Subscribe" on any plan +3. You'll be redirected to checkout +4. The stub automatically completes checkout +5. You land on `/dashboard` with an active subscription + +**What happens behind the scenes:** +``` +[Stripe Stub] Creating checkout session +[Stripe Stub] Retrieving checkout session +[Stripe Stub] Retrieving subscription +``` + +**Database changes:** +- Team gets `stripeCustomerId` (e.g., `cus_stub_123456`) +- Team gets `stripeSubscriptionId` (e.g., `sub_stub_123456`) +- Team gets `stripeProductId` (e.g., `prod_stub_basic`) +- Team `subscriptionStatus` set to `trialing` + +### Scenario 3: Customer Portal + +1. From your dashboard, click "Manage Subscription" +2. You'll be redirected to a stub portal URL +3. The URL contains `?stub=billing-portal` query param + +**Expected behavior:** +- No actual Stripe portal is opened +- You stay on your local application +- Can simulate subscription changes via webhooks + +### Scenario 4: Webhook Simulation + +Use curl or Postman to simulate a webhook: + +```bash +curl -X POST http://localhost:3000/api/stripe/webhook \ + -H "Content-Type: application/json" \ + -H "stripe-signature: stub_signature" \ + -d '{ + "type": "customer.subscription.updated", + "data": { + "object": { + "id": "sub_stub_123", + "customer": "cus_stub_123", + "status": "active", + "items": { + "data": [{ + "plan": { + "product": { + "id": "prod_stub_basic", + "name": "Basic Plan" + } + } + }] + } + } + } + }' +``` + +**Expected response:** +```json +{ "received": true } +``` + +### Scenario 5: Subscription Cancellation + +Simulate a cancellation webhook: + +```bash +curl -X POST http://localhost:3000/api/stripe/webhook \ + -H "Content-Type: application/json" \ + -H "stripe-signature: stub_signature" \ + -d '{ + "type": "customer.subscription.deleted", + "data": { + "object": { + "id": "sub_stub_123", + "customer": "cus_stub_123", + "status": "canceled" + } + } + }' +``` + +**Expected database changes:** +- `stripeSubscriptionId` → `null` +- `stripeProductId` → `null` +- `planName` → `null` +- `subscriptionStatus` → `canceled` + +## Debugging Tips + +### Enable Verbose Logging + +All stub operations log to console. Look for: +``` +[Stripe Stub] Creating checkout session: { priceId: 'price_stub_basic_monthly', ... } +[Stripe Stub] Retrieving subscription: sub_stub_123456 +``` + +### Check Database State + +After any operation, check your database: + +```sql +SELECT + id, + name, + stripeCustomerId, + stripeSubscriptionId, + stripeProductId, + planName, + subscriptionStatus +FROM teams; +``` + +### Common Issues + +**Issue: Not seeing stub mode message** +- ✅ Solution: Make sure `.env` has `STRIPE_MODE=stub` +- ✅ Solution: Restart your dev server + +**Issue: Checkout redirects to real Stripe** +- ✅ Solution: Check console for stub mode indicator +- ✅ Solution: Verify `STRIPE_MODE=stub` is set correctly + +**Issue: Webhook fails** +- ✅ Solution: Include `stripe-signature` header (any value works) +- ✅ Solution: Check request body matches expected format + +## Comparing Stub vs Real Stripe + +| Feature | Stub Mode | Real Stripe | +|---------|-----------|-------------| +| Speed | Instant ⚡ | Network latency | +| Cost | Free 💰 | Test mode free, production paid | +| Internet Required | No 📡 | Yes | +| Payment Processing | Simulated ✨ | Real | +| Webhook Events | Manual 🔧 | Automatic | +| Customer Portal | Mocked 🎭 | Real Stripe UI | +| Product Management | Hardcoded 📝 | Dashboard managed | + +## When to Use Each Mode + +### Use Stub Mode For: +- ✅ Initial development +- ✅ UI/UX work +- ✅ Offline development +- ✅ Fast iteration +- ✅ Testing subscription logic +- ✅ CI/CD pipelines + +### Use Real Stripe For: +- ✅ Payment flow testing +- ✅ Webhook integration testing +- ✅ Customer portal testing +- ✅ Pre-production testing +- ✅ Production deployment + +## Switching Between Modes + +### Enable Stub Mode +```bash +# In .env +STRIPE_MODE=stub +``` + +### Enable Real Stripe +```bash +# In .env +# STRIPE_MODE=stub # Comment out or remove this line +``` + +Then restart your dev server. + +## Next Steps + +1. **Build your features** using stub mode +2. **Test thoroughly** with real Stripe in test mode +3. **Deploy to production** with real Stripe in live mode + +## API Reference + +See [stripe-stub.ts](stripe-stub.ts) for complete stub implementation details. + +For real Stripe API, see [Stripe API Docs](https://stripe.com/docs/api). diff --git a/apps/feature-flags/next-js/15-app-router-saas/lib/payments/actions.ts b/apps/feature-flags/next-js/15-app-router-saas/lib/payments/actions.ts new file mode 100644 index 000000000..26492c19e --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/lib/payments/actions.ts @@ -0,0 +1,15 @@ +'use server'; + +import { redirect } from 'next/navigation'; +import { createCheckoutSession, createCustomerPortalSession } from './stripe'; +import { withTeam } from '@/lib/auth/middleware'; + +export const checkoutAction = withTeam(async (formData, team) => { + const priceId = formData.get('priceId') as string; + await createCheckoutSession({ team: team, priceId }); +}); + +export const customerPortalAction = withTeam(async (_, team) => { + const portalSession = await createCustomerPortalSession(team); + redirect(portalSession.url); +}); diff --git a/apps/feature-flags/next-js/15-app-router-saas/lib/payments/stripe-stub.ts b/apps/feature-flags/next-js/15-app-router-saas/lib/payments/stripe-stub.ts new file mode 100644 index 000000000..270a91322 --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/lib/payments/stripe-stub.ts @@ -0,0 +1,354 @@ +/** + * Stripe Stub Implementation + * + * This file provides mock implementations of Stripe API calls for development + * without requiring real Stripe API keys. + * + * To use: Set STRIPE_MODE=stub in your .env file + */ + +export interface StubCheckoutSession { + id: string; + url: string; + customer: { id: string }; + subscription: string; + client_reference_id: string; + payment_method_types: string[]; + line_items: Array<{ price: string; quantity: number }>; + mode: string; + success_url: string; + cancel_url: string; + allow_promotion_codes: boolean; + subscription_data?: { trial_period_days: number }; +} + +export interface StubBillingPortalSession { + id: string; + url: string; + customer: string; + return_url: string; + configuration: string; +} + +export interface StubSubscription { + id: string; + customer: string; + status: 'active' | 'trialing' | 'canceled' | 'unpaid' | 'past_due'; + items: { + data: Array<{ + id: string; + price: StubPrice; + plan?: StubPrice; + }>; + }; +} + +export interface StubPrice { + id: string; + product: string | StubProduct; + unit_amount: number; + currency: string; + recurring?: { + interval: 'day' | 'week' | 'month' | 'year'; + trial_period_days?: number; + }; +} + +export interface StubProduct { + id: string; + name: string; + description: string | null; + active: boolean; + default_price?: string | StubPrice; +} + +export interface StubBillingPortalConfiguration { + id: string; + business_profile: { headline: string }; + features: Record; +} + +export interface StubEvent { + id: string; + type: string; + data: { + object: StubSubscription | Record; + }; +} + +/** + * Stub Stripe Client + * Mimics the Stripe SDK structure but returns mock data + */ +export class StripeStub { + checkout = { + sessions: { + create: async (params: Record): Promise => { + const sessionId = `cs_stub_${Date.now()}`; + const customerId = (params.customer as string) || `cus_stub_${Date.now()}`; + const subscriptionId = `sub_stub_${Date.now()}`; + + console.log('[Stripe Stub] Creating checkout session:', params); + + return { + id: sessionId, + url: `${params.success_url}`.replace('{CHECKOUT_SESSION_ID}', sessionId), + customer: { id: customerId }, + subscription: subscriptionId, + client_reference_id: params.client_reference_id as string, + payment_method_types: params.payment_method_types as string[], + line_items: params.line_items as Array<{ price: string; quantity: number }>, + mode: params.mode as string, + success_url: params.success_url as string, + cancel_url: params.cancel_url as string, + allow_promotion_codes: params.allow_promotion_codes as boolean, + subscription_data: params.subscription_data as { trial_period_days: number } | undefined, + }; + }, + retrieve: async ( + sessionId: string, + options?: { expand?: string[] } + ): Promise => { + console.log('[Stripe Stub] Retrieving checkout session:', sessionId); + + const customerId = `cus_stub_${Date.now()}`; + const subscriptionId = `sub_stub_${Date.now()}`; + + return { + id: sessionId, + url: `http://localhost:3000/dashboard`, + customer: { id: customerId }, + subscription: subscriptionId, + client_reference_id: '1', // Default user ID + payment_method_types: ['card'], + line_items: [{ price: 'price_stub_basic', quantity: 1 }], + mode: 'subscription', + success_url: 'http://localhost:3000/api/stripe/checkout', + cancel_url: 'http://localhost:3000/pricing', + allow_promotion_codes: true, + subscription_data: { trial_period_days: 14 }, + }; + }, + }, + }; + + subscriptions = { + retrieve: async ( + subscriptionId: string, + options?: { expand?: string[] } + ): Promise => { + console.log('[Stripe Stub] Retrieving subscription:', subscriptionId); + + return { + id: subscriptionId, + customer: `cus_stub_${Date.now()}`, + status: 'trialing', + items: { + data: [ + { + id: 'si_stub_1', + price: { + id: 'price_stub_basic', + product: { + id: 'prod_stub_basic', + name: 'Basic Plan', + description: 'Basic tier subscription', + active: true, + }, + unit_amount: 1000, + currency: 'usd', + recurring: { + interval: 'month', + trial_period_days: 14, + }, + }, + }, + ], + }, + }; + }, + }; + + billingPortal = { + configurations: { + list: async (): Promise<{ data: StubBillingPortalConfiguration[] }> => { + console.log('[Stripe Stub] Listing billing portal configurations'); + return { + data: [ + { + id: 'bpc_stub_1', + business_profile: { headline: 'Manage your subscription' }, + features: {}, + }, + ], + }; + }, + create: async (params: Record): Promise => { + console.log('[Stripe Stub] Creating billing portal configuration:', params); + return { + id: `bpc_stub_${Date.now()}`, + business_profile: params.business_profile as { headline: string }, + features: params.features as Record, + }; + }, + }, + sessions: { + create: async (params: { + customer: string; + return_url: string; + configuration?: string; + }): Promise => { + console.log('[Stripe Stub] Creating billing portal session:', params); + return { + id: `bps_stub_${Date.now()}`, + url: `http://localhost:3000/dashboard?stub=billing-portal`, + customer: params.customer, + return_url: params.return_url, + configuration: params.configuration || 'bpc_stub_default', + }; + }, + }, + }; + + products = { + list: async (params?: { + active?: boolean; + expand?: string[]; + }): Promise<{ data: StubProduct[] }> => { + console.log('[Stripe Stub] Listing products:', params); + return { + data: [ + { + id: 'prod_stub_basic', + name: 'Basic Plan', + description: 'Perfect for getting started', + active: true, + default_price: 'price_stub_basic_monthly', + }, + { + id: 'prod_stub_pro', + name: 'Pro Plan', + description: 'For growing teams', + active: true, + default_price: 'price_stub_pro_monthly', + }, + { + id: 'prod_stub_enterprise', + name: 'Enterprise Plan', + description: 'For large organizations', + active: true, + default_price: 'price_stub_enterprise_monthly', + }, + ], + }; + }, + retrieve: async (productId: string): Promise => { + console.log('[Stripe Stub] Retrieving product:', productId); + return { + id: productId, + name: 'Basic Plan', + description: 'Perfect for getting started', + active: true, + }; + }, + }; + + prices = { + list: async (params?: { + active?: boolean; + type?: string; + product?: string; + expand?: string[]; + }): Promise<{ data: StubPrice[] }> => { + console.log('[Stripe Stub] Listing prices:', params); + return { + data: [ + { + id: 'price_stub_basic_monthly', + product: { + id: 'prod_stub_basic', + name: 'Basic Plan', + description: 'Perfect for getting started', + active: true, + }, + unit_amount: 1000, // $10.00 + currency: 'usd', + recurring: { + interval: 'month', + trial_period_days: 14, + }, + }, + { + id: 'price_stub_pro_monthly', + product: { + id: 'prod_stub_pro', + name: 'Pro Plan', + description: 'For growing teams', + active: true, + }, + unit_amount: 2500, // $25.00 + currency: 'usd', + recurring: { + interval: 'month', + trial_period_days: 14, + }, + }, + { + id: 'price_stub_enterprise_monthly', + product: { + id: 'prod_stub_enterprise', + name: 'Enterprise Plan', + description: 'For large organizations', + active: true, + }, + unit_amount: 10000, // $100.00 + currency: 'usd', + recurring: { + interval: 'month', + trial_period_days: 14, + }, + }, + ], + }; + }, + }; + + webhooks = { + constructEvent: ( + payload: string, + signature: string, + secret: string + ): StubEvent => { + console.log('[Stripe Stub] Constructing webhook event'); + + // Parse the payload + const parsedPayload = JSON.parse(payload); + + return { + id: `evt_stub_${Date.now()}`, + type: parsedPayload.type || 'customer.subscription.updated', + data: { + object: parsedPayload.data?.object || { + id: `sub_stub_${Date.now()}`, + customer: `cus_stub_${Date.now()}`, + status: 'active', + items: { + data: [ + { + plan: { + product: { + id: 'prod_stub_basic', + name: 'Basic Plan', + }, + }, + }, + ], + }, + }, + }, + }; + }, + }; +} + +export const stripeStub = new StripeStub(); diff --git a/apps/feature-flags/next-js/15-app-router-saas/lib/payments/stripe.ts b/apps/feature-flags/next-js/15-app-router-saas/lib/payments/stripe.ts new file mode 100644 index 000000000..291e6ba06 --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/lib/payments/stripe.ts @@ -0,0 +1,195 @@ +import Stripe from 'stripe'; +import { redirect } from 'next/navigation'; +import { Team } from '@/lib/db/schema'; +import { + getTeamByStripeCustomerId, + getUser, + updateTeamSubscription +} from '@/lib/db/queries'; +import { stripeStub } from './stripe-stub'; + +// Use stub if STRIPE_MODE=stub or if STRIPE_SECRET_KEY is missing/invalid +const useStub = + process.env.STRIPE_MODE === 'stub' || + !process.env.STRIPE_SECRET_KEY || + process.env.STRIPE_SECRET_KEY === 'sk_test_***'; + +export const stripe = useStub + ? (stripeStub as unknown as Stripe) + : new Stripe(process.env.STRIPE_SECRET_KEY!, { + apiVersion: '2025-04-30.basil' + }); + +if (useStub) { + console.log('🎭 Running in Stripe STUB mode - no real API calls will be made'); +} + +export async function createCheckoutSession({ + team, + priceId +}: { + team: Team | null; + priceId: string; +}) { + const user = await getUser(); + + if (!team || !user) { + redirect(`/sign-up?redirect=checkout&priceId=${priceId}`); + } + + const session = await stripe.checkout.sessions.create({ + payment_method_types: ['card'], + line_items: [ + { + price: priceId, + quantity: 1 + } + ], + mode: 'subscription', + success_url: `${process.env.BASE_URL}/api/stripe/checkout?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${process.env.BASE_URL}/pricing`, + customer: team.stripeCustomerId || undefined, + client_reference_id: user.id.toString(), + allow_promotion_codes: true, + subscription_data: { + trial_period_days: 14 + } + }); + + redirect(session.url!); +} + +export async function createCustomerPortalSession(team: Team) { + if (!team.stripeCustomerId || !team.stripeProductId) { + redirect('/pricing'); + } + + let configuration: Stripe.BillingPortal.Configuration; + const configurations = await stripe.billingPortal.configurations.list(); + + if (configurations.data.length > 0) { + configuration = configurations.data[0]; + } else { + const product = await stripe.products.retrieve(team.stripeProductId); + if (!product.active) { + throw new Error("Team's product is not active in Stripe"); + } + + const prices = await stripe.prices.list({ + product: product.id, + active: true + }); + if (prices.data.length === 0) { + throw new Error("No active prices found for the team's product"); + } + + configuration = await stripe.billingPortal.configurations.create({ + business_profile: { + headline: 'Manage your subscription' + }, + features: { + subscription_update: { + enabled: true, + default_allowed_updates: ['price', 'quantity', 'promotion_code'], + proration_behavior: 'create_prorations', + products: [ + { + product: product.id, + prices: prices.data.map((price) => price.id) + } + ] + }, + subscription_cancel: { + enabled: true, + mode: 'at_period_end', + cancellation_reason: { + enabled: true, + options: [ + 'too_expensive', + 'missing_features', + 'switched_service', + 'unused', + 'other' + ] + } + }, + payment_method_update: { + enabled: true + } + } + }); + } + + return stripe.billingPortal.sessions.create({ + customer: team.stripeCustomerId, + return_url: `${process.env.BASE_URL}/dashboard`, + configuration: configuration.id + }); +} + +export async function handleSubscriptionChange( + subscription: Stripe.Subscription +) { + const customerId = subscription.customer as string; + const subscriptionId = subscription.id; + const status = subscription.status; + + const team = await getTeamByStripeCustomerId(customerId); + + if (!team) { + console.error('Team not found for Stripe customer:', customerId); + return; + } + + if (status === 'active' || status === 'trialing') { + const plan = subscription.items.data[0]?.plan; + await updateTeamSubscription(team.id, { + stripeSubscriptionId: subscriptionId, + stripeProductId: plan?.product as string, + planName: (plan?.product as Stripe.Product).name, + subscriptionStatus: status + }); + } else if (status === 'canceled' || status === 'unpaid') { + await updateTeamSubscription(team.id, { + stripeSubscriptionId: null, + stripeProductId: null, + planName: null, + subscriptionStatus: status + }); + } +} + +export async function getStripePrices() { + const prices = await stripe.prices.list({ + expand: ['data.product'], + active: true, + type: 'recurring' + }); + + return prices.data.map((price) => ({ + id: price.id, + productId: + typeof price.product === 'string' ? price.product : price.product.id, + unitAmount: price.unit_amount, + currency: price.currency, + interval: price.recurring?.interval, + trialPeriodDays: price.recurring?.trial_period_days + })); +} + +export async function getStripeProducts() { + const products = await stripe.products.list({ + active: true, + expand: ['data.default_price'] + }); + + return products.data.map((product) => ({ + id: product.id, + name: product.name, + description: product.description, + defaultPriceId: + typeof product.default_price === 'string' + ? product.default_price + : product.default_price?.id + })); +} diff --git a/apps/feature-flags/next-js/15-app-router-saas/lib/utils.ts b/apps/feature-flags/next-js/15-app-router-saas/lib/utils.ts new file mode 100644 index 000000000..bd0c391dd --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/apps/feature-flags/next-js/15-app-router-saas/middleware.ts b/apps/feature-flags/next-js/15-app-router-saas/middleware.ts new file mode 100644 index 000000000..1c8a638e4 --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/middleware.ts @@ -0,0 +1,49 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; +import { signToken, verifyToken } from '@/lib/auth/session'; + +const protectedRoutes = '/dashboard'; + +export async function middleware(request: NextRequest) { + const { pathname } = request.nextUrl; + const sessionCookie = request.cookies.get('session'); + const isProtectedRoute = pathname.startsWith(protectedRoutes); + + if (isProtectedRoute && !sessionCookie) { + return NextResponse.redirect(new URL('/sign-in', request.url)); + } + + let res = NextResponse.next(); + + if (sessionCookie && request.method === 'GET') { + try { + const parsed = await verifyToken(sessionCookie.value); + const expiresInOneDay = new Date(Date.now() + 24 * 60 * 60 * 1000); + + res.cookies.set({ + name: 'session', + value: await signToken({ + ...parsed, + expires: expiresInOneDay.toISOString() + }), + httpOnly: true, + secure: true, + sameSite: 'lax', + expires: expiresInOneDay + }); + } catch (error) { + console.error('Error updating session:', error); + res.cookies.delete('session'); + if (isProtectedRoute) { + return NextResponse.redirect(new URL('/sign-in', request.url)); + } + } + } + + return res; +} + +export const config = { + matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'], + runtime: 'nodejs' +}; diff --git a/apps/feature-flags/next-js/15-app-router-saas/next.config.ts b/apps/feature-flags/next-js/15-app-router-saas/next.config.ts new file mode 100644 index 000000000..fd4fd4546 --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/next.config.ts @@ -0,0 +1,9 @@ +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = { + // Configuration for stable Next.js 15 + // To enable experimental features like PPR, upgrade to canary: + // pnpm add next@canary +}; + +export default nextConfig; diff --git a/apps/feature-flags/next-js/15-app-router-saas/package.json b/apps/feature-flags/next-js/15-app-router-saas/package.json new file mode 100644 index 000000000..020d334a4 --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/package.json @@ -0,0 +1,42 @@ +{ + "private": true, + "scripts": { + "dev": "next dev --turbopack", + "build": "next build", + "start": "next start", + "db:setup": "npx tsx lib/db/setup.ts", + "db:seed": "npx tsx lib/db/seed.ts", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:studio": "drizzle-kit studio" + }, + "dependencies": { + "@tailwindcss/postcss": "4.1.7", + "@types/node": "^22.15.18", + "@types/react": "19.1.4", + "@types/react-dom": "19.1.5", + "autoprefixer": "^10.4.21", + "bcryptjs": "^3.0.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "dotenv": "^16.5.0", + "drizzle-kit": "^0.31.1", + "drizzle-orm": "^0.43.1", + "jose": "^6.0.11", + "lucide-react": "^0.511.0", + "next": "15.5.7", + "postcss": "^8.5.3", + "postgres": "^3.4.5", + "radix-ui": "^1.4.2", + "react": "19.1.2", + "react-dom": "19.1.2", + "server-only": "^0.0.1", + "stripe": "^18.1.0", + "swr": "^2.3.3", + "tailwind-merge": "^3.3.0", + "tailwindcss": "4.1.7", + "tw-animate-css": "^1.3.0", + "typescript": "^5.8.3", + "zod": "^3.24.4" + } +} diff --git a/apps/feature-flags/next-js/15-app-router-saas/pnpm-lock.yaml b/apps/feature-flags/next-js/15-app-router-saas/pnpm-lock.yaml new file mode 100644 index 000000000..c4753ff95 --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/pnpm-lock.yaml @@ -0,0 +1,3794 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@tailwindcss/postcss': + specifier: 4.1.7 + version: 4.1.7 + '@types/node': + specifier: ^22.15.18 + version: 22.15.18 + '@types/react': + specifier: 19.1.4 + version: 19.1.4 + '@types/react-dom': + specifier: 19.1.5 + version: 19.1.5(@types/react@19.1.4) + autoprefixer: + specifier: ^10.4.21 + version: 10.4.21(postcss@8.5.3) + bcryptjs: + specifier: ^3.0.2 + version: 3.0.2 + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + dotenv: + specifier: ^16.5.0 + version: 16.5.0 + drizzle-kit: + specifier: ^0.31.1 + version: 0.31.1 + drizzle-orm: + specifier: ^0.43.1 + version: 0.43.1(gel@2.1.0)(postgres@3.4.5) + jose: + specifier: ^6.0.11 + version: 6.0.11 + lucide-react: + specifier: ^0.511.0 + version: 0.511.0(react@19.1.2) + next: + specifier: 15.5.7 + version: 15.5.7(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + postcss: + specifier: ^8.5.3 + version: 8.5.3 + postgres: + specifier: ^3.4.5 + version: 3.4.5 + radix-ui: + specifier: ^1.4.2 + version: 1.4.2(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + react: + specifier: 19.1.2 + version: 19.1.2 + react-dom: + specifier: 19.1.2 + version: 19.1.2(react@19.1.2) + server-only: + specifier: ^0.0.1 + version: 0.0.1 + stripe: + specifier: ^18.1.0 + version: 18.1.0(@types/node@22.15.18) + swr: + specifier: ^2.3.3 + version: 2.3.3(react@19.1.2) + tailwind-merge: + specifier: ^3.3.0 + version: 3.3.0 + tailwindcss: + specifier: 4.1.7 + version: 4.1.7 + tw-animate-css: + specifier: ^1.3.0 + version: 1.3.0 + typescript: + specifier: ^5.8.3 + version: 5.8.3 + zod: + specifier: ^3.24.4 + version: 3.24.4 + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + + '@emnapi/runtime@1.7.1': + resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} + + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.is' + + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.is' + + '@esbuild/aix-ppc64@0.25.4': + resolution: {integrity: sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.4': + resolution: {integrity: sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.4': + resolution: {integrity: sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.4': + resolution: {integrity: sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.4': + resolution: {integrity: sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.4': + resolution: {integrity: sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.4': + resolution: {integrity: sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.4': + resolution: {integrity: sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.4': + resolution: {integrity: sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.4': + resolution: {integrity: sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.25.4': + resolution: {integrity: sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.25.4': + resolution: {integrity: sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.4': + resolution: {integrity: sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.4': + resolution: {integrity: sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.4': + resolution: {integrity: sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.4': + resolution: {integrity: sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.4': + resolution: {integrity: sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.4': + resolution: {integrity: sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.4': + resolution: {integrity: sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.4': + resolution: {integrity: sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.4': + resolution: {integrity: sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.4': + resolution: {integrity: sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.4': + resolution: {integrity: sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.4': + resolution: {integrity: sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.4': + resolution: {integrity: sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@floating-ui/core@1.7.0': + resolution: {integrity: sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA==} + + '@floating-ui/dom@1.7.0': + resolution: {integrity: sha512-lGTor4VlXcesUMh1cupTUTDoCxMb0V6bm3CnxHzQcw8Eaf1jQbgQX4i02fYgT0vJ82tb5MZ4CZk1LRGkktJCzg==} + + '@floating-ui/react-dom@2.1.2': + resolution: {integrity: sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.9': + resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==} + + '@img/colour@1.0.0': + resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + + '@jridgewell/gen-mapping@0.3.8': + resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} + engines: {node: '>=6.0.0'} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/set-array@1.2.1': + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.0': + resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + + '@jridgewell/trace-mapping@0.3.25': + resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + + '@next/env@15.5.7': + resolution: {integrity: sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==} + + '@next/swc-darwin-arm64@15.5.7': + resolution: {integrity: sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@15.5.7': + resolution: {integrity: sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@15.5.7': + resolution: {integrity: sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-arm64-musl@15.5.7': + resolution: {integrity: sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-x64-gnu@15.5.7': + resolution: {integrity: sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-linux-x64-musl@15.5.7': + resolution: {integrity: sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-win32-arm64-msvc@15.5.7': + resolution: {integrity: sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@15.5.7': + resolution: {integrity: sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@petamoriken/float16@3.9.2': + resolution: {integrity: sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog==} + + '@radix-ui/number@1.1.1': + resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + + '@radix-ui/primitive@1.1.2': + resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==} + + '@radix-ui/react-accessible-icon@1.1.7': + resolution: {integrity: sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==} + 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-accordion@1.2.11': + resolution: {integrity: sha512-l3W5D54emV2ues7jjeG1xcyN7S3jnK3zE2zHqgn0CmMsy9lNJwmgcrmaxS+7ipw15FAivzKNzH3d5EcGoFKw0A==} + 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-alert-dialog@1.1.14': + resolution: {integrity: sha512-IOZfZ3nPvN6lXpJTBCunFQPRSvK8MDgSc1FB85xnIpUKOw9en0dJj8JmCAxV7BiZdtYlUpmrQjoTFkVYtdoWzQ==} + 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-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + 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-aspect-ratio@1.1.7': + resolution: {integrity: sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==} + 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-avatar@1.1.10': + resolution: {integrity: sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==} + 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-checkbox@1.3.2': + resolution: {integrity: sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA==} + 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.11': + resolution: {integrity: sha512-2qrRsVGSCYasSz1RFOorXwl0H7g7J1frQtgpQgYrt+MOidtPAINHn9CPovQXb83r8ahapdx3Tu0fa/pdFFSdPg==} + 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-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + 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-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context-menu@2.2.15': + resolution: {integrity: sha512-UsQUMjcYTsBjTSXw0P3GO0werEQvUY2plgRQuKoCTtkNr45q1DiL51j4m7gxhABzZ0BadoXNsIbg7F3KwiUBbw==} + 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-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.14': + resolution: {integrity: sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==} + 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-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.10': + resolution: {integrity: sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==} + 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-dropdown-menu@2.1.15': + resolution: {integrity: sha512-mIBnOjgwo9AH3FyKaSWoSu/dYj6VdhJ7frEPiGTeXCdUFHjl9h3mFh2wwhEtINOmYXWhdpf1rY2minFsmaNgVQ==} + 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-focus-guards@1.1.2': + resolution: {integrity: sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + 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-form@0.1.7': + resolution: {integrity: sha512-IXLKFnaYvFg/KkeV5QfOX7tRnwHXp127koOFUjLWMTrRv5Rny3DQcAtIFFeA/Cli4HHM8DuJCXAUsgnFVJndlw==} + 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-hover-card@1.1.14': + resolution: {integrity: sha512-CPYZ24Mhirm+g6D8jArmLzjYu4Eyg3TTUHswR26QgzXBHBe64BO/RHOJKzmF/Dxb4y4f9PKyJdwm/O/AhNkb+Q==} + 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-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.7': + resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==} + 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-menu@2.1.15': + resolution: {integrity: sha512-tVlmA3Vb9n8SZSd+YSbuFR66l87Wiy4du+YE+0hzKQEANA+7cWKH1WgqcEX4pXqxUFQKrWQGHdvEfw00TjFiew==} + 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-menubar@1.1.15': + resolution: {integrity: sha512-Z71C7LGD+YDYo3TV81paUs8f3Zbmkvg6VLRQpKYfzioOE6n7fOhA3ApK/V/2Odolxjoc4ENk8AYCjohCNayd5A==} + 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-navigation-menu@1.2.13': + resolution: {integrity: sha512-WG8wWfDiJlSF5hELjwfjSGOXcBR/ZMhBFCGYe8vERpC39CQYZeq1PQ2kaYHdye3V95d06H89KGMsVCIE4LWo3g==} + 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-one-time-password-field@0.1.7': + resolution: {integrity: sha512-w1vm7AGI8tNXVovOK7TYQHrAGpRF7qQL+ENpT1a743De5Zmay2RbWGKAiYDKIyIuqptns+znCKwNztE2xl1n0Q==} + 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-password-toggle-field@0.1.2': + resolution: {integrity: sha512-F90uYnlBsLPU1UbSLciLsWQmk8+hdWa6SFw4GXaIdNWxFxI5ITKVdAG64f+Twaa9ic6xE7pqxPyUmodrGjT4pQ==} + 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-popover@1.1.14': + resolution: {integrity: sha512-ODz16+1iIbGUfFEfKx2HTPKizg2MN39uIOV8MXeHnmdd3i/N9Wt7vU46wbHsqA0xoaQyXVcs0KIlBdOA2Y95bw==} + 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-popper@1.2.7': + resolution: {integrity: sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==} + 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-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + 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-presence@1.1.4': + resolution: {integrity: sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==} + 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-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + 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-progress@1.1.7': + resolution: {integrity: sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==} + 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-radio-group@1.3.7': + resolution: {integrity: sha512-9w5XhD0KPOrm92OTTE0SysH3sYzHsSTHNvZgUBo/VZ80VdYyB5RneDbc0dKpURS24IxkoFRu/hI0i4XyfFwY6g==} + 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-roving-focus@1.1.10': + resolution: {integrity: sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q==} + 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-scroll-area@1.2.9': + resolution: {integrity: sha512-YSjEfBXnhUELsO2VzjdtYYD4CfQjvao+lhhrX5XsHD7/cyUNzljF1FHEbgTPN7LH2MClfwRMIsYlqTYpKTTe2A==} + 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-select@2.2.5': + resolution: {integrity: sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA==} + 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-separator@1.1.7': + resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==} + 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-slider@1.3.5': + resolution: {integrity: sha512-rkfe2pU2NBAYfGaxa3Mqosi7VZEWX5CxKaanRv0vZd4Zhl9fvQrg0VM93dv3xGLGfrHuoTRF3JXH8nb9g+B3fw==} + 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-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-switch@1.2.5': + resolution: {integrity: sha512-5ijLkak6ZMylXsaImpZ8u4Rlf5grRmoc0p0QeX9VJtlrM4f5m3nCTX8tWga/zOA8PZYIR/t0p2Mnvd7InrJ6yQ==} + 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-tabs@1.1.12': + resolution: {integrity: sha512-GTVAlRVrQrSw3cEARM0nAx73ixrWDPNZAruETn3oHCNP6SbZ/hNxdxp+u7VkIEv3/sFoLq1PfcHrl7Pnp0CDpw==} + 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-toast@1.2.14': + resolution: {integrity: sha512-nAP5FBxBJGQ/YfUB+r+O6USFVkWq3gAInkxyEnmvEV5jtSbfDhfa4hwX8CraCnbjMLsE7XSf/K75l9xXY7joWg==} + 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-toggle-group@1.1.10': + resolution: {integrity: sha512-kiU694Km3WFLTC75DdqgM/3Jauf3rD9wxeS9XtyWFKsBUeZA337lC+6uUazT7I1DhanZ5gyD5Stf8uf2dbQxOQ==} + 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-toggle@1.1.9': + resolution: {integrity: sha512-ZoFkBBz9zv9GWer7wIjvdRxmh2wyc2oKWw6C6CseWd6/yq1DK/l5lJ+wnsmFwJZbBYqr02mrf8A2q/CVCuM3ZA==} + 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-toolbar@1.1.10': + resolution: {integrity: sha512-jiwQsduEL++M4YBIurjSa+voD86OIytCod0/dbIxFZDLD8NfO1//keXYMfsW8BPcfqwoNjt+y06XcJqAb4KR7A==} + 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-tooltip@1.2.7': + resolution: {integrity: sha512-Ap+fNYwKTYJ9pzqW+Xe2HtMRbQ/EeWkj2qykZ6SuEV4iS/o1bZI5ssJbk4D2r8XuDuOBVz/tIx2JObtuqU+5Zw==} + 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-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-is-hydrated@0.1.0': + resolution: {integrity: sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.3': + resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + 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/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + + '@tailwindcss/node@4.1.7': + resolution: {integrity: sha512-9rsOpdY9idRI2NH6CL4wORFY0+Q6fnx9XP9Ju+iq/0wJwGD5IByIgFmwVbyy4ymuyprj8Qh4ErxMKTUL4uNh3g==} + + '@tailwindcss/oxide-android-arm64@4.1.7': + resolution: {integrity: sha512-IWA410JZ8fF7kACus6BrUwY2Z1t1hm0+ZWNEzykKmMNM09wQooOcN/VXr0p/WJdtHZ90PvJf2AIBS/Ceqx1emg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.1.7': + resolution: {integrity: sha512-81jUw9To7fimGGkuJ2W5h3/oGonTOZKZ8C2ghm/TTxbwvfSiFSDPd6/A/KE2N7Jp4mv3Ps9OFqg2fEKgZFfsvg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.1.7': + resolution: {integrity: sha512-q77rWjEyGHV4PdDBtrzO0tgBBPlQWKY7wZK0cUok/HaGgbNKecegNxCGikuPJn5wFAlIywC3v+WMBt0PEBtwGw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.1.7': + resolution: {integrity: sha512-RfmdbbK6G6ptgF4qqbzoxmH+PKfP4KSVs7SRlTwcbRgBwezJkAO3Qta/7gDy10Q2DcUVkKxFLXUQO6J3CRvBGw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.7': + resolution: {integrity: sha512-OZqsGvpwOa13lVd1z6JVwQXadEobmesxQ4AxhrwRiPuE04quvZHWn/LnihMg7/XkN+dTioXp/VMu/p6A5eZP3g==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.7': + resolution: {integrity: sha512-voMvBTnJSfKecJxGkoeAyW/2XRToLZ227LxswLAwKY7YslG/Xkw9/tJNH+3IVh5bdYzYE7DfiaPbRkSHFxY1xA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.1.7': + resolution: {integrity: sha512-PjGuNNmJeKHnP58M7XyjJyla8LPo+RmwHQpBI+W/OxqrwojyuCQ+GUtygu7jUqTEexejZHr/z3nBc/gTiXBj4A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.1.7': + resolution: {integrity: sha512-HMs+Va+ZR3gC3mLZE00gXxtBo3JoSQxtu9lobbZd+DmfkIxR54NO7Z+UQNPsa0P/ITn1TevtFxXTpsRU7qEvWg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.1.7': + resolution: {integrity: sha512-MHZ6jyNlutdHH8rd+YTdr3QbXrHXqwIhHw9e7yXEBcQdluGwhpQY2Eku8UZK6ReLaWtQ4gijIv5QoM5eE+qlsA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.1.7': + resolution: {integrity: sha512-ANaSKt74ZRzE2TvJmUcbFQ8zS201cIPxUDm5qez5rLEwWkie2SkGtA4P+GPTj+u8N6JbPrC8MtY8RmJA35Oo+A==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.7': + resolution: {integrity: sha512-HUiSiXQ9gLJBAPCMVRk2RT1ZrBjto7WvqsPBwUrNK2BcdSxMnk19h4pjZjI7zgPhDxlAbJSumTC4ljeA9y0tEw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.1.7': + resolution: {integrity: sha512-rYHGmvoHiLJ8hWucSfSOEmdCBIGZIq7SpkPRSqLsH2Ab2YUNgKeAPT1Fi2cx3+hnYOrAb0jp9cRyode3bBW4mQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.1.7': + resolution: {integrity: sha512-5SF95Ctm9DFiUyjUPnDGkoKItPX/k+xifcQhcqX5RA85m50jw1pT/KzjdvlqxRja45Y52nR4MR9fD1JYd7f8NQ==} + engines: {node: '>= 10'} + + '@tailwindcss/postcss@4.1.7': + resolution: {integrity: sha512-88g3qmNZn7jDgrrcp3ZXEQfp9CVox7xjP1HN2TFKI03CltPVd/c61ydn5qJJL8FYunn0OqBaW5HNUga0kmPVvw==} + + '@types/node@22.15.18': + resolution: {integrity: sha512-v1DKRfUdyW+jJhZNEI1PYy29S2YRxMV5AOO/x/SjKmW0acCIOqmbj6Haf9eHAhsPmrhlHSxEhv/1WszcLWV4cg==} + + '@types/react-dom@19.1.5': + resolution: {integrity: sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg==} + peerDependencies: + '@types/react': ^19.0.0 + + '@types/react@19.1.4': + resolution: {integrity: sha512-EB1yiiYdvySuIITtD5lhW4yPyJ31RkJkkDw794LaQYrxCSaQV/47y5o1FMC4zF9ZyjUjzJMZwbovEnT5yHTW6g==} + + aria-hidden@1.2.4: + resolution: {integrity: sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==} + engines: {node: '>=10'} + + autoprefixer@10.4.21: + resolution: {integrity: sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + bcryptjs@3.0.2: + resolution: {integrity: sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==} + hasBin: true + + browserslist@4.24.5: + resolution: {integrity: sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + caniuse-lite@1.0.30001718: + resolution: {integrity: sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + + debug@4.4.1: + resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.0.4: + resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} + engines: {node: '>=8'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + dotenv@16.5.0: + resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==} + engines: {node: '>=12'} + + drizzle-kit@0.31.1: + resolution: {integrity: sha512-PUjYKWtzOzPtdtQlTHQG3qfv4Y0XT8+Eas6UbxCmxTj7qgMf+39dDujf1BP1I+qqZtw9uzwTh8jYtkMuCq+B0Q==} + hasBin: true + + drizzle-orm@0.43.1: + resolution: {integrity: sha512-dUcDaZtE/zN4RV/xqGrVSMpnEczxd5cIaoDeor7Zst9wOe/HzC/7eAaulywWGYXdDEc9oBPMjayVEDg0ziTLJA==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.155: + resolution: {integrity: sha512-ps5KcGGmwL8VaeJlvlDlu4fORQpv3+GIcF5I3f9tUKUlJ/wsysh6HU8P5L1XWRYeXfA0oJd4PyM8ds8zTFf6Ng==} + + enhanced-resolve@5.18.1: + resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==} + engines: {node: '>=10.13.0'} + + 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-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + esbuild-register@3.6.0: + resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} + peerDependencies: + esbuild: '>=0.12 <1' + + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.25.4: + resolution: {integrity: sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + fraction.js@4.3.7: + resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gel@2.1.0: + resolution: {integrity: sha512-HCeRqInCt6BjbMmeghJ6BKeYwOj7WJT5Db6IWWAA3IMUUa7or7zJfTUEkUWCxiOtoXnwnm96sFK9Fr47Yh2hOA==} + engines: {node: '>= 18.0.0'} + hasBin: true + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.10.0: + resolution: {integrity: sha512-kGzZ3LWWQcGIAmg6iWvXn0ei6WDtV26wzHRMwDSzmAbcXrTEXxHy6IehI6/4eT6VRKyMP1eF1VqwrVUmE/LR7A==} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + isexe@3.1.1: + resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==} + engines: {node: '>=16'} + + jiti@2.4.2: + resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} + hasBin: true + + jose@6.0.11: + resolution: {integrity: sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg==} + + lightningcss-darwin-arm64@1.30.1: + resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.30.1: + resolution: {integrity: sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.30.1: + resolution: {integrity: sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.30.1: + resolution: {integrity: sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.30.1: + resolution: {integrity: sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.30.1: + resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.30.1: + resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.30.1: + resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.30.1: + resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.30.1: + resolution: {integrity: sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.30.1: + resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==} + engines: {node: '>= 12.0.0'} + + lucide-react@0.511.0: + resolution: {integrity: sha512-VK5a2ydJ7xm8GvBeKLS9mu1pVK6ucef9780JVUjw6bAjJL/QXnd4Y0p7SPeOUMC27YhzNCZvm5d/QX0Tp3rc0w==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + magic-string@0.30.17: + resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.0.2: + resolution: {integrity: sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==} + engines: {node: '>= 18'} + + mkdirp@3.0.1: + resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} + engines: {node: '>=10'} + hasBin: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + next@15.5.7: + resolution: {integrity: sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==} + engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} + 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-releases@2.0.19: + resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} + + normalize-range@0.1.2: + resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + postcss@8.5.3: + resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==} + engines: {node: ^10 || ^12 || >=14} + + postgres@3.4.5: + resolution: {integrity: sha512-cDWgoah1Gez9rN3H4165peY9qfpEo+SA61oQv65O3cRUE1pOEoJWwddwcqKE8XZYjbblOJlYDlLV4h67HrEVDg==} + engines: {node: '>=12'} + + qs@6.14.0: + resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} + engines: {node: '>=0.6'} + + radix-ui@1.4.2: + resolution: {integrity: sha512-fT/3YFPJzf2WUpqDoQi005GS8EpCi+53VhcLaHUj5fwkPYiZAjk1mSxFvbMA8Uq71L03n+WysuYC+mlKkXxt/Q==} + 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 + + react-dom@19.1.2: + resolution: {integrity: sha512-dEoydsCp50i7kS1xHOmPXq4zQYoGWedUsvqv9H6zdif2r7yLHygyfP9qou71TulRN0d6ng9EbRVsQhSqfUc19g==} + peerDependencies: + react: ^19.1.2 + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.6.3: + resolution: {integrity: sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react@19.1.2: + resolution: {integrity: sha512-MdWVitvLbQULD+4DP8GYjZUrepGW7d+GQkNVqJEzNxE+e9WIa4egVFE/RDfVb1u9u/Jw7dNMmPB4IqxzbFYJ0w==} + engines: {node: '>=0.10.0'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + scheduler@0.26.0: + resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shell-quote@1.8.2: + resolution: {integrity: sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==} + engines: {node: '>= 0.4'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + stripe@18.1.0: + resolution: {integrity: sha512-MLDiniPTHqcfIT3anyBPmOEcaiDhYa7/jRaNypQ3Rt2SJnayQZBvVbFghIziUCZdltGAndm/ZxVOSw6uuSCDig==} + engines: {node: '>=12.*'} + peerDependencies: + '@types/node': '>=12.x.x' + peerDependenciesMeta: + '@types/node': + optional: true + + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + swr@2.3.3: + resolution: {integrity: sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A==} + peerDependencies: + react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + tailwind-merge@3.3.0: + resolution: {integrity: sha512-fyW/pEfcQSiigd5SNn0nApUOxx0zB/dm6UDU/rEwc2c3sX2smWUNbapHv+QRqLGVp9GWX3THIa7MUGPo+YkDzQ==} + + tailwindcss@4.1.7: + resolution: {integrity: sha512-kr1o/ErIdNhTz8uzAYL7TpaUuzKIE6QPQ4qmSdxnoX/lo+5wmUHQA6h3L5yIqEImSRnAAURDirLu/BgiXGPAhg==} + + tapable@2.2.1: + resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} + engines: {node: '>=6'} + + tar@7.4.3: + resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} + engines: {node: '>=18'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tw-animate-css@1.3.0: + resolution: {integrity: sha512-jrJ0XenzS9KVuDThJDvnhalbl4IYiMQ/XvpA0a2FL8KmlK+6CSMviO7ROY/I7z1NnUs5NnDhlM6fXmF40xPxzw==} + + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + update-browserslist-db@1.1.3: + resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.5.0: + resolution: {integrity: sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + which@4.0.0: + resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} + engines: {node: ^16.13.0 || >=18.0.0} + hasBin: true + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + + zod@3.24.4: + resolution: {integrity: sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==} + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.8 + '@jridgewell/trace-mapping': 0.3.25 + + '@drizzle-team/brocli@0.10.2': {} + + '@emnapi/runtime@1.7.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.18.20 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.10.0 + + '@esbuild/aix-ppc64@0.25.4': + optional: true + + '@esbuild/android-arm64@0.18.20': + optional: true + + '@esbuild/android-arm64@0.25.4': + optional: true + + '@esbuild/android-arm@0.18.20': + optional: true + + '@esbuild/android-arm@0.25.4': + optional: true + + '@esbuild/android-x64@0.18.20': + optional: true + + '@esbuild/android-x64@0.25.4': + optional: true + + '@esbuild/darwin-arm64@0.18.20': + optional: true + + '@esbuild/darwin-arm64@0.25.4': + optional: true + + '@esbuild/darwin-x64@0.18.20': + optional: true + + '@esbuild/darwin-x64@0.25.4': + optional: true + + '@esbuild/freebsd-arm64@0.18.20': + optional: true + + '@esbuild/freebsd-arm64@0.25.4': + optional: true + + '@esbuild/freebsd-x64@0.18.20': + optional: true + + '@esbuild/freebsd-x64@0.25.4': + optional: true + + '@esbuild/linux-arm64@0.18.20': + optional: true + + '@esbuild/linux-arm64@0.25.4': + optional: true + + '@esbuild/linux-arm@0.18.20': + optional: true + + '@esbuild/linux-arm@0.25.4': + optional: true + + '@esbuild/linux-ia32@0.18.20': + optional: true + + '@esbuild/linux-ia32@0.25.4': + optional: true + + '@esbuild/linux-loong64@0.18.20': + optional: true + + '@esbuild/linux-loong64@0.25.4': + optional: true + + '@esbuild/linux-mips64el@0.18.20': + optional: true + + '@esbuild/linux-mips64el@0.25.4': + optional: true + + '@esbuild/linux-ppc64@0.18.20': + optional: true + + '@esbuild/linux-ppc64@0.25.4': + optional: true + + '@esbuild/linux-riscv64@0.18.20': + optional: true + + '@esbuild/linux-riscv64@0.25.4': + optional: true + + '@esbuild/linux-s390x@0.18.20': + optional: true + + '@esbuild/linux-s390x@0.25.4': + optional: true + + '@esbuild/linux-x64@0.18.20': + optional: true + + '@esbuild/linux-x64@0.25.4': + optional: true + + '@esbuild/netbsd-arm64@0.25.4': + optional: true + + '@esbuild/netbsd-x64@0.18.20': + optional: true + + '@esbuild/netbsd-x64@0.25.4': + optional: true + + '@esbuild/openbsd-arm64@0.25.4': + optional: true + + '@esbuild/openbsd-x64@0.18.20': + optional: true + + '@esbuild/openbsd-x64@0.25.4': + optional: true + + '@esbuild/sunos-x64@0.18.20': + optional: true + + '@esbuild/sunos-x64@0.25.4': + optional: true + + '@esbuild/win32-arm64@0.18.20': + optional: true + + '@esbuild/win32-arm64@0.25.4': + optional: true + + '@esbuild/win32-ia32@0.18.20': + optional: true + + '@esbuild/win32-ia32@0.25.4': + optional: true + + '@esbuild/win32-x64@0.18.20': + optional: true + + '@esbuild/win32-x64@0.25.4': + optional: true + + '@floating-ui/core@1.7.0': + dependencies: + '@floating-ui/utils': 0.2.9 + + '@floating-ui/dom@1.7.0': + dependencies: + '@floating-ui/core': 1.7.0 + '@floating-ui/utils': 0.2.9 + + '@floating-ui/react-dom@2.1.2(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@floating-ui/dom': 1.7.0 + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + + '@floating-ui/utils@0.2.9': {} + + '@img/colour@1.0.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.7.1 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.2 + + '@jridgewell/gen-mapping@0.3.8': + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping': 0.3.25 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/set-array@1.2.1': {} + + '@jridgewell/sourcemap-codec@1.5.0': {} + + '@jridgewell/trace-mapping@0.3.25': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.0 + + '@next/env@15.5.7': {} + + '@next/swc-darwin-arm64@15.5.7': + optional: true + + '@next/swc-darwin-x64@15.5.7': + optional: true + + '@next/swc-linux-arm64-gnu@15.5.7': + optional: true + + '@next/swc-linux-arm64-musl@15.5.7': + optional: true + + '@next/swc-linux-x64-gnu@15.5.7': + optional: true + + '@next/swc-linux-x64-musl@15.5.7': + optional: true + + '@next/swc-win32-arm64-msvc@15.5.7': + optional: true + + '@next/swc-win32-x64-msvc@15.5.7': + optional: true + + '@petamoriken/float16@3.9.2': + optional: true + + '@radix-ui/number@1.1.1': {} + + '@radix-ui/primitive@1.1.2': {} + + '@radix-ui/react-accessible-icon@1.1.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-accordion@1.2.11(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-collapsible': 1.1.11(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.4)(react@19.1.2) + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-alert-dialog@1.1.14(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-dialog': 1.1.14(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.4)(react@19.1.2) + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-aspect-ratio@1.1.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-avatar@1.1.10(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/react-context': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.4)(react@19.1.2) + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-checkbox@1.3.2(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.4)(react@19.1.2) + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-collapsible@1.1.11(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.4)(react@19.1.2) + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.4)(react@19.1.2) + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.1.4)(react@19.1.2)': + dependencies: + react: 19.1.2 + optionalDependencies: + '@types/react': 19.1.4 + + '@radix-ui/react-context-menu@2.2.15(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-context': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-menu': 2.1.15(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.4)(react@19.1.2) + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-context@1.1.2(@types/react@19.1.4)(react@19.1.2)': + dependencies: + react: 19.1.2 + optionalDependencies: + '@types/react': 19.1.4 + + '@radix-ui/react-dialog@1.1.14(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.4)(react@19.1.2) + aria-hidden: 1.2.4 + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + react-remove-scroll: 2.6.3(@types/react@19.1.4)(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-direction@1.1.1(@types/react@19.1.4)(react@19.1.2)': + dependencies: + react: 19.1.2 + optionalDependencies: + '@types/react': 19.1.4 + + '@radix-ui/react-dismissable-layer@1.1.10(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.1.4)(react@19.1.2) + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-dropdown-menu@2.1.15(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-menu': 2.1.15(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.4)(react@19.1.2) + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-focus-guards@1.1.2(@types/react@19.1.4)(react@19.1.2)': + dependencies: + react: 19.1.2 + optionalDependencies: + '@types/react': 19.1.4 + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.4)(react@19.1.2) + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-form@0.1.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-label': 2.1.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-hover-card@1.1.14(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-popper': 1.2.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.4)(react@19.1.2) + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-id@1.1.1(@types/react@19.1.4)(react@19.1.2)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.4)(react@19.1.2) + react: 19.1.2 + optionalDependencies: + '@types/react': 19.1.4 + + '@radix-ui/react-label@2.1.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-menu@2.1.15(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-popper': 1.2.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-roving-focus': 1.1.10(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.4)(react@19.1.2) + aria-hidden: 1.2.4 + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + react-remove-scroll: 2.6.3(@types/react@19.1.4)(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-menubar@1.1.15(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-menu': 2.1.15(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-roving-focus': 1.1.10(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.4)(react@19.1.2) + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-navigation-menu@1.2.13(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-one-time-password-field@0.1.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-roving-focus': 1.1.10(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.4)(react@19.1.2) + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-password-toggle-field@0.1.2(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.1.4)(react@19.1.2) + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-popover@1.1.14(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-popper': 1.2.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.4)(react@19.1.2) + aria-hidden: 1.2.4 + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + react-remove-scroll: 2.6.3(@types/react@19.1.4)(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-popper@1.2.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@floating-ui/react-dom': 2.1.2(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/rect': 1.1.1 + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.4)(react@19.1.2) + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-presence@1.1.4(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.4)(react@19.1.2) + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.4)(react@19.1.2) + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-progress@1.1.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/react-context': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-radio-group@1.3.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-roving-focus': 1.1.10(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.4)(react@19.1.2) + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-roving-focus@1.1.10(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.4)(react@19.1.2) + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-scroll-area@1.2.9(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.4)(react@19.1.2) + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-select@2.2.5(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-popper': 1.2.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + aria-hidden: 1.2.4 + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + react-remove-scroll: 2.6.3(@types/react@19.1.4)(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-separator@1.1.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-slider@1.3.5(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.4)(react@19.1.2) + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-slot@1.2.3(@types/react@19.1.4)(react@19.1.2)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.4)(react@19.1.2) + react: 19.1.2 + optionalDependencies: + '@types/react': 19.1.4 + + '@radix-ui/react-switch@1.2.5(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.4)(react@19.1.2) + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-tabs@1.1.12(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-context': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-roving-focus': 1.1.10(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.4)(react@19.1.2) + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-toast@1.2.14(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-toggle-group@1.1.10(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-context': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-roving-focus': 1.1.10(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-toggle': 1.1.9(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.4)(react@19.1.2) + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-toggle@1.1.9(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.4)(react@19.1.2) + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-toolbar@1.1.10(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-context': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-roving-focus': 1.1.10(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-toggle-group': 1.1.10(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-tooltip@1.2.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-popper': 1.2.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.1.4)(react@19.1.2)': + dependencies: + react: 19.1.2 + optionalDependencies: + '@types/react': 19.1.4 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.1.4)(react@19.1.2)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.4)(react@19.1.2) + react: 19.1.2 + optionalDependencies: + '@types/react': 19.1.4 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.1.4)(react@19.1.2)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.4)(react@19.1.2) + react: 19.1.2 + optionalDependencies: + '@types/react': 19.1.4 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.1.4)(react@19.1.2)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.4)(react@19.1.2) + react: 19.1.2 + optionalDependencies: + '@types/react': 19.1.4 + + '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.1.4)(react@19.1.2)': + dependencies: + react: 19.1.2 + use-sync-external-store: 1.5.0(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.1.4)(react@19.1.2)': + dependencies: + react: 19.1.2 + optionalDependencies: + '@types/react': 19.1.4 + + '@radix-ui/react-use-previous@1.1.1(@types/react@19.1.4)(react@19.1.2)': + dependencies: + react: 19.1.2 + optionalDependencies: + '@types/react': 19.1.4 + + '@radix-ui/react-use-rect@1.1.1(@types/react@19.1.4)(react@19.1.2)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 19.1.2 + optionalDependencies: + '@types/react': 19.1.4 + + '@radix-ui/react-use-size@1.1.1(@types/react@19.1.4)(react@19.1.2)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.4)(react@19.1.2) + react: 19.1.2 + optionalDependencies: + '@types/react': 19.1.4 + + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + '@radix-ui/rect@1.1.1': {} + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@tailwindcss/node@4.1.7': + dependencies: + '@ampproject/remapping': 2.3.0 + enhanced-resolve: 5.18.1 + jiti: 2.4.2 + lightningcss: 1.30.1 + magic-string: 0.30.17 + source-map-js: 1.2.1 + tailwindcss: 4.1.7 + + '@tailwindcss/oxide-android-arm64@4.1.7': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.1.7': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.1.7': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.1.7': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.7': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.7': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.1.7': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.1.7': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.1.7': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.1.7': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.7': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.1.7': + optional: true + + '@tailwindcss/oxide@4.1.7': + dependencies: + detect-libc: 2.0.4 + tar: 7.4.3 + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.1.7 + '@tailwindcss/oxide-darwin-arm64': 4.1.7 + '@tailwindcss/oxide-darwin-x64': 4.1.7 + '@tailwindcss/oxide-freebsd-x64': 4.1.7 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.7 + '@tailwindcss/oxide-linux-arm64-gnu': 4.1.7 + '@tailwindcss/oxide-linux-arm64-musl': 4.1.7 + '@tailwindcss/oxide-linux-x64-gnu': 4.1.7 + '@tailwindcss/oxide-linux-x64-musl': 4.1.7 + '@tailwindcss/oxide-wasm32-wasi': 4.1.7 + '@tailwindcss/oxide-win32-arm64-msvc': 4.1.7 + '@tailwindcss/oxide-win32-x64-msvc': 4.1.7 + + '@tailwindcss/postcss@4.1.7': + dependencies: + '@alloc/quick-lru': 5.2.0 + '@tailwindcss/node': 4.1.7 + '@tailwindcss/oxide': 4.1.7 + postcss: 8.5.3 + tailwindcss: 4.1.7 + + '@types/node@22.15.18': + dependencies: + undici-types: 6.21.0 + + '@types/react-dom@19.1.5(@types/react@19.1.4)': + dependencies: + '@types/react': 19.1.4 + + '@types/react@19.1.4': + dependencies: + csstype: 3.1.3 + + aria-hidden@1.2.4: + dependencies: + tslib: 2.8.1 + + autoprefixer@10.4.21(postcss@8.5.3): + dependencies: + browserslist: 4.24.5 + caniuse-lite: 1.0.30001718 + fraction.js: 4.3.7 + normalize-range: 0.1.2 + picocolors: 1.1.1 + postcss: 8.5.3 + postcss-value-parser: 4.2.0 + + bcryptjs@3.0.2: {} + + browserslist@4.24.5: + dependencies: + caniuse-lite: 1.0.30001718 + electron-to-chromium: 1.5.155 + node-releases: 2.0.19 + update-browserslist-db: 1.1.3(browserslist@4.24.5) + + buffer-from@1.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + caniuse-lite@1.0.30001718: {} + + chownr@3.0.0: {} + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + client-only@0.0.1: {} + + clsx@2.1.1: {} + + csstype@3.1.3: {} + + debug@4.4.1: + dependencies: + ms: 2.1.3 + + dequal@2.0.3: {} + + detect-libc@2.0.4: {} + + detect-libc@2.1.2: + optional: true + + detect-node-es@1.1.0: {} + + dotenv@16.5.0: {} + + drizzle-kit@0.31.1: + dependencies: + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.4 + esbuild-register: 3.6.0(esbuild@0.25.4) + transitivePeerDependencies: + - supports-color + + drizzle-orm@0.43.1(gel@2.1.0)(postgres@3.4.5): + optionalDependencies: + gel: 2.1.0 + postgres: 3.4.5 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.155: {} + + enhanced-resolve@5.18.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.2.1 + + env-paths@3.0.0: + optional: true + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + esbuild-register@3.6.0(esbuild@0.25.4): + dependencies: + debug: 4.4.1 + esbuild: 0.25.4 + transitivePeerDependencies: + - supports-color + + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + + esbuild@0.25.4: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.4 + '@esbuild/android-arm': 0.25.4 + '@esbuild/android-arm64': 0.25.4 + '@esbuild/android-x64': 0.25.4 + '@esbuild/darwin-arm64': 0.25.4 + '@esbuild/darwin-x64': 0.25.4 + '@esbuild/freebsd-arm64': 0.25.4 + '@esbuild/freebsd-x64': 0.25.4 + '@esbuild/linux-arm': 0.25.4 + '@esbuild/linux-arm64': 0.25.4 + '@esbuild/linux-ia32': 0.25.4 + '@esbuild/linux-loong64': 0.25.4 + '@esbuild/linux-mips64el': 0.25.4 + '@esbuild/linux-ppc64': 0.25.4 + '@esbuild/linux-riscv64': 0.25.4 + '@esbuild/linux-s390x': 0.25.4 + '@esbuild/linux-x64': 0.25.4 + '@esbuild/netbsd-arm64': 0.25.4 + '@esbuild/netbsd-x64': 0.25.4 + '@esbuild/openbsd-arm64': 0.25.4 + '@esbuild/openbsd-x64': 0.25.4 + '@esbuild/sunos-x64': 0.25.4 + '@esbuild/win32-arm64': 0.25.4 + '@esbuild/win32-ia32': 0.25.4 + '@esbuild/win32-x64': 0.25.4 + + escalade@3.2.0: {} + + fraction.js@4.3.7: {} + + function-bind@1.1.2: {} + + gel@2.1.0: + dependencies: + '@petamoriken/float16': 3.9.2 + debug: 4.4.1 + env-paths: 3.0.0 + semver: 7.7.3 + shell-quote: 1.8.2 + which: 4.0.0 + transitivePeerDependencies: + - supports-color + optional: true + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-nonce@1.0.1: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-tsconfig@4.10.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-symbols@1.1.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + isexe@3.1.1: + optional: true + + jiti@2.4.2: {} + + jose@6.0.11: {} + + lightningcss-darwin-arm64@1.30.1: + optional: true + + lightningcss-darwin-x64@1.30.1: + optional: true + + lightningcss-freebsd-x64@1.30.1: + optional: true + + lightningcss-linux-arm-gnueabihf@1.30.1: + optional: true + + lightningcss-linux-arm64-gnu@1.30.1: + optional: true + + lightningcss-linux-arm64-musl@1.30.1: + optional: true + + lightningcss-linux-x64-gnu@1.30.1: + optional: true + + lightningcss-linux-x64-musl@1.30.1: + optional: true + + lightningcss-win32-arm64-msvc@1.30.1: + optional: true + + lightningcss-win32-x64-msvc@1.30.1: + optional: true + + lightningcss@1.30.1: + dependencies: + detect-libc: 2.0.4 + optionalDependencies: + lightningcss-darwin-arm64: 1.30.1 + lightningcss-darwin-x64: 1.30.1 + lightningcss-freebsd-x64: 1.30.1 + lightningcss-linux-arm-gnueabihf: 1.30.1 + lightningcss-linux-arm64-gnu: 1.30.1 + lightningcss-linux-arm64-musl: 1.30.1 + lightningcss-linux-x64-gnu: 1.30.1 + lightningcss-linux-x64-musl: 1.30.1 + lightningcss-win32-arm64-msvc: 1.30.1 + lightningcss-win32-x64-msvc: 1.30.1 + + lucide-react@0.511.0(react@19.1.2): + dependencies: + react: 19.1.2 + + magic-string@0.30.17: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 + + math-intrinsics@1.1.0: {} + + minipass@7.1.2: {} + + minizlib@3.0.2: + dependencies: + minipass: 7.1.2 + + mkdirp@3.0.1: {} + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + next@15.5.7(react-dom@19.1.2(react@19.1.2))(react@19.1.2): + dependencies: + '@next/env': 15.5.7 + '@swc/helpers': 0.5.15 + caniuse-lite: 1.0.30001718 + postcss: 8.4.31 + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + styled-jsx: 5.1.6(react@19.1.2) + optionalDependencies: + '@next/swc-darwin-arm64': 15.5.7 + '@next/swc-darwin-x64': 15.5.7 + '@next/swc-linux-arm64-gnu': 15.5.7 + '@next/swc-linux-arm64-musl': 15.5.7 + '@next/swc-linux-x64-gnu': 15.5.7 + '@next/swc-linux-x64-musl': 15.5.7 + '@next/swc-win32-arm64-msvc': 15.5.7 + '@next/swc-win32-x64-msvc': 15.5.7 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + node-releases@2.0.19: {} + + normalize-range@0.1.2: {} + + object-inspect@1.13.4: {} + + picocolors@1.1.1: {} + + postcss-value-parser@4.2.0: {} + + postcss@8.4.31: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postcss@8.5.3: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postgres@3.4.5: {} + + qs@6.14.0: + dependencies: + side-channel: 1.1.0 + + radix-ui@1.4.2(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2): + dependencies: + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-accessible-icon': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-accordion': 1.2.11(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-alert-dialog': 1.1.14(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-aspect-ratio': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-avatar': 1.1.10(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-checkbox': 1.3.2(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-collapsible': 1.1.11(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-context-menu': 2.2.15(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-dialog': 1.1.14(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-direction': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-dismissable-layer': 1.1.10(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-dropdown-menu': 2.1.15(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-focus-guards': 1.1.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-form': 0.1.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-hover-card': 1.1.14(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-label': 2.1.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-menu': 2.1.15(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-menubar': 1.1.15(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-navigation-menu': 1.2.13(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-one-time-password-field': 0.1.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-password-toggle-field': 0.1.2(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-popover': 1.1.14(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-popper': 1.2.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-presence': 1.1.4(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-progress': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-radio-group': 1.3.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-roving-focus': 1.1.10(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-scroll-area': 1.2.9(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-select': 2.2.5(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-slider': 1.3.5(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-switch': 1.2.5(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-tabs': 1.1.12(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-toast': 1.2.14(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-toggle': 1.1.9(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-toggle-group': 1.1.10(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-toolbar': 1.1.10(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-tooltip': 1.2.7(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.1.4)(react@19.1.2) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.5(@types/react@19.1.4))(@types/react@19.1.4)(react-dom@19.1.2(react@19.1.2))(react@19.1.2) + react: 19.1.2 + react-dom: 19.1.2(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + '@types/react-dom': 19.1.5(@types/react@19.1.4) + + react-dom@19.1.2(react@19.1.2): + dependencies: + react: 19.1.2 + scheduler: 0.26.0 + + react-remove-scroll-bar@2.3.8(@types/react@19.1.4)(react@19.1.2): + dependencies: + react: 19.1.2 + react-style-singleton: 2.2.3(@types/react@19.1.4)(react@19.1.2) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.1.4 + + react-remove-scroll@2.6.3(@types/react@19.1.4)(react@19.1.2): + dependencies: + react: 19.1.2 + react-remove-scroll-bar: 2.3.8(@types/react@19.1.4)(react@19.1.2) + react-style-singleton: 2.2.3(@types/react@19.1.4)(react@19.1.2) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.1.4)(react@19.1.2) + use-sidecar: 1.1.3(@types/react@19.1.4)(react@19.1.2) + optionalDependencies: + '@types/react': 19.1.4 + + react-style-singleton@2.2.3(@types/react@19.1.4)(react@19.1.2): + dependencies: + get-nonce: 1.0.1 + react: 19.1.2 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.1.4 + + react@19.1.2: {} + + resolve-pkg-maps@1.0.0: {} + + scheduler@0.26.0: {} + + semver@7.7.3: + optional: true + + server-only@0.0.1: {} + + sharp@0.34.5: + dependencies: + '@img/colour': 1.0.0 + detect-libc: 2.1.2 + semver: 7.7.3 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + + shell-quote@1.8.2: + optional: true + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + stripe@18.1.0(@types/node@22.15.18): + dependencies: + qs: 6.14.0 + optionalDependencies: + '@types/node': 22.15.18 + + styled-jsx@5.1.6(react@19.1.2): + dependencies: + client-only: 0.0.1 + react: 19.1.2 + + swr@2.3.3(react@19.1.2): + dependencies: + dequal: 2.0.3 + react: 19.1.2 + use-sync-external-store: 1.5.0(react@19.1.2) + + tailwind-merge@3.3.0: {} + + tailwindcss@4.1.7: {} + + tapable@2.2.1: {} + + tar@7.4.3: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.2 + minizlib: 3.0.2 + mkdirp: 3.0.1 + yallist: 5.0.0 + + tslib@2.8.1: {} + + tw-animate-css@1.3.0: {} + + typescript@5.8.3: {} + + undici-types@6.21.0: {} + + update-browserslist-db@1.1.3(browserslist@4.24.5): + dependencies: + browserslist: 4.24.5 + escalade: 3.2.0 + picocolors: 1.1.1 + + use-callback-ref@1.3.3(@types/react@19.1.4)(react@19.1.2): + dependencies: + react: 19.1.2 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.1.4 + + use-sidecar@1.1.3(@types/react@19.1.4)(react@19.1.2): + dependencies: + detect-node-es: 1.1.0 + react: 19.1.2 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.1.4 + + use-sync-external-store@1.5.0(react@19.1.2): + dependencies: + react: 19.1.2 + + which@4.0.0: + dependencies: + isexe: 3.1.1 + optional: true + + yallist@5.0.0: {} + + zod@3.24.4: {} diff --git a/apps/feature-flags/next-js/15-app-router-saas/postcss.config.mjs b/apps/feature-flags/next-js/15-app-router-saas/postcss.config.mjs new file mode 100644 index 000000000..a34a3d560 --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; diff --git a/apps/feature-flags/next-js/15-app-router-saas/tsconfig.json b/apps/feature-flags/next-js/15-app-router-saas/tsconfig.json new file mode 100644 index 000000000..f969d125d --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/tsconfig.json @@ -0,0 +1,41 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "baseUrl": ".", + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": [ + "./*" + ] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/apps/manifest.json b/apps/manifest.json index 57f1b86fb..32eb15e4b 100644 --- a/apps/manifest.json +++ b/apps/manifest.json @@ -93,6 +93,13 @@ "label": "Error Tracking", "description": "Set up PostHog error tracking; installs the SDK first if the project doesn't have it, and wires source-map upload where the platform needs it", "ciCapable": false + }, + { + "id": "feature-flags", + "dir": "feature-flags", + "label": "Feature Flags", + "description": "Create an example PostHog feature flag for each side of the app (frontend, backend) and wire one correct evaluation of each; installs the SDKs first if the project doesn't have them", + "ciCapable": true } ] } diff --git a/package.json b/package.json index 1d7d4a00c..484a80c3c 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "type": "module", "scripts": { "evaluate": "tsx services/pr-evaluator/index.ts", + "gateway-token": "tsx services/gateway-token/index.ts", "wizard-ci": "tsx services/wizard-ci/index.ts", "wizard-ci-snapshots": "tsx services/wizard-ci/snapshots.ts", "wizard-ci-snapshots-review": "tsx services/wizard-ci/snapshots-review.ts", @@ -14,7 +15,8 @@ "mcp-stub:record": "tsx services/mcp-stub/record.ts", "test:evaluator": "tsx --test services/pr-evaluator/evaluator.test.ts", "test:mcp-stub": "tsx --test services/mcp-stub/mcp-stub.test.ts", - "test:warehouse-checks": "tsx --test services/wizard-ci/warehouse-checks.test.ts" + "test:warehouse-checks": "tsx --test services/wizard-ci/warehouse-checks.test.ts", + "test:gateway-token": "tsx --test services/gateway-token/gateway-token.test.ts" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "0.2.73", diff --git a/services/gateway-token/gateway-token.test.ts b/services/gateway-token/gateway-token.test.ts new file mode 100644 index 000000000..5d7c5a923 --- /dev/null +++ b/services/gateway-token/gateway-token.test.ts @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, beforeEach, describe, it } from "node:test"; + +import { ensureGatewayToken, type MintRequest, type SavedGatewayToken } from "./index.js"; + +const NOW_MS = 1_800_000_000_000; +const GATEWAY_URL = "https://ai-gateway.us.posthog.com"; + +function saveToken(tokenFile: string, saved: SavedGatewayToken): void { + mkdirSync(dirname(tokenFile), { recursive: true }); + writeFileSync(tokenFile, "phe_saved"); + writeFileSync(`${tokenFile}.json`, JSON.stringify(saved)); +} + +describe("ensureGatewayToken", () => { + let workDir: string; + let tokenFile: string; + let mintRequests: MintRequest[]; + + const recordingMint = async (request: MintRequest) => { + mintRequests.push(request); + saveToken(request.tokenFile, { + program: request.program, + projectId: Number(request.projectId), + gatewayUrl: GATEWAY_URL, + refreshAtMs: NOW_MS + 60_000, + }); + }; + + const options = (overrides: Partial[0]> = {}) => ({ + program: "feature-flags", + projectId: "483112", + tokenFile, + wizardPath: "/wizard", + now: () => NOW_MS, + mintToken: recordingMint, + ...overrides, + }); + + beforeEach(() => { + workDir = mkdtempSync(join(tmpdir(), "gateway-token-test-")); + tokenFile = join(workDir, "posthog", "wizard-gateway-token"); + mintRequests = []; + }); + + afterEach(() => rmSync(workDir, { recursive: true, force: true })); + + it("mints through the wizard login when no token is saved", async () => { + const token = await ensureGatewayToken(options()); + + assert.deepEqual(mintRequests, [ + { program: "feature-flags", projectId: "483112", tokenFile, wizardPath: "/wizard" }, + ]); + assert.deepEqual(token, { tokenFile, gatewayUrl: GATEWAY_URL }); + }); + + it("reuses a saved token for the same program and project before its refresh time", async () => { + saveToken(tokenFile, { program: "feature-flags", projectId: 483112, gatewayUrl: GATEWAY_URL, refreshAtMs: NOW_MS + 1 }); + + await ensureGatewayToken(options()); + + assert.equal(mintRequests.length, 0); + }); + + it("re-mints once the saved token is past its refresh time", async () => { + saveToken(tokenFile, { program: "feature-flags", projectId: 483112, gatewayUrl: GATEWAY_URL, refreshAtMs: NOW_MS }); + + await ensureGatewayToken(options()); + + assert.equal(mintRequests.length, 1); + }); + + it("re-mints when the saved token was minted for another program", async () => { + saveToken(tokenFile, { program: "error-tracking", projectId: 483112, gatewayUrl: GATEWAY_URL, refreshAtMs: NOW_MS + 60_000 }); + + await ensureGatewayToken(options()); + + assert.equal(mintRequests[0]?.program, "feature-flags"); + }); + + it("re-mints when the saved token belongs to another project", async () => { + saveToken(tokenFile, { program: "feature-flags", projectId: 1, gatewayUrl: GATEWAY_URL, refreshAtMs: NOW_MS + 60_000 }); + + await ensureGatewayToken(options()); + + assert.equal(mintRequests.length, 1); + }); + + it("fails when the mint leaves no usable token behind", async () => { + await assert.rejects( + ensureGatewayToken(options({ mintToken: async () => undefined })), + /did not leave a usable token for feature-flags/, + ); + }); +}); diff --git a/services/gateway-token/index.ts b/services/gateway-token/index.ts new file mode 100644 index 000000000..5ae9b4bc6 --- /dev/null +++ b/services/gateway-token/index.ts @@ -0,0 +1,138 @@ +import "dotenv/config"; +import { spawn } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +export const DEFAULT_GATEWAY_TOKEN_FILE = join( + homedir(), + ".config", + "posthog", + "wizard-gateway-token", +); + +export interface SavedGatewayToken { + program: string; + projectId: number; + gatewayUrl: string; + refreshAtMs: number; +} + +export interface GatewayToken { + tokenFile: string; + gatewayUrl: string; +} + +export interface GatewayTokenOptions { + program: string; + projectId: string; + tokenFile?: string; + wizardPath?: string; + refresh?: boolean; + now?: () => number; + mintToken?: (request: MintRequest) => Promise; +} + +export interface MintRequest { + program: string; + projectId: string; + tokenFile: string; + wizardPath: string; +} + +export function readSavedGatewayToken(tokenFile: string): SavedGatewayToken | undefined { + const metadataFile = `${tokenFile}.json`; + if (!existsSync(tokenFile) || !existsSync(metadataFile)) return undefined; + if (!readFileSync(tokenFile, "utf8").trim()) return undefined; + try { + return JSON.parse(readFileSync(metadataFile, "utf8")) as SavedGatewayToken; + } catch { + return undefined; + } +} + +export function isSavedTokenUsable( + saved: SavedGatewayToken | undefined, + program: string, + projectId: string, + nowMs: number, +): saved is SavedGatewayToken { + if (!saved) return false; + if (saved.program !== program) return false; + if (String(saved.projectId) !== projectId) return false; + return nowMs < saved.refreshAtMs; +} + +function resolveWizardPath(wizardPath: string | undefined): string { + const configured = wizardPath ?? process.env.WIZARD_PATH; + if (!configured) throw new Error("WIZARD_PATH must point at the wizard repo."); + return configured.replace(/^~/, homedir()); +} + +function mintWithWizardLogin(request: MintRequest): Promise { + const tsx = join(request.wizardPath, "node_modules", ".bin", "tsx"); + const child = spawn(tsx, ["scripts/mint-gateway-token.no-jest.ts"], { + cwd: request.wizardPath, + stdio: "inherit", + env: { + ...process.env, + PROGRAM: request.program, + PROJECT_ID: request.projectId, + TOKEN_FILE: request.tokenFile, + }, + }); + return new Promise((resolve, reject) => { + child.on("error", reject); + child.on("close", (code) => + code === 0 ? resolve() : reject(new Error(`gateway token mint exited with code ${code}`)), + ); + }); +} + +export async function ensureGatewayToken(options: GatewayTokenOptions): Promise { + const tokenFile = options.tokenFile ?? DEFAULT_GATEWAY_TOKEN_FILE; + const nowMs = (options.now ?? Date.now)(); + const saved = readSavedGatewayToken(tokenFile); + if (!options.refresh && isSavedTokenUsable(saved, options.program, options.projectId, nowMs)) { + return { tokenFile, gatewayUrl: saved.gatewayUrl }; + } + + const mintToken = options.mintToken ?? mintWithWizardLogin; + await mintToken({ + program: options.program, + projectId: options.projectId, + tokenFile, + wizardPath: resolveWizardPath(options.wizardPath), + }); + + const minted = readSavedGatewayToken(tokenFile); + if (!isSavedTokenUsable(minted, options.program, options.projectId, nowMs)) { + throw new Error(`the mint did not leave a usable token for ${options.program} in ${tokenFile}`); + } + return { tokenFile, gatewayUrl: minted.gatewayUrl }; +} + +function readFlag(name: string): string | undefined { + const index = process.argv.indexOf(`--${name}`); + return index === -1 ? undefined : process.argv[index + 1]; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const projectId = readFlag("project-id") ?? process.env.POSTHOG_WIZARD_PROJECT_ID; + const program = readFlag("program") ?? "posthog-integration"; + if (!projectId) { + console.error("✖ project id required: --project-id or POSTHOG_WIZARD_PROJECT_ID."); + process.exit(2); + } + ensureGatewayToken({ + program, + projectId, + tokenFile: process.env.WIZARD_CI_GATEWAY_TOKEN_FILE, + refresh: process.argv.includes("--refresh"), + }) + .then(({ tokenFile }) => console.log(`✓ gateway token for ${program} ready: ${tokenFile}`)) + .catch((error: Error) => { + console.error(`✖ ${error.message}`); + process.exit(1); + }); +} diff --git a/services/wizard-ci/e2e.ts b/services/wizard-ci/e2e.ts index 9229e71f7..e877f23b4 100644 --- a/services/wizard-ci/e2e.ts +++ b/services/wizard-ci/e2e.ts @@ -22,6 +22,7 @@ import { readdirSync, statSync, } from "fs"; +import { ensureGatewayToken } from "../gateway-token/index.js"; import { loadFixtures } from "../mcp-stub/fixtures.js"; import { startMcpStub, type McpStub } from "../mcp-stub/index.js"; import { readJournal } from "../mcp-stub/journal.js"; @@ -247,6 +248,18 @@ export async function runE2e(opts: E2eOptions): Promise { return 2; } + let gatewayToken: { tokenFile: string; gatewayUrl: string }; + try { + gatewayToken = await ensureGatewayToken({ + program: opts.program ?? "posthog-integration", + projectId, + tokenFile: process.env.WIZARD_CI_GATEWAY_TOKEN_FILE, + }); + } catch (error) { + console.error(`✖ gateway token: ${(error as Error).message}`); + return 2; + } + // A scenario may run against a sibling app's source tree (`sourceApp`), so a // run variation gets its own matrix leg without a second copy of the fixture. const expect = loadExpect(APPS_DIR, app); @@ -292,6 +305,8 @@ export async function runE2e(opts: E2eOptions): Promise { for (const k of Object.keys(childEnv)) if (STRIP_HOST_AUTH.test(k)) delete childEnv[k]; childEnv.POSTHOG_PERSONAL_API_KEY = apiKey; + childEnv.WIZARD_CI_GATEWAY_TOKEN_FILE = gatewayToken.tokenFile; + childEnv.WIZARD_CI_GATEWAY_URL = gatewayToken.gatewayUrl; childEnv.APP_DIR = appDir; childEnv.PROJECT_ID = projectId; childEnv.POSTHOG_REGION = region; From f8c1112271387365ad8242ff465a109fc1befa1b Mon Sep 17 00:00:00 2001 From: John W Date: Wed, 23 Sep 2026 13:49:14 -0700 Subject: [PATCH 2/3] chore(wizard-ci): keep gateway token minting out of this change --e2e keeps reading the already-issued gateway token from WIZARD_CI_GATEWAY_TOKEN_FILE, as CI supplies it. --- package.json | 4 +- services/gateway-token/gateway-token.test.ts | 98 ------------- services/gateway-token/index.ts | 138 ------------------- services/wizard-ci/e2e.ts | 15 -- 4 files changed, 1 insertion(+), 254 deletions(-) delete mode 100644 services/gateway-token/gateway-token.test.ts delete mode 100644 services/gateway-token/index.ts diff --git a/package.json b/package.json index 484a80c3c..1d7d4a00c 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,6 @@ "type": "module", "scripts": { "evaluate": "tsx services/pr-evaluator/index.ts", - "gateway-token": "tsx services/gateway-token/index.ts", "wizard-ci": "tsx services/wizard-ci/index.ts", "wizard-ci-snapshots": "tsx services/wizard-ci/snapshots.ts", "wizard-ci-snapshots-review": "tsx services/wizard-ci/snapshots-review.ts", @@ -15,8 +14,7 @@ "mcp-stub:record": "tsx services/mcp-stub/record.ts", "test:evaluator": "tsx --test services/pr-evaluator/evaluator.test.ts", "test:mcp-stub": "tsx --test services/mcp-stub/mcp-stub.test.ts", - "test:warehouse-checks": "tsx --test services/wizard-ci/warehouse-checks.test.ts", - "test:gateway-token": "tsx --test services/gateway-token/gateway-token.test.ts" + "test:warehouse-checks": "tsx --test services/wizard-ci/warehouse-checks.test.ts" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "0.2.73", diff --git a/services/gateway-token/gateway-token.test.ts b/services/gateway-token/gateway-token.test.ts deleted file mode 100644 index 5d7c5a923..000000000 --- a/services/gateway-token/gateway-token.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { afterEach, beforeEach, describe, it } from "node:test"; - -import { ensureGatewayToken, type MintRequest, type SavedGatewayToken } from "./index.js"; - -const NOW_MS = 1_800_000_000_000; -const GATEWAY_URL = "https://ai-gateway.us.posthog.com"; - -function saveToken(tokenFile: string, saved: SavedGatewayToken): void { - mkdirSync(dirname(tokenFile), { recursive: true }); - writeFileSync(tokenFile, "phe_saved"); - writeFileSync(`${tokenFile}.json`, JSON.stringify(saved)); -} - -describe("ensureGatewayToken", () => { - let workDir: string; - let tokenFile: string; - let mintRequests: MintRequest[]; - - const recordingMint = async (request: MintRequest) => { - mintRequests.push(request); - saveToken(request.tokenFile, { - program: request.program, - projectId: Number(request.projectId), - gatewayUrl: GATEWAY_URL, - refreshAtMs: NOW_MS + 60_000, - }); - }; - - const options = (overrides: Partial[0]> = {}) => ({ - program: "feature-flags", - projectId: "483112", - tokenFile, - wizardPath: "/wizard", - now: () => NOW_MS, - mintToken: recordingMint, - ...overrides, - }); - - beforeEach(() => { - workDir = mkdtempSync(join(tmpdir(), "gateway-token-test-")); - tokenFile = join(workDir, "posthog", "wizard-gateway-token"); - mintRequests = []; - }); - - afterEach(() => rmSync(workDir, { recursive: true, force: true })); - - it("mints through the wizard login when no token is saved", async () => { - const token = await ensureGatewayToken(options()); - - assert.deepEqual(mintRequests, [ - { program: "feature-flags", projectId: "483112", tokenFile, wizardPath: "/wizard" }, - ]); - assert.deepEqual(token, { tokenFile, gatewayUrl: GATEWAY_URL }); - }); - - it("reuses a saved token for the same program and project before its refresh time", async () => { - saveToken(tokenFile, { program: "feature-flags", projectId: 483112, gatewayUrl: GATEWAY_URL, refreshAtMs: NOW_MS + 1 }); - - await ensureGatewayToken(options()); - - assert.equal(mintRequests.length, 0); - }); - - it("re-mints once the saved token is past its refresh time", async () => { - saveToken(tokenFile, { program: "feature-flags", projectId: 483112, gatewayUrl: GATEWAY_URL, refreshAtMs: NOW_MS }); - - await ensureGatewayToken(options()); - - assert.equal(mintRequests.length, 1); - }); - - it("re-mints when the saved token was minted for another program", async () => { - saveToken(tokenFile, { program: "error-tracking", projectId: 483112, gatewayUrl: GATEWAY_URL, refreshAtMs: NOW_MS + 60_000 }); - - await ensureGatewayToken(options()); - - assert.equal(mintRequests[0]?.program, "feature-flags"); - }); - - it("re-mints when the saved token belongs to another project", async () => { - saveToken(tokenFile, { program: "feature-flags", projectId: 1, gatewayUrl: GATEWAY_URL, refreshAtMs: NOW_MS + 60_000 }); - - await ensureGatewayToken(options()); - - assert.equal(mintRequests.length, 1); - }); - - it("fails when the mint leaves no usable token behind", async () => { - await assert.rejects( - ensureGatewayToken(options({ mintToken: async () => undefined })), - /did not leave a usable token for feature-flags/, - ); - }); -}); diff --git a/services/gateway-token/index.ts b/services/gateway-token/index.ts deleted file mode 100644 index 5ae9b4bc6..000000000 --- a/services/gateway-token/index.ts +++ /dev/null @@ -1,138 +0,0 @@ -import "dotenv/config"; -import { spawn } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; - -export const DEFAULT_GATEWAY_TOKEN_FILE = join( - homedir(), - ".config", - "posthog", - "wizard-gateway-token", -); - -export interface SavedGatewayToken { - program: string; - projectId: number; - gatewayUrl: string; - refreshAtMs: number; -} - -export interface GatewayToken { - tokenFile: string; - gatewayUrl: string; -} - -export interface GatewayTokenOptions { - program: string; - projectId: string; - tokenFile?: string; - wizardPath?: string; - refresh?: boolean; - now?: () => number; - mintToken?: (request: MintRequest) => Promise; -} - -export interface MintRequest { - program: string; - projectId: string; - tokenFile: string; - wizardPath: string; -} - -export function readSavedGatewayToken(tokenFile: string): SavedGatewayToken | undefined { - const metadataFile = `${tokenFile}.json`; - if (!existsSync(tokenFile) || !existsSync(metadataFile)) return undefined; - if (!readFileSync(tokenFile, "utf8").trim()) return undefined; - try { - return JSON.parse(readFileSync(metadataFile, "utf8")) as SavedGatewayToken; - } catch { - return undefined; - } -} - -export function isSavedTokenUsable( - saved: SavedGatewayToken | undefined, - program: string, - projectId: string, - nowMs: number, -): saved is SavedGatewayToken { - if (!saved) return false; - if (saved.program !== program) return false; - if (String(saved.projectId) !== projectId) return false; - return nowMs < saved.refreshAtMs; -} - -function resolveWizardPath(wizardPath: string | undefined): string { - const configured = wizardPath ?? process.env.WIZARD_PATH; - if (!configured) throw new Error("WIZARD_PATH must point at the wizard repo."); - return configured.replace(/^~/, homedir()); -} - -function mintWithWizardLogin(request: MintRequest): Promise { - const tsx = join(request.wizardPath, "node_modules", ".bin", "tsx"); - const child = spawn(tsx, ["scripts/mint-gateway-token.no-jest.ts"], { - cwd: request.wizardPath, - stdio: "inherit", - env: { - ...process.env, - PROGRAM: request.program, - PROJECT_ID: request.projectId, - TOKEN_FILE: request.tokenFile, - }, - }); - return new Promise((resolve, reject) => { - child.on("error", reject); - child.on("close", (code) => - code === 0 ? resolve() : reject(new Error(`gateway token mint exited with code ${code}`)), - ); - }); -} - -export async function ensureGatewayToken(options: GatewayTokenOptions): Promise { - const tokenFile = options.tokenFile ?? DEFAULT_GATEWAY_TOKEN_FILE; - const nowMs = (options.now ?? Date.now)(); - const saved = readSavedGatewayToken(tokenFile); - if (!options.refresh && isSavedTokenUsable(saved, options.program, options.projectId, nowMs)) { - return { tokenFile, gatewayUrl: saved.gatewayUrl }; - } - - const mintToken = options.mintToken ?? mintWithWizardLogin; - await mintToken({ - program: options.program, - projectId: options.projectId, - tokenFile, - wizardPath: resolveWizardPath(options.wizardPath), - }); - - const minted = readSavedGatewayToken(tokenFile); - if (!isSavedTokenUsable(minted, options.program, options.projectId, nowMs)) { - throw new Error(`the mint did not leave a usable token for ${options.program} in ${tokenFile}`); - } - return { tokenFile, gatewayUrl: minted.gatewayUrl }; -} - -function readFlag(name: string): string | undefined { - const index = process.argv.indexOf(`--${name}`); - return index === -1 ? undefined : process.argv[index + 1]; -} - -if (import.meta.url === `file://${process.argv[1]}`) { - const projectId = readFlag("project-id") ?? process.env.POSTHOG_WIZARD_PROJECT_ID; - const program = readFlag("program") ?? "posthog-integration"; - if (!projectId) { - console.error("✖ project id required: --project-id or POSTHOG_WIZARD_PROJECT_ID."); - process.exit(2); - } - ensureGatewayToken({ - program, - projectId, - tokenFile: process.env.WIZARD_CI_GATEWAY_TOKEN_FILE, - refresh: process.argv.includes("--refresh"), - }) - .then(({ tokenFile }) => console.log(`✓ gateway token for ${program} ready: ${tokenFile}`)) - .catch((error: Error) => { - console.error(`✖ ${error.message}`); - process.exit(1); - }); -} diff --git a/services/wizard-ci/e2e.ts b/services/wizard-ci/e2e.ts index e877f23b4..9229e71f7 100644 --- a/services/wizard-ci/e2e.ts +++ b/services/wizard-ci/e2e.ts @@ -22,7 +22,6 @@ import { readdirSync, statSync, } from "fs"; -import { ensureGatewayToken } from "../gateway-token/index.js"; import { loadFixtures } from "../mcp-stub/fixtures.js"; import { startMcpStub, type McpStub } from "../mcp-stub/index.js"; import { readJournal } from "../mcp-stub/journal.js"; @@ -248,18 +247,6 @@ export async function runE2e(opts: E2eOptions): Promise { return 2; } - let gatewayToken: { tokenFile: string; gatewayUrl: string }; - try { - gatewayToken = await ensureGatewayToken({ - program: opts.program ?? "posthog-integration", - projectId, - tokenFile: process.env.WIZARD_CI_GATEWAY_TOKEN_FILE, - }); - } catch (error) { - console.error(`✖ gateway token: ${(error as Error).message}`); - return 2; - } - // A scenario may run against a sibling app's source tree (`sourceApp`), so a // run variation gets its own matrix leg without a second copy of the fixture. const expect = loadExpect(APPS_DIR, app); @@ -305,8 +292,6 @@ export async function runE2e(opts: E2eOptions): Promise { for (const k of Object.keys(childEnv)) if (STRIP_HOST_AUTH.test(k)) delete childEnv[k]; childEnv.POSTHOG_PERSONAL_API_KEY = apiKey; - childEnv.WIZARD_CI_GATEWAY_TOKEN_FILE = gatewayToken.tokenFile; - childEnv.WIZARD_CI_GATEWAY_URL = gatewayToken.gatewayUrl; childEnv.APP_DIR = appDir; childEnv.PROJECT_ID = projectId; childEnv.POSTHOG_REGION = region; From 5c65ba9fe5b35c8ef5f6d8b581d4ffd6779a6483 Mon Sep 17 00:00:00 2001 From: John W Date: Wed, 23 Sep 2026 14:23:49 -0700 Subject: [PATCH 3/3] fix(feature-flags): grade the feature-flags apps only through e2e The diff evaluator has no feature-flags rubric, and the weekly sweep would run these apps through it. --- apps/manifest.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/manifest.json b/apps/manifest.json index 32eb15e4b..21130e671 100644 --- a/apps/manifest.json +++ b/apps/manifest.json @@ -99,7 +99,8 @@ "dir": "feature-flags", "label": "Feature Flags", "description": "Create an example PostHog feature flag for each side of the app (frontend, backend) and wire one correct evaluation of each; installs the SDKs first if the project doesn't have them", - "ciCapable": true + "ciCapable": true, + "e2eOnly": true } ] }