research docs & vibed UI
417
ENTERPRISE_FEATURE_ANALYSIS.md
Normal file
@@ -0,0 +1,417 @@
|
||||
# Enterprise Feature Access: Status Quo Analysis
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Formbricks currently uses **two completely different mechanisms** to gate enterprise features depending on deployment type:
|
||||
|
||||
| Deployment | Gating Mechanism | Activation | Feature Control |
|
||||
|------------|------------------|------------|-----------------|
|
||||
| **Cloud** (`IS_FORMBRICKS_CLOUD=1`) | Billing Plan (`organization.billing.plan`) | Stripe subscription | Plan-based (FREE/STARTUP/CUSTOM) |
|
||||
| **On-Premise** | License Key (`ENTERPRISE_LICENSE_KEY`) | License API validation | License feature flags |
|
||||
|
||||
This dual approach creates **significant complexity**, **code duplication**, and **inconsistent behavior** across the codebase.
|
||||
|
||||
---
|
||||
|
||||
## 1. Core Architecture
|
||||
|
||||
### 1.1 Cloud (Formbricks Cloud)
|
||||
|
||||
**Source of Truth:** `organization.billing.plan`
|
||||
|
||||
```typescript
|
||||
// packages/database/zod/organizations.ts
|
||||
plan: z.enum(["free", "startup", "scale", "enterprise"]).default("free")
|
||||
```
|
||||
|
||||
**Plans and Limits:**
|
||||
- `FREE`: 3 projects, 1,500 responses/month, 2,000 MIU
|
||||
- `STARTUP`: 3 projects, 5,000 responses/month, 7,500 MIU
|
||||
- `CUSTOM`: Unlimited (negotiated limits)
|
||||
|
||||
**Activation:** Stripe webhook updates `organization.billing` on checkout/subscription events.
|
||||
|
||||
### 1.2 On-Premise (Self-Hosted)
|
||||
|
||||
**Source of Truth:** `ENTERPRISE_LICENSE_KEY` environment variable
|
||||
|
||||
**License Features Schema:**
|
||||
```typescript
|
||||
// apps/web/modules/ee/license-check/types/enterprise-license.ts
|
||||
{
|
||||
isMultiOrgEnabled: boolean,
|
||||
contacts: boolean,
|
||||
projects: number | null,
|
||||
whitelabel: boolean,
|
||||
removeBranding: boolean,
|
||||
twoFactorAuth: boolean,
|
||||
sso: boolean,
|
||||
saml: boolean,
|
||||
spamProtection: boolean,
|
||||
ai: boolean,
|
||||
auditLogs: boolean,
|
||||
multiLanguageSurveys: boolean,
|
||||
accessControl: boolean,
|
||||
quotas: boolean,
|
||||
}
|
||||
```
|
||||
|
||||
**Activation:** License key validated against `https://ee.formbricks.com/api/licenses/check` (cached for 24h, grace period of 3 days).
|
||||
|
||||
---
|
||||
|
||||
## 2. Feature Gating Patterns
|
||||
|
||||
### 2.1 Pattern A: Dual-Path Check (Most Common)
|
||||
|
||||
Features that need **both** Cloud billing **and** on-premise license checks:
|
||||
|
||||
```typescript
|
||||
// apps/web/modules/ee/license-check/lib/utils.ts
|
||||
const getFeaturePermission = async (billingPlan, featureKey) => {
|
||||
const license = await getEnterpriseLicense();
|
||||
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
return license.active && billingPlan !== PROJECT_FEATURE_KEYS.FREE;
|
||||
} else {
|
||||
return license.active && !!license.features?.[featureKey];
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Used by:**
|
||||
- `getRemoveBrandingPermission()` - Remove branding
|
||||
- `getWhiteLabelPermission()` - Whitelabel features
|
||||
- `getBiggerUploadFileSizePermission()` - Large file uploads
|
||||
- `getIsSpamProtectionEnabled()` - reCAPTCHA spam protection
|
||||
- `getMultiLanguagePermission()` - Multi-language surveys
|
||||
- `getAccessControlPermission()` - Teams & roles
|
||||
- `getIsQuotasEnabled()` - Quota management
|
||||
- `getOrganizationProjectsLimit()` - Project limits
|
||||
|
||||
### 2.2 Pattern B: License-Only Check
|
||||
|
||||
Features checked **only** against license (works same for cloud and on-premise):
|
||||
|
||||
```typescript
|
||||
// apps/web/modules/ee/license-check/lib/utils.ts
|
||||
const getSpecificFeatureFlag = async (featureKey) => {
|
||||
const licenseFeatures = await getLicenseFeatures();
|
||||
if (!licenseFeatures) return false;
|
||||
return licenseFeatures[featureKey] ?? false;
|
||||
};
|
||||
```
|
||||
|
||||
**Used by:**
|
||||
- `getIsMultiOrgEnabled()` - Multiple organizations
|
||||
- `getIsContactsEnabled()` - Contacts & segments
|
||||
- `getIsTwoFactorAuthEnabled()` - 2FA
|
||||
- `getIsSsoEnabled()` - SSO
|
||||
- `getIsAuditLogsEnabled()` - Audit logs
|
||||
|
||||
### 2.3 Pattern C: Cloud-Only (No License Check)
|
||||
|
||||
Features available only on Cloud, gated purely by billing plan:
|
||||
|
||||
```typescript
|
||||
// apps/web/modules/survey/lib/permission.ts
|
||||
export const getExternalUrlsPermission = async (billingPlan) => {
|
||||
if (IS_FORMBRICKS_CLOUD) return billingPlan !== PROJECT_FEATURE_KEYS.FREE;
|
||||
return true; // Always allowed on self-hosted
|
||||
};
|
||||
```
|
||||
|
||||
**Used by:**
|
||||
- External URLs permission
|
||||
- Survey follow-ups (Custom plan only)
|
||||
|
||||
### 2.4 Pattern D: On-Premise Only (Disabled on Cloud)
|
||||
|
||||
Features explicitly disabled on Cloud:
|
||||
|
||||
```typescript
|
||||
// apps/web/modules/ee/license-check/lib/utils.ts
|
||||
export const getIsSamlSsoEnabled = async () => {
|
||||
if (IS_FORMBRICKS_CLOUD) return false; // Never on Cloud
|
||||
const licenseFeatures = await getLicenseFeatures();
|
||||
return licenseFeatures.sso && licenseFeatures.saml;
|
||||
};
|
||||
```
|
||||
|
||||
**Used by:**
|
||||
- SAML SSO
|
||||
- Pretty URLs (slug feature)
|
||||
- Domain/Organization settings page
|
||||
|
||||
---
|
||||
|
||||
## 3. Files Using Enterprise Features
|
||||
|
||||
### 3.1 Core License/Feature Check Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `apps/web/modules/ee/license-check/lib/license.ts` | License fetching & caching |
|
||||
| `apps/web/modules/ee/license-check/lib/utils.ts` | Permission check functions |
|
||||
| `apps/web/modules/ee/license-check/types/enterprise-license.ts` | Type definitions |
|
||||
| `apps/web/lib/constants.ts` | `IS_FORMBRICKS_CLOUD`, `ENTERPRISE_LICENSE_KEY` |
|
||||
|
||||
### 3.2 Feature-Specific Implementation Files
|
||||
|
||||
#### Remove Branding
|
||||
- `apps/web/modules/ee/whitelabel/remove-branding/actions.ts`
|
||||
- `apps/web/modules/ee/whitelabel/remove-branding/components/branding-settings-card.tsx`
|
||||
- `apps/web/modules/projects/settings/look/page.tsx`
|
||||
- `apps/web/modules/projects/settings/actions.ts`
|
||||
|
||||
#### Whitelabel / Email Customization
|
||||
- `apps/web/modules/ee/whitelabel/email-customization/actions.ts`
|
||||
- `apps/web/app/(app)/environments/[environmentId]/settings/(organization)/general/page.tsx`
|
||||
- `apps/web/app/(app)/environments/[environmentId]/settings/(organization)/domain/page.tsx`
|
||||
|
||||
#### Multi-Language Surveys
|
||||
- `apps/web/modules/ee/multi-language-surveys/lib/actions.ts`
|
||||
- `apps/web/modules/ee/multi-language-surveys/components/*.tsx`
|
||||
- `apps/web/modules/ee/languages/page.tsx`
|
||||
|
||||
#### Contacts & Segments
|
||||
- `apps/web/modules/ee/contacts/segments/actions.ts`
|
||||
- `apps/web/modules/ee/contacts/page.tsx`
|
||||
- `apps/web/modules/ee/contacts/api/v1/**/*.ts`
|
||||
- `apps/web/modules/ee/contacts/api/v2/**/*.ts`
|
||||
|
||||
#### Teams & Access Control
|
||||
- `apps/web/modules/ee/teams/team-list/components/teams-view.tsx`
|
||||
- `apps/web/modules/ee/role-management/actions.ts`
|
||||
- `apps/web/modules/organization/settings/teams/page.tsx`
|
||||
- `apps/web/modules/organization/settings/teams/actions.ts`
|
||||
|
||||
#### SSO / SAML
|
||||
- `apps/web/modules/ee/sso/lib/sso-handlers.ts`
|
||||
- `apps/web/modules/ee/auth/saml/api/**/*.ts`
|
||||
- `apps/web/modules/ee/auth/saml/lib/*.ts`
|
||||
- `apps/web/modules/auth/lib/authOptions.ts`
|
||||
|
||||
#### Two-Factor Authentication
|
||||
- `apps/web/modules/ee/two-factor-auth/actions.ts`
|
||||
- `apps/web/modules/ee/two-factor-auth/components/*.tsx`
|
||||
|
||||
#### Quotas
|
||||
- `apps/web/modules/ee/quotas/actions.ts`
|
||||
- `apps/web/modules/ee/quotas/components/*.tsx`
|
||||
- `apps/web/modules/ee/quotas/lib/*.ts`
|
||||
|
||||
#### Audit Logs
|
||||
- `apps/web/modules/ee/audit-logs/lib/handler.ts`
|
||||
- `apps/web/modules/ee/audit-logs/lib/service.ts`
|
||||
|
||||
#### Billing (Cloud Only)
|
||||
- `apps/web/modules/ee/billing/page.tsx`
|
||||
- `apps/web/modules/ee/billing/api/lib/*.ts`
|
||||
- `apps/web/modules/ee/billing/components/*.tsx`
|
||||
|
||||
### 3.3 API Routes Using Feature Checks
|
||||
|
||||
| Route | Feature Check |
|
||||
|-------|---------------|
|
||||
| `apps/web/app/api/v1/client/[environmentId]/responses/route.ts` | Spam protection |
|
||||
| `apps/web/app/api/v2/client/[environmentId]/responses/route.ts` | Spam protection |
|
||||
| `apps/web/app/api/v1/client/[environmentId]/environment/lib/environmentState.ts` | Cloud limits |
|
||||
| `apps/web/modules/ee/contacts/api/v1/client/[environmentId]/user/route.ts` | Contacts |
|
||||
| `apps/web/modules/api/v2/management/responses/lib/response.ts` | Cloud limits |
|
||||
|
||||
### 3.4 UI Pages with Conditional Rendering
|
||||
|
||||
| Page | Condition |
|
||||
|------|-----------|
|
||||
| `apps/web/app/(app)/environments/[environmentId]/settings/(organization)/billing/` | Cloud only |
|
||||
| `apps/web/app/(app)/environments/[environmentId]/settings/(organization)/enterprise/page.tsx` | On-premise only |
|
||||
| `apps/web/app/(app)/environments/[environmentId]/settings/(organization)/domain/page.tsx` | On-premise only |
|
||||
| `apps/web/app/p/[slug]/page.tsx` (Pretty URLs) | On-premise only |
|
||||
|
||||
---
|
||||
|
||||
## 4. Configuration & Environment Variables
|
||||
|
||||
### 4.1 Key Environment Variables
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
|----------|---------|---------|
|
||||
| `IS_FORMBRICKS_CLOUD` | Enables cloud mode | `"0"` |
|
||||
| `ENTERPRISE_LICENSE_KEY` | License key for on-premise | (empty) |
|
||||
| `STRIPE_SECRET_KEY` | Stripe API key (Cloud) | (empty) |
|
||||
| `AUDIT_LOG_ENABLED` | Enable audit logs | `"0"` |
|
||||
| `SAML_DATABASE_URL` | SAML configuration DB | (empty) |
|
||||
|
||||
### 4.2 Database Schema
|
||||
|
||||
```prisma
|
||||
// Organization billing stored in JSON column
|
||||
billing: {
|
||||
stripeCustomerId: string | null,
|
||||
plan: "free" | "startup" | "scale" | "enterprise",
|
||||
period: "monthly" | "yearly",
|
||||
limits: {
|
||||
projects: number | null,
|
||||
monthly: {
|
||||
responses: number | null,
|
||||
miu: number | null,
|
||||
}
|
||||
},
|
||||
periodStart: Date | null
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Problems with Current Approach
|
||||
|
||||
### 5.1 Code Duplication
|
||||
|
||||
Almost every feature check function has this pattern:
|
||||
```typescript
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
// Check billing plan
|
||||
} else {
|
||||
// Check license feature
|
||||
}
|
||||
```
|
||||
|
||||
This is repeated in:
|
||||
- 8+ permission check functions in `utils.ts`
|
||||
- 30+ files that consume these functions
|
||||
- Multiple API routes and pages
|
||||
|
||||
### 5.2 Inconsistent Feature Gating
|
||||
|
||||
| Feature | Cloud Gating | On-Premise Gating |
|
||||
|---------|--------------|-------------------|
|
||||
| Remove Branding | `plan !== FREE` | `license.features.removeBranding` |
|
||||
| Multi-Language | `plan === CUSTOM` OR `license.multiLanguageSurveys` | `license.multiLanguageSurveys` |
|
||||
| Follow-ups | `plan === CUSTOM` | Always allowed |
|
||||
| SAML SSO | Never allowed | `license.sso && license.saml` |
|
||||
| Teams | `plan === CUSTOM` OR `license.accessControl` | `license.accessControl` |
|
||||
|
||||
### 5.3 Confusing License Requirement on Cloud
|
||||
|
||||
Cloud deployments still require `ENTERPRISE_LICENSE_KEY` to be set for enterprise features to work:
|
||||
```typescript
|
||||
// utils.ts - getFeaturePermission
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
return license.active && billingPlan !== PROJECT_FEATURE_KEYS.FREE;
|
||||
// ^^^^^^^^^^^^^^ Still checks license!
|
||||
}
|
||||
```
|
||||
|
||||
This means Cloud needs **both**:
|
||||
1. Active billing plan (Stripe subscription)
|
||||
2. Active enterprise license
|
||||
|
||||
### 5.4 Fallback Logic Complexity
|
||||
|
||||
```typescript
|
||||
const featureFlagFallback = async (billingPlan) => {
|
||||
const license = await getEnterpriseLicense();
|
||||
if (IS_FORMBRICKS_CLOUD) return license.active && billingPlan === PROJECT_FEATURE_KEYS.CUSTOM;
|
||||
else if (!IS_FORMBRICKS_CLOUD) return license.active;
|
||||
return false;
|
||||
};
|
||||
```
|
||||
|
||||
Features have "fallback" behavior for backwards compatibility, adding another layer of complexity.
|
||||
|
||||
### 5.5 Testing Complexity
|
||||
|
||||
Tests must mock both:
|
||||
- `IS_FORMBRICKS_CLOUD` constant
|
||||
- `getEnterpriseLicense()` function
|
||||
- `organization.billing.plan` in some cases
|
||||
|
||||
See: `apps/web/modules/ee/license-check/lib/utils.test.ts` (400+ lines of test mocking)
|
||||
|
||||
---
|
||||
|
||||
## 6. Feature Availability Matrix
|
||||
|
||||
| Feature | Free (Cloud) | Startup (Cloud) | Custom (Cloud) | No License (On-Prem) | License (On-Prem) |
|
||||
|---------|--------------|-----------------|----------------|---------------------|-------------------|
|
||||
| Remove Branding | ❌ | ✅ | ✅ | ❌ | ✅* |
|
||||
| Whitelabel | ❌ | ✅ | ✅ | ❌ | ✅* |
|
||||
| Multi-Language | ❌ | ❌ | ✅ | ❌ | ✅* |
|
||||
| Teams & Roles | ❌ | ❌ | ✅ | ❌ | ✅* |
|
||||
| Contacts | ❌ | ❌ | ❌ | ❌ | ✅* |
|
||||
| SSO (OIDC) | ❌ | ❌ | ❌ | ❌ | ✅* |
|
||||
| SAML SSO | ❌ | ❌ | ❌ | ❌ | ✅* |
|
||||
| 2FA | ❌ | ❌ | ❌ | ❌ | ✅* |
|
||||
| Audit Logs | ❌ | ❌ | ❌ | ❌ | ✅* |
|
||||
| Quotas | ❌ | ❌ | ✅ | ❌ | ✅* |
|
||||
| Spam Protection | ❌ | ❌ | ✅ | ❌ | ✅* |
|
||||
| Follow-ups | ❌ | ❌ | ✅ | ✅ | ✅ |
|
||||
| Pretty URLs | ❌ | ❌ | ❌ | ✅ | ✅ |
|
||||
| Projects Limit | 3 | 3 | Custom | 3 | Custom* |
|
||||
|
||||
*Depends on specific license feature flags
|
||||
|
||||
---
|
||||
|
||||
## 7. Recommendations for Refactoring
|
||||
|
||||
### 7.1 Unified Feature Access Layer
|
||||
|
||||
Create a single `FeatureAccess` service that abstracts the deployment type:
|
||||
|
||||
```typescript
|
||||
interface FeatureAccessService {
|
||||
canAccessFeature(feature: FeatureKey, context: AccessContext): Promise<boolean>;
|
||||
getLimit(limit: LimitKey, context: LimitContext): Promise<number>;
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 Normalize Feature Flags
|
||||
|
||||
Both Cloud and On-Premise should use the same feature flag schema. Cloud billing plans should map to predefined feature sets.
|
||||
|
||||
### 7.3 Remove License Requirement from Cloud
|
||||
|
||||
Cloud should not need `ENTERPRISE_LICENSE_KEY`. The license server should be bypassed entirely, with features controlled by billing plan.
|
||||
|
||||
### 7.4 Consider Feature Entitlements
|
||||
|
||||
Move to an "entitlements" model where:
|
||||
- Cloud: Stripe subscription metadata defines entitlements
|
||||
- On-Premise: License API returns entitlements
|
||||
|
||||
Both resolve to the same `TFeatureEntitlements` type.
|
||||
|
||||
---
|
||||
|
||||
## 8. Files That Would Need Changes
|
||||
|
||||
### High Priority (Core Logic)
|
||||
1. `apps/web/modules/ee/license-check/lib/license.ts`
|
||||
2. `apps/web/modules/ee/license-check/lib/utils.ts`
|
||||
3. `apps/web/lib/constants.ts`
|
||||
|
||||
### Medium Priority (Feature Implementations)
|
||||
4. All files in `apps/web/modules/ee/*/actions.ts`
|
||||
5. `apps/web/modules/environments/lib/utils.ts`
|
||||
6. `apps/web/modules/survey/lib/permission.ts`
|
||||
7. `apps/web/modules/survey/follow-ups/lib/utils.ts`
|
||||
|
||||
### Lower Priority (UI Conditional Rendering)
|
||||
8. Settings pages with `IS_FORMBRICKS_CLOUD` checks
|
||||
9. `UpgradePrompt` component usages
|
||||
10. Navigation components
|
||||
|
||||
---
|
||||
|
||||
## 9. Summary
|
||||
|
||||
The current implementation has **organic complexity** from evolving independently for Cloud and On-Premise deployments. A refactor should:
|
||||
|
||||
1. **Unify** the feature access mechanism behind a single interface
|
||||
2. **Simplify** by removing the dual-check pattern
|
||||
3. **Normalize** feature definitions across deployment types
|
||||
4. **Test** with a cleaner mocking strategy
|
||||
|
||||
This would reduce the 100+ files touching enterprise features to a single source of truth, making the codebase more maintainable and reducing bugs from inconsistent feature gating.
|
||||
428
ENTERPRISE_PRD.md
Normal file
@@ -0,0 +1,428 @@
|
||||
I've spent ~2 days iterating over this, setting up Stripe, building our update pricing table, etc. So even though the formatting suggests this to be AI Slob, it's hand-crafted and I've read every line to make sure there is no misleading information 😇
|
||||
|
||||
------
|
||||
|
||||
### Unified Billing & Feature Access
|
||||
|
||||
**Document Version:** 2.1
|
||||
**Last Updated:** January 17, 2026
|
||||
**Status:** Ready for development
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
Formbricks Cloud needs a unified, Stripe-native approach to billing, feature entitlements, and usage metering. The current implementation has billing logic scattered throughout the codebase, making it difficult to maintain pricing consistency and add new features.
|
||||
|
||||
This PRD outlines the requirements for:
|
||||
1. Using Stripe as the single source of truth for features and billing
|
||||
2. Implementing usage-based billing with graduated pricing
|
||||
3. Giving customers control through spending caps
|
||||
|
||||
**Scope**: This initiative focuses on Formbricks Cloud. On-Premise licensing will be addressed separately.
|
||||
|
||||
---
|
||||
|
||||
## 2. Problem Statement
|
||||
|
||||
### Current Pain Points
|
||||
|
||||
1. **Scattered Billing Logic**: Feature availability is determined by code checks against `organization.billing.plan`, requiring code changes for any pricing adjustment.
|
||||
|
||||
2. **Inconsistent Feature Gating**: Different features use different patterns to check access, making it unclear what's available on each plan.
|
||||
|
||||
3. **No Usage-Based Billing**: Current plans have hard limits. Customers hitting limits must upgrade to a higher tier even if they only need slightly more capacity.
|
||||
|
||||
4. **No Spending Controls**: Customers on usage-based plans have no way to cap their spending.
|
||||
|
||||
5. **Manual Usage Tracking**: Response and user counts are tracked locally without integration to billing.
|
||||
|
||||
---
|
||||
|
||||
## 3. Goals
|
||||
|
||||
1. **Stripe as Source of Truth**: All feature entitlements and pricing come from Stripe, not hardcoded in the application.
|
||||
|
||||
2. **Usage-Based Billing**: Implement graduated pricing where customers pay for what they use beyond included amounts.
|
||||
|
||||
3. **Customer Control**: Allow customers to set spending caps to avoid unexpected charges.
|
||||
|
||||
4. **Proactive Communication**: Notify customers as they approach usage limits.
|
||||
|
||||
---
|
||||
|
||||
## 4. Feature Requirements
|
||||
|
||||
### 4.1 Stripe as Single Source of Truth
|
||||
|
||||
**Requirement**: The Formbricks instance should not contain billing or pricing logic. All feature availability must be determined by querying Stripe.
|
||||
|
||||
**What This Means**:
|
||||
- No hardcoded plan names or feature mappings in the codebase
|
||||
- No `if (plan === 'pro')` style checks
|
||||
- Feature checks query Stripe Entitlements API
|
||||
- Pricing displayed in UI is fetched from Stripe Products/Prices
|
||||
- Plan changes take effect immediately via Stripe webhooks
|
||||
|
||||
**Benefits**:
|
||||
- Change pricing without code deployment
|
||||
- Add new plans without code changes
|
||||
- A/B test pricing externally
|
||||
- Single source of truth for sales, support, and product
|
||||
|
||||
---
|
||||
|
||||
### 4.2 Stripe Entitlements for Feature Access
|
||||
|
||||
**Requirement**: Use Stripe's Entitlements API to determine which features each customer can access.
|
||||
|
||||
**How It Works**:
|
||||
1. Define Features in Stripe (see inventory below)
|
||||
2. Attach Features to Products via ProductFeature
|
||||
3. When customer subscribes, Stripe creates Active Entitlements
|
||||
4. Application checks entitlements before enabling features
|
||||
5. Stripe is already setup correctly with all Products & Features ✅
|
||||
|
||||
**Multi-Item Subscriptions Simplify Entitlements**:
|
||||
- Each plan subscription includes multiple prices (flat fee + metered usage) on the **same Product**
|
||||
- Since all prices belong to one Product, calling `stripe.entitlements.activeEntitlements.list()` returns all features for that plan automatically
|
||||
- No need to check multiple products or stitch together entitlements from separate subscriptions
|
||||
|
||||
**Feature Inventory (not up-to-date but you get the idea)**:
|
||||
|
||||
| Feature Name | Lookup Key | Description |
|
||||
|--------------|------------|-------------|
|
||||
| Hide Branding | `hide-branding` | Hide "Powered by Formbricks" |
|
||||
| API Access | `api-access` | Gates API key generation & API page access |
|
||||
| Integrations | `integrations` | Gates integrations page & configuration |
|
||||
| Custom Webhooks | `webhooks` | Webhook integrations |
|
||||
| Email Follow-ups | `follow-ups` | Automated email follow-ups |
|
||||
| Custom Links in Surveys | `custom-links-in-surveys` | Custom links within surveys |
|
||||
| Custom Redirect URL | `custom-redirect-url` | Custom thank-you redirects |
|
||||
| Two Factor Auth | `two-fa` | 2FA for user accounts |
|
||||
| Contacts & Segments | `contacts` | Contact management & segmentation |
|
||||
| Teams & Access Roles | `rbac` | Team-based permissions |
|
||||
| Quota Management | `quota-management` | Response quota controls |
|
||||
| Spam Protection | `spam-protection` | reCAPTCHA integration |
|
||||
| Workspace Limit 1 | `workspace-limit-1` | Up to 1 workspaces |
|
||||
| Workspace Limit 3 | `workspace-limit-3` | Up to 3 workspaces |
|
||||
| Workspace Limit 5 | `workspace-limit-5` | Up to 5 workspaces |
|
||||
|
||||
<img width="915" height="827" alt="Image" src="https://github.com/user-attachments/assets/1f0e17b5-82c3-475c-9c05-968fdc51e948" />
|
||||
|
||||
---
|
||||
|
||||
### 4.3 Plan Structure
|
||||
|
||||
**Plans & Pricing**:
|
||||
|
||||
| Plan | Monthly Price | Annual Price | Savings |
|
||||
|------|---------------|--------------|---------|
|
||||
| **Hobby** | Free | Free | — |
|
||||
| **Pro** | $89/month | $890/year | 2 months free |
|
||||
| **Scale** | $390/month | $3,900/year | 2 months free |
|
||||
|
||||
**Usage Limits**:
|
||||
|
||||
| Plan | Workspaces | Responses/mo | Contacts/mo | Overage Billing |
|
||||
|------|------------|--------------|-------------|-----------------|
|
||||
| **Hobby** | 1 | 250 | — | No |
|
||||
| **Pro** | 3 | 2,000 | 5,000 | Yes |
|
||||
| **Scale** | 5 | 5,000 | 10,000 | Yes |
|
||||
|
||||
**Note**: Hobby plan does not include Respondent Identification or Contact Management. Overage billing is only available on Pro and Scale plans.
|
||||
|
||||
<img width="1205" height="955" alt="Image" src="https://github.com/user-attachments/assets/047d4097-f7ee-4022-920a-e2cbeb8ceb5d" />
|
||||
|
||||
---
|
||||
|
||||
### 4.4 Restricted Features (Hobby & Trial Exclusions)
|
||||
|
||||
**Requirement**: Certain high-risk features must be excluded from Free (Hobby) plan AND Trial users to prevent fraud and abuse. Other features are included in Trial to maximize conversion.
|
||||
|
||||
**Restricted Features (blocked from Hobby + Trial)**:
|
||||
|
||||
| Feature | Lookup Key | Abuse Risk | Why Restricted |
|
||||
|---------|------------|------------|----------------|
|
||||
| Custom Redirect URL | `custom-redirect-url` | High | Phishing redirects after survey |
|
||||
| Custom Links in Surveys | `custom-links-in-surveys` | High | Malicious link distribution in survey content |
|
||||
|
||||
**Trial-Included Features (to drive conversion)**:
|
||||
|
||||
| Feature | Lookup Key | Why Included in Trial |
|
||||
|---------|------------|----------------------|
|
||||
| Webhooks | `webhooks` | Low abuse risk, high setup effort = conversion driver |
|
||||
| API Access | `api-access` | Low abuse risk, high integration value |
|
||||
| Integrations | `integrations` | Low abuse risk, high integration value |
|
||||
| Email Follow-ups | `follow-ups` | Requires email verification, monitored |
|
||||
| Hide Branding | `hide-branding` | No abuse risk, strong conversion driver |
|
||||
| RBAC | `rbac` | No abuse risk, team adoption driver |
|
||||
| Spam Protection | `spam-protection` | Actually prevents abuse |
|
||||
| Quota Management | `quota-management` | Administrative feature |
|
||||
|
||||
**Implementation**:
|
||||
- Restricted features are NOT attached to Hobby or Trial products in Stripe
|
||||
- Trial includes most Pro/Scale features to maximize value demonstration
|
||||
- Application checks entitlements via Stripe API - if feature not present, show existing upgrade UI
|
||||
|
||||
---
|
||||
|
||||
### 4.5 Usage-Based Billing with Graduated Pricing
|
||||
|
||||
<img width="1041" height="125" alt="Image" src="https://github.com/user-attachments/assets/f12a56da-89d2-4784-b3c0-6c55dbee85e6" />
|
||||
|
||||
**Requirement**: Implement usage-based billing where customers pay a base fee that includes a usage allowance, with flat overage pricing.
|
||||
|
||||
**Metrics to Meter**:
|
||||
|
||||
| Metric | Event Name | Description |
|
||||
|--------|------------|-------------|
|
||||
| **Responses** | `response_created` | Survey responses submitted |
|
||||
| **Identified Contacts** | `unique_contact_identified` | Unique contacts identified per month |
|
||||
|
||||
**Identified Contacts Definition (ON HOLD)**:
|
||||
An identified contact is one that has been identified in the current billing period via:
|
||||
- SDK call: `formbricks.setUserId(userId)`
|
||||
- Personal Survey Link access
|
||||
- This OUT OF SCOPE for the first iteration to not become a blocker. We can add it if all works end-to-end
|
||||
|
||||
**Counting Rules**:
|
||||
- Each contact identification counts (even if same contact identified multiple times via different methods)
|
||||
- Same contact re-accessing their personal link = 1 count (same contact)
|
||||
- Billing period is monthly (even for annual subscribers)
|
||||
- Meter events sent immediately (real-time)
|
||||
|
||||
**Hard Limits via Stripe Metering**:
|
||||
- Usage is metered through Stripe for billing AND enforcement
|
||||
- When included usage is exhausted, overage rates apply
|
||||
- No separate local limit enforcement needed
|
||||
|
||||
---
|
||||
|
||||
### 4.6 Spending Caps
|
||||
|
||||
**Requirement**: Customers must be able to set a maximum monthly spend for usage-based charges.
|
||||
|
||||
**Behavior**:
|
||||
|
||||
| Cap Setting | Effect |
|
||||
|-------------|--------|
|
||||
| No cap (default) | Usage billed without limit |
|
||||
| Cap with "Warn" | Notifications sent, billing continues |
|
||||
| Cap with "Pause" | Surveys paused when cap reached |
|
||||
|
||||
**Configuration**:
|
||||
- Minimum spending cap: **$10**
|
||||
- No grace period when cap is hit
|
||||
- Immediate pause if "Pause" mode selected
|
||||
- Stripe does not provide spending caps out of the box, this is something we need to custom develop
|
||||
|
||||
**When Cap is Reached (Pause mode)**:
|
||||
- All surveys for the organization stop collecting responses (needs to be implemented)
|
||||
- Existing responses are preserved
|
||||
- In-app banner explains the pause
|
||||
- Email notification sent to billing contacts
|
||||
- Owner can lift pause or increase cap
|
||||
|
||||
<img width="925" height="501" alt="Image" src="https://github.com/user-attachments/assets/511d1ec6-4550-4aec-8f31-ab68e8c9e383" />
|
||||
|
||||
_The Pause vs. Alert mode is missing so far._
|
||||
|
||||
---
|
||||
|
||||
### 4.7 Usage Alerts via Stripe Meter Alerts
|
||||
|
||||
**Requirement**: Proactively notify customers as they approach their included usage limits.
|
||||
|
||||
**Alert Thresholds**:
|
||||
|
||||
| Threshold | Notification |
|
||||
|-----------|--------------|
|
||||
| 80% of included | Email notification |
|
||||
| 90% of included | Email + in-app banner |
|
||||
| 100% of included | Email + in-app + (if cap) action |
|
||||
|
||||
**Notification Content**:
|
||||
- Current usage vs included amount
|
||||
- What happens next (overage pricing applies)
|
||||
- Link to upgrade or adjust spending cap
|
||||
|
||||
<img width="415" height="348" alt="Image" src="https://github.com/user-attachments/assets/7bd990b4-7150-4357-af84-9c5e98f75140" />
|
||||
|
||||
---
|
||||
|
||||
### 4.8 Annual Billing with Monthly Limits
|
||||
|
||||
**Requirement**: Support annual payment option while keeping all usage limits monthly.
|
||||
|
||||
**Behavior**:
|
||||
- Annual subscribers pay upfront for 12 months
|
||||
- **2 months free** discount (annual = 10x monthly price)
|
||||
- Usage limits reset monthly (same as monthly subscribers)
|
||||
- Overage is billed monthly
|
||||
- Example: Annual Pro pays $890/year, gets 2,000 responses/month every month
|
||||
|
||||
<img width="1033" height="429" alt="Image" src="https://github.com/user-attachments/assets/58df55c7-e20f-448c-953d-e62c57268421" />
|
||||
|
||||
---
|
||||
|
||||
### 4.9 Reverse Trial Experience
|
||||
|
||||
**Requirement**: New users should experience premium features immediately through a Reverse Trial model.
|
||||
|
||||
**Trigger**: We have UI to present to them to opt into the free trial
|
||||
|
||||
**Trial Terms**:
|
||||
- Duration: 14 days
|
||||
- Features: Enroll to Trial Product (free)
|
||||
- Limits: We have to see how to enforce those, gotta check what Stripe API offers us. Probably a dedicated Trial Meter
|
||||
- No payment required to start
|
||||
- Stripe customer created immediately (for metering)
|
||||
|
||||
**Post-Trial (No Conversion)**:
|
||||
- Downgrade to Hobby (Free) tier immediately
|
||||
- Pro features disabled immediately
|
||||
- Data preserved but locked behind upgrade
|
||||
|
||||
---
|
||||
|
||||
### 4.10 Stripe Customer Creation on Signup
|
||||
|
||||
**Requirement**: Create a Stripe customer immediately when a new organization is created.
|
||||
|
||||
**Rationale**:
|
||||
- Enables usage metering from day one
|
||||
- Stripe handles hard limits via metering
|
||||
- Simplifies upgrade flow (customer already exists)
|
||||
|
||||
**What Gets Created**:
|
||||
- Stripe Customer with organization ID in metadata
|
||||
- No subscription (Hobby tier has no subscription)
|
||||
- No payment method (added on first upgrade)
|
||||
|
||||
---
|
||||
|
||||
### 5.1 Subscription Architecture: Multi-Item Subscriptions
|
||||
|
||||
**Key Insight**: Each plan uses a **Subscription with Multiple Items** — one flat-fee price and metered usage prices, all belonging to the same Product. This allows us to charge for the base plan, meter and charge per used item.
|
||||
|
||||
**How It Works**:
|
||||
|
||||
```javascript
|
||||
// Creating a Pro subscription with flat fee + usage metering
|
||||
const subscription = await stripe.subscriptions.create({
|
||||
customer: 'cus_12345',
|
||||
items: [
|
||||
{ price: 'price_pro_monthly' }, // $89/mo flat fee
|
||||
{ price: 'price_pro_responses_usage' }, // Metered responses
|
||||
{ price: 'price_pro_contacts_usage' }, // Metered contacts
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
**Why This Matters for Entitlements**:
|
||||
- All prices belong to the **same Product** (e.g., `prod_ProPlan`)
|
||||
- Stripe Entitlements API automatically returns all features attached to that Product
|
||||
- No need to check multiple products or subscriptions
|
||||
- Single source of truth for feature access
|
||||
|
||||
**What Customers See** (Single Invoice):
|
||||
|
||||
| Description | Qty | Amount |
|
||||
|-------------|-----|--------|
|
||||
| Pro Plan (Jan 1 - Feb 1) | 1 | $89.00 |
|
||||
| Pro Plan - Responses (Jan 1 - Feb 1) | 1,500 (First 1,000 included) | $40.00 |
|
||||
| Pro Plan - Contacts (Jan 1 - Feb 1) | 2,500 (All included) | $0.00 |
|
||||
| **Total** | | **$129.00** |
|
||||
|
||||
### 5.2 Products & Prices
|
||||
|
||||
Each plan Product contains multiple Prices:
|
||||
|
||||
| Product | Stripe ID | Prices |
|
||||
|---------|-----------|--------|
|
||||
| **Hobby Tier** | `prod_ToYKB5ESOMZZk5` | Free (no subscription required) |
|
||||
| **Pro Tier** | `prod_ToYKQ8WxS3ecgf` | `price_pro_monthly` ($89), `price_pro_yearly` ($890), `price_pro_usage_responses`, `price_pro_usage_contacts` |
|
||||
| **Scale Tier** | `prod_ToYLW5uCQTMa6v` | `price_scale_monthly` ($390), `price_scale_yearly` ($3,900), `price_scale_usage_responses`, `price_scale_usage_contacts` |
|
||||
| **Trial Tier** | `prod_TodVcJiEnK5ABK` | `price_trial_free` ($0), metered prices for tracking |
|
||||
|
||||
**Note**: Response and Contact metered prices use **graduated tiers** where the first N units are included (priced at $0), then overage rates apply.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 6. Non-Functional Requirements
|
||||
|
||||
### 6.1 Performance
|
||||
|
||||
- Entitlement checks: <100ms (p50), <200ms (p99) with caching
|
||||
- Usage metering: Non-blocking, immediate send
|
||||
- Spending cap checks: <50ms
|
||||
|
||||
### 6.2 Reliability
|
||||
|
||||
- Stripe unavailable: Use cached entitlements (max 5 min stale)
|
||||
- Meter event fails: Queue for retry (at-least-once delivery)
|
||||
- Webhook missed: Entitlements auto-refresh on access
|
||||
|
||||
### 6.3 Data Consistency
|
||||
|
||||
- Stripe is source of truth
|
||||
- Local `organization.billing` is a cache only
|
||||
- Cache invalidated via webhooks
|
||||
|
||||
---
|
||||
|
||||
## 7. Migration Considerations
|
||||
|
||||
### Existing Customers with Custom Limits
|
||||
|
||||
**Problem**: Some existing customers have negotiated custom limits that don't fit the new plan structure.
|
||||
|
||||
**Approach**: Grandfather indefinitely on legacy pricing until they choose to migrate.
|
||||
|
||||
- Existing customers keep their current plans and limits
|
||||
- No forced migration
|
||||
- New billing system only applies to new signups and customers who voluntarily upgrade/change plans
|
||||
- Legacy customers get a simplified view with the new usage meters UI and a the "Manage subscription" button. We hide all of the other UI and prompt them to reach out to Support to change their pricing (Alert component)
|
||||
|
||||
---
|
||||
|
||||
## 8. Key Decisions
|
||||
|
||||
| Topic | Decision |
|
||||
|-------|----------|
|
||||
| Free tier metering | Use Stripe for hard limits (no local enforcement) |
|
||||
| Annual discount | 2 months free |
|
||||
| Minimum spending cap | $10 |
|
||||
| Cap grace period | None (immediate) |
|
||||
| Contact identification counting | Each identification counts |
|
||||
| Personal link re-access | Same contact = 1 count |
|
||||
| Downgrade behavior for restricted features | Immediate disable |
|
||||
| Meter event timing | Immediate (real-time) |
|
||||
| Currency | USD only |
|
||||
| Default spending cap | No cap |
|
||||
| Overage visibility | Billing page |
|
||||
| Migration for custom limits | Grandfather indefinitely |
|
||||
|
||||
---
|
||||
|
||||
## 9. Out of Scope
|
||||
|
||||
1. **On-Premise licensing**: Will be addressed separately
|
||||
2. **Self-serve downgrade**: Handled via Stripe Customer Portal
|
||||
3. **Refunds**: Handled via Stripe Dashboard
|
||||
4. **Tax calculation**: Handled by Stripe Tax
|
||||
5. **Invoice customization**: Handled via Stripe settings
|
||||
|
||||
---
|
||||
|
||||
## 10. Setting up
|
||||
|
||||
**Stripe** Sandbox Cloud: Dev
|
||||
|
||||
<img width="300" height="180" alt="Image" src="https://github.com/user-attachments/assets/3e6a7fb6-8efb-4cb5-acf0-ccc2a5efabd2" />
|
||||
|
||||
In this branch you find;
|
||||
- All of the dummy UI screenshotted here. Make sure to clean up after it was successfully implemented (has dummy UI code)
|
||||
- A comprehensive analysis of our current, inconsistent feature flagging called ENTERPRISE_FEATURE_ANALYSIS
|
||||
@@ -0,0 +1,20 @@
|
||||
import { SelectPlanCard } from "@/modules/ee/billing/components/select-plan-card";
|
||||
import { Header } from "@/modules/ui/components/header";
|
||||
|
||||
interface SelectPlanOnboardingProps {
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
export const SelectPlanOnboarding = ({ organizationId }: SelectPlanOnboardingProps) => {
|
||||
const nextUrl = `/organizations/${organizationId}/workspaces/new/mode`;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-8">
|
||||
<Header
|
||||
title="Ship professional, unbranded surveys today!"
|
||||
subtitle="No credit card required, no strings attached."
|
||||
/>
|
||||
<SelectPlanCard nextUrl={nextUrl} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getOrganizationAuth } from "@/modules/organization/lib/utils";
|
||||
import { SelectPlanOnboarding } from "./components/select-plan-onboarding";
|
||||
|
||||
interface PlanPageProps {
|
||||
params: Promise<{
|
||||
organizationId: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
const Page = async (props: PlanPageProps) => {
|
||||
const params = await props.params;
|
||||
|
||||
// Only show on Cloud
|
||||
if (!IS_FORMBRICKS_CLOUD) {
|
||||
return redirect(`/organizations/${params.organizationId}/workspaces/new/mode`);
|
||||
}
|
||||
|
||||
const { session } = await getOrganizationAuth(params.organizationId);
|
||||
|
||||
if (!session?.user) {
|
||||
return redirect(`/auth/login`);
|
||||
}
|
||||
|
||||
return <SelectPlanOnboarding organizationId={params.organizationId} />;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -2,6 +2,7 @@ import type { Session } from "next-auth";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import ClientEnvironmentRedirect from "@/app/ClientEnvironmentRedirect";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getIsFreshInstance } from "@/lib/instance/service";
|
||||
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
@@ -66,6 +67,10 @@ const Page = async () => {
|
||||
|
||||
if (!firstProductionEnvironmentId) {
|
||||
if (isOwner || isManager) {
|
||||
// On Cloud, show plan selection first for new users without any projects
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
return redirect(`/organizations/${userOrganizations[0].id}/workspaces/new/plan`);
|
||||
}
|
||||
return redirect(`/organizations/${userOrganizations[0].id}/workspaces/new/mode`);
|
||||
} else {
|
||||
return redirect(`/organizations/${userOrganizations[0].id}/landing`);
|
||||
|
||||
9
apps/web/images/customer-logos/cal-logo-light.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg width="101" height="22" viewBox="0 0 101 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.0582 20.817C4.32115 20.817 0 16.2763 0 10.6704C0 5.04589 4.1005 0.467773 10.0582 0.467773C13.2209 0.467773 15.409 1.43945 17.1191 3.66311L14.3609 5.96151C13.2025 4.72822 11.805 4.11158 10.0582 4.11158C6.17833 4.11158 4.04533 7.08268 4.04533 10.6704C4.04533 14.2582 6.38059 17.1732 10.0582 17.1732C11.7866 17.1732 13.2577 16.5566 14.4161 15.3233L17.1375 17.7151C15.501 19.8453 13.2577 20.817 10.0582 20.817Z" fill="#292929"/>
|
||||
<path d="M29.0161 5.88601H32.7304V20.4612H29.0161V18.331C28.2438 19.8446 26.9566 20.8536 24.4927 20.8536C20.5577 20.8536 17.4133 17.4341 17.4133 13.2297C17.4133 9.02528 20.5577 5.60571 24.4927 5.60571C26.9383 5.60571 28.2438 6.61477 29.0161 8.12835V5.88601ZM29.1264 13.2297C29.1264 10.95 27.5634 9.06266 25.0995 9.06266C22.7274 9.06266 21.1828 10.9686 21.1828 13.2297C21.1828 15.4346 22.7274 17.3967 25.0995 17.3967C27.5451 17.3967 29.1264 15.4907 29.1264 13.2297Z" fill="#292929"/>
|
||||
<path d="M35.3599 0H39.0742V20.4427H35.3599V0Z" fill="#292929"/>
|
||||
<path d="M40.7291 18.5182C40.7291 17.3223 41.6853 16.3132 42.9908 16.3132C44.2964 16.3132 45.2158 17.3223 45.2158 18.5182C45.2158 19.7515 44.278 20.7605 42.9908 20.7605C41.7037 20.7605 40.7291 19.7515 40.7291 18.5182Z" fill="#292929"/>
|
||||
<path d="M59.4296 18.1068C58.0505 19.7885 55.9543 20.8536 53.4719 20.8536C49.0404 20.8536 45.7858 17.4341 45.7858 13.2297C45.7858 9.02528 49.0404 5.60571 53.4719 5.60571C55.8623 5.60571 57.9402 6.61477 59.3193 8.20309L56.4508 10.6136C55.7336 9.71667 54.7958 9.04397 53.4719 9.04397C51.0999 9.04397 49.5553 10.95 49.5553 13.211C49.5553 15.472 51.0999 17.378 53.4719 17.378C54.9062 17.378 55.8991 16.6306 56.6346 15.6215L59.4296 18.1068Z" fill="#292929"/>
|
||||
<path d="M59.7422 13.2297C59.7422 9.02528 62.9968 5.60571 67.4283 5.60571C71.8598 5.60571 75.1144 9.02528 75.1144 13.2297C75.1144 17.4341 71.8598 20.8536 67.4283 20.8536C62.9968 20.8349 59.7422 17.4341 59.7422 13.2297ZM71.3449 13.2297C71.3449 10.95 69.8003 9.06266 67.4283 9.06266C65.0563 9.04397 63.5117 10.95 63.5117 13.2297C63.5117 15.4907 65.0563 17.3967 67.4283 17.3967C69.8003 17.3967 71.3449 15.4907 71.3449 13.2297Z" fill="#292929"/>
|
||||
<path d="M100.232 11.5482V20.4428H96.518V12.4638C96.518 9.94119 95.3412 8.85739 93.576 8.85739C91.921 8.85739 90.7442 9.67958 90.7442 12.4638V20.4428H87.0299V12.4638C87.0299 9.94119 85.8346 8.85739 84.0878 8.85739C82.4329 8.85739 80.9802 9.67958 80.9802 12.4638V20.4428H77.2659V5.8676H80.9802V7.88571C81.7525 6.31607 83.15 5.53125 85.3014 5.53125C87.3425 5.53125 89.0525 6.5403 89.9903 8.24074C90.9281 6.50293 92.3072 5.53125 94.8079 5.53125C97.8603 5.54994 100.232 7.86702 100.232 11.5482Z" fill="#292929"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
BIN
apps/web/images/customer-logos/ethereum-logo.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
38
apps/web/images/customer-logos/flixbus-white.svg
Normal file
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 28.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 948 299.3" style="enable-background:new 0 0 948 299.3;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill:#73D700;}
|
||||
.st1{fill:#FFFFFF;}
|
||||
</style>
|
||||
<g id="BG">
|
||||
<rect class="st0" width="948" height="299.3"/>
|
||||
</g>
|
||||
<g id="Logo">
|
||||
<rect x="81.7" y="149.4" class="st1" width="2.5" height="0.8"/>
|
||||
<g>
|
||||
<path class="st1" d="M94.7,82c-7.2,0-13,5.8-13,13v122.5h34.7v-53.2h49.1c7.1,0,13-5.9,13-13v-14.7h-62.1v-20.4
|
||||
c0-3.6,2.9-6.5,6.5-6.5h58.8c7.1,0,13-5.8,13-13V82L94.7,82L94.7,82z"/>
|
||||
<path class="st1" d="M250.7,189.6c-3.6,0-6.5-2.9-6.5-6.5V95.1c0-7.2-5.9-13.1-13.1-13.1h-21.8v122.4c0,7.2,5.9,13.1,13.1,13.1
|
||||
h71.3c7.2,0,13.1-5.9,13.1-13.1v-14.8H250.7L250.7,189.6z"/>
|
||||
<path class="st1" d="M356.7,217.3H322v-70.7c0-7.2,5.9-13,13-13h21.7L356.7,217.3L356.7,217.3z"/>
|
||||
<path class="st1" d="M343.7,121.1H322V95.1c0-7.2,5.9-13,13-13h8.7c7.2,0,13,5.9,13,13v13C356.7,115.2,350.8,121.1,343.7,121.1"/>
|
||||
<path class="st1" d="M580.4,195.4h-23.9c-6.9,0-12.5-5.6-12.5-12.5v-22.7h36.2c9.5,0,17.2,7.9,17.2,17.6S589.8,195.4,580.4,195.4
|
||||
M543.9,104h32.5c8.6,0,15.5,7,15.5,15.7s-6.8,15.6-15.3,15.7h-21.5c-6.2,0-11.2-5-11.2-11.2L543.9,104L543.9,104z M617.5,150.7
|
||||
c-0.8-0.7-2.9-2.4-3.4-2.8c6.5-6.6,9.2-15.4,9.2-26.6c0-24.7-16-39.3-40.7-39.3h-53.4c-7.1,0-13,5.8-13,13v109.4
|
||||
c0,7.1,5.8,13,13,13h59.5c24.7,0,39.7-14.4,39.7-39.1C628.3,166.7,624.3,157.4,617.5,150.7"/>
|
||||
<path class="st1" d="M752.5,82.1H737c-7.1,0-13,5.8-13,13V175c0,11.7-8,19.5-22,19.5h-6.4c-13.9,0-22-7.8-22-19.5V82.1h-15.5
|
||||
c-7.1,0-13,5.8-13,13v84.2c0,24.2,16.6,40.3,45.3,40.3h16.6c28.7,0,45.3-16.1,45.3-40.3L752.5,82.1L752.5,82.1z"/>
|
||||
<path class="st1" d="M810.1,109.8h43.8c7.1,0,13-5.8,13-13V82h-56.8c-22.7,0.2-41,18.7-41,41.4s18,39.6,40.4,40.1l0,0l17.9,0h0
|
||||
c7.2,0.1,13,5.9,13,13.1s-5.8,13-12.9,13.1h-56.7v14.7c0,7.1,5.8,13,13,13h44c22.5-0.4,40.7-18.8,40.7-41.4s-17.8-39.4-40.1-40.1
|
||||
v0h-18.2c-7.2-0.1-13-5.9-13-13.1S802.9,109.8,810.1,109.8"/>
|
||||
<path class="st1" d="M489,193.8l-23.7-32.6l-20.4,28.1l17.4,23.9c5.2,7.2,15.5,8.9,22.7,3.6l0.4-0.3
|
||||
C492.6,211.2,494.2,201,489,193.8"/>
|
||||
<path class="st1" d="M457.1,149.9l-20.4-28.1l-25.6-35.2c-5.2-7.2-15.5-8.8-22.7-3.6l-0.4,0.3c-7.2,5.2-8.9,15.5-3.6,22.7
|
||||
l31.8,43.8l-31.8,43.9c-5.3,7.3-3.7,17.5,3.5,22.8l0.4,0.3c7.2,5.2,17.5,3.6,22.7-3.6l18.8-25.8L457.1,149.9L457.1,149.9z"/>
|
||||
<path class="st1" d="M485.4,83.3L485,83c-7.2-5.2-17.5-3.6-22.7,3.6l-17.4,23.9l20.4,28.1L489,106
|
||||
C494.2,98.8,492.6,88.6,485.4,83.3"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
BIN
apps/web/images/customer-logos/github-logo.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
apps/web/images/customer-logos/pigment-logo.webp
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
apps/web/images/customer-logos/siemens.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
apps/web/images/customer-logos/university-of-copenhegen.png
Normal file
|
After Width: | Height: | Size: 53 KiB |
@@ -56,7 +56,7 @@ export const SignupPage = async ({ searchParams: searchParamsProps }) => {
|
||||
const emailFromSearchParams = searchParams["email"];
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen w-full items-center justify-center bg-[#00C4B8]">
|
||||
<div className="flex min-h-screen w-full items-center justify-center bg-[#D9F6F4]">
|
||||
<FormWrapper>
|
||||
<SignupForm
|
||||
webAppUrl={WEBAPP_URL}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { TFunction } from "i18next";
|
||||
|
||||
export type TUsageLimit = {
|
||||
label: string;
|
||||
value: string;
|
||||
overage?: boolean;
|
||||
};
|
||||
|
||||
export type TPricingPlan = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -10,77 +16,89 @@ export type TPricingPlan = {
|
||||
monthly: string;
|
||||
yearly: string;
|
||||
};
|
||||
mainFeatures: string[];
|
||||
usageLimits: TUsageLimit[];
|
||||
features: string[];
|
||||
addons?: string[];
|
||||
href?: string;
|
||||
};
|
||||
|
||||
export const getCloudPricingData = (t: TFunction): { plans: TPricingPlan[] } => {
|
||||
const freePlan: TPricingPlan = {
|
||||
const hobbyPlan: TPricingPlan = {
|
||||
id: "free",
|
||||
name: t("environments.settings.billing.free"),
|
||||
name: "Hobby",
|
||||
featured: false,
|
||||
description: t("environments.settings.billing.free_description"),
|
||||
price: { monthly: "$0", yearly: "$0" },
|
||||
mainFeatures: [
|
||||
t("environments.settings.billing.unlimited_surveys"),
|
||||
t("environments.settings.billing.1000_monthly_responses"),
|
||||
t("environments.settings.billing.2000_contacts"),
|
||||
t("environments.settings.billing.1_workspace"),
|
||||
t("environments.settings.billing.unlimited_team_members"),
|
||||
t("environments.settings.billing.link_surveys"),
|
||||
t("environments.settings.billing.website_surveys"),
|
||||
t("environments.settings.billing.app_surveys"),
|
||||
t("environments.settings.billing.ios_android_sdks"),
|
||||
t("environments.settings.billing.email_embedded_surveys"),
|
||||
t("environments.settings.billing.logic_jumps_hidden_fields_recurring_surveys"),
|
||||
t("environments.settings.billing.api_webhooks"),
|
||||
t("environments.settings.billing.all_integrations"),
|
||||
t("environments.settings.billing.hosted_in_frankfurt") + " 🇪🇺",
|
||||
description: "Unlimited Surveys, Team Members, and more.",
|
||||
price: { monthly: "Start free", yearly: "Start free" },
|
||||
usageLimits: [
|
||||
{ label: "Workspaces", value: "1" },
|
||||
{ label: "Responses per month", value: "250" },
|
||||
],
|
||||
features: [
|
||||
"Link-based Surveys",
|
||||
"In-product Surveys",
|
||||
"All Question Types",
|
||||
"Multi-language Surveys incl. RTL",
|
||||
"Conditional Logic",
|
||||
"Hidden Fields",
|
||||
"Partial Responses",
|
||||
"Recall Information",
|
||||
"Multi-media Backgrounds",
|
||||
"File Uploads",
|
||||
"Single-use Links",
|
||||
"Hosted in Frankfurt 🇪🇺",
|
||||
],
|
||||
};
|
||||
|
||||
const startupPlan: TPricingPlan = {
|
||||
id: "startup",
|
||||
name: t("environments.settings.billing.startup"),
|
||||
const proPlan: TPricingPlan = {
|
||||
id: "pro",
|
||||
name: "Pro",
|
||||
featured: true,
|
||||
CTA: t("common.start_free_trial"),
|
||||
description: t("environments.settings.billing.startup_description"),
|
||||
price: { monthly: "$49", yearly: "$490" },
|
||||
mainFeatures: [
|
||||
t("environments.settings.billing.everything_in_free"),
|
||||
t("environments.settings.billing.5000_monthly_responses"),
|
||||
t("environments.settings.billing.7500_contacts"),
|
||||
t("environments.settings.billing.3_workspaces"),
|
||||
t("environments.settings.billing.remove_branding"),
|
||||
t("environments.settings.billing.attribute_based_targeting"),
|
||||
description: "Everything in Free with additional features.",
|
||||
price: { monthly: "$89", yearly: "$74" },
|
||||
usageLimits: [
|
||||
{ label: "Workspaces", value: "3" },
|
||||
{ label: "Responses per month", value: "2,000", overage: true },
|
||||
{ label: "Identified Contacts per month", value: "5,000", overage: true },
|
||||
],
|
||||
features: [
|
||||
"Everything in Free",
|
||||
"Unlimited Seats",
|
||||
"Hide Formbricks Branding",
|
||||
"Respondent Identification",
|
||||
"Contact & Segment Management",
|
||||
"Attribute-based Segmentation",
|
||||
"iOS & Android SDKs",
|
||||
"Email Follow-ups",
|
||||
"Custom Webhooks",
|
||||
"All Integrations",
|
||||
],
|
||||
};
|
||||
|
||||
const customPlan: TPricingPlan = {
|
||||
id: "custom",
|
||||
name: t("environments.settings.billing.custom"),
|
||||
const scalePlan: TPricingPlan = {
|
||||
id: "scale",
|
||||
name: "Scale",
|
||||
featured: false,
|
||||
CTA: t("common.request_pricing"),
|
||||
description: t("environments.settings.billing.enterprise_description"),
|
||||
price: {
|
||||
monthly: t("environments.settings.billing.custom"),
|
||||
yearly: t("environments.settings.billing.custom"),
|
||||
},
|
||||
mainFeatures: [
|
||||
t("environments.settings.billing.everything_in_startup"),
|
||||
t("environments.settings.billing.email_follow_ups"),
|
||||
t("environments.settings.billing.custom_response_limit"),
|
||||
t("environments.settings.billing.custom_contacts_limit"),
|
||||
t("environments.settings.billing.custom_workspace_limit"),
|
||||
t("environments.settings.billing.team_access_roles"),
|
||||
t("environments.workspace.languages.multi_language_surveys"),
|
||||
t("environments.settings.billing.uptime_sla_99"),
|
||||
t("environments.settings.billing.premium_support_with_slas"),
|
||||
CTA: t("common.start_free_trial"),
|
||||
description: "Advanced features for scaling your business.",
|
||||
price: { monthly: "$390", yearly: "$325" },
|
||||
usageLimits: [
|
||||
{ label: "Workspaces", value: "5" },
|
||||
{ label: "Responses per month", value: "5,000", overage: true },
|
||||
{ label: "Identified Contacts per month", value: "10,000", overage: true },
|
||||
],
|
||||
href: "https://formbricks.com/custom-plan?source=billingView",
|
||||
features: [
|
||||
"Everything in Pro",
|
||||
"Teams & Access Roles",
|
||||
"Full API Access",
|
||||
"Quota Management",
|
||||
"Two-Factor Auth",
|
||||
"Spam Protection (ReCaptcha)",
|
||||
],
|
||||
addons: ["SSO Enforcement", "Custom SSO", "Hosting in USA 🇺🇸", "SOC-2 Verification"],
|
||||
};
|
||||
|
||||
return {
|
||||
plans: [freePlan, startupPlan, customPlan],
|
||||
plans: [hobbyPlan, proPlan, scalePlan],
|
||||
};
|
||||
};
|
||||
|
||||
292
apps/web/modules/ee/billing/components/overage-card.tsx
Normal file
@@ -0,0 +1,292 @@
|
||||
"use client";
|
||||
|
||||
import { PencilIcon, TrashIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { TabToggle } from "@/modules/ui/components/tab-toggle";
|
||||
import { SettingsId } from "./settings-id";
|
||||
|
||||
type OverageMode = "allow" | "blocked";
|
||||
|
||||
interface OverageUsage {
|
||||
responses: number;
|
||||
responseCost: number;
|
||||
contacts: number;
|
||||
contactsCost: number;
|
||||
}
|
||||
|
||||
interface OverageCardProps {
|
||||
currentMode: OverageMode;
|
||||
spendingLimit: number | null;
|
||||
overageUsage: OverageUsage;
|
||||
onModeChange: (mode: OverageMode) => void | Promise<void>;
|
||||
onSpendingLimitChange: (limit: number | null) => void | Promise<void>;
|
||||
}
|
||||
|
||||
const OVERAGE_MODE_CONFIG: Record<
|
||||
OverageMode,
|
||||
{
|
||||
label: string;
|
||||
tooltip: string;
|
||||
}
|
||||
> = {
|
||||
allow: {
|
||||
label: "Allow",
|
||||
tooltip:
|
||||
"You're currently allowing additional responses over your included response volumes. This is good to keep your surveys running and contacts identified.",
|
||||
},
|
||||
blocked: {
|
||||
label: "Blocked",
|
||||
tooltip:
|
||||
"Overage is blocked. When you reach your included limits, surveys will stop collecting responses and contacts won't be identified until the next billing cycle.",
|
||||
},
|
||||
};
|
||||
|
||||
const MIN_SPENDING_LIMIT = 10;
|
||||
|
||||
export const OverageCard = ({
|
||||
currentMode,
|
||||
spendingLimit,
|
||||
overageUsage,
|
||||
onModeChange,
|
||||
onSpendingLimitChange,
|
||||
}: OverageCardProps) => {
|
||||
const [isChanging, setIsChanging] = useState(false);
|
||||
const [isEditingLimit, setIsEditingLimit] = useState(false);
|
||||
const [limitInput, setLimitInput] = useState(spendingLimit?.toString() ?? "");
|
||||
const [limitError, setLimitError] = useState<string | null>(null);
|
||||
const [showBlockConfirmation, setShowBlockConfirmation] = useState(false);
|
||||
|
||||
const totalOverageCost = overageUsage.responseCost + overageUsage.contactsCost;
|
||||
|
||||
const handleModeChange = (newMode: OverageMode) => {
|
||||
if (newMode === currentMode) return;
|
||||
|
||||
// Show confirmation when switching to blocked with outstanding overage
|
||||
if (newMode === "blocked" && currentMode === "allow" && totalOverageCost > 0) {
|
||||
setShowBlockConfirmation(true);
|
||||
return;
|
||||
}
|
||||
|
||||
executeModeChange(newMode);
|
||||
};
|
||||
|
||||
const executeModeChange = (newMode: OverageMode) => {
|
||||
setIsChanging(true);
|
||||
Promise.resolve(onModeChange(newMode)).finally(() => {
|
||||
setIsChanging(false);
|
||||
setShowBlockConfirmation(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveLimit = () => {
|
||||
const value = Number.parseFloat(limitInput);
|
||||
|
||||
if (limitInput === "" || Number.isNaN(value)) {
|
||||
setLimitError("Please enter a valid amount");
|
||||
return;
|
||||
}
|
||||
|
||||
if (value < MIN_SPENDING_LIMIT) {
|
||||
setLimitError(`Minimum spending limit is $${MIN_SPENDING_LIMIT}`);
|
||||
return;
|
||||
}
|
||||
|
||||
setLimitError(null);
|
||||
setIsChanging(true);
|
||||
Promise.resolve(onSpendingLimitChange(value)).finally(() => {
|
||||
setIsChanging(false);
|
||||
setIsEditingLimit(false);
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveLimit = () => {
|
||||
setIsChanging(true);
|
||||
Promise.resolve(onSpendingLimitChange(null)).finally(() => {
|
||||
setIsChanging(false);
|
||||
setLimitInput("");
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setIsEditingLimit(false);
|
||||
setLimitInput(spendingLimit?.toString() ?? "");
|
||||
setLimitError(null);
|
||||
};
|
||||
|
||||
const handleStartEdit = () => {
|
||||
setLimitInput(spendingLimit?.toString() ?? "");
|
||||
setIsEditingLimit(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<SettingsId
|
||||
label="Overage Mode"
|
||||
value={OVERAGE_MODE_CONFIG[currentMode].label}
|
||||
tooltip={OVERAGE_MODE_CONFIG[currentMode].tooltip}
|
||||
/>
|
||||
|
||||
<div className="w-48">
|
||||
<TabToggle
|
||||
id="overage-mode"
|
||||
options={[
|
||||
{ value: "allow", label: "Allow" },
|
||||
{ value: "blocked", label: "Blocked" },
|
||||
]}
|
||||
defaultSelected={currentMode}
|
||||
onChange={(value) => handleModeChange(value as OverageMode)}
|
||||
disabled={isChanging}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Overage Usage Meters (only when Allow mode) */}
|
||||
{currentMode === "allow" && (
|
||||
<div className="mt-6 grid grid-cols-2 gap-4">
|
||||
{/* Responses Overage */}
|
||||
<div className="rounded-lg border border-slate-100 bg-slate-50 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm text-slate-500">Responses</p>
|
||||
<span className="rounded bg-slate-200 px-1.5 py-0.5 text-xs font-medium text-slate-600">
|
||||
Overage
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-semibold text-slate-900">
|
||||
{overageUsage.responses.toLocaleString()}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-slate-500">${overageUsage.responseCost.toFixed(2)} this month</p>
|
||||
</div>
|
||||
|
||||
{/* Contacts Overage */}
|
||||
<div className="rounded-lg border border-slate-100 bg-slate-50 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm text-slate-500">Identified Contacts</p>
|
||||
<span className="rounded bg-slate-200 px-1.5 py-0.5 text-xs font-medium text-slate-600">
|
||||
Overage
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-semibold text-slate-900">
|
||||
{overageUsage.contacts.toLocaleString()}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-slate-500">${overageUsage.contactsCost.toFixed(2)} this month</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Total Overage Cost & Spending Limit Section (only when Allow mode) */}
|
||||
{currentMode === "allow" && (
|
||||
<div className="mt-6">
|
||||
{/* Spending Stats Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<SettingsId
|
||||
label="Total overage this month"
|
||||
value={`$${totalOverageCost.toFixed(2)}`}
|
||||
tooltip="Billed on Feb 1, 2025"
|
||||
/>
|
||||
{spendingLimit && (
|
||||
<SettingsId
|
||||
label="Spending limit"
|
||||
value={`$${spendingLimit.toLocaleString()} / month`}
|
||||
align="right"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Spending Limit Progress Bar (if limit is set) */}
|
||||
{spendingLimit && (
|
||||
<div className="mt-5">
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-slate-100">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all duration-300",
|
||||
totalOverageCost / spendingLimit > 0.9 ? "bg-red-500" : "bg-teal-500"
|
||||
)}
|
||||
style={{ width: `${Math.min((totalOverageCost / spendingLimit) * 100, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
{((totalOverageCost / spendingLimit) * 100).toFixed(0)}% of spending limit used
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
{isEditingLimit && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label className="text-sm font-medium text-slate-700">Monthly Spending Limit</Label>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative w-32">
|
||||
<span className="absolute top-1/2 left-3 -translate-y-1/2 text-slate-400">$</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={MIN_SPENDING_LIMIT}
|
||||
step="1"
|
||||
value={limitInput}
|
||||
onChange={(e) => {
|
||||
setLimitInput(e.target.value);
|
||||
setLimitError(null);
|
||||
}}
|
||||
placeholder={`${MIN_SPENDING_LIMIT}`}
|
||||
className="pl-7"
|
||||
isInvalid={!!limitError}
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" onClick={handleSaveLimit} loading={isChanging} disabled={isChanging}>
|
||||
Save
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={handleCancelEdit} disabled={isChanging}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
{limitError && <p className="text-sm text-red-500">{limitError}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEditingLimit && spendingLimit && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={handleStartEdit} disabled={isChanging}>
|
||||
<PencilIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||
Edit limit
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={handleRemoveLimit}
|
||||
disabled={isChanging}
|
||||
loading={isChanging}>
|
||||
<TrashIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||
Remove limit
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEditingLimit && !spendingLimit && (
|
||||
<Button size="sm" variant="secondary" onClick={handleStartEdit} disabled={isChanging}>
|
||||
Set spending limit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirmation Modal for blocking with outstanding overage */}
|
||||
<ConfirmationModal
|
||||
open={showBlockConfirmation}
|
||||
setOpen={setShowBlockConfirmation}
|
||||
title="Outstanding overage will be billed"
|
||||
description={`You have $${totalOverageCost.toFixed(2)} in outstanding overage charges.`}
|
||||
body="By blocking overage, this amount will be billed immediately to your payment method on file. After blocking, your surveys will stop collecting responses and contacts won't be identified once you reach your included limits."
|
||||
buttonText="Block overage & bill now"
|
||||
buttonVariant="destructive"
|
||||
onConfirm={() => executeModeChange("blocked")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { CheckIcon, PlusIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOrganization, TOrganizationBillingPeriod } from "@formbricks/types/organizations";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
|
||||
import { TPricingPlan } from "../api/lib/constants";
|
||||
@@ -16,11 +14,8 @@ interface PricingCardProps {
|
||||
organization: TOrganization;
|
||||
onUpgrade: () => Promise<void>;
|
||||
onManageSubscription: () => Promise<void>;
|
||||
projectFeatureKeys: {
|
||||
FREE: string;
|
||||
STARTUP: string;
|
||||
CUSTOM: string;
|
||||
};
|
||||
isTrialActive?: boolean;
|
||||
currentPlan: string;
|
||||
}
|
||||
|
||||
export const PricingCard = ({
|
||||
@@ -29,178 +24,246 @@ export const PricingCard = ({
|
||||
onUpgrade,
|
||||
onManageSubscription,
|
||||
organization,
|
||||
projectFeatureKeys,
|
||||
isTrialActive = false,
|
||||
currentPlan,
|
||||
}: PricingCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [contactModalOpen, setContactModalOpen] = useState(false);
|
||||
|
||||
const displayPrice = (() => {
|
||||
if (plan.id === projectFeatureKeys.CUSTOM) {
|
||||
return plan.price.monthly;
|
||||
}
|
||||
return planPeriod === "monthly" ? plan.price.monthly : plan.price.yearly;
|
||||
})();
|
||||
const displayPrice = planPeriod === "monthly" ? plan.price.monthly : plan.price.yearly;
|
||||
const isMonetaryPrice = displayPrice.startsWith("$");
|
||||
|
||||
const isCurrentPlan = useMemo(() => {
|
||||
if (organization.billing.plan === projectFeatureKeys.FREE && plan.id === projectFeatureKeys.FREE) {
|
||||
return true;
|
||||
}
|
||||
if (currentPlan === "free" && plan.id === "free") return true;
|
||||
if (currentPlan === "pro" && plan.id === "pro") return true;
|
||||
if (currentPlan === "scale" && plan.id === "scale") return true;
|
||||
return false;
|
||||
}, [currentPlan, plan.id]);
|
||||
|
||||
if (organization.billing.plan === projectFeatureKeys.CUSTOM && plan.id === projectFeatureKeys.CUSTOM) {
|
||||
return true;
|
||||
}
|
||||
const hasActiveSubscription =
|
||||
!!organization.billing.stripeCustomerId && isCurrentPlan && plan.id !== "free";
|
||||
|
||||
return organization.billing.plan === plan.id && organization.billing.period === planPeriod;
|
||||
}, [
|
||||
organization.billing.period,
|
||||
organization.billing.plan,
|
||||
plan.id,
|
||||
planPeriod,
|
||||
projectFeatureKeys.CUSTOM,
|
||||
projectFeatureKeys.FREE,
|
||||
]);
|
||||
// Check if this is the "other" paid plan (for Change plan button)
|
||||
const isOtherPaidPlan =
|
||||
(currentPlan === "pro" && plan.id === "scale") || (currentPlan === "scale" && plan.id === "pro");
|
||||
|
||||
const CTAButton = useMemo(() => {
|
||||
if (isCurrentPlan) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (plan.id === projectFeatureKeys.CUSTOM) {
|
||||
// Trial state: show "Subscribe now" to convert
|
||||
if (isTrialActive && plan.id === "scale") {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
window.open(plan.href, "_blank", "noopener,noreferrer");
|
||||
variant="default"
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
await onUpgrade();
|
||||
setLoading(false);
|
||||
}}
|
||||
className="flex justify-center bg-white">
|
||||
{plan.CTA ?? t("common.request_pricing")}
|
||||
className="w-full justify-center">
|
||||
Subscribe now
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (plan.id === projectFeatureKeys.STARTUP) {
|
||||
if (organization.billing.plan === projectFeatureKeys.FREE) {
|
||||
return (
|
||||
<Button
|
||||
loading={loading}
|
||||
variant="default"
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
await onUpgrade();
|
||||
setLoading(false);
|
||||
}}
|
||||
className="flex justify-center">
|
||||
{plan.CTA ?? t("common.start_free_trial")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Current paid plan with subscription
|
||||
if (hasActiveSubscription) {
|
||||
return (
|
||||
<Button
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
setContactModalOpen(true);
|
||||
variant="secondary"
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
await onManageSubscription();
|
||||
setLoading(false);
|
||||
}}
|
||||
className="flex justify-center">
|
||||
{t("environments.settings.billing.switch_plan")}
|
||||
className="w-full justify-center">
|
||||
Manage subscription
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Current plan (free/hobby)
|
||||
if (isCurrentPlan && plan.id === "free") {
|
||||
return (
|
||||
<Button variant="secondary" disabled className="w-full justify-center">
|
||||
Get started
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Free plan for non-free users - cannot downgrade
|
||||
if (plan.id === "free" && currentPlan !== "free") {
|
||||
return (
|
||||
<Button variant="secondary" disabled className="w-full justify-center">
|
||||
Get started
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// If user is on a paid plan and this is the other paid plan, show "Change plan"
|
||||
if (isOtherPaidPlan) {
|
||||
return (
|
||||
<Button
|
||||
loading={loading}
|
||||
variant="secondary"
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
await onUpgrade();
|
||||
setLoading(false);
|
||||
}}
|
||||
className="w-full justify-center">
|
||||
Change plan
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// User is on Hobby, show "Upgrade" for paid plans
|
||||
if (currentPlan === "free" && (plan.id === "pro" || plan.id === "scale")) {
|
||||
return (
|
||||
<Button
|
||||
loading={loading}
|
||||
variant={plan.featured ? "default" : "secondary"}
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
await onUpgrade();
|
||||
setLoading(false);
|
||||
}}
|
||||
className="w-full justify-center">
|
||||
Upgrade
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return (
|
||||
<Button
|
||||
loading={loading}
|
||||
variant={plan.featured ? "default" : "secondary"}
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
await onUpgrade();
|
||||
setLoading(false);
|
||||
}}
|
||||
className="w-full justify-center">
|
||||
Upgrade
|
||||
</Button>
|
||||
);
|
||||
}, [
|
||||
currentPlan,
|
||||
hasActiveSubscription,
|
||||
isCurrentPlan,
|
||||
isOtherPaidPlan,
|
||||
isTrialActive,
|
||||
loading,
|
||||
onManageSubscription,
|
||||
onUpgrade,
|
||||
plan.featured,
|
||||
plan.id,
|
||||
]);
|
||||
|
||||
// Determine badge to show
|
||||
const getBadge = () => {
|
||||
// Active trial badge
|
||||
if (isTrialActive && plan.id === "scale") {
|
||||
return (
|
||||
<span className="rounded-full bg-amber-100 px-2.5 py-0.5 text-xs font-medium text-amber-700">
|
||||
Active trial
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Current plan badge (for paid plans)
|
||||
if (isCurrentPlan && plan.id !== "free") {
|
||||
return (
|
||||
<span className="rounded-full bg-teal-100 px-2.5 py-0.5 text-xs font-medium text-teal-700">
|
||||
Current plan
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Only show "Most popular" if user is on Hobby plan
|
||||
if (plan.featured && currentPlan === "free" && !isCurrentPlan) {
|
||||
return (
|
||||
<span className="rounded-full bg-teal-100 px-2.5 py-0.5 text-xs font-medium text-teal-700">
|
||||
Most popular
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [
|
||||
isCurrentPlan,
|
||||
loading,
|
||||
onUpgrade,
|
||||
organization.billing.plan,
|
||||
plan.CTA,
|
||||
plan.featured,
|
||||
plan.href,
|
||||
plan.id,
|
||||
projectFeatureKeys.CUSTOM,
|
||||
projectFeatureKeys.FREE,
|
||||
projectFeatureKeys.STARTUP,
|
||||
t,
|
||||
]);
|
||||
};
|
||||
|
||||
// Highlight the current plan card or the featured card for Hobby users
|
||||
const shouldHighlight = isCurrentPlan || (plan.featured && currentPlan === "free");
|
||||
|
||||
return (
|
||||
<div
|
||||
key={plan.id}
|
||||
className={cn(
|
||||
plan.featured
|
||||
? "z-10 bg-white shadow-lg ring-1 ring-slate-900/10"
|
||||
: "bg-slate-100 ring-1 ring-white/10 lg:bg-transparent lg:pb-8 lg:ring-0",
|
||||
"relative rounded-xl"
|
||||
"flex flex-col rounded-lg border",
|
||||
shouldHighlight ? "border-slate-900 bg-white shadow-lg" : "border-slate-200 bg-white"
|
||||
)}>
|
||||
<div className="p-8 lg:pt-12 xl:p-10 xl:pt-14">
|
||||
<div className="flex gap-x-2">
|
||||
<h2
|
||||
id={plan.id}
|
||||
className={cn(
|
||||
plan.featured ? "text-slate-900" : "text-slate-800",
|
||||
"text-sm font-semibold leading-6"
|
||||
)}>
|
||||
{plan.name}
|
||||
</h2>
|
||||
{isCurrentPlan && (
|
||||
<Badge type="success" size="normal" text={t("environments.settings.billing.current_plan")} />
|
||||
)}
|
||||
{/* Header */}
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium text-slate-600">{plan.name}</h3>
|
||||
{getBadge()}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-6 sm:flex-row sm:justify-between lg:flex-col lg:items-stretch">
|
||||
<div className="mt-2 flex items-end gap-x-1">
|
||||
<p
|
||||
className={cn(
|
||||
plan.featured ? "text-slate-900" : "text-slate-800",
|
||||
"text-4xl font-bold tracking-tight"
|
||||
)}>
|
||||
{displayPrice}
|
||||
</p>
|
||||
{plan.id !== projectFeatureKeys.CUSTOM && (
|
||||
<div className="text-sm leading-5">
|
||||
<p className={plan.featured ? "text-slate-700" : "text-slate-600"}>
|
||||
/ {planPeriod === "monthly" ? "Month" : "Year"}
|
||||
</p>
|
||||
|
||||
{/* Price */}
|
||||
<div className="mt-4 flex items-baseline">
|
||||
<span className="text-4xl font-bold text-slate-900">{displayPrice}</span>
|
||||
{isMonetaryPrice && <span className="ml-1 text-sm text-slate-500">/ Month</span>}
|
||||
</div>
|
||||
|
||||
{/* CTA Button */}
|
||||
<div className="mt-6">{CTAButton}</div>
|
||||
</div>
|
||||
|
||||
{/* Usage Limits */}
|
||||
<div className="border-t border-slate-100 px-6 py-4">
|
||||
<p className="text-xs font-semibold tracking-wide text-slate-500 uppercase">Usage</p>
|
||||
<div className="mt-3 space-y-3">
|
||||
{plan.usageLimits.map((limit) => (
|
||||
<div
|
||||
key={limit.label}
|
||||
className="flex items-center justify-between rounded-lg border border-slate-100 bg-slate-50 px-3 py-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-slate-700">{limit.label}</span>
|
||||
{limit.overage && <span className="text-xs text-slate-400">Overage billing available</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{CTAButton}
|
||||
|
||||
{plan.id !== projectFeatureKeys.FREE && isCurrentPlan && (
|
||||
<Button
|
||||
loading={loading}
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
await onManageSubscription();
|
||||
setLoading(false);
|
||||
}}
|
||||
className="flex justify-center bg-[#635bff]">
|
||||
{t("environments.settings.billing.manage_subscription")}
|
||||
</Button>
|
||||
)}
|
||||
<span className="text-sm font-semibold text-slate-900">{limit.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-8 flow-root sm:mt-10">
|
||||
<ul
|
||||
className={cn(
|
||||
plan.featured
|
||||
? "divide-slate-900/5 border-slate-900/5 text-slate-600"
|
||||
: "divide-white/5 border-white/5 text-slate-800",
|
||||
"-my-2 divide-y border-t text-sm leading-6 lg:border-t-0"
|
||||
)}>
|
||||
{plan.mainFeatures.map((mainFeature) => (
|
||||
<li key={mainFeature} className="flex gap-x-3 py-2">
|
||||
<CheckIcon
|
||||
className={cn(plan.featured ? "text-brand-dark" : "text-slate-500", "h-6 w-5 flex-none")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{mainFeature}
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div className="flex-1 border-t border-slate-100 px-6 py-4">
|
||||
<p className="text-xs font-semibold tracking-wide text-slate-500 uppercase">Features</p>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{plan.features.map((feature) => (
|
||||
<li key={feature} className="flex items-start gap-2 text-sm text-slate-600">
|
||||
<CheckIcon className="mt-0.5 h-4 w-4 flex-shrink-0 text-teal-500" />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Addons (if any) */}
|
||||
{plan.addons && plan.addons.length > 0 && (
|
||||
<div className="border-t border-slate-100 px-6 py-4">
|
||||
<p className="text-xs font-semibold tracking-wide text-slate-500 uppercase">Available Add-ons</p>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{plan.addons.map((addon) => (
|
||||
<li key={addon} className="flex items-start gap-2 text-sm text-slate-600">
|
||||
<PlusIcon className="mt-0.5 h-4 w-4 flex-shrink-0 text-slate-400" />
|
||||
<span>{addon}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmationModal
|
||||
title="Please reach out to us"
|
||||
|
||||
@@ -5,13 +5,75 @@ import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOrganization, TOrganizationBillingPeriod } from "@formbricks/types/organizations";
|
||||
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Alert, AlertButton, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { isSubscriptionCancelledAction, manageSubscriptionAction, upgradePlanAction } from "../actions";
|
||||
import { getCloudPricingData } from "../api/lib/constants";
|
||||
import { BillingSlider } from "./billing-slider";
|
||||
import { OverageCard } from "./overage-card";
|
||||
import { PricingCard } from "./pricing-card";
|
||||
import { SettingsId } from "./settings-id";
|
||||
|
||||
type DemoPlanState = "trial" | "hobby" | "pro" | "scale";
|
||||
|
||||
function getPlanDisplayName(plan: string) {
|
||||
switch (plan) {
|
||||
case "free":
|
||||
return "Hobby";
|
||||
case "pro":
|
||||
return "Pro";
|
||||
case "scale":
|
||||
return "Scale";
|
||||
default:
|
||||
return plan.charAt(0).toUpperCase() + plan.slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
const DEMO_PLAN_CONFIGS: Record<
|
||||
DemoPlanState,
|
||||
{
|
||||
plan: string;
|
||||
displayName: string;
|
||||
limits: { responses: number; miu: number; projects: number };
|
||||
hasStripeCustomer: boolean;
|
||||
trialEndsAt: Date | null;
|
||||
billingPeriod: TOrganizationBillingPeriod;
|
||||
}
|
||||
> = {
|
||||
trial: {
|
||||
plan: "scale",
|
||||
displayName: "Scale (Trial)",
|
||||
limits: { responses: 5000, miu: 10000, projects: 5 },
|
||||
hasStripeCustomer: false,
|
||||
trialEndsAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // 14 days from now
|
||||
billingPeriod: "monthly",
|
||||
},
|
||||
hobby: {
|
||||
plan: "free",
|
||||
displayName: "Hobby",
|
||||
limits: { responses: 500, miu: 1250, projects: 1 },
|
||||
hasStripeCustomer: false,
|
||||
trialEndsAt: null,
|
||||
billingPeriod: "monthly",
|
||||
},
|
||||
pro: {
|
||||
plan: "pro",
|
||||
displayName: "Pro",
|
||||
limits: { responses: 1000, miu: 2500, projects: 3 },
|
||||
hasStripeCustomer: true,
|
||||
trialEndsAt: null,
|
||||
billingPeriod: "monthly",
|
||||
},
|
||||
scale: {
|
||||
plan: "scale",
|
||||
displayName: "Scale",
|
||||
limits: { responses: 5000, miu: 10000, projects: 5 },
|
||||
hasStripeCustomer: true,
|
||||
trialEndsAt: null,
|
||||
billingPeriod: "monthly",
|
||||
},
|
||||
};
|
||||
|
||||
interface PricingTableProps {
|
||||
organization: TOrganization;
|
||||
@@ -23,11 +85,6 @@ interface PricingTableProps {
|
||||
STARTUP_MAY25_MONTHLY: string;
|
||||
STARTUP_MAY25_YEARLY: string;
|
||||
};
|
||||
projectFeatureKeys: {
|
||||
FREE: string;
|
||||
STARTUP: string;
|
||||
CUSTOM: string;
|
||||
};
|
||||
hasBillingRights: boolean;
|
||||
}
|
||||
|
||||
@@ -35,7 +92,6 @@ export const PricingTable = ({
|
||||
environmentId,
|
||||
organization,
|
||||
peopleCount,
|
||||
projectFeatureKeys,
|
||||
responseCount,
|
||||
projectCount,
|
||||
stripePriceLookupKeys,
|
||||
@@ -46,8 +102,17 @@ export const PricingTable = ({
|
||||
organization.billing.period ?? "monthly"
|
||||
);
|
||||
|
||||
const handleMonthlyToggle = (period: TOrganizationBillingPeriod) => {
|
||||
setPlanPeriod(period);
|
||||
// Demo mode state
|
||||
const [demoPlan, setDemoPlan] = useState<DemoPlanState | null>(null);
|
||||
const [demoOverageMode, setDemoOverageMode] = useState<"allow" | "blocked">("allow");
|
||||
const [demoSpendingLimit, setDemoSpendingLimit] = useState<number | null>(null);
|
||||
|
||||
// Demo overage usage (simulated values for demo)
|
||||
const demoOverageUsage = {
|
||||
responses: 150,
|
||||
responseCost: 12, // $0.08/response for Pro
|
||||
contacts: 320,
|
||||
contactsCost: 12.8, // $0.04/contact for Pro
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
@@ -65,7 +130,48 @@ export const PricingTable = ({
|
||||
checkSubscriptionStatus();
|
||||
}, [organization.id]);
|
||||
|
||||
// Get effective values based on demo mode
|
||||
const demoConfig = demoPlan ? DEMO_PLAN_CONFIGS[demoPlan] : null;
|
||||
const effectivePlan = demoConfig?.plan ?? organization.billing.plan;
|
||||
const effectiveDisplayName = demoConfig?.displayName ?? getPlanDisplayName(organization.billing.plan);
|
||||
const effectiveResponsesLimit =
|
||||
demoConfig?.limits.responses ?? organization.billing.limits.monthly.responses;
|
||||
const effectiveMiuLimit = demoConfig?.limits.miu ?? organization.billing.limits.monthly.miu;
|
||||
const effectiveProjectsLimit = demoConfig?.limits.projects ?? organization.billing.limits.projects;
|
||||
const effectiveHasStripeCustomer = demoConfig?.hasStripeCustomer ?? !!organization.billing.stripeCustomerId;
|
||||
const effectiveTrialEndsAt = demoConfig?.trialEndsAt ?? null;
|
||||
const effectiveBillingPeriod = demoConfig?.billingPeriod ?? organization.billing.period ?? "monthly";
|
||||
|
||||
// Determine if user is on a paid plan
|
||||
const isOnPaidPlan = effectivePlan === "pro" || effectivePlan === "scale";
|
||||
const isOnMonthlyBilling = effectiveBillingPeriod === "monthly" && isOnPaidPlan;
|
||||
|
||||
// Create a mock organization for the pricing cards when in demo mode
|
||||
const effectiveOrganization: TOrganization = demoPlan
|
||||
? {
|
||||
...organization,
|
||||
billing: {
|
||||
...organization.billing,
|
||||
plan: effectivePlan,
|
||||
period: effectiveBillingPeriod,
|
||||
stripeCustomerId: effectiveHasStripeCustomer ? "demo_stripe_id" : null,
|
||||
limits: {
|
||||
...organization.billing.limits,
|
||||
monthly: {
|
||||
responses: effectiveResponsesLimit,
|
||||
miu: effectiveMiuLimit,
|
||||
},
|
||||
projects: effectiveProjectsLimit,
|
||||
},
|
||||
},
|
||||
}
|
||||
: organization;
|
||||
|
||||
const openCustomerPortal = async () => {
|
||||
if (demoPlan) {
|
||||
toast.success("Demo: Would open Stripe Customer Portal");
|
||||
return;
|
||||
}
|
||||
const manageSubscriptionResponse = await manageSubscriptionAction({
|
||||
environmentId,
|
||||
});
|
||||
@@ -74,7 +180,11 @@ export const PricingTable = ({
|
||||
}
|
||||
};
|
||||
|
||||
const upgradePlan = async (priceLookupKey) => {
|
||||
const upgradePlan = async (priceLookupKey: string) => {
|
||||
if (demoPlan) {
|
||||
toast.success(`Demo: Would upgrade to ${priceLookupKey}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const upgradePlanResponse = await upgradePlanAction({
|
||||
environmentId,
|
||||
@@ -107,7 +217,8 @@ export const PricingTable = ({
|
||||
};
|
||||
|
||||
const onUpgrade = async (planId: string) => {
|
||||
if (planId === "startup") {
|
||||
// Map new plan IDs to existing Stripe keys
|
||||
if (planId === "pro") {
|
||||
await upgradePlan(
|
||||
planPeriod === "monthly"
|
||||
? stripePriceLookupKeys.STARTUP_MAY25_MONTHLY
|
||||
@@ -116,8 +227,13 @@ export const PricingTable = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (planId === "custom") {
|
||||
window.location.href = "https://formbricks.com/custom-plan?source=billingView";
|
||||
if (planId === "scale") {
|
||||
if (demoPlan) {
|
||||
toast.success("Demo: Would redirect to Scale plan signup");
|
||||
return;
|
||||
}
|
||||
// Scale plan redirects to custom plan page for now
|
||||
globalThis.location.href = "https://formbricks.com/custom-plan?source=billingView";
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -126,177 +242,274 @@ export const PricingTable = ({
|
||||
}
|
||||
};
|
||||
|
||||
const responsesUnlimitedCheck =
|
||||
organization.billing.plan === "custom" && organization.billing.limits.monthly.responses === null;
|
||||
const peopleUnlimitedCheck =
|
||||
organization.billing.plan === "custom" && organization.billing.limits.monthly.miu === null;
|
||||
const projectsUnlimitedCheck =
|
||||
organization.billing.plan === "custom" && organization.billing.limits.projects === null;
|
||||
const handleUpgradeToAnnual = async () => {
|
||||
if (demoPlan) {
|
||||
toast.success("Demo: Would upgrade to annual billing");
|
||||
return;
|
||||
}
|
||||
await openCustomerPortal();
|
||||
};
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex w-full">
|
||||
<h2 className="mr-2 mb-3 inline-flex w-full text-2xl font-bold text-slate-700">
|
||||
{t("environments.settings.billing.current_plan")}:{" "}
|
||||
<span className="capitalize">{organization.billing.plan}</span>
|
||||
{cancellingOn && (
|
||||
<Badge
|
||||
className="mx-2"
|
||||
size="normal"
|
||||
type="warning"
|
||||
text={`Cancelling: ${
|
||||
cancellingOn
|
||||
? cancellingOn.toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
})
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</h2>
|
||||
<div className="space-y-6">
|
||||
{/* Demo Mode Toggle */}
|
||||
<div className="max-w-4xl rounded-lg border-2 border-dashed border-amber-300 bg-amber-50 p-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-xs font-semibold tracking-wide text-amber-700 uppercase">Demo Mode</span>
|
||||
<span className="text-xs text-amber-600">— Preview different plan states</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{(["trial", "hobby", "pro", "scale"] as DemoPlanState[]).map((plan) => (
|
||||
<button
|
||||
key={plan}
|
||||
onClick={() => setDemoPlan(demoPlan === plan ? null : plan)}
|
||||
className={cn(
|
||||
"rounded-md px-4 py-2 text-sm font-medium transition-colors",
|
||||
demoPlan === plan ? "bg-amber-500 text-white" : "bg-white text-slate-700 hover:bg-amber-100"
|
||||
)}>
|
||||
{plan.charAt(0).toUpperCase() + plan.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
{demoPlan && (
|
||||
<button
|
||||
onClick={() => setDemoPlan(null)}
|
||||
className="ml-2 text-sm text-amber-600 underline hover:text-amber-800">
|
||||
Reset to actual
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{organization.billing.stripeCustomerId && organization.billing.plan === "free" && (
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="justify-center py-2 shadow-sm"
|
||||
onClick={openCustomerPortal}>
|
||||
{t("environments.settings.billing.manage_card_details")}
|
||||
</Button>
|
||||
{/* Your Plan Status */}
|
||||
<SettingsCard
|
||||
title="Your Plan"
|
||||
description="Manage your subscription and usage."
|
||||
className="my-0"
|
||||
buttonInfo={
|
||||
effectiveHasStripeCustomer
|
||||
? {
|
||||
text: "Manage billing",
|
||||
onClick: () => {
|
||||
void openCustomerPortal();
|
||||
},
|
||||
variant: "secondary",
|
||||
}
|
||||
: undefined
|
||||
}>
|
||||
<div className="mb-6 flex items-center gap-4">
|
||||
<SettingsId label="Current Plan" value={effectiveDisplayName} />
|
||||
{effectiveTrialEndsAt && (
|
||||
<Badge
|
||||
type="gray"
|
||||
size="normal"
|
||||
text={`Trial ends ${effectiveTrialEndsAt.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}`}
|
||||
/>
|
||||
)}
|
||||
{cancellingOn && !demoPlan && (
|
||||
<Badge
|
||||
type="warning"
|
||||
size="normal"
|
||||
text={`Cancels ${cancellingOn.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
})}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Usage Stats */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="rounded-lg border border-slate-100 bg-slate-50 p-4">
|
||||
<p className="text-sm text-slate-500">Responses this month</p>
|
||||
<p className="mt-1 text-lg font-semibold text-slate-900">
|
||||
{responseCount.toLocaleString()}
|
||||
{effectiveResponsesLimit && (
|
||||
<span className="text-sm font-normal text-slate-400">
|
||||
{" "}
|
||||
/ {effectiveResponsesLimit.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{effectiveResponsesLimit && (
|
||||
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-slate-200">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full",
|
||||
responseCount / effectiveResponsesLimit > 0.9 ? "bg-red-500" : "bg-teal-500"
|
||||
)}
|
||||
style={{ width: `${Math.min((responseCount / effectiveResponsesLimit) * 100, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-col rounded-xl border border-slate-200 bg-white py-4 shadow-sm dark:bg-slate-800">
|
||||
<div
|
||||
className={cn(
|
||||
"relative mx-8 mb-8 flex flex-col gap-4",
|
||||
responsesUnlimitedCheck && "mb-0 flex-row"
|
||||
)}>
|
||||
<p className="text-md font-semibold text-slate-700">{t("common.responses")}</p>
|
||||
{organization.billing.limits.monthly.responses && (
|
||||
<BillingSlider
|
||||
className="slider-class mb-8"
|
||||
value={responseCount}
|
||||
max={organization.billing.limits.monthly.responses * 1.5}
|
||||
freeTierLimit={organization.billing.limits.monthly.responses}
|
||||
metric={t("common.responses")}
|
||||
<div className="rounded-lg border border-slate-100 bg-slate-50 p-4">
|
||||
<p className="text-sm text-slate-500">Identified Contacts</p>
|
||||
<p className="mt-1 text-lg font-semibold text-slate-900">
|
||||
{peopleCount.toLocaleString()}
|
||||
{effectiveMiuLimit && (
|
||||
<span className="text-sm font-normal text-slate-400">
|
||||
{" "}
|
||||
/ {effectiveMiuLimit.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{effectiveMiuLimit && (
|
||||
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-slate-200">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full",
|
||||
peopleCount / effectiveMiuLimit > 0.9 ? "bg-red-500" : "bg-teal-500"
|
||||
)}
|
||||
style={{ width: `${Math.min((peopleCount / effectiveMiuLimit) * 100, 100)}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{responsesUnlimitedCheck && (
|
||||
<Badge
|
||||
type="success"
|
||||
size="normal"
|
||||
text={t("environments.settings.billing.unlimited_responses")}
|
||||
<div className="rounded-lg border border-slate-100 bg-slate-50 p-4">
|
||||
<p className="text-sm text-slate-500">Workspaces</p>
|
||||
<p className="mt-1 text-lg font-semibold text-slate-900">
|
||||
{projectCount}
|
||||
{effectiveProjectsLimit && (
|
||||
<span className="text-sm font-normal text-slate-400"> / {effectiveProjectsLimit}</span>
|
||||
)}
|
||||
</p>
|
||||
{effectiveProjectsLimit && (
|
||||
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-slate-200">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full",
|
||||
projectCount / effectiveProjectsLimit > 0.9 ? "bg-red-500" : "bg-teal-500"
|
||||
)}
|
||||
style={{ width: `${Math.min((projectCount / effectiveProjectsLimit) * 100, 100)}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"relative mx-8 mb-8 flex flex-col gap-4",
|
||||
peopleUnlimitedCheck && "mt-4 mb-0 flex-row pb-0"
|
||||
)}>
|
||||
<p className="text-md font-semibold text-slate-700">
|
||||
{t("environments.settings.billing.monthly_identified_users")}
|
||||
</p>
|
||||
{organization.billing.limits.monthly.miu && (
|
||||
<BillingSlider
|
||||
className="slider-class mb-8"
|
||||
value={peopleCount}
|
||||
max={organization.billing.limits.monthly.miu * 1.5}
|
||||
freeTierLimit={organization.billing.limits.monthly.miu}
|
||||
metric={"MIU"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{peopleUnlimitedCheck && (
|
||||
<Badge type="success" size="normal" text={t("environments.settings.billing.unlimited_miu")} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"relative mx-8 flex flex-col gap-4 pb-6",
|
||||
projectsUnlimitedCheck && "mt-4 mb-0 flex-row pb-0"
|
||||
)}>
|
||||
<p className="text-md font-semibold text-slate-700">{t("common.workspaces")}</p>
|
||||
{organization.billing.limits.projects && (
|
||||
<BillingSlider
|
||||
className="slider-class mb-8"
|
||||
value={projectCount}
|
||||
max={organization.billing.limits.projects * 1.5}
|
||||
freeTierLimit={organization.billing.limits.projects}
|
||||
metric={t("common.workspaces")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{projectsUnlimitedCheck && (
|
||||
<Badge
|
||||
type="success"
|
||||
size="normal"
|
||||
text={t("environments.settings.billing.unlimited_workspaces")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasBillingRights && (
|
||||
<div className="mx-auto mb-12">
|
||||
<div className="gap-x-2">
|
||||
<div className="mb-4 flex w-fit cursor-pointer overflow-hidden rounded-lg border border-slate-200 p-1 lg:mb-0">
|
||||
<button
|
||||
aria-pressed={planPeriod === "monthly"}
|
||||
className={`flex-1 rounded-md px-4 py-0.5 text-center ${
|
||||
planPeriod === "monthly" ? "bg-slate-200 font-semibold" : "bg-transparent"
|
||||
}`}
|
||||
onClick={() => handleMonthlyToggle("monthly")}>
|
||||
{t("environments.settings.billing.monthly")}
|
||||
</button>
|
||||
<button
|
||||
aria-pressed={planPeriod === "yearly"}
|
||||
className={`flex-1 items-center rounded-md py-0.5 pr-2 pl-4 text-center whitespace-nowrap ${
|
||||
planPeriod === "yearly" ? "bg-slate-200 font-semibold" : "bg-transparent"
|
||||
}`}
|
||||
onClick={() => handleMonthlyToggle("yearly")}>
|
||||
{t("environments.settings.billing.annually")}
|
||||
<span className="ml-2 inline-flex items-center rounded-full border border-green-200 bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
|
||||
{t("environments.settings.billing.get_2_months_free")} 🔥
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative mx-auto grid max-w-md grid-cols-1 gap-y-8 lg:mx-0 lg:-mb-14 lg:max-w-none lg:grid-cols-3">
|
||||
<div
|
||||
className="hidden lg:absolute lg:inset-x-px lg:top-4 lg:bottom-0 lg:block lg:rounded-xl lg:rounded-t-2xl lg:border lg:border-slate-200 lg:bg-slate-100 lg:pb-8 lg:ring-1 lg:ring-white/10"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{getCloudPricingData(t).plans.map((plan) => (
|
||||
<PricingCard
|
||||
planPeriod={planPeriod}
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
onUpgrade={async () => {
|
||||
await onUpgrade(plan.id);
|
||||
}}
|
||||
organization={organization}
|
||||
projectFeatureKeys={projectFeatureKeys}
|
||||
onManageSubscription={openCustomerPortal}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-slate-500">Your volumes renew on Feb 1, 2025</p>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Overage Card (only for paid plans or trial) */}
|
||||
{(isOnPaidPlan || effectiveTrialEndsAt) && (
|
||||
<SettingsCard
|
||||
title="Dynamic Overage Handling"
|
||||
description="Control how your surveys behave when you exceed your included usage limits."
|
||||
className="my-0">
|
||||
<OverageCard
|
||||
currentMode={demoOverageMode}
|
||||
spendingLimit={demoSpendingLimit}
|
||||
overageUsage={demoOverageUsage}
|
||||
onModeChange={async (mode) => {
|
||||
if (demoPlan) {
|
||||
toast.success(`Demo: Would change overage mode to ${mode}`);
|
||||
setDemoOverageMode(mode);
|
||||
return;
|
||||
}
|
||||
// TODO: Implement actual overage mode change via API
|
||||
toast.success(`Overage mode changed to ${mode}`);
|
||||
setDemoOverageMode(mode);
|
||||
}}
|
||||
onSpendingLimitChange={async (limit) => {
|
||||
if (demoPlan) {
|
||||
toast.success(
|
||||
limit ? `Demo: Would set spending limit to $${limit}` : "Demo: Would remove spending limit"
|
||||
);
|
||||
setDemoSpendingLimit(limit);
|
||||
return;
|
||||
}
|
||||
// TODO: Implement actual spending limit change via API
|
||||
toast.success(limit ? `Spending limit set to $${limit}` : "Spending limit removed");
|
||||
setDemoSpendingLimit(limit);
|
||||
}}
|
||||
/>
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
{/* Alert: Annual Billing Upgrade (only for monthly paid plans) */}
|
||||
{isOnMonthlyBilling && (
|
||||
<Alert variant="info" className="max-w-4xl">
|
||||
<AlertTitle>Save 20% on Annual Billing</AlertTitle>
|
||||
<AlertDescription>Simplify your billing cycle and get 2 months free.</AlertDescription>
|
||||
<AlertButton onClick={handleUpgradeToAnnual}>Switch to Annual</AlertButton>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Alert: Special Pricing Programs (only for non-paid plans) */}
|
||||
{!isOnPaidPlan && (
|
||||
<Alert variant="info" className="max-w-4xl">
|
||||
<AlertTitle>Special Pricing Programs</AlertTitle>
|
||||
<AlertDescription>
|
||||
Exclusive discounts for Startups, Non-profits, and Open Source projects.
|
||||
</AlertDescription>
|
||||
<AlertButton
|
||||
onClick={() => {
|
||||
if (demoPlan) {
|
||||
toast.success("Demo: Would open discount application form");
|
||||
return;
|
||||
}
|
||||
globalThis.open("https://formbricks.com/discount", "_blank");
|
||||
}}>
|
||||
Apply for Discount
|
||||
</AlertButton>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Pricing Plans */}
|
||||
{hasBillingRights && (
|
||||
<div className="max-w-5xl">
|
||||
{/* Period Toggle */}
|
||||
<div className="mb-6 flex items-center gap-4">
|
||||
<div className="flex overflow-hidden rounded-lg border border-slate-200 p-1">
|
||||
<button
|
||||
className={cn(
|
||||
"rounded-md px-4 py-1.5 text-sm font-medium transition-colors",
|
||||
planPeriod === "monthly" ? "bg-slate-900 text-white" : "text-slate-600 hover:text-slate-900"
|
||||
)}
|
||||
onClick={() => setPlanPeriod("monthly")}>
|
||||
Monthly
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"rounded-md px-4 py-1.5 text-sm font-medium transition-colors",
|
||||
planPeriod === "yearly" ? "bg-slate-900 text-white" : "text-slate-600 hover:text-slate-900"
|
||||
)}
|
||||
onClick={() => setPlanPeriod("yearly")}>
|
||||
Annually
|
||||
</button>
|
||||
</div>
|
||||
{planPeriod === "yearly" && (
|
||||
<span className="inline-flex items-center rounded-full bg-orange-100 px-2.5 py-0.5 text-xs font-medium text-orange-700">
|
||||
Get 2 months free 🔥
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Plan Cards */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{getCloudPricingData(t).plans.map((plan) => (
|
||||
<PricingCard
|
||||
planPeriod={planPeriod}
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
onUpgrade={async () => {
|
||||
await onUpgrade(plan.id);
|
||||
}}
|
||||
organization={effectiveOrganization}
|
||||
onManageSubscription={openCustomerPortal}
|
||||
isTrialActive={demoPlan === "trial" && plan.id === "scale"}
|
||||
currentPlan={effectivePlan}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
116
apps/web/modules/ee/billing/components/select-plan-card.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon, GiftIcon } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import calLogo from "@/images/customer-logos/cal-logo-light.svg";
|
||||
import ethereumLogo from "@/images/customer-logos/ethereum-logo.png";
|
||||
import flixbusLogo from "@/images/customer-logos/flixbus-white.svg";
|
||||
import githubLogo from "@/images/customer-logos/github-logo.png";
|
||||
import siemensLogo from "@/images/customer-logos/siemens.png";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
|
||||
interface SelectPlanCardProps {
|
||||
/** URL to redirect after starting trial or continuing with free */
|
||||
nextUrl: string;
|
||||
}
|
||||
|
||||
const TRIAL_FEATURES = [
|
||||
"Fully white-labeled surveys",
|
||||
"All team & collaboration features",
|
||||
"Setup custom webhooks",
|
||||
"Get full API access",
|
||||
"Setup email follow-ups",
|
||||
"Manage quotas",
|
||||
];
|
||||
|
||||
const CUSTOMER_LOGOS = [
|
||||
{ src: siemensLogo, alt: "Siemens" },
|
||||
{ src: calLogo, alt: "Cal.com" },
|
||||
{ src: flixbusLogo, alt: "FlixBus" },
|
||||
{ src: githubLogo, alt: "GitHub" },
|
||||
{ src: ethereumLogo, alt: "Ethereum" },
|
||||
];
|
||||
|
||||
export const SelectPlanCard = ({ nextUrl }: SelectPlanCardProps) => {
|
||||
const router = useRouter();
|
||||
const [isStartingTrial, setIsStartingTrial] = useState(false);
|
||||
|
||||
const handleStartTrial = async () => {
|
||||
setIsStartingTrial(true);
|
||||
// TODO: Implement trial activation via Stripe
|
||||
router.push(nextUrl);
|
||||
};
|
||||
|
||||
const handleContinueFree = () => {
|
||||
router.push(nextUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-md flex-col items-center space-y-6">
|
||||
{/* Trial Card */}
|
||||
<div className="relative w-full overflow-hidden rounded-xl border border-slate-200 bg-white shadow-lg">
|
||||
<div className="flex flex-col items-center space-y-6 p-8">
|
||||
{/* Gift Icon */}
|
||||
<div className="rounded-full bg-slate-100 p-4">
|
||||
<GiftIcon className="h-10 w-10 text-slate-600" />
|
||||
</div>
|
||||
|
||||
{/* Title & Subtitle */}
|
||||
<div className="text-center">
|
||||
<h3 className="text-2xl font-semibold text-slate-800">Try Pro features for free!</h3>
|
||||
<p className="mt-2 text-slate-600">14 days trial, no credit card required</p>
|
||||
</div>
|
||||
|
||||
{/* Features List */}
|
||||
<ul className="w-full space-y-3 text-left">
|
||||
{TRIAL_FEATURES.map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-3 text-slate-700">
|
||||
<CheckIcon className="h-5 w-5 flex-shrink-0 text-slate-900" />
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* CTA Button */}
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={handleStartTrial}
|
||||
className="mt-4 w-full"
|
||||
loading={isStartingTrial}
|
||||
disabled={isStartingTrial}>
|
||||
Start Free Trial
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Logo Carousel */}
|
||||
<div className="w-full overflow-hidden border-t border-slate-100 bg-slate-50 py-4">
|
||||
<div className="animate-logo-scroll flex w-max gap-12 hover:[animation-play-state:paused]">
|
||||
{/* Duplicate logos for seamless infinite scroll */}
|
||||
{[...CUSTOMER_LOGOS, ...CUSTOMER_LOGOS].map((logo, index) => (
|
||||
<div
|
||||
key={`${logo.alt}-${index}`}
|
||||
className="flex h-5 items-center opacity-50 grayscale transition-all duration-200 hover:opacity-100 hover:grayscale-0">
|
||||
<Image
|
||||
src={logo.src}
|
||||
alt={logo.alt}
|
||||
height={20}
|
||||
width={100}
|
||||
className="h-5 w-auto max-w-[100px] object-contain"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Skip Option */}
|
||||
<button
|
||||
onClick={handleContinueFree}
|
||||
className="text-sm text-slate-400 underline-offset-2 transition-colors hover:text-slate-600 hover:underline">
|
||||
I want to stay on the Hobby plan
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
36
apps/web/modules/ee/billing/components/settings-id.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { HelpCircleIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
|
||||
interface SettingsIdProps {
|
||||
label: string;
|
||||
value: string;
|
||||
tooltip?: string;
|
||||
className?: string;
|
||||
align?: "left" | "right";
|
||||
}
|
||||
|
||||
export const SettingsId = ({ label, value, tooltip, className, align = "left" }: SettingsIdProps) => {
|
||||
return (
|
||||
<div className={cn("flex flex-col", align === "right" && "items-end", className)}>
|
||||
<div className={cn("flex items-center gap-1", align === "right" && "flex-row-reverse")}>
|
||||
<span className="text-sm text-slate-500">{label}</span>
|
||||
{tooltip && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<HelpCircleIcon className="h-3.5 w-3.5 cursor-help text-slate-400" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-base font-medium text-slate-800">{value}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,6 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { PROJECT_FEATURE_KEYS, STRIPE_PRICE_LOOKUP_KEYS } from "@/lib/constants";
|
||||
import { IS_FORMBRICKS_CLOUD, STRIPE_PRICE_LOOKUP_KEYS } from "@/lib/constants";
|
||||
import {
|
||||
getMonthlyActiveOrganizationPeopleCount,
|
||||
getMonthlyOrganizationResponseCount,
|
||||
@@ -49,7 +48,6 @@ export const PricingPage = async (props) => {
|
||||
responseCount={responseCount}
|
||||
projectCount={projectCount}
|
||||
stripePriceLookupKeys={STRIPE_PRICE_LOOKUP_KEYS}
|
||||
projectFeatureKeys={PROJECT_FEATURE_KEYS}
|
||||
hasBillingRights={hasBillingRights}
|
||||
/>
|
||||
</PageContentWrapper>
|
||||
|
||||
@@ -20,6 +20,7 @@ module.exports = {
|
||||
fadeOut: "fadeOut 0.2s ease-out",
|
||||
surveyLoading: "surveyLoadingAnimation 0.5s ease-out forwards",
|
||||
surveyExit: "surveyExitAnimation 0.5s ease-out forwards",
|
||||
"logo-scroll": "logo-scroll 20s linear infinite",
|
||||
},
|
||||
blur: {
|
||||
xxs: "0.33px",
|
||||
@@ -130,6 +131,10 @@ module.exports = {
|
||||
"0%": { transform: "translateY(0)", opacity: "1" },
|
||||
"100%": { transform: "translateY(-50px)", opacity: "0" },
|
||||
},
|
||||
"logo-scroll": {
|
||||
"0%": { transform: "translateX(0)" },
|
||||
"100%": { transform: "translateX(-50%)" },
|
||||
},
|
||||
},
|
||||
width: {
|
||||
"sidebar-expanded": "4rem",
|
||||
|
||||