diff --git a/apps/feature-flags/django/django3-saas/.env.example b/apps/feature-flags/django/django3-saas/.env.example deleted file mode 100644 index b8e3ce5c6..000000000 --- a/apps/feature-flags/django/django3-saas/.env.example +++ /dev/null @@ -1,21 +0,0 @@ -# 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 deleted file mode 100644 index be9c7d9b8..000000000 --- a/apps/feature-flags/django/django3-saas/.gitignore +++ /dev/null @@ -1,29 +0,0 @@ -# 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/.wizard-ci/feature-flags.json b/apps/feature-flags/django/django3-saas/.wizard-ci/feature-flags.json new file mode 100644 index 000000000..f6747db61 --- /dev/null +++ b/apps/feature-flags/django/django3-saas/.wizard-ci/feature-flags.json @@ -0,0 +1 @@ +{ "flagKeys": ["wizard-example-backend-flag"] } diff --git a/apps/feature-flags/django/django3-saas/.wizard-ci/source.json b/apps/feature-flags/django/django3-saas/.wizard-ci/source.json new file mode 100644 index 000000000..03d6c740c --- /dev/null +++ b/apps/feature-flags/django/django3-saas/.wizard-ci/source.json @@ -0,0 +1 @@ +{ "sourceApp": "basic-integration/django/django3-saas" } diff --git a/apps/feature-flags/django/django3-saas/README.md b/apps/feature-flags/django/django3-saas/README.md index 6e117bfca..a66942b3e 100644 --- a/apps/feature-flags/django/django3-saas/README.md +++ b/apps/feature-flags/django/django3-saas/README.md @@ -1,131 +1,21 @@ -# Django SaaS example app +# Feature flags: Django 3 SaaS -A Django 3.0+ SaaS application for testing PostHog wizard integration. This app provides subscription billing, user authentication, and project management features. +Runs the `wizard feature-flags` e2e against the source tree of +[`basic-integration/django/django3-saas`](../../../basic-integration/django/django3-saas), +named by `sourceApp` in `.wizard-ci/source.json`. PostHog is not installed there. -## Running the app +There is no code here on purpose: a copy of the app would drift from the one it +was copied from. -### Prerequisites +Expected wizard outcome, graded by `services/wizard-ci/feature-flag-checks.ts` +against the keys in `.wizard-ci/feature-flags.json`: -- Python 3.10+ -- SQLite (included with Python, used by default) -- Stripe account (optional, app runs in demo mode without it) +- the backend example flag exists in the project, inactive at 0% rollout; the + templates hold no scripts, so there is no frontend side and no frontend flag +- the key appears in exactly one changed file, its constants module, and + another changed file imports that module +- no changed file holds a `wizard-example-*` key outside that list +- `posthog-feature-flags-report.md` exists -### 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 +The flag checks cannot prove this run created the flag: CI never deletes the +shared key, because parallel legs share it. diff --git a/apps/feature-flags/django/django3-saas/accounts/__init__.py b/apps/feature-flags/django/django3-saas/accounts/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/feature-flags/django/django3-saas/accounts/admin.py b/apps/feature-flags/django/django3-saas/accounts/admin.py deleted file mode 100644 index 6b043c786..000000000 --- a/apps/feature-flags/django/django3-saas/accounts/admin.py +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 3edb36845..000000000 --- a/apps/feature-flags/django/django3-saas/accounts/forms.py +++ /dev/null @@ -1,22 +0,0 @@ -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 deleted file mode 100644 index c4e96d1fa..000000000 --- a/apps/feature-flags/django/django3-saas/accounts/migrations/0001_initial.py +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index cc6746b56..000000000 --- a/apps/feature-flags/django/django3-saas/accounts/migrations/0002_user_email_verified_at_user_stripe_customer_id.py +++ /dev/null @@ -1,23 +0,0 @@ -# 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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/feature-flags/django/django3-saas/accounts/models.py b/apps/feature-flags/django/django3-saas/accounts/models.py deleted file mode 100644 index 747a354d4..000000000 --- a/apps/feature-flags/django/django3-saas/accounts/models.py +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 52720159b..000000000 --- a/apps/feature-flags/django/django3-saas/accounts/urls.py +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 03b8ea066..000000000 --- a/apps/feature-flags/django/django3-saas/accounts/views.py +++ /dev/null @@ -1,71 +0,0 @@ -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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/feature-flags/django/django3-saas/billing/admin.py b/apps/feature-flags/django/django3-saas/billing/admin.py deleted file mode 100644 index 489b3f002..000000000 --- a/apps/feature-flags/django/django3-saas/billing/admin.py +++ /dev/null @@ -1,117 +0,0 @@ -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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index e69de29bb..000000000 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 deleted file mode 100644 index 425ff16be..000000000 --- a/apps/feature-flags/django/django3-saas/billing/management/commands/seed_plans.py +++ /dev/null @@ -1,75 +0,0 @@ -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 deleted file mode 100644 index a7b81374e..000000000 --- a/apps/feature-flags/django/django3-saas/billing/migrations/0001_initial.py +++ /dev/null @@ -1,56 +0,0 @@ -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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/feature-flags/django/django3-saas/billing/models.py b/apps/feature-flags/django/django3-saas/billing/models.py deleted file mode 100644 index 282e2207f..000000000 --- a/apps/feature-flags/django/django3-saas/billing/models.py +++ /dev/null @@ -1,71 +0,0 @@ -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 deleted file mode 100644 index 6aa1342da..000000000 --- a/apps/feature-flags/django/django3-saas/billing/urls.py +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 075923e23..000000000 --- a/apps/feature-flags/django/django3-saas/billing/views.py +++ /dev/null @@ -1,327 +0,0 @@ -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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/feature-flags/django/django3-saas/config/settings.py b/apps/feature-flags/django/django3-saas/config/settings.py deleted file mode 100644 index 3c02dea1b..000000000 --- a/apps/feature-flags/django/django3-saas/config/settings.py +++ /dev/null @@ -1,113 +0,0 @@ -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 deleted file mode 100644 index a3d56f6d2..000000000 --- a/apps/feature-flags/django/django3-saas/config/urls.py +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index 43a79d154..000000000 --- a/apps/feature-flags/django/django3-saas/config/views.py +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 885c6e5b7..000000000 --- a/apps/feature-flags/django/django3-saas/config/wsgi.py +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/feature-flags/django/django3-saas/dashboard/admin.py b/apps/feature-flags/django/django3-saas/dashboard/admin.py deleted file mode 100644 index abd0a00c9..000000000 --- a/apps/feature-flags/django/django3-saas/dashboard/admin.py +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index 547886cb7..000000000 --- a/apps/feature-flags/django/django3-saas/dashboard/forms.py +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index 606d09e60..000000000 --- a/apps/feature-flags/django/django3-saas/dashboard/migrations/0001_initial.py +++ /dev/null @@ -1,43 +0,0 @@ -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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/feature-flags/django/django3-saas/dashboard/models.py b/apps/feature-flags/django/django3-saas/dashboard/models.py deleted file mode 100644 index ce9aeed42..000000000 --- a/apps/feature-flags/django/django3-saas/dashboard/models.py +++ /dev/null @@ -1,38 +0,0 @@ -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 deleted file mode 100644 index fd79a18f8..000000000 --- a/apps/feature-flags/django/django3-saas/dashboard/urls.py +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index be99138b9..000000000 --- a/apps/feature-flags/django/django3-saas/dashboard/views.py +++ /dev/null @@ -1,111 +0,0 @@ -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 deleted file mode 100644 index fcc53d76a..000000000 --- a/apps/feature-flags/django/django3-saas/manage.py +++ /dev/null @@ -1,18 +0,0 @@ -#!/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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/feature-flags/django/django3-saas/marketing/urls.py b/apps/feature-flags/django/django3-saas/marketing/urls.py deleted file mode 100644 index 5425d47ba..000000000 --- a/apps/feature-flags/django/django3-saas/marketing/urls.py +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index eebb4b294..000000000 --- a/apps/feature-flags/django/django3-saas/marketing/views.py +++ /dev/null @@ -1,11 +0,0 @@ -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 deleted file mode 100644 index e10e2f8dc..000000000 --- a/apps/feature-flags/django/django3-saas/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/feature-flags/django/django3-saas/templates/accounts/login.html b/apps/feature-flags/django/django3-saas/templates/accounts/login.html deleted file mode 100644 index 9c6880b7d..000000000 --- a/apps/feature-flags/django/django3-saas/templates/accounts/login.html +++ /dev/null @@ -1,18 +0,0 @@ -{% 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 deleted file mode 100644 index 4170b3d9a..000000000 --- a/apps/feature-flags/django/django3-saas/templates/accounts/password_reset.html +++ /dev/null @@ -1,16 +0,0 @@ -{% 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 deleted file mode 100644 index aa5a58577..000000000 --- a/apps/feature-flags/django/django3-saas/templates/accounts/password_reset_complete.html +++ /dev/null @@ -1,9 +0,0 @@ -{% 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 deleted file mode 100644 index fb0d2a4c6..000000000 --- a/apps/feature-flags/django/django3-saas/templates/accounts/password_reset_confirm.html +++ /dev/null @@ -1,18 +0,0 @@ -{% 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 deleted file mode 100644 index 832749f1a..000000000 --- a/apps/feature-flags/django/django3-saas/templates/accounts/password_reset_done.html +++ /dev/null @@ -1,11 +0,0 @@ -{% 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 deleted file mode 100644 index ced6109aa..000000000 --- a/apps/feature-flags/django/django3-saas/templates/accounts/password_reset_email.html +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index 7a3a81292..000000000 --- a/apps/feature-flags/django/django3-saas/templates/accounts/password_reset_subject.txt +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index e6b09da53..000000000 --- a/apps/feature-flags/django/django3-saas/templates/accounts/register.html +++ /dev/null @@ -1,15 +0,0 @@ -{% 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 deleted file mode 100644 index 14edba6ce..000000000 --- a/apps/feature-flags/django/django3-saas/templates/accounts/settings.html +++ /dev/null @@ -1,23 +0,0 @@ -{% 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 deleted file mode 100644 index 6da2962b1..000000000 --- a/apps/feature-flags/django/django3-saas/templates/base.html +++ /dev/null @@ -1,87 +0,0 @@ - - - - - - {% 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 deleted file mode 100644 index 9e11d38a2..000000000 --- a/apps/feature-flags/django/django3-saas/templates/billing/cancel.html +++ /dev/null @@ -1,23 +0,0 @@ -{% 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 deleted file mode 100644 index 5df476a59..000000000 --- a/apps/feature-flags/django/django3-saas/templates/billing/change_plan.html +++ /dev/null @@ -1,23 +0,0 @@ -{% 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 deleted file mode 100644 index d94a59989..000000000 --- a/apps/feature-flags/django/django3-saas/templates/billing/manage.html +++ /dev/null @@ -1,46 +0,0 @@ -{% 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 deleted file mode 100644 index d8045564c..000000000 --- a/apps/feature-flags/django/django3-saas/templates/billing/pricing.html +++ /dev/null @@ -1,53 +0,0 @@ -{% 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 deleted file mode 100644 index 2b40bb727..000000000 --- a/apps/feature-flags/django/django3-saas/templates/billing/subscribe.html +++ /dev/null @@ -1,30 +0,0 @@ -{% 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 deleted file mode 100644 index 38d627f6a..000000000 --- a/apps/feature-flags/django/django3-saas/templates/dashboard/create_project.html +++ /dev/null @@ -1,16 +0,0 @@ -{% 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 deleted file mode 100644 index 06a4c12b3..000000000 --- a/apps/feature-flags/django/django3-saas/templates/dashboard/delete_project.html +++ /dev/null @@ -1,18 +0,0 @@ -{% 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 deleted file mode 100644 index 5ac1bd361..000000000 --- a/apps/feature-flags/django/django3-saas/templates/dashboard/edit_project.html +++ /dev/null @@ -1,16 +0,0 @@ -{% 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 deleted file mode 100644 index 3162eef2e..000000000 --- a/apps/feature-flags/django/django3-saas/templates/dashboard/index.html +++ /dev/null @@ -1,114 +0,0 @@ -{% 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 deleted file mode 100644 index e9752a485..000000000 --- a/apps/feature-flags/django/django3-saas/templates/dashboard/projects.html +++ /dev/null @@ -1,38 +0,0 @@ -{% 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 deleted file mode 100644 index 04a38f32c..000000000 --- a/apps/feature-flags/django/django3-saas/templates/errors/404.html +++ /dev/null @@ -1,12 +0,0 @@ -{% 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 deleted file mode 100644 index 9e5dd1db1..000000000 --- a/apps/feature-flags/django/django3-saas/templates/errors/500.html +++ /dev/null @@ -1,12 +0,0 @@ -{% 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 deleted file mode 100644 index 148aeb69b..000000000 --- a/apps/feature-flags/django/django3-saas/templates/marketing/features.html +++ /dev/null @@ -1,44 +0,0 @@ -{% 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 deleted file mode 100644 index e5ad8aadf..000000000 --- a/apps/feature-flags/django/django3-saas/templates/marketing/home.html +++ /dev/null @@ -1,38 +0,0 @@ -{% 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 deleted file mode 100644 index 9f67031a4..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/.env.example +++ /dev/null @@ -1,11 +0,0 @@ -# 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/.wizard-ci/feature-flags.json b/apps/feature-flags/next-js/15-app-router-saas/.wizard-ci/feature-flags.json new file mode 100644 index 000000000..169308693 --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/.wizard-ci/feature-flags.json @@ -0,0 +1 @@ +{ "flagKeys": ["wizard-example-frontend-flag", "wizard-example-backend-flag"] } diff --git a/apps/feature-flags/next-js/15-app-router-saas/.wizard-ci/source.json b/apps/feature-flags/next-js/15-app-router-saas/.wizard-ci/source.json new file mode 100644 index 000000000..336705208 --- /dev/null +++ b/apps/feature-flags/next-js/15-app-router-saas/.wizard-ci/source.json @@ -0,0 +1 @@ +{ "sourceApp": "basic-integration/next-js/15-app-router-saas" } diff --git a/apps/feature-flags/next-js/15-app-router-saas/LICENSE b/apps/feature-flags/next-js/15-app-router-saas/LICENSE deleted file mode 100644 index b3ddd8d1d..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -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 index 093d91d2e..b839ba71c 100644 --- a/apps/feature-flags/next-js/15-app-router-saas/README.md +++ b/apps/feature-flags/next-js/15-app-router-saas/README.md @@ -1,119 +1,20 @@ -# Next.js SaaS Starter +# Feature flags: Next.js App Router SaaS -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. +Runs the `wizard feature-flags` e2e against the source tree of +[`basic-integration/next-js/15-app-router-saas`](../../../basic-integration/next-js/15-app-router-saas), +named by `sourceApp` in `.wizard-ci/source.json`. PostHog is not installed there. -**Demo: [https://next-saas-start.vercel.app/](https://next-saas-start.vercel.app/)** +There is no code here on purpose: a copy of the app would drift from the one it +was copied from. -## Features +Expected wizard outcome, graded by `services/wizard-ci/feature-flag-checks.ts` +against the keys in `.wizard-ci/feature-flags.json`: -- 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 +- both example flags exist in the project, inactive at 0% rollout +- each key appears in exactly one changed file, its constants module, and + another changed file imports that module +- no changed file holds a `wizard-example-*` key outside that list +- `posthog-feature-flags-report.md` exists -## 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 +The flag checks cannot prove this run created the flags: CI never deletes the +shared keys, because parallel legs share them. 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 deleted file mode 100644 index 965f34cb5..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/dashboard/activity/loading.tsx +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 68ada9699..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/dashboard/activity/page.tsx +++ /dev/null @@ -1,126 +0,0 @@ -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 deleted file mode 100644 index e3e0165aa..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/dashboard/general/page.tsx +++ /dev/null @@ -1,121 +0,0 @@ -'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 deleted file mode 100644 index be0396866..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/dashboard/layout.tsx +++ /dev/null @@ -1,73 +0,0 @@ -'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 deleted file mode 100644 index 60475a858..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/dashboard/page.tsx +++ /dev/null @@ -1,287 +0,0 @@ -'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 deleted file mode 100644 index 642b072da..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/dashboard/security/page.tsx +++ /dev/null @@ -1,167 +0,0 @@ -'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 deleted file mode 100644 index 5e1d0b1b2..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/layout.tsx +++ /dev/null @@ -1,106 +0,0 @@ -'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 deleted file mode 100644 index 07e3b487f..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/page.tsx +++ /dev/null @@ -1,130 +0,0 @@ -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 deleted file mode 100644 index 234cbf5ed..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/pricing/page.tsx +++ /dev/null @@ -1,94 +0,0 @@ -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 deleted file mode 100644 index aa04ea9db..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/pricing/submit-button.tsx +++ /dev/null @@ -1,30 +0,0 @@ -'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 deleted file mode 100644 index a80112317..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/app/(dashboard)/terminal.tsx +++ /dev/null @@ -1,68 +0,0 @@ -'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 deleted file mode 100644 index 532adc0ef..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/app/(login)/actions.ts +++ /dev/null @@ -1,459 +0,0 @@ -'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 deleted file mode 100644 index 86c7e280d..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/app/(login)/login.tsx +++ /dev/null @@ -1,142 +0,0 @@ -'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 deleted file mode 100644 index ed47e387e..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/app/(login)/sign-in/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index 10ff615db..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/app/(login)/sign-up/page.tsx +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index d1eba26fd..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/app/api/stripe/checkout/route.ts +++ /dev/null @@ -1,97 +0,0 @@ -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 deleted file mode 100644 index 446066dc9..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/app/api/stripe/webhook/route.ts +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 0659e7f8b..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/app/api/team/route.ts +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 6c3dc014c..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/app/api/user/route.ts +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 718d6fea4..000000000 Binary files a/apps/feature-flags/next-js/15-app-router-saas/app/favicon.ico and /dev/null differ 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 deleted file mode 100644 index 794a380a2..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/app/globals.css +++ /dev/null @@ -1,275 +0,0 @@ -@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 deleted file mode 100644 index 9c2596780..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/app/layout.tsx +++ /dev/null @@ -1,44 +0,0 @@ -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 deleted file mode 100644 index 7aad74b69..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/app/not-found.tsx +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index 5a3c7506d..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/components.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "$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 deleted file mode 100644 index 852cb27a6..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/components/ui/avatar.tsx +++ /dev/null @@ -1,53 +0,0 @@ -"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 deleted file mode 100644 index 82cad4b47..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/components/ui/button.tsx +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index 4733d5844..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/components/ui/card.tsx +++ /dev/null @@ -1,92 +0,0 @@ -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 deleted file mode 100644 index f84f00586..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/components/ui/dropdown-menu.tsx +++ /dev/null @@ -1,257 +0,0 @@ -"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 deleted file mode 100644 index 0316cc455..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/components/ui/input.tsx +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index 387e860dd..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/components/ui/label.tsx +++ /dev/null @@ -1,24 +0,0 @@ -"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 deleted file mode 100644 index cdf5e9dd5..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/components/ui/radio-group.tsx +++ /dev/null @@ -1,45 +0,0 @@ -"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 deleted file mode 100644 index 017d66652..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/docker-compose.yml +++ /dev/null @@ -1,16 +0,0 @@ - -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 deleted file mode 100644 index 6f6cc8873..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/drizzle.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index a17f25e0d..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/lib/auth/middleware.ts +++ /dev/null @@ -1,75 +0,0 @@ -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 deleted file mode 100644 index 0c474c70a..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/lib/auth/session.ts +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index 7dc4a1b4f..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/lib/db/drizzle.ts +++ /dev/null @@ -1,13 +0,0 @@ -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 deleted file mode 100644 index b3dae0509..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/lib/db/migrations/0000_soft_the_anarchist.sql +++ /dev/null @@ -1,88 +0,0 @@ -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 deleted file mode 100644 index 622eb9779..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/lib/db/migrations/meta/0000_snapshot.json +++ /dev/null @@ -1,389 +0,0 @@ -{ - "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 deleted file mode 100644 index fd4447417..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/lib/db/migrations/meta/_journal.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "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 deleted file mode 100644 index 1bcdf644b..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/lib/db/queries.ts +++ /dev/null @@ -1,130 +0,0 @@ -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 deleted file mode 100644 index 1d047ce66..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/lib/db/schema.ts +++ /dev/null @@ -1,142 +0,0 @@ -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 deleted file mode 100644 index 66b60513e..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/lib/db/seed.ts +++ /dev/null @@ -1,118 +0,0 @@ -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 deleted file mode 100644 index c23d7d4fa..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/lib/db/setup.ts +++ /dev/null @@ -1,216 +0,0 @@ -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 deleted file mode 100644 index 80111f5ca..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/lib/payments/STRIPE_STUB_README.md +++ /dev/null @@ -1,212 +0,0 @@ -# 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 deleted file mode 100644 index c991dde04..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/lib/payments/TESTING_GUIDE.md +++ /dev/null @@ -1,223 +0,0 @@ -# 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 deleted file mode 100644 index 26492c19e..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/lib/payments/actions.ts +++ /dev/null @@ -1,15 +0,0 @@ -'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 deleted file mode 100644 index 270a91322..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/lib/payments/stripe-stub.ts +++ /dev/null @@ -1,354 +0,0 @@ -/** - * 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 deleted file mode 100644 index 291e6ba06..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/lib/payments/stripe.ts +++ /dev/null @@ -1,195 +0,0 @@ -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 deleted file mode 100644 index bd0c391dd..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/lib/utils.ts +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 1c8a638e4..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/middleware.ts +++ /dev/null @@ -1,49 +0,0 @@ -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 deleted file mode 100644 index fd4fd4546..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/next.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -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 deleted file mode 100644 index 020d334a4..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/package.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "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 deleted file mode 100644 index c4753ff95..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/pnpm-lock.yaml +++ /dev/null @@ -1,3794 +0,0 @@ -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 deleted file mode 100644 index a34a3d560..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/postcss.config.mjs +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index f969d125d..000000000 --- a/apps/feature-flags/next-js/15-app-router-saas/tsconfig.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "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/package.json b/package.json index 1d7d4a00c..ed8bff2ab 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,9 @@ "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:feature-flag-checks": "tsx --test services/wizard-ci/feature-flag-checks.test.ts", + "test:source-app": "tsx --test services/wizard-ci/source-app.test.ts" }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "0.2.73", diff --git a/services/wizard-ci/e2e.ts b/services/wizard-ci/e2e.ts index 9229e71f7..da5e29677 100644 --- a/services/wizard-ci/e2e.ts +++ b/services/wizard-ci/e2e.ts @@ -25,7 +25,18 @@ import { import { loadFixtures } from "../mcp-stub/fixtures.js"; import { startMcpStub, type McpStub } from "../mcp-stub/index.js"; import { readJournal } from "../mcp-stub/journal.js"; +import { + changedFiles, + featureFlagChecks, + fetchFlagsByKey, + loadFeatureFlagsExpect, + posthogAppHost, + FEATURE_FLAGS_REPORT_FILE, + type FeatureFlagsExpect, + type RemoteFlag, +} from "./feature-flag-checks.js"; import { runChild } from "./run-child.js"; +import { resolveSourceApp } from "./source-app.js"; import { checksPassed, formatCheck, @@ -250,7 +261,15 @@ export async function runE2e(opts: E2eOptions): Promise { // 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); - const sourceApp = expect?.sourceApp ?? app; + let sourceApp: string; + let featureFlagsExpect: FeatureFlagsExpect | null; + try { + sourceApp = resolveSourceApp(APPS_DIR, app, expect?.sourceApp); + featureFlagsExpect = loadFeatureFlagsExpect(APPS_DIR, app); + } catch (error) { + console.error(`✖ ${(error as Error).message}`); + return 2; + } const appSrc = join(APPS_DIR, sourceApp); if (!existsSync(appSrc)) { console.error(`✖ app not found: apps/${sourceApp}`); @@ -283,9 +302,8 @@ export async function runE2e(opts: E2eOptions): Promise { console.log(`\n=== wizard-ci --e2e: ${app} (project ${projectId}, ${region}) ===`); console.log(` policy: skip mcp · skip slack · ${opts.keepSkills ? "keep" : "delete"} skills · continue past health issues`); - if (expect) { - console.log(` expectations: apps/${app}/.wizard-ci/expect.json${sourceApp === app ? "" : ` (source: apps/${sourceApp})`}`); - } + if (expect) console.log(` expectations: apps/${app}/.wizard-ci/expect.json`); + if (sourceApp !== app) console.log(` source: apps/${sourceApp}`); console.log(""); const childEnv: NodeJS.ProcessEnv = { ...process.env }; @@ -395,10 +413,23 @@ export async function runE2e(opts: E2eOptions): Promise { }); } + let featureFlags: Check[] = []; + if (featureFlagsExpect) { + featureFlags = await gradeFeatureFlags({ + flagKeys: featureFlagsExpect.flagKeys, + sourceDir: appSrc, + appDir, + region, + projectId, + apiKey, + }); + } + const passed = (!!expect || run.status === 0) && checks.every(([, ok]) => ok) && - checksPassed(warehouse); + checksPassed(warehouse) && + checksPassed(featureFlags); if (result) { writeFileSync(resultJson, JSON.stringify({ ...result, app, passed }, null, 2)); @@ -424,10 +455,44 @@ export async function runE2e(opts: E2eOptions): Promise { console.log(`journal : ${journalPath}`); } + if (featureFlagsExpect) { + console.log(""); + for (const c of featureFlags) console.log(formatCheck(c)); + } + console.log(`\n${passed ? "✓ E2E PASS" : "✗ E2E FAIL"} — ${app}\n`); return passed ? 0 : 1; } +async function gradeFeatureFlags(args: { + flagKeys: string[]; + sourceDir: string; + appDir: string; + region: string; + projectId: string; + apiKey: string; +}): Promise { + let remoteFlagsByKey = new Map(); + let flagsApiError: string | null = null; + try { + remoteFlagsByKey = await fetchFlagsByKey({ + host: posthogAppHost(args.region), + projectId: args.projectId, + apiKey: args.apiKey, + flagKeys: args.flagKeys, + }); + } catch (error) { + flagsApiError = (error as Error).message; + } + return featureFlagChecks({ + expectedFlagKeys: args.flagKeys, + changedFileContentsByPath: changedFiles(args.sourceDir, args.appDir), + remoteFlagsByKey, + flagsApiError, + isReportWritten: existsSync(join(args.appDir, FEATURE_FLAGS_REPORT_FILE)), + }); +} + /** * The run-shape checks that apply to every program, before the warehouse block. * diff --git a/services/wizard-ci/feature-flag-checks.test.ts b/services/wizard-ci/feature-flag-checks.test.ts new file mode 100644 index 000000000..21af905c4 --- /dev/null +++ b/services/wizard-ci/feature-flag-checks.test.ts @@ -0,0 +1,442 @@ +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 { after, describe, it } from "node:test"; +import { APPS_DIR } from "./e2e.js"; +import { + changedFiles, + featureFlagChecks, + fetchFlagsByKey, + loadFeatureFlagsExpect, + FEATURE_FLAGS_REPORT_FILE, + type FeatureFlagEvidence, + type RemoteFlag, +} from "./feature-flag-checks.js"; +import type { Check } from "./warehouse-checks.js"; + +const FRONTEND_KEY = "wizard-example-frontend-flag"; +const BACKEND_KEY = "wizard-example-backend-flag"; + +const scratchDir = mkdtempSync(join(tmpdir(), "feature-flag-checks-test-")); +after(() => rmSync(scratchDir, { recursive: true, force: true })); + +function inactiveFlag(key: string): RemoteFlag { + return { key, active: false, filters: { groups: [{ rollout_percentage: 0 }] } }; +} + +function passingEvidence(): FeatureFlagEvidence { + return { + expectedFlagKeys: [FRONTEND_KEY, BACKEND_KEY], + changedFileContentsByPath: new Map([ + ["lib/feature-flags.ts", `export const FRONTEND = "${FRONTEND_KEY}";\nexport const BACKEND = "${BACKEND_KEY}";`], + ["app/page.tsx", `import { FRONTEND } from "@/lib/feature-flags";`], + ["app/api/route.ts", `import { BACKEND } from "@/lib/feature-flags";`], + [FEATURE_FLAGS_REPORT_FILE, `Turn on ${FRONTEND_KEY} and ${BACKEND_KEY}.`], + ]), + remoteFlagsByKey: new Map([ + [FRONTEND_KEY, inactiveFlag(FRONTEND_KEY)], + [BACKEND_KEY, inactiveFlag(BACKEND_KEY)], + ]), + flagsApiError: null, + isReportWritten: true, + }; +} + +function failedCheckNames(checks: Check[]): string[] { + return checks.filter((c) => !c.ok).map((c) => c.name); +} + +function writeTree(root: string, contentsByPath: Record): void { + for (const [path, contents] of Object.entries(contentsByPath)) { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), contents); + } +} + +describe("featureFlagChecks", () => { + it("passes a run that wired both flags through one constants module", () => { + assert.deepEqual(failedCheckNames(featureFlagChecks(passingEvidence())), []); + }); + + it("fails a flag missing from the project", () => { + const evidence = passingEvidence(); + evidence.remoteFlagsByKey.delete(BACKEND_KEY); + assert.deepEqual(failedCheckNames(featureFlagChecks(evidence)), [`flag ${BACKEND_KEY} exists`]); + }); + + it("fails an active flag", () => { + const evidence = passingEvidence(); + evidence.remoteFlagsByKey.set(FRONTEND_KEY, { ...inactiveFlag(FRONTEND_KEY), active: true }); + assert.deepEqual(failedCheckNames(featureFlagChecks(evidence)), [`flag ${FRONTEND_KEY} inactive`]); + }); + + for (const rollout of [100, null]) { + it(`fails a flag rolled out at ${rollout}`, () => { + const evidence = passingEvidence(); + evidence.remoteFlagsByKey.set(FRONTEND_KEY, { + key: FRONTEND_KEY, + active: false, + filters: { groups: [{ rollout_percentage: rollout }] }, + }); + assert.deepEqual(failedCheckNames(featureFlagChecks(evidence)), [`flag ${FRONTEND_KEY} at 0%`]); + }); + } + + it("fails every flag when the Flags API is unreachable", () => { + const evidence = passingEvidence(); + evidence.flagsApiError = "Flags API returned HTTP 401"; + assert.deepEqual(failedCheckNames(featureFlagChecks(evidence)), [ + `flag ${FRONTEND_KEY} exists`, + `flag ${BACKEND_KEY} exists`, + ]); + }); + + it("fails a key written inline outside the constants module", () => { + const evidence = passingEvidence(); + evidence.changedFileContentsByPath.set("app/page.tsx", `useFeatureFlagEnabled("${FRONTEND_KEY}")`); + assert.deepEqual(failedCheckNames(featureFlagChecks(evidence)), [ + `key ${FRONTEND_KEY} in one constants module`, + ]); + }); + + it("fails a key in no changed file", () => { + const evidence = passingEvidence(); + evidence.changedFileContentsByPath.set("lib/feature-flags.ts", `export const BACKEND = "${BACKEND_KEY}";`); + assert.deepEqual(failedCheckNames(featureFlagChecks(evidence)), [ + `key ${FRONTEND_KEY} in one constants module`, + ]); + }); + + it("fails a constants module nothing imports", () => { + const evidence = passingEvidence(); + evidence.changedFileContentsByPath.set("app/page.tsx", "export default function Page() {}"); + evidence.changedFileContentsByPath.set("app/api/route.ts", "export function GET() {}"); + assert.deepEqual(failedCheckNames(featureFlagChecks(evidence)), [ + `constants module for ${FRONTEND_KEY} used by another changed file`, + `constants module for ${BACKEND_KEY} used by another changed file`, + ]); + }); + + it("names a Python package constants module by its directory", () => { + const evidence: FeatureFlagEvidence = { + ...passingEvidence(), + expectedFlagKeys: [BACKEND_KEY], + changedFileContentsByPath: new Map([ + ["flags/__init__.py", `BACKEND_FLAG = "${BACKEND_KEY}"`], + ["dashboard/views.py", "from flags import BACKEND_FLAG"], + ]), + }; + assert.deepEqual(failedCheckNames(featureFlagChecks(evidence)), []); + }); + + it("fails a constants module that other files only mention in a comment or string", () => { + const evidence = passingEvidence(); + evidence.changedFileContentsByPath.set("app/page.tsx", "// set up feature-flags here\nexport default function Page() {}"); + evidence.changedFileContentsByPath.set("app/api/route.ts", `export const label = "feature-flags";`); + assert.deepEqual(failedCheckNames(featureFlagChecks(evidence)), [ + `constants module for ${FRONTEND_KEY} used by another changed file`, + `constants module for ${BACKEND_KEY} used by another changed file`, + ]); + }); + + it("fails a Python constants module that other files only mention in a comment", () => { + const evidence: FeatureFlagEvidence = { + ...passingEvidence(), + expectedFlagKeys: [BACKEND_KEY], + changedFileContentsByPath: new Map([ + ["config/posthog_flags.py", `BACKEND_FLAG = "${BACKEND_KEY}"`], + ["dashboard/views.py", "# see posthog_flags for the key\ndef index(request): pass"], + ]), + }; + assert.deepEqual(failedCheckNames(featureFlagChecks(evidence)), [ + `constants module for ${BACKEND_KEY} used by another changed file`, + ]); + }); + + it("passes a Python constants module imported by its dotted path", () => { + const evidence: FeatureFlagEvidence = { + ...passingEvidence(), + expectedFlagKeys: [BACKEND_KEY], + changedFileContentsByPath: new Map([ + ["config/posthog_flags.py", `BACKEND_FLAG = "${BACKEND_KEY}"`], + ["dashboard/views.py", "from config.posthog_flags import BACKEND_FLAG"], + ]), + }; + assert.deepEqual(failedCheckNames(featureFlagChecks(evidence)), []); + }); + + for (const submoduleImport of [ + "from config import posthog_flags", + "from . import posthog_flags", + "from .. import posthog_flags", + "from config import urls, posthog_flags", + "from config import posthog_flags as flags", + "from config import (\n urls as routes,\n posthog_flags,\n)", + "from config import (urls, # routes\n posthog_flags)", + "import os, posthog_flags", + "import os as operating_system, config.posthog_flags as flags", + ]) { + it(`passes a Python constants module imported as a submodule via ${JSON.stringify(submoduleImport)}`, () => { + const evidence: FeatureFlagEvidence = { + ...passingEvidence(), + expectedFlagKeys: [BACKEND_KEY], + changedFileContentsByPath: new Map([ + ["config/posthog_flags.py", `BACKEND_FLAG = "${BACKEND_KEY}"`], + ["dashboard/views.py", submoduleImport], + ]), + }; + assert.deepEqual(failedCheckNames(featureFlagChecks(evidence)), []); + }); + } + + it("fails a Python constants module when another file imports a differently named submodule", () => { + const evidence: FeatureFlagEvidence = { + ...passingEvidence(), + expectedFlagKeys: [BACKEND_KEY], + changedFileContentsByPath: new Map([ + ["config/posthog_flags.py", `BACKEND_FLAG = "${BACKEND_KEY}"`], + ["dashboard/views.py", "from config import posthog_flags_extra"], + ]), + }; + assert.deepEqual(failedCheckNames(featureFlagChecks(evidence)), [ + `constants module for ${BACKEND_KEY} used by another changed file`, + ]); + }); + + it("fails a Python constants module when another file plainly imports a differently named module after others", () => { + const evidence: FeatureFlagEvidence = { + ...passingEvidence(), + expectedFlagKeys: [BACKEND_KEY], + changedFileContentsByPath: new Map([ + ["config/posthog_flags.py", `BACKEND_FLAG = "${BACKEND_KEY}"`], + ["dashboard/views.py", "import os, posthog_flags_extra"], + ]), + }; + assert.deepEqual(failedCheckNames(featureFlagChecks(evidence)), [ + `constants module for ${BACKEND_KEY} used by another changed file`, + ]); + }); + + function rubyEvidenceWithConstants(constantsContents: string, controllerContents: string): FeatureFlagEvidence { + return { + ...passingEvidence(), + expectedFlagKeys: [BACKEND_KEY], + changedFileContentsByPath: new Map([ + ["app/models/post_hog_feature_flags.rb", constantsContents], + ["app/controllers/events_controller.rb", controllerContents], + ]), + }; + } + + function rubyEvidence(controllerContents: string): FeatureFlagEvidence { + return rubyEvidenceWithConstants( + `module PostHogFeatureFlags\n WIZARD_EXAMPLE_BACKEND = "${BACKEND_KEY}"\nend`, + controllerContents, + ); + } + + const NESTED_RUBY_CONSTANTS = `module PostHog\n module FeatureFlags\n WIZARD_EXAMPLE_BACKEND = "${BACKEND_KEY}"\n end\nend`; + const COMPACT_RUBY_CONSTANTS = `module PostHog::FeatureFlags\n WIZARD_EXAMPLE_BACKEND = "${BACKEND_KEY}"\nend`; + + it("fails a nested Ruby constants module when another file only references the outer namespace", () => { + const evidence = rubyEvidenceWithConstants(NESTED_RUBY_CONSTANTS, "client = PostHog::Client.new(api_key)"); + assert.deepEqual(failedCheckNames(featureFlagChecks(evidence)), [ + `constants module for ${BACKEND_KEY} used by another changed file`, + ]); + }); + + it("passes a nested Ruby constants module that another file references by its full path", () => { + const evidence = rubyEvidenceWithConstants( + NESTED_RUBY_CONSTANTS, + "flags.enabled?(PostHog::FeatureFlags::WIZARD_EXAMPLE_BACKEND)", + ); + assert.deepEqual(failedCheckNames(featureFlagChecks(evidence)), []); + }); + + it("passes a nested Ruby constants module that another file references by its innermost name", () => { + const evidence = rubyEvidenceWithConstants( + NESTED_RUBY_CONSTANTS, + "module PostHog\n class Client\n KEY = FeatureFlags::WIZARD_EXAMPLE_BACKEND\n end\nend", + ); + assert.deepEqual(failedCheckNames(featureFlagChecks(evidence)), []); + }); + + const NESTED_RUBY_CONSTANTS_AFTER_METHOD = `module PostHog\n module FeatureFlags\n def self.all\n constants\n end\n\n WIZARD_EXAMPLE_BACKEND = "${BACKEND_KEY}"\n end\nend`; + const RUBY_CONSTANTS_AFTER_SINGLETON_CLASS = `module PostHogFeatureFlags\n class << self\n def refresh; 1; end\n end\n\n WIZARD_EXAMPLE_BACKEND = "${BACKEND_KEY}"\nend`; + const RUBY_CONSTANTS_AFTER_DO_BLOCK = `module PostHogFeatureFlags\n %w[a b].each do |name|\n const_set(name, name)\n end\n\n WIZARD_EXAMPLE_BACKEND = "${BACKEND_KEY}"\nend`; + + it("fails a nested Ruby constants module with a method before the key when another file only references the outer namespace", () => { + const evidence = rubyEvidenceWithConstants( + NESTED_RUBY_CONSTANTS_AFTER_METHOD, + "client = PostHog::Client.new(api_key)", + ); + assert.deepEqual(failedCheckNames(featureFlagChecks(evidence)), [ + `constants module for ${BACKEND_KEY} used by another changed file`, + ]); + }); + + it("passes a nested Ruby constants module with a method before the key when another file references its full path", () => { + const evidence = rubyEvidenceWithConstants( + NESTED_RUBY_CONSTANTS_AFTER_METHOD, + "flags.enabled?(PostHog::FeatureFlags::WIZARD_EXAMPLE_BACKEND)", + ); + assert.deepEqual(failedCheckNames(featureFlagChecks(evidence)), []); + }); + + const NESTED_RUBY_CONSTANTS_AFTER_MEMOIZED_BEGIN = `module PostHog\n module FeatureFlags\n def self.all\n @all ||= begin\n constants\n end\n end\n\n WIZARD_EXAMPLE_BACKEND = "${BACKEND_KEY}"\n end\nend`; + const NESTED_RUBY_CONSTANTS_AFTER_PRIVATE_DEF = `module PostHog\n module FeatureFlags\n private def helper\n 1\n end\n\n WIZARD_EXAMPLE_BACKEND = "${BACKEND_KEY}"\n end\nend`; + + for (const [blockForm, constantsContents] of [ + ["a memoized begin block", NESTED_RUBY_CONSTANTS_AFTER_MEMOIZED_BEGIN], + ["a private def", NESTED_RUBY_CONSTANTS_AFTER_PRIVATE_DEF], + ]) { + it(`fails a nested Ruby constants module with ${blockForm} before the key when another file only references the outer namespace`, () => { + const evidence = rubyEvidenceWithConstants(constantsContents, "client = PostHog::Client.new(api_key)"); + assert.deepEqual(failedCheckNames(featureFlagChecks(evidence)), [ + `constants module for ${BACKEND_KEY} used by another changed file`, + ]); + }); + + it(`passes a nested Ruby constants module with ${blockForm} before the key when another file references its full path`, () => { + const evidence = rubyEvidenceWithConstants( + constantsContents, + "flags.enabled?(PostHog::FeatureFlags::WIZARD_EXAMPLE_BACKEND)", + ); + assert.deepEqual(failedCheckNames(featureFlagChecks(evidence)), []); + }); + } + + it("passes a Ruby constants module with a singleton class before the key when another file references it", () => { + const evidence = rubyEvidenceWithConstants( + RUBY_CONSTANTS_AFTER_SINGLETON_CLASS, + "flags.enabled?(PostHogFeatureFlags::WIZARD_EXAMPLE_BACKEND)", + ); + assert.deepEqual(failedCheckNames(featureFlagChecks(evidence)), []); + }); + + it("passes a Ruby constants module with a do block before the key when another file references it", () => { + const evidence = rubyEvidenceWithConstants( + RUBY_CONSTANTS_AFTER_DO_BLOCK, + "flags.enabled?(PostHogFeatureFlags::WIZARD_EXAMPLE_BACKEND)", + ); + assert.deepEqual(failedCheckNames(featureFlagChecks(evidence)), []); + }); + + it("passes a compact nested Ruby constants module that another file references by its full path", () => { + const evidence = rubyEvidenceWithConstants( + COMPACT_RUBY_CONSTANTS, + "flags.enabled?(PostHog::FeatureFlags::WIZARD_EXAMPLE_BACKEND)", + ); + assert.deepEqual(failedCheckNames(featureFlagChecks(evidence)), []); + }); + + it("passes a Ruby constants module that another file references with a top-level qualifier", () => { + const evidence = rubyEvidence("flags.enabled?(::PostHogFeatureFlags::WIZARD_EXAMPLE_BACKEND)"); + assert.deepEqual(failedCheckNames(featureFlagChecks(evidence)), []); + }); + + it("fails a Ruby constants module when another file references a same-named constant in a different namespace", () => { + const evidence = rubyEvidence("Other::PostHogFeatureFlags::WIZARD_EXAMPLE_BACKEND"); + assert.deepEqual(failedCheckNames(featureFlagChecks(evidence)), [ + `constants module for ${BACKEND_KEY} used by another changed file`, + ]); + }); + + it("passes a Ruby constants module that another file references by its autoloaded name", () => { + const evidence = rubyEvidence("flags.enabled?(PostHogFeatureFlags::WIZARD_EXAMPLE_BACKEND)"); + assert.deepEqual(failedCheckNames(featureFlagChecks(evidence)), []); + }); + + it("fails a Ruby constants module that other files only mention in a comment", () => { + const evidence = rubyEvidence("# PostHogFeatureFlags holds the keys\nclass EventsController; end"); + assert.deepEqual(failedCheckNames(featureFlagChecks(evidence)), [ + `constants module for ${BACKEND_KEY} used by another changed file`, + ]); + }); + + it("fails a Ruby constants module when another file references a longer namespace name", () => { + const evidence = rubyEvidence("MyPostHogFeatureFlags::WIZARD_EXAMPLE_BACKEND"); + assert.deepEqual(failedCheckNames(featureFlagChecks(evidence)), [ + `constants module for ${BACKEND_KEY} used by another changed file`, + ]); + }); + + it("fails an example key the app does not expect", () => { + const evidence = passingEvidence(); + evidence.expectedFlagKeys = [BACKEND_KEY]; + assert.deepEqual(failedCheckNames(featureFlagChecks(evidence)), ["no unexpected flag key"]); + }); + + it("fails a run without a report", () => { + const evidence = passingEvidence(); + evidence.isReportWritten = false; + assert.deepEqual(failedCheckNames(featureFlagChecks(evidence)), ["report file written"]); + }); +}); + +describe("changedFiles", () => { + it("returns files the run added or edited, skipping unchanged and dependency trees", () => { + const sourceDir = join(scratchDir, "source"); + const copyDir = join(scratchDir, "copy"); + writeTree(sourceDir, { "app/page.tsx": "original", "README.md": "same" }); + writeTree(copyDir, { + "app/page.tsx": "edited", + "README.md": "same", + "lib/flags.ts": "added", + "node_modules/pkg/index.js": "installed", + "venv/lib/site.py": "installed", + }); + assert.deepEqual( + [...changedFiles(sourceDir, copyDir)].sort(), + [ + ["app/page.tsx", "edited"], + ["lib/flags.ts", "added"], + ], + ); + }); +}); + +describe("fetchFlagsByKey", () => { + it("keeps only exact, undeleted key matches", async () => { + const requestedUrls: string[] = []; + const fetchFlags = (async (url: string) => { + requestedUrls.push(url); + const results: RemoteFlag[] = [ + inactiveFlag(`${BACKEND_KEY}-old`), + { ...inactiveFlag(BACKEND_KEY), deleted: true }, + inactiveFlag(BACKEND_KEY), + ]; + return new Response(JSON.stringify({ results })); + }) as typeof fetch; + const flagsByKey = await fetchFlagsByKey({ + host: "https://us.posthog.com", + projectId: "483112", + apiKey: "phx_test", + flagKeys: [BACKEND_KEY], + fetchFlags, + }); + assert.deepEqual(requestedUrls, [ + `https://us.posthog.com/api/projects/483112/feature_flags/?search=${BACKEND_KEY}`, + ]); + assert.deepEqual(flagsByKey.get(BACKEND_KEY), inactiveFlag(BACKEND_KEY)); + }); + + it("throws on an HTTP error", async () => { + const fetchFlags = (async () => new Response("denied", { status: 403 })) as typeof fetch; + await assert.rejects( + fetchFlagsByKey({ host: "https://us.posthog.com", projectId: "1", apiKey: "x", flagKeys: [BACKEND_KEY], fetchFlags }), + /HTTP 403/, + ); + }); +}); + +describe("feature-flags app expectations", () => { + it("expects both keys for Next.js and only the backend key for Django", () => { + assert.deepEqual(loadFeatureFlagsExpect(APPS_DIR, "feature-flags/next-js/15-app-router-saas")?.flagKeys, [ + FRONTEND_KEY, + BACKEND_KEY, + ]); + assert.deepEqual(loadFeatureFlagsExpect(APPS_DIR, "feature-flags/django/django3-saas")?.flagKeys, [BACKEND_KEY]); + }); +}); diff --git a/services/wizard-ci/feature-flag-checks.ts b/services/wizard-ci/feature-flag-checks.ts new file mode 100644 index 000000000..8d9b28c53 --- /dev/null +++ b/services/wizard-ci/feature-flag-checks.ts @@ -0,0 +1,257 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { basename, dirname, extname, join } from "node:path"; +import type { Check } from "./warehouse-checks.js"; + +export const FEATURE_FLAGS_REPORT_FILE = "posthog-feature-flags-report.md"; + +const EXAMPLE_FLAG_KEY_PATTERN = /wizard-example-[a-z0-9]+(?:-[a-z0-9]+)*/g; + +const UNTRACKED_DIRECTORIES = new Set([ + ".git", + ".next", + ".venv", + "__pycache__", + "node_modules", + "venv", +]); + +export interface FeatureFlagsExpect { + flagKeys: string[]; +} + +export interface RemoteFlag { + key: string; + active: boolean; + deleted?: boolean; + filters?: { groups?: Array<{ rollout_percentage?: number | null }> }; +} + +export interface FeatureFlagEvidence { + expectedFlagKeys: string[]; + changedFileContentsByPath: Map; + remoteFlagsByKey: Map; + flagsApiError: string | null; + isReportWritten: boolean; +} + +export function loadFeatureFlagsExpect(appsDir: string, app: string): FeatureFlagsExpect | null { + const expectFile = join(appsDir, app, ".wizard-ci", "feature-flags.json"); + let raw: string; + try { + raw = readFileSync(expectFile, "utf8"); + } catch { + return null; + } + const parsed = JSON.parse(raw) as Partial; + const flagKeys = parsed.flagKeys; + const isKeyList = + Array.isArray(flagKeys) && + flagKeys.length > 0 && + flagKeys.every((key) => typeof key === "string" && key !== ""); + if (!isKeyList) { + throw new Error(`apps/${app}/.wizard-ci/feature-flags.json needs a non-empty "flagKeys" list`); + } + return { flagKeys }; +} + +export function posthogAppHost(region: string): string { + return region === "eu" ? "https://eu.posthog.com" : "https://us.posthog.com"; +} + +export async function fetchFlagsByKey(args: { + host: string; + projectId: string; + apiKey: string; + flagKeys: string[]; + fetchFlags?: typeof fetch; +}): Promise> { + const fetchFlags = args.fetchFlags ?? fetch; + const flagsByKey = new Map(); + for (const flagKey of args.flagKeys) { + const url = + `${args.host}/api/projects/${encodeURIComponent(args.projectId)}/feature_flags/` + + `?search=${encodeURIComponent(flagKey)}`; + const response = await fetchFlags(url, { + headers: { Authorization: `Bearer ${args.apiKey}` }, + }); + if (!response.ok) { + throw new Error(`Flags API returned HTTP ${response.status} for ${flagKey}`); + } + const page = (await response.json()) as { results?: RemoteFlag[] }; + const match = (page.results ?? []).find((flag) => flag.key === flagKey && !flag.deleted); + if (match) flagsByKey.set(flagKey, match); + } + return flagsByKey; +} + +export function changedFiles(sourceDir: string, copyDir: string): Map { + const contentsByPath = new Map(); + const walk = (relativeDir: string): void => { + for (const entry of readdirSync(join(copyDir, relativeDir), { withFileTypes: true })) { + const relativePath = relativeDir ? join(relativeDir, entry.name) : entry.name; + if (entry.isDirectory()) { + if (!UNTRACKED_DIRECTORIES.has(entry.name)) walk(relativePath); + continue; + } + if (!entry.isFile()) continue; + const copied = readFileSync(join(copyDir, relativePath)); + const sourcePath = join(sourceDir, relativePath); + if (existsSync(sourcePath) && readFileSync(sourcePath).equals(copied)) continue; + contentsByPath.set(relativePath, copied.toString("utf8")); + } + }; + walk(""); + return contentsByPath; +} + +function moduleName(modulePath: string): string { + const stem = basename(modulePath, extname(modulePath)); + if (stem === "index" || stem === "__init__") return basename(dirname(modulePath)); + return stem; +} + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function moduleImportPattern(module: string): RegExp { + const escapedModule = escapeRegExp(module); + const jsSpecifier = `['"](?:[^'"\\n]*/)?${escapedModule}(?:\\.[cm]?[jt]sx?)?['"]`; + const jsImport = `(?:\\bfrom|\\brequire\\(|\\bimport\\()\\s*${jsSpecifier}`; + const pythonDottedPath = `(?:[\\w.]*\\.)?${escapedModule}`; + const pythonWhitespaceOrComments = `(?:\\s|#[^\\n]*\\n)*`; + const pythonPrecedingImportedNames = `(?:\\w+(?:\\s+as\\s+\\w+)?\\s*,${pythonWhitespaceOrComments})*`; + const pythonSubmoduleImport = `from[ \\t]+[\\w.]+[ \\t]+import[ \\t]+\\(?${pythonWhitespaceOrComments}${pythonPrecedingImportedNames}${escapedModule}\\b`; + const pythonPrecedingImportedModules = `(?:[\\w.]+(?:[ \\t]+as[ \\t]+\\w+)?[ \\t]*,[ \\t]*)*`; + const pythonModuleImport = `import[ \\t]+${pythonPrecedingImportedModules}${pythonDottedPath}\\b`; + const pythonImport = `^[ \\t]*(?:from[ \\t]+${pythonDottedPath}[ \\t]+import\\b|${pythonModuleImport}|${pythonSubmoduleImport})`; + return new RegExp(`${jsImport}|${pythonImport}`, "m"); +} + +const RUBY_NAMESPACE_OPENING = /^[ \t]*(?:module|class)[ \t]+([A-Z]\w*(?:::[A-Z]\w*)*)/; +const RUBY_LINE_COMMENT = /#.*/; + +interface RubyOpenNamespace { + indent: number; + segments: string[]; +} + +function indentWidth(line: string): number { + return line.length - line.trimStart().length; +} + +function rubyNamespacePathAroundFlagKey(constantsContents: string, flagKey: string): string[] { + const openNamespaces: RubyOpenNamespace[] = []; + for (const rawLine of constantsContents.split("\n")) { + const hasFlagKey = rawLine.includes(flagKey); + const line = rawLine.replace(RUBY_LINE_COMMENT, ""); + if (!hasFlagKey && line.trim() === "") continue; + const indent = indentWidth(rawLine); + while (openNamespaces.length > 0 && openNamespaces[openNamespaces.length - 1].indent >= indent) { + openNamespaces.pop(); + } + if (hasFlagKey) return openNamespaces.flatMap((openNamespace) => openNamespace.segments); + const namespaceOpening = RUBY_NAMESPACE_OPENING.exec(line); + if (namespaceOpening) openNamespaces.push({ indent, segments: namespaceOpening[1].split("::") }); + } + return []; +} + +function rubyReferencePattern(constantsContents: string, flagKey: string): RegExp | null { + const namespacePath = rubyNamespacePathAroundFlagKey(constantsContents, flagKey); + if (namespacePath.length === 0) return null; + const innermostName = namespacePath[namespacePath.length - 1]; + const acceptedNames = [...new Set([namespacePath.join("::"), innermostName])].map(escapeRegExp); + return new RegExp(`(? group.rollout_percentage ?? null); + const isAtZeroPercent = groups.length > 0 && rollouts.every((rollout) => rollout === 0); + return [ + check(`flag ${flagKey} exists`, true, "found"), + check(`flag ${flagKey} inactive`, flag.active === false, `active=${flag.active}`), + check(`flag ${flagKey} at 0%`, isAtZeroPercent, `rollouts=${JSON.stringify(rollouts)}`), + ]; +} + +function codeChecks(flagKey: string, sourceFiles: Map): Check[] { + const filesWithKey = [...sourceFiles].filter(([, contents]) => contents.includes(flagKey)); + const filePaths = filesWithKey.map(([path]) => path); + const inOneModule = check( + `key ${flagKey} in one constants module`, + filesWithKey.length === 1, + filePaths.length ? filePaths.join(", ") : "in no changed file", + ); + if (filesWithKey.length !== 1) return [inOneModule]; + + const [[constantsPath, constantsContents]] = filesWithKey; + const usagePatterns = constantsModuleUsagePatterns(constantsPath, constantsContents, flagKey); + const users = [...sourceFiles] + .filter( + ([path, contents]) => + path !== constantsPath && usagePatterns.some((usagePattern) => usagePattern.test(contents)), + ) + .map(([path]) => path); + return [ + inOneModule, + check( + `constants module for ${flagKey} used by another changed file`, + users.length > 0, + users.length ? `${constantsPath} used by ${users.join(", ")}` : `nothing uses ${constantsPath}`, + ), + ]; +} + +export function featureFlagChecks(evidence: FeatureFlagEvidence): Check[] { + const sourceFiles = new Map( + [...evidence.changedFileContentsByPath].filter(([path]) => path !== FEATURE_FLAGS_REPORT_FILE), + ); + const expectedKeys = new Set(evidence.expectedFlagKeys); + const unexpectedKeys = new Set(); + for (const contents of evidence.changedFileContentsByPath.values()) { + for (const [foundKey] of contents.matchAll(EXAMPLE_FLAG_KEY_PATTERN)) { + if (!expectedKeys.has(foundKey)) unexpectedKeys.add(foundKey); + } + } + + return [ + ...evidence.expectedFlagKeys.flatMap((flagKey) => [ + ...remoteFlagChecks(flagKey, evidence), + ...codeChecks(flagKey, sourceFiles), + ]), + check( + "no unexpected flag key", + unexpectedKeys.size === 0, + unexpectedKeys.size ? [...unexpectedKeys].join(", ") : "none", + ), + check( + "report file written", + evidence.isReportWritten, + evidence.isReportWritten ? FEATURE_FLAGS_REPORT_FILE : `missing ${FEATURE_FLAGS_REPORT_FILE}`, + ), + ]; +} diff --git a/services/wizard-ci/source-app.test.ts b/services/wizard-ci/source-app.test.ts new file mode 100644 index 000000000..429bf2e0a --- /dev/null +++ b/services/wizard-ci/source-app.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, describe, it } from "node:test"; +import { APPS_DIR } from "./e2e.js"; +import { loadExpect } from "./warehouse-checks.js"; +import { loadSourcePointer, resolveSourceApp } from "./source-app.js"; + +const appsDir = mkdtempSync(join(tmpdir(), "source-app-test-")); +after(() => rmSync(appsDir, { recursive: true, force: true })); + +function writePointer(app: string, contents: string): void { + mkdirSync(join(appsDir, app, ".wizard-ci"), { recursive: true }); + writeFileSync(join(appsDir, app, ".wizard-ci", "source.json"), contents); +} + +describe("resolveSourceApp", () => { + it("returns the app itself when it has no pointer", () => { + assert.equal(resolveSourceApp(appsDir, "plain/app", undefined), "plain/app"); + }); + + it("follows source.json", () => { + writePointer("pointer/app", JSON.stringify({ sourceApp: "real/app" })); + assert.equal(resolveSourceApp(appsDir, "pointer/app", undefined), "real/app"); + }); + + it("falls back to the expect.json sourceApp", () => { + assert.equal(resolveSourceApp(appsDir, "plain/app", "sibling/app"), "sibling/app"); + }); + + it("prefers source.json over the expect.json sourceApp", () => { + writePointer("both/app", JSON.stringify({ sourceApp: "from-pointer" })); + assert.equal(resolveSourceApp(appsDir, "both/app", "from-expect"), "from-pointer"); + }); + + it("rejects a pointer without a sourceApp", () => { + writePointer("broken/app", JSON.stringify({ source: "real/app" })); + assert.throws(() => loadSourcePointer(appsDir, "broken/app"), /non-empty "sourceApp"/); + }); +}); + +describe("feature-flags pointer apps", () => { + for (const app of [ + "feature-flags/next-js/15-app-router-saas", + "feature-flags/django/django3-saas", + ]) { + it(`${app} points at an existing app and carries no expect.json`, () => { + const sourceApp = loadSourcePointer(APPS_DIR, app)?.sourceApp; + assert.ok(sourceApp, "source.json is missing"); + assert.ok(existsSync(join(APPS_DIR, sourceApp, "README.md")), `apps/${sourceApp} is missing`); + assert.equal(loadExpect(APPS_DIR, sourceApp), null); + assert.equal(loadExpect(APPS_DIR, app), null); + assert.equal(loadSourcePointer(APPS_DIR, sourceApp), null); + }); + } +}); diff --git a/services/wizard-ci/source-app.ts b/services/wizard-ci/source-app.ts new file mode 100644 index 000000000..a003595d1 --- /dev/null +++ b/services/wizard-ci/source-app.ts @@ -0,0 +1,29 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +export interface SourcePointer { + sourceApp: string; +} + +export function loadSourcePointer(appsDir: string, app: string): SourcePointer | null { + const pointerFile = join(appsDir, app, ".wizard-ci", "source.json"); + let raw: string; + try { + raw = readFileSync(pointerFile, "utf8"); + } catch { + return null; + } + const parsed = JSON.parse(raw) as Partial; + if (typeof parsed.sourceApp !== "string" || parsed.sourceApp === "") { + throw new Error(`apps/${app}/.wizard-ci/source.json needs a non-empty "sourceApp"`); + } + return { sourceApp: parsed.sourceApp }; +} + +export function resolveSourceApp( + appsDir: string, + app: string, + expectSourceApp: string | undefined, +): string { + return loadSourcePointer(appsDir, app)?.sourceApp ?? expectSourceApp ?? app; +} diff --git a/services/wizard-commands.ts b/services/wizard-commands.ts index b57aa7b62..814678eb8 100644 --- a/services/wizard-commands.ts +++ b/services/wizard-commands.ts @@ -48,7 +48,7 @@ const MANIFEST_PATH = join(__dirname, "..", "apps", "manifest.json"); /** * Map a command id to its wizard program id where they differ — used to locate - * the program's e2e test definition (`src/lib/programs//test/e2e.json`) + * the program's e2e test definition (`src/programs//test/e2e.json`) * in the wizard repo. */ const COMMAND_PROGRAM: Record = { @@ -67,13 +67,18 @@ export function commandToProgram(id: string): string { return COMMAND_PROGRAM[id] ?? id; } +const WIZARD_PROGRAM_DIRS_BEFORE_AND_AFTER_LAYER_REFACTOR = [ + join("src", "programs"), + join("src", "lib", "programs"), +]; + /** Whether the wizard repo ships an e2e.json flow definition for this command. */ function hasE2eDefinition(commandId: string): boolean { const wizardPath = process.env.WIZARD_PATH; if (!wizardPath) return false; const program = COMMAND_PROGRAM[commandId] ?? commandId; - return existsSync( - join(wizardPath, "src", "lib", "programs", program, "test", "e2e.json"), + return WIZARD_PROGRAM_DIRS_BEFORE_AND_AFTER_LAYER_REFACTOR.some((programsDir) => + existsSync(join(wizardPath, programsDir, program, "test", "e2e.json")), ); }