Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 53e7d18ee6 | |||
| 38ea5ed6ae | |||
| 6e19de32f7 | |||
| 957a4432f4 | |||
| 22a5d4bb7d | |||
| 226dff0344 | |||
| d474a94a21 | |||
| c1a4cc308b | |||
| 210da98b69 | |||
| 2fc183d384 | |||
| 78fb111610 | |||
| 11c0cb4b61 | |||
| 95831f7c7f | |||
| a31e7bfaa5 | |||
| 6e35fc1769 | |||
| 48cded1646 | |||
| db752cee15 | |||
| b33aae0a73 | |||
| 72126ad736 | |||
| 4a2eeac90b | |||
| 46be3e7d70 | |||
| 6d140532a7 | |||
| 8c4a7f1518 |
@@ -1,61 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Build & Deployment Best Practices
|
||||
|
||||
## Build Process
|
||||
|
||||
### Running Builds
|
||||
- Use `pnpm build` from project root for full build
|
||||
- Monitor for React hooks warnings and fix them immediately
|
||||
- Ensure all TypeScript errors are resolved before deployment
|
||||
|
||||
### Common Build Issues & Fixes
|
||||
|
||||
#### React Hooks Warnings
|
||||
- Capture ref values in variables within useEffect cleanup
|
||||
- Avoid accessing `.current` directly in cleanup functions
|
||||
- Pattern for fixing ref cleanup warnings:
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
const currentRef = myRef.current;
|
||||
return () => {
|
||||
if (currentRef) {
|
||||
currentRef.cleanup();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
```
|
||||
|
||||
#### Test Failures During Build
|
||||
- Ensure all test mocks include required constants like `SESSION_MAX_AGE`
|
||||
- Mock Next.js navigation hooks properly: `useParams`, `useRouter`, `useSearchParams`
|
||||
- Remove unused imports and constants from test files
|
||||
- Use literal values instead of imported constants when the constant isn't actually needed
|
||||
|
||||
### Test Execution
|
||||
- Run `pnpm test` to execute all tests
|
||||
- Use `pnpm test -- --run filename.test.tsx` for specific test files
|
||||
- Fix test failures before merging code
|
||||
- Ensure 100% test coverage for new components
|
||||
|
||||
### Performance Monitoring
|
||||
- Monitor build times and optimize if necessary
|
||||
- Watch for memory usage during builds
|
||||
- Use proper caching strategies for faster rebuilds
|
||||
|
||||
### Deployment Checklist
|
||||
1. All tests passing
|
||||
2. Build completes without warnings
|
||||
3. TypeScript compilation successful
|
||||
4. No linter errors
|
||||
5. Database migrations applied (if any)
|
||||
6. Environment variables configured
|
||||
|
||||
### EKS Deployment Considerations
|
||||
- Ensure latest code is deployed to all pods
|
||||
- Monitor AWS RDS Performance Insights for database issues
|
||||
- Verify environment-specific configurations
|
||||
- Check pod health and resource usage
|
||||
@@ -1,415 +0,0 @@
|
||||
---
|
||||
description: Caching rules for performance improvements
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Cache Optimization Patterns for Formbricks
|
||||
|
||||
## Cache Strategy Overview
|
||||
|
||||
Formbricks uses a **hybrid caching approach** optimized for enterprise scale:
|
||||
|
||||
- **Redis** for persistent cross-request caching
|
||||
- **React `cache()`** for request-level deduplication
|
||||
- **NO Next.js `unstable_cache()`** - avoid for reliability
|
||||
|
||||
## Key Files
|
||||
|
||||
### Core Cache Infrastructure
|
||||
- [packages/cache/src/service.ts](mdc:packages/cache/src/service.ts) - Redis cache service
|
||||
- [packages/cache/src/client.ts](mdc:packages/cache/src/client.ts) - Cache client initialization and singleton management
|
||||
- [apps/web/lib/cache/index.ts](mdc:apps/web/lib/cache/index.ts) - Cache service proxy for web app
|
||||
- [packages/cache/src/index.ts](mdc:packages/cache/src/index.ts) - Cache package exports and utilities
|
||||
|
||||
### Environment State Caching (Critical Endpoint)
|
||||
- [apps/web/app/api/v1/client/[environmentId]/environment/route.ts](mdc:apps/web/app/api/v1/client/[environmentId]/environment/route.ts) - Main endpoint serving hundreds of thousands of SDK clients
|
||||
- [apps/web/app/api/v1/client/[environmentId]/environment/lib/data.ts](mdc:apps/web/app/api/v1/client/[environmentId]/environment/lib/data.ts) - Optimized data layer with caching
|
||||
|
||||
## Enterprise-Grade Cache Key Patterns
|
||||
|
||||
**Always use** the `createCacheKey` utilities from the cache package:
|
||||
|
||||
```typescript
|
||||
// ✅ Correct patterns
|
||||
createCacheKey.environment.state(environmentId) // "fb:env:abc123:state"
|
||||
createCacheKey.organization.billing(organizationId) // "fb:org:xyz789:billing"
|
||||
createCacheKey.license.status(organizationId) // "fb:license:org123:status"
|
||||
createCacheKey.user.permissions(userId, orgId) // "fb:user:456:org:123:permissions"
|
||||
|
||||
// ❌ Never use flat keys - collision-prone
|
||||
"environment_abc123"
|
||||
"user_data_456"
|
||||
```
|
||||
|
||||
## When to Use Each Cache Type
|
||||
|
||||
### Use React `cache()` for Request Deduplication
|
||||
```typescript
|
||||
// ✅ Prevents multiple calls within same request
|
||||
export const getEnterpriseLicense = reactCache(async () => {
|
||||
// Complex license validation logic
|
||||
});
|
||||
```
|
||||
|
||||
### Use `cache.withCache()` for Simple Database Queries
|
||||
```typescript
|
||||
// ✅ Simple caching with automatic fallback (TTL in milliseconds)
|
||||
export const getActionClasses = (environmentId: string) => {
|
||||
return cache.withCache(() => fetchActionClassesFromDB(environmentId),
|
||||
createCacheKey.environment.actionClasses(environmentId),
|
||||
60 * 30 * 1000 // 30 minutes in milliseconds
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### Use Explicit Redis Cache for Complex Business Logic
|
||||
```typescript
|
||||
// ✅ Full control for high-stakes endpoints
|
||||
export const getEnvironmentState = async (environmentId: string) => {
|
||||
const cached = await environmentStateCache.getEnvironmentState(environmentId);
|
||||
if (cached) return cached;
|
||||
|
||||
const fresh = await buildComplexState(environmentId);
|
||||
await environmentStateCache.setEnvironmentState(environmentId, fresh);
|
||||
return fresh;
|
||||
};
|
||||
```
|
||||
|
||||
## Caching Decision Framework
|
||||
|
||||
### When TO Add Caching
|
||||
|
||||
```typescript
|
||||
// ✅ Expensive operations that benefit from caching
|
||||
- Database queries (>10ms typical)
|
||||
- External API calls (>50ms typical)
|
||||
- Complex computations (>5ms)
|
||||
- File system operations
|
||||
- Heavy data transformations
|
||||
|
||||
// Example: Database query with complex joins (TTL in milliseconds)
|
||||
export const getEnvironmentWithDetails = withCache(
|
||||
async (environmentId: string) => {
|
||||
return prisma.environment.findUnique({
|
||||
where: { id: environmentId },
|
||||
include: { /* complex joins */ }
|
||||
});
|
||||
},
|
||||
{ key: createCacheKey.environment.details(environmentId), ttl: 60 * 30 * 1000 } // 30 minutes
|
||||
)();
|
||||
```
|
||||
|
||||
### When NOT to Add Caching
|
||||
|
||||
```typescript
|
||||
// ❌ Don't cache these operations - minimal overhead
|
||||
- Simple property access (<0.1ms)
|
||||
- Basic transformations (<1ms)
|
||||
- Functions that just call already-cached functions
|
||||
- Pure computation without I/O
|
||||
|
||||
// ❌ Bad example: Redundant caching
|
||||
const getCachedLicenseFeatures = withCache(
|
||||
async () => {
|
||||
const license = await getEnterpriseLicense(); // Already cached!
|
||||
return license.active ? license.features : null; // Just property access
|
||||
},
|
||||
{ key: "license-features", ttl: 1800 * 1000 } // 30 minutes in milliseconds
|
||||
);
|
||||
|
||||
// ✅ Good example: Simple and efficient
|
||||
const getLicenseFeatures = async () => {
|
||||
const license = await getEnterpriseLicense(); // Already cached
|
||||
return license.active ? license.features : null; // 0.1ms overhead
|
||||
};
|
||||
```
|
||||
|
||||
### Computational Overhead Analysis
|
||||
|
||||
Before adding caching, analyze the overhead:
|
||||
|
||||
```typescript
|
||||
// ✅ High overhead - CACHE IT
|
||||
- Database queries: ~10-100ms
|
||||
- External APIs: ~50-500ms
|
||||
- File I/O: ~5-50ms
|
||||
- Complex algorithms: >5ms
|
||||
|
||||
// ❌ Low overhead - DON'T CACHE
|
||||
- Property access: ~0.001ms
|
||||
- Simple lookups: ~0.1ms
|
||||
- Basic validation: ~1ms
|
||||
- Type checks: ~0.01ms
|
||||
|
||||
// Example decision tree:
|
||||
const expensiveOperation = async () => {
|
||||
return prisma.query(); // 50ms - CACHE IT
|
||||
};
|
||||
|
||||
const cheapOperation = (data: any) => {
|
||||
return data.property; // 0.001ms - DON'T CACHE
|
||||
};
|
||||
```
|
||||
|
||||
### Avoid Cache Wrapper Anti-Pattern
|
||||
|
||||
```typescript
|
||||
// ❌ Don't create wrapper functions just for caching
|
||||
const getCachedUserPermissions = withCache(
|
||||
async (userId: string) => getUserPermissions(userId),
|
||||
{ key: createCacheKey.user.permissions(userId), ttl: 3600 * 1000 } // 1 hour in milliseconds
|
||||
);
|
||||
|
||||
// ✅ Add caching directly to the original function
|
||||
export const getUserPermissions = withCache(
|
||||
async (userId: string) => {
|
||||
return prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: { permissions: true }
|
||||
});
|
||||
},
|
||||
{ key: createCacheKey.user.permissions(userId), ttl: 3600 * 1000 } // 1 hour in milliseconds
|
||||
);
|
||||
```
|
||||
|
||||
## TTL Coordination Strategy
|
||||
|
||||
### Multi-Layer Cache Coordination
|
||||
For endpoints serving client SDKs, coordinate TTLs across layers:
|
||||
|
||||
```typescript
|
||||
// Client SDK cache (expiresAt) - longest TTL for fewer requests
|
||||
const CLIENT_TTL = 60; // 1 minute (seconds for client)
|
||||
|
||||
// Server Redis cache - shorter TTL ensures fresh data for clients
|
||||
const SERVER_TTL = 60 * 1000; // 1 minutes in milliseconds
|
||||
|
||||
// HTTP cache headers (seconds)
|
||||
const BROWSER_TTL = 60; // 1 minute (max-age)
|
||||
const CDN_TTL = 60; // 1 minute (s-maxage)
|
||||
const CORS_TTL = 60 * 60; // 1 hour (balanced approach)
|
||||
```
|
||||
|
||||
### Standard TTL Guidelines (in milliseconds for cache-manager + Keyv)
|
||||
```typescript
|
||||
// Configuration data - rarely changes
|
||||
const CONFIG_TTL = 60 * 60 * 24 * 1000; // 24 hours
|
||||
|
||||
// User data - moderate frequency
|
||||
const USER_TTL = 60 * 60 * 2 * 1000; // 2 hours
|
||||
|
||||
// Survey data - changes moderately
|
||||
const SURVEY_TTL = 60 * 15 * 1000; // 15 minutes
|
||||
|
||||
// Billing data - expensive to compute
|
||||
const BILLING_TTL = 60 * 30 * 1000; // 30 minutes
|
||||
|
||||
// Action classes - infrequent changes
|
||||
const ACTION_CLASS_TTL = 60 * 30 * 1000; // 30 minutes
|
||||
```
|
||||
|
||||
## High-Frequency Endpoint Optimization
|
||||
|
||||
### Performance Patterns for High-Volume Endpoints
|
||||
|
||||
```typescript
|
||||
// ✅ Optimized high-frequency endpoint pattern
|
||||
export const GET = async (request: NextRequest, props: { params: Promise<{ id: string }> }) => {
|
||||
const params = await props.params;
|
||||
|
||||
try {
|
||||
// Simple validation (avoid Zod for high-frequency)
|
||||
if (!params.id || typeof params.id !== 'string') {
|
||||
return responses.badRequestResponse("ID is required", undefined, true);
|
||||
}
|
||||
|
||||
// Single optimized query with caching
|
||||
const data = await getOptimizedData(params.id);
|
||||
|
||||
return responses.successResponse(
|
||||
{
|
||||
data,
|
||||
expiresAt: new Date(Date.now() + CLIENT_TTL * 1000), // SDK cache duration
|
||||
},
|
||||
true,
|
||||
"public, s-maxage=1800, max-age=3600, stale-while-revalidate=1800, stale-if-error=3600"
|
||||
);
|
||||
} catch (err) {
|
||||
// Simplified error handling for performance
|
||||
if (err instanceof ResourceNotFoundError) {
|
||||
return responses.notFoundResponse(err.resourceType, err.resourceId);
|
||||
}
|
||||
logger.error({ error: err, url: request.url }, "Error in high-frequency endpoint");
|
||||
return responses.internalServerErrorResponse(err.message, true);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Avoid These Performance Anti-Patterns
|
||||
|
||||
```typescript
|
||||
// ❌ Avoid for high-frequency endpoints
|
||||
const inputValidation = ZodSchema.safeParse(input); // Too slow
|
||||
const startTime = Date.now(); logger.debug(...); // Logging overhead
|
||||
const { data, revalidateEnvironment } = await get(); // Complex return types
|
||||
```
|
||||
|
||||
### CORS Optimization
|
||||
```typescript
|
||||
// ✅ Balanced CORS caching (not too aggressive)
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
return responses.successResponse(
|
||||
{},
|
||||
true,
|
||||
"public, s-maxage=3600, max-age=3600" // 1 hour balanced approach
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Redis Cache Migration from Next.js
|
||||
|
||||
### Avoid Legacy Next.js Patterns
|
||||
```typescript
|
||||
// ❌ Old Next.js unstable_cache pattern (avoid)
|
||||
const getCachedData = unstable_cache(
|
||||
async (id) => fetchData(id),
|
||||
['cache-key'],
|
||||
{ tags: ['environment'], revalidate: 900 }
|
||||
);
|
||||
|
||||
// ❌ Don't use revalidateEnvironment flags with Redis
|
||||
return { data, revalidateEnvironment: true }; // This gets cached incorrectly!
|
||||
|
||||
// ✅ New Redis pattern with withCache (TTL in milliseconds)
|
||||
export const getCachedData = (id: string) =>
|
||||
withCache(
|
||||
() => fetchData(id),
|
||||
{
|
||||
key: createCacheKey.environment.data(id),
|
||||
ttl: 60 * 15 * 1000, // 15 minutes in milliseconds
|
||||
}
|
||||
)();
|
||||
```
|
||||
|
||||
### Remove Revalidation Logic
|
||||
When migrating from Next.js `unstable_cache`:
|
||||
- Remove `revalidateEnvironment` or similar flags
|
||||
- Remove tag-based invalidation logic
|
||||
- Use TTL-based expiration instead
|
||||
- Handle one-time updates (like `appSetupCompleted`) directly in cache
|
||||
|
||||
## Data Layer Optimization
|
||||
|
||||
### Single Query Pattern
|
||||
```typescript
|
||||
// ✅ Optimize with single database query
|
||||
export const getOptimizedEnvironmentData = async (environmentId: string) => {
|
||||
return prisma.environment.findUniqueOrThrow({
|
||||
where: { id: environmentId },
|
||||
include: {
|
||||
project: {
|
||||
select: { id: true, recontactDays: true, /* ... */ }
|
||||
},
|
||||
organization: {
|
||||
select: { id: true, billing: true }
|
||||
},
|
||||
surveys: {
|
||||
where: { status: "inProgress" },
|
||||
select: { id: true, name: true, /* ... */ }
|
||||
},
|
||||
actionClasses: {
|
||||
select: { id: true, name: true, /* ... */ }
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// ❌ Avoid multiple separate queries
|
||||
const environment = await getEnvironment(id);
|
||||
const organization = await getOrganization(environment.organizationId);
|
||||
const surveys = await getSurveys(id);
|
||||
const actionClasses = await getActionClasses(id);
|
||||
```
|
||||
|
||||
## Invalidation Best Practices
|
||||
|
||||
**Always use explicit key-based invalidation:**
|
||||
|
||||
```typescript
|
||||
// ✅ Clear and debuggable
|
||||
await invalidateCache(createCacheKey.environment.state(environmentId));
|
||||
await invalidateCache([
|
||||
createCacheKey.environment.surveys(environmentId),
|
||||
createCacheKey.environment.actionClasses(environmentId)
|
||||
]);
|
||||
|
||||
// ❌ Avoid complex tag systems
|
||||
await invalidateByTags(["environment", "survey"]); // Don't do this
|
||||
```
|
||||
|
||||
## Critical Performance Targets
|
||||
|
||||
### High-Frequency Endpoint Goals
|
||||
- **Cache hit ratio**: >85%
|
||||
- **Response time P95**: <200ms
|
||||
- **Database load reduction**: >60%
|
||||
- **HTTP cache duration**: 1hr browser, 30min Cloudflare
|
||||
- **SDK refresh interval**: 1 hour with 30min server cache
|
||||
|
||||
### Performance Monitoring
|
||||
- Use **existing elastic cache analytics** for metrics
|
||||
- Log cache errors and warnings (not debug info)
|
||||
- Track database query reduction
|
||||
- Monitor response times for cached endpoints
|
||||
- **Avoid performance logging** in high-frequency endpoints
|
||||
|
||||
## Error Handling Pattern
|
||||
|
||||
Always provide fallback to fresh data on cache errors:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const cached = await cache.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const fresh = await fetchFresh();
|
||||
await cache.set(key, fresh, ttl); // ttl in milliseconds
|
||||
return fresh;
|
||||
} catch (error) {
|
||||
// ✅ Always fallback to fresh data
|
||||
logger.warn("Cache error, fetching fresh", { key, error });
|
||||
return fetchFresh();
|
||||
}
|
||||
```
|
||||
|
||||
## Common Pitfalls to Avoid
|
||||
|
||||
1. **Never use Next.js `unstable_cache()`** - unreliable in production
|
||||
2. **Don't use revalidation flags with Redis** - they get cached incorrectly
|
||||
3. **Avoid Zod validation** for simple parameters in high-frequency endpoints
|
||||
4. **Don't add performance logging** to high-frequency endpoints
|
||||
5. **Coordinate TTLs** between client and server caches
|
||||
6. **Don't over-engineer** with complex tag systems
|
||||
7. **Avoid caching rapidly changing data** (real-time metrics)
|
||||
8. **Always validate cache keys** to prevent collisions
|
||||
9. **Don't add redundant caching layers** - analyze computational overhead first
|
||||
10. **Avoid cache wrapper functions** - add caching directly to expensive operations
|
||||
11. **Don't cache property access or simple transformations** - overhead is negligible
|
||||
12. **Analyze the full call chain** before adding caching to avoid double-caching
|
||||
13. **Remember TTL is in milliseconds** for cache-manager + Keyv stack (not seconds)
|
||||
|
||||
## Monitoring Strategy
|
||||
|
||||
- Use **existing elastic cache analytics** for metrics
|
||||
- Log cache errors and warnings
|
||||
- Track database query reduction
|
||||
- Monitor response times for cached endpoints
|
||||
- **Don't add custom metrics** that duplicate existing monitoring
|
||||
|
||||
## Important Notes
|
||||
|
||||
### TTL Units
|
||||
- **cache-manager + Keyv**: TTL in **milliseconds**
|
||||
- **Direct Redis commands**: TTL in **seconds** (EXPIRE, SETEX) or **milliseconds** (PEXPIRE, PSETEX)
|
||||
- **HTTP cache headers**: TTL in **seconds** (max-age, s-maxage)
|
||||
- **Client SDK**: TTL in **seconds** (expiresAt calculation)
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Database Performance & Prisma Best Practices
|
||||
|
||||
## Critical Performance Rules
|
||||
|
||||
### Response Count Queries
|
||||
- **NEVER** use `skip`/`offset` with `prisma.response.count()` - this causes expensive subqueries with OFFSET
|
||||
- Always use only `where` clauses for count operations: `prisma.response.count({ where: { ... } })`
|
||||
- For pagination, separate count queries from data queries
|
||||
- Reference: [apps/web/lib/response/service.ts](mdc:apps/web/lib/response/service.ts) line 654-686
|
||||
|
||||
### Prisma Query Optimization
|
||||
- Use proper indexes defined in [packages/database/schema.prisma](mdc:packages/database/schema.prisma)
|
||||
- Leverage existing indexes: `@@index([surveyId, createdAt])`, `@@index([createdAt])`
|
||||
- Use cursor-based pagination for large datasets instead of offset-based
|
||||
- Cache frequently accessed data using React Cache and custom cache tags
|
||||
|
||||
### Date Range Filtering
|
||||
- When filtering by `createdAt`, always use indexed queries
|
||||
- Combine with `surveyId` for optimal performance: `{ surveyId, createdAt: { gte: start, lt: end } }`
|
||||
- Avoid complex WHERE clauses that can't utilize indexes
|
||||
|
||||
### Count vs Data Separation
|
||||
- Always separate count queries from data fetching queries
|
||||
- Use `Promise.all()` to run count and data queries in parallel
|
||||
- Example pattern from [apps/web/modules/api/v2/management/responses/lib/response.ts](mdc:apps/web/modules/api/v2/management/responses/lib/response.ts):
|
||||
```typescript
|
||||
const [responses, totalCount] = await Promise.all([
|
||||
prisma.response.findMany(query),
|
||||
prisma.response.count({ where: whereClause }),
|
||||
]);
|
||||
```
|
||||
|
||||
### Monitoring & Debugging
|
||||
- Monitor AWS RDS Performance Insights for problematic queries
|
||||
- Look for queries with OFFSET in count operations - these indicate performance issues
|
||||
- Use proper error handling with `DatabaseError` for Prisma exceptions
|
||||
@@ -1,105 +0,0 @@
|
||||
---
|
||||
description: >
|
||||
globs: schema.prisma
|
||||
alwaysApply: false
|
||||
---
|
||||
# Formbricks Database Schema Reference
|
||||
|
||||
This rule provides a reference to the Formbricks database structure. For the most up-to-date and complete schema definitions, please refer to the schema.prisma file directly.
|
||||
|
||||
## Database Overview
|
||||
|
||||
Formbricks uses PostgreSQL with Prisma ORM. The schema is designed for multi-tenancy with strong data isolation between organizations.
|
||||
|
||||
### Core Hierarchy
|
||||
|
||||
```
|
||||
Organization
|
||||
└── Project
|
||||
└── Environment (production/development)
|
||||
├── Survey
|
||||
├── Contact
|
||||
├── ActionClass
|
||||
└── Integration
|
||||
```
|
||||
|
||||
## Schema Reference
|
||||
|
||||
For the complete and up-to-date database schema, please refer to:
|
||||
|
||||
- Main schema: `packages/database/schema.prisma`
|
||||
- JSON type definitions: `packages/database/json-types.ts`
|
||||
|
||||
The schema.prisma file contains all model definitions, relationships, enums, and field types. The json-types.ts file contains TypeScript type definitions for JSON fields.
|
||||
|
||||
## Data Access Patterns
|
||||
|
||||
### Multi-tenancy
|
||||
|
||||
- All data is scoped by Organization
|
||||
- Environment-level isolation for surveys and contacts
|
||||
- Project-level grouping for related surveys
|
||||
|
||||
### Soft Deletion
|
||||
|
||||
Some models use soft deletion patterns:
|
||||
|
||||
- Check `isActive` fields where present
|
||||
- Use proper filtering in queries
|
||||
|
||||
### Cascading Deletes
|
||||
|
||||
Configured cascade relationships:
|
||||
|
||||
- Organization deletion cascades to all child entities
|
||||
- Survey deletion removes responses, displays, triggers
|
||||
- Contact deletion removes attributes and responses
|
||||
|
||||
## Common Query Patterns
|
||||
|
||||
### Survey with Responses
|
||||
|
||||
```typescript
|
||||
// Include response count and latest responses
|
||||
const survey = await prisma.survey.findUnique({
|
||||
where: { id: surveyId },
|
||||
include: {
|
||||
responses: {
|
||||
take: 10,
|
||||
orderBy: { createdAt: "desc" },
|
||||
},
|
||||
_count: {
|
||||
select: { responses: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Environment Scoping
|
||||
|
||||
```typescript
|
||||
// Always scope by environment
|
||||
const surveys = await prisma.survey.findMany({
|
||||
where: {
|
||||
environmentId: environmentId,
|
||||
// Additional filters...
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Contact with Attributes
|
||||
|
||||
```typescript
|
||||
const contact = await prisma.contact.findUnique({
|
||||
where: { id: contactId },
|
||||
include: {
|
||||
attributes: {
|
||||
include: {
|
||||
attributeKey: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
This schema supports Formbricks' core functionality: multi-tenant survey management, user targeting, response collection, and analysis, all while maintaining strict data isolation and security.
|
||||
@@ -1,28 +0,0 @@
|
||||
---
|
||||
description: Guideline for writing end-user facing documentation in the apps/docs folder
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
Follow these instructions and guidelines when asked to write documentation in the apps/docs folder
|
||||
|
||||
Follow this structure to write the title, describtion and pick a matching icon and insert it at the top of the MDX file:
|
||||
|
||||
---
|
||||
|
||||
title: "FEATURE NAME"
|
||||
description: "1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT."
|
||||
icon: "link"
|
||||
|
||||
---
|
||||
|
||||
- Description: 1 concise sentence to describe WHEN the feature is being used and FOR WHAT BENEFIT.
|
||||
- Make ample use of the Mintlify components you can find here https://mintlify.com/docs/llms.txt - e.g. if docs describe consecutive steps, always use Mintlify Step component.
|
||||
- In all Headlines, only capitalize the current feature and nothing else, to Camel Case.
|
||||
- The page should never start with H1 headline, because it's already part of the template.
|
||||
- Tonality: Keep it concise and to the point. Avoid Jargon where possible.
|
||||
- If a feature is part of the Enterprise Edition, use this note:
|
||||
|
||||
<Note>
|
||||
FEATURE NAME is part of the [Enterprise Edition](/self-hosting/advanced/license)
|
||||
</Note>
|
||||
@@ -1,332 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Formbricks Architecture & Patterns
|
||||
|
||||
## Monorepo Structure
|
||||
|
||||
### Apps Directory
|
||||
- `apps/web/` - Main Next.js web application
|
||||
- `packages/` - Shared packages and utilities
|
||||
|
||||
### Key Directories in Web App
|
||||
```
|
||||
apps/web/
|
||||
├── app/ # Next.js 13+ app directory
|
||||
│ ├── (app)/ # Main application routes
|
||||
│ ├── (auth)/ # Authentication routes
|
||||
│ ├── api/ # API routes
|
||||
├── components/ # Shared components
|
||||
├── lib/ # Utility functions and services
|
||||
└── modules/ # Feature-specific modules
|
||||
```
|
||||
|
||||
## Routing Patterns
|
||||
|
||||
### App Router Structure
|
||||
The application uses Next.js 13+ app router with route groups:
|
||||
|
||||
```
|
||||
(app)/environments/[environmentId]/
|
||||
├── surveys/[surveyId]/
|
||||
│ ├── (analysis)/ # Analysis views
|
||||
│ │ ├── responses/ # Response management
|
||||
│ │ ├── summary/ # Survey summary
|
||||
│ │ └── hooks/ # Analysis-specific hooks
|
||||
│ ├── edit/ # Survey editing
|
||||
│ └── settings/ # Survey settings
|
||||
```
|
||||
|
||||
### Dynamic Routes
|
||||
- `[environmentId]` - Environment-specific routes
|
||||
- `[surveyId]` - Survey-specific routes
|
||||
|
||||
## Service Layer Pattern
|
||||
|
||||
### Service Organization
|
||||
Services are organized by domain in `apps/web/lib/`:
|
||||
|
||||
```typescript
|
||||
// Example: Response service
|
||||
// apps/web/lib/response/service.ts
|
||||
export const getResponseCountAction = async ({
|
||||
surveyId,
|
||||
filterCriteria,
|
||||
}: {
|
||||
surveyId: string;
|
||||
filterCriteria: any;
|
||||
}) => {
|
||||
// Service implementation
|
||||
};
|
||||
```
|
||||
|
||||
### Action Pattern
|
||||
Server actions follow a consistent pattern:
|
||||
|
||||
```typescript
|
||||
// Action wrapper for service calls
|
||||
export const getResponseCountAction = async (params) => {
|
||||
try {
|
||||
const result = await responseService.getCount(params);
|
||||
return { data: result };
|
||||
} catch (error) {
|
||||
return { error: error.message };
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Context Patterns
|
||||
|
||||
### Provider Structure
|
||||
Context providers follow a consistent pattern:
|
||||
|
||||
```typescript
|
||||
// Provider component
|
||||
export const ResponseFilterProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [selectedFilter, setSelectedFilter] = useState(defaultFilter);
|
||||
|
||||
const value = {
|
||||
selectedFilter,
|
||||
setSelectedFilter,
|
||||
// ... other state and methods
|
||||
};
|
||||
|
||||
return (
|
||||
<ResponseFilterContext.Provider value={value}>
|
||||
{children}
|
||||
</ResponseFilterContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Hook for consuming context
|
||||
export const useResponseFilter = () => {
|
||||
const context = useContext(ResponseFilterContext);
|
||||
if (!context) {
|
||||
throw new Error('useResponseFilter must be used within ResponseFilterProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
```
|
||||
|
||||
### Context Composition
|
||||
Multiple contexts are often composed together:
|
||||
|
||||
```typescript
|
||||
// Layout component with multiple providers
|
||||
export default function AnalysisLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ResponseFilterProvider>
|
||||
<ResponseCountProvider>
|
||||
{children}
|
||||
</ResponseCountProvider>
|
||||
</ResponseFilterProvider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Component Patterns
|
||||
|
||||
### Page Components
|
||||
Page components are located in the app directory and follow this pattern:
|
||||
|
||||
```typescript
|
||||
// apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page.tsx
|
||||
export default function ResponsesPage() {
|
||||
return (
|
||||
<div>
|
||||
<ResponsesTable />
|
||||
<ResponsesPagination />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Component Organization
|
||||
- **Pages** - Route components in app directory
|
||||
- **Components** - Reusable UI components
|
||||
- **Modules** - Feature-specific components and logic
|
||||
|
||||
### Shared Components
|
||||
Common components are in `apps/web/components/`:
|
||||
- UI components (buttons, inputs, modals)
|
||||
- Layout components (headers, sidebars)
|
||||
- Data display components (tables, charts)
|
||||
|
||||
## Hook Patterns
|
||||
|
||||
### Custom Hook Structure
|
||||
Custom hooks follow consistent patterns:
|
||||
|
||||
```typescript
|
||||
export const useResponseCount = ({
|
||||
survey,
|
||||
initialCount
|
||||
}: {
|
||||
survey: TSurvey;
|
||||
initialCount?: number;
|
||||
}) => {
|
||||
const [responseCount, setResponseCount] = useState(initialCount ?? 0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Hook logic...
|
||||
|
||||
return {
|
||||
responseCount,
|
||||
isLoading,
|
||||
refetch,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### Hook Dependencies
|
||||
- Use context hooks for shared state
|
||||
- Implement proper cleanup with AbortController
|
||||
- Optimize dependency arrays to prevent unnecessary re-renders
|
||||
|
||||
## Data Fetching Patterns
|
||||
|
||||
### Server Actions
|
||||
The app uses Next.js server actions for data fetching:
|
||||
|
||||
```typescript
|
||||
// Server action
|
||||
export async function getResponsesAction(params: GetResponsesParams) {
|
||||
const responses = await getResponses(params);
|
||||
return { data: responses };
|
||||
}
|
||||
|
||||
// Client usage
|
||||
const { data } = await getResponsesAction(params);
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
Consistent error handling across the application:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const result = await apiCall();
|
||||
return { data: result };
|
||||
} catch (error) {
|
||||
console.error("Operation failed:", error);
|
||||
return { error: error.message };
|
||||
}
|
||||
```
|
||||
|
||||
## Type Safety
|
||||
|
||||
### Type Organization
|
||||
Types are organized in packages:
|
||||
- `@formbricks/types` - Shared type definitions
|
||||
- Local types in component/hook files
|
||||
|
||||
### Common Types
|
||||
```typescript
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
### Local State
|
||||
- Use `useState` for component-specific state
|
||||
- Use `useReducer` for complex state logic
|
||||
- Use refs for mutable values that don't trigger re-renders
|
||||
|
||||
### Global State
|
||||
- React Context for feature-specific shared state
|
||||
- URL state for filters and pagination
|
||||
- Server state through server actions
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Code Splitting
|
||||
- Dynamic imports for heavy components
|
||||
- Route-based code splitting with app router
|
||||
- Lazy loading for non-critical features
|
||||
|
||||
### Caching Strategy
|
||||
- Server-side caching for database queries
|
||||
- Client-side caching with React Query (where applicable)
|
||||
- Static generation for public pages
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Test Organization
|
||||
```
|
||||
component/
|
||||
├── Component.tsx
|
||||
├── Component.test.tsx
|
||||
└── hooks/
|
||||
├── useHook.ts
|
||||
└── useHook.test.tsx
|
||||
```
|
||||
|
||||
### Test Patterns
|
||||
- Unit tests for utilities and services
|
||||
- Integration tests for components with context
|
||||
- Hook tests with proper mocking
|
||||
|
||||
## Build & Deployment
|
||||
|
||||
### Build Process
|
||||
- TypeScript compilation
|
||||
- Next.js build optimization
|
||||
- Asset optimization and bundling
|
||||
|
||||
### Environment Configuration
|
||||
- Environment-specific configurations
|
||||
- Feature flags for gradual rollouts
|
||||
- Database connection management
|
||||
|
||||
## Security Patterns
|
||||
|
||||
### Authentication
|
||||
- Session-based authentication
|
||||
- Environment-based access control
|
||||
- API route protection
|
||||
|
||||
### Data Validation
|
||||
- Input validation on both client and server
|
||||
- Type-safe API contracts
|
||||
- Sanitization of user inputs
|
||||
|
||||
## Monitoring & Observability
|
||||
|
||||
### Error Tracking
|
||||
- Client-side error boundaries
|
||||
- Server-side error logging
|
||||
- Performance monitoring
|
||||
|
||||
### Analytics
|
||||
- User interaction tracking
|
||||
- Performance metrics
|
||||
- Database query monitoring
|
||||
|
||||
## Best Practices Summary
|
||||
|
||||
### Code Organization
|
||||
- ✅ Follow the established directory structure
|
||||
- ✅ Use consistent naming conventions
|
||||
- ✅ Separate concerns (UI, logic, data)
|
||||
- ✅ Keep components focused and small
|
||||
|
||||
### Performance
|
||||
- ✅ Implement proper loading states
|
||||
- ✅ Use AbortController for async operations
|
||||
- ✅ Optimize database queries
|
||||
- ✅ Implement proper caching strategies
|
||||
|
||||
### Type Safety
|
||||
- ✅ Use TypeScript throughout
|
||||
- ✅ Define proper interfaces for props
|
||||
- ✅ Use type guards for runtime validation
|
||||
- ✅ Leverage shared type packages
|
||||
|
||||
### Testing
|
||||
- ✅ Write tests for critical functionality
|
||||
- ✅ Mock external dependencies properly
|
||||
- ✅ Test error scenarios and edge cases
|
||||
- ✅ Maintain good test coverage
|
||||
@@ -1,232 +0,0 @@
|
||||
---
|
||||
description: Security best practices and guidelines for writing GitHub Actions and workflows
|
||||
globs: .github/workflows/*.yml,.github/workflows/*.yaml,.github/actions/*/action.yml,.github/actions/*/action.yaml
|
||||
---
|
||||
|
||||
# GitHub Actions Security Best Practices
|
||||
|
||||
## Required Security Measures
|
||||
|
||||
### 1. Set Minimum GITHUB_TOKEN Permissions
|
||||
|
||||
Always explicitly set the minimum required permissions for GITHUB_TOKEN:
|
||||
|
||||
```yaml
|
||||
permissions:
|
||||
contents: read
|
||||
# Only add additional permissions if absolutely necessary:
|
||||
# pull-requests: write # for commenting on PRs
|
||||
# issues: write # for creating/updating issues
|
||||
# checks: write # for publishing check results
|
||||
```
|
||||
|
||||
### 2. Add Harden-Runner as First Step
|
||||
|
||||
For **every job** on `ubuntu-latest`, add Harden-Runner as the first step:
|
||||
|
||||
```yaml
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
with:
|
||||
egress-policy: audit # or 'block' for stricter security
|
||||
```
|
||||
|
||||
### 3. Pin Actions to Full Commit SHA
|
||||
|
||||
**Always** pin third-party actions to their full commit SHA, not tags:
|
||||
|
||||
```yaml
|
||||
# ❌ BAD - uses mutable tag
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# ✅ GOOD - pinned to immutable commit SHA
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
```
|
||||
|
||||
### 4. Secure Variable Handling
|
||||
|
||||
Prevent command injection by properly quoting variables:
|
||||
|
||||
```yaml
|
||||
# ❌ BAD - potential command injection
|
||||
run: echo "Processing ${{ inputs.user_input }}"
|
||||
|
||||
# ✅ GOOD - properly quoted
|
||||
env:
|
||||
USER_INPUT: ${{ inputs.user_input }}
|
||||
run: echo "Processing ${USER_INPUT}"
|
||||
```
|
||||
|
||||
Use `${VARIABLE}` syntax in shell scripts instead of `$VARIABLE`.
|
||||
|
||||
### 5. Environment Variables for Secrets
|
||||
|
||||
Store sensitive data in environment variables, not inline:
|
||||
|
||||
```yaml
|
||||
# ❌ BAD
|
||||
run: curl -H "Authorization: Bearer ${{ secrets.TOKEN }}" api.example.com
|
||||
|
||||
# ✅ GOOD
|
||||
env:
|
||||
API_TOKEN: ${{ secrets.TOKEN }}
|
||||
run: curl -H "Authorization: Bearer ${API_TOKEN}" api.example.com
|
||||
```
|
||||
|
||||
## Workflow Structure Best Practices
|
||||
|
||||
### Required Workflow Elements
|
||||
|
||||
```yaml
|
||||
name: "Descriptive Workflow Name"
|
||||
|
||||
on:
|
||||
# Define specific triggers
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
# Always set explicit permissions
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
job-name:
|
||||
name: "Descriptive Job Name"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30 # tune per job; standardize repo-wide
|
||||
|
||||
# Set job-level permissions if different from workflow level
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
# Always start with Harden-Runner on ubuntu-latest
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@v2
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
# Pin all actions to commit SHA
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
```
|
||||
|
||||
### Input Validation for Actions
|
||||
|
||||
For composite actions, always validate inputs:
|
||||
|
||||
```yaml
|
||||
inputs:
|
||||
user_input:
|
||||
description: "User provided input"
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Validate input
|
||||
shell: bash
|
||||
run: |
|
||||
# Harden shell and validate input format/content before use
|
||||
set -euo pipefail
|
||||
|
||||
USER_INPUT="${{ inputs.user_input }}"
|
||||
|
||||
if [[ ! "${USER_INPUT}" =~ ^[A-Za-z0-9._-]+$ ]]; then
|
||||
echo "❌ Invalid input format"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
## Docker Security in Actions
|
||||
|
||||
### Pin Docker Images to Digests
|
||||
|
||||
```yaml
|
||||
# ❌ BAD - mutable tag
|
||||
container: node:18
|
||||
|
||||
# ✅ GOOD - pinned to digest
|
||||
container: node:18@sha256:a1ba21bf0c92931d02a8416f0a54daad66cb36a85d6a37b82dfe1604c4c09cad
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Secure File Operations
|
||||
|
||||
```yaml
|
||||
- name: Process files securely
|
||||
shell: bash
|
||||
env:
|
||||
FILE_PATH: ${{ inputs.file_path }}
|
||||
run: |
|
||||
set -euo pipefail # Fail on errors, undefined vars, pipe failures
|
||||
|
||||
# Use absolute paths and validate
|
||||
SAFE_PATH=$(realpath "${FILE_PATH}")
|
||||
if [[ "$SAFE_PATH" != "${GITHUB_WORKSPACE}"/* ]]; then
|
||||
echo "❌ Path outside workspace"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
### Artifact Handling
|
||||
|
||||
```yaml
|
||||
- name: Upload artifacts securely
|
||||
uses: actions/upload-artifact@50769540e7f4bd5e21e526ee35c689e35e0d6874 # v4.4.0
|
||||
with:
|
||||
name: build-artifacts
|
||||
path: |
|
||||
dist/
|
||||
!dist/**/*.log # Exclude sensitive files
|
||||
retention-days: 30
|
||||
```
|
||||
|
||||
### GHCR authentication for pulls/scans
|
||||
|
||||
```yaml
|
||||
# Minimal permissions required for GHCR pulls/scans
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
|
||||
steps:
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
```
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [ ] Minimum GITHUB_TOKEN permissions set
|
||||
- [ ] Harden-Runner added to all ubuntu-latest jobs
|
||||
- [ ] All third-party actions pinned to commit SHA
|
||||
- [ ] Input validation implemented for custom actions
|
||||
- [ ] Variables properly quoted in shell scripts
|
||||
- [ ] Secrets stored in environment variables
|
||||
- [ ] Docker images pinned to digests (if used)
|
||||
- [ ] Error handling with `set -euo pipefail`
|
||||
- [ ] File paths validated and sanitized
|
||||
- [ ] No sensitive data in logs or outputs
|
||||
- [ ] GHCR login performed before pulls/scans (packages: read)
|
||||
- [ ] Job timeouts configured (`timeout-minutes`)
|
||||
|
||||
## Recommended Additional Workflows
|
||||
|
||||
Consider adding these security-focused workflows to your repository:
|
||||
|
||||
1. **CodeQL Analysis** - Static Application Security Testing (SAST)
|
||||
2. **Dependency Review** - Scan for vulnerable dependencies in PRs
|
||||
3. **Dependabot Configuration** - Automated dependency updates
|
||||
|
||||
## Resources
|
||||
|
||||
- [GitHub Security Hardening Guide](https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions)
|
||||
- [Step Security Harden-Runner](https://github.com/step-security/harden-runner)
|
||||
- [Secure-Repo Best Practices](https://github.com/step-security/secure-repo)
|
||||
@@ -1,457 +0,0 @@
|
||||
---
|
||||
title: i18n Management with Lingo.dev
|
||||
description: Guidelines for managing internationalization (i18n) with Lingo.dev, including translation workflow, key validation, and best practices
|
||||
---
|
||||
|
||||
# i18n Management with Lingo.dev
|
||||
|
||||
This rule defines the workflow and best practices for managing internationalization (i18n) in the Formbricks project using Lingo.dev.
|
||||
|
||||
## Overview
|
||||
|
||||
Formbricks uses [Lingo.dev](https://lingo.dev) for managing translations across multiple languages. The translation workflow includes:
|
||||
|
||||
1. **Translation Keys**: Defined in code using the `t()` function from `react-i18next`
|
||||
2. **Translation Files**: JSON files stored in `apps/web/locales/` for each supported language
|
||||
3. **Validation**: Automated scanning to detect missing and unused translation keys
|
||||
4. **CI/CD**: Pre-commit hooks and GitHub Actions to enforce translation quality
|
||||
|
||||
## Translation Workflow
|
||||
|
||||
### 1. Using Translations in Code
|
||||
|
||||
When adding translatable text in the web app, use the `t()` function or `<Trans>` component:
|
||||
|
||||
**Using the `t()` function:**
|
||||
```tsx
|
||||
import { useTranslate } from "@/lib/i18n/translate";
|
||||
|
||||
const MyComponent = () => {
|
||||
const { t } = useTranslate();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{t("common.welcome")}</h1>
|
||||
<p>{t("pages.dashboard.description")}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
**Using the `<Trans>` component (for text with HTML elements):**
|
||||
```tsx
|
||||
import { Trans } from "react-i18next";
|
||||
|
||||
const MyComponent = () => {
|
||||
return (
|
||||
<div>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey="auth.terms_agreement"
|
||||
components={{
|
||||
link: <a href="/terms" />,
|
||||
b: <b />
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
**Key Naming Conventions:**
|
||||
- Use dot notation for nested keys: `section.subsection.key`
|
||||
- Use descriptive names: `auth.login.success_message` not `auth.msg1`
|
||||
- Group related keys together: `auth.*`, `errors.*`, `common.*`
|
||||
- Use lowercase with underscores: `user_profile_settings` not `UserProfileSettings`
|
||||
|
||||
### 2. Translation File Structure
|
||||
|
||||
Translation files are located in `apps/web/locales/` and use the following naming convention:
|
||||
- `en-US.json` (English - United States, default)
|
||||
- `de-DE.json` (German)
|
||||
- `fr-FR.json` (French)
|
||||
- `pt-BR.json` (Portuguese - Brazil)
|
||||
- etc.
|
||||
|
||||
**File Structure:**
|
||||
```json
|
||||
{
|
||||
"common": {
|
||||
"welcome": "Welcome",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"auth": {
|
||||
"login": {
|
||||
"title": "Login",
|
||||
"email_placeholder": "Enter your email",
|
||||
"password_placeholder": "Enter your password"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Adding New Translation Keys
|
||||
|
||||
When adding new translation keys:
|
||||
|
||||
1. **Add the key in your code** using `t("your.new.key")`
|
||||
2. **Add translation for that key in en-US.json file**
|
||||
3. **Run the translation workflow:**
|
||||
```bash
|
||||
pnpm i18n
|
||||
```
|
||||
This will:
|
||||
- Generate translations for all languages using Lingo.dev
|
||||
- Validate that all keys are present and used
|
||||
|
||||
4. **Review and commit** the generated translation files
|
||||
|
||||
### 4. Available Scripts
|
||||
|
||||
```bash
|
||||
# Generate translations using Lingo.dev
|
||||
pnpm generate-translations
|
||||
|
||||
# Scan and validate translation keys
|
||||
pnpm scan-translations
|
||||
|
||||
# Full workflow: generate + validate
|
||||
pnpm i18n
|
||||
|
||||
# Validate only (without generation)
|
||||
pnpm i18n:validate
|
||||
```
|
||||
|
||||
## Translation Key Validation
|
||||
|
||||
### Automated Validation
|
||||
|
||||
The project includes automated validation that runs:
|
||||
- **Pre-commit hook**: Validates translations before allowing commits (when `LINGODOTDEV_API_KEY` is set)
|
||||
- **GitHub Actions**: Validates translations on every PR and push to main
|
||||
|
||||
### Validation Rules
|
||||
|
||||
The validation script (`scan-translations.ts`) checks for:
|
||||
|
||||
1. **Missing Keys**: Translation keys used in code but not present in translation files
|
||||
2. **Unused Keys**: Translation keys present in translation files but not used in code
|
||||
3. **Incomplete Translations**: Keys that exist in the default language (`en-US`) but are missing in target languages
|
||||
|
||||
**What gets scanned:**
|
||||
- All `.ts` and `.tsx` files in `apps/web/`
|
||||
- Both `t()` function calls and `<Trans i18nKey="">` components
|
||||
- All locale files (`de-DE.json`, `fr-FR.json`, `ja-JP.json`, etc.)
|
||||
|
||||
**What gets excluded:**
|
||||
- Test files (`*.test.ts`, `*.test.tsx`, `*.spec.ts`, `*.spec.tsx`)
|
||||
- Build directories (`node_modules`, `dist`, `build`, `.next`, `coverage`)
|
||||
- Locale files themselves (from code scanning)
|
||||
|
||||
**Note:** Test files are excluded because they often use mock or example translation keys for testing purposes that don't need to exist in production translation files.
|
||||
|
||||
### Fixing Validation Errors
|
||||
|
||||
#### Missing Keys
|
||||
|
||||
If you encounter missing key errors:
|
||||
|
||||
```
|
||||
❌ MISSING KEYS (2):
|
||||
|
||||
These keys are used in code but not found in translation files:
|
||||
|
||||
• auth.signup.email_required
|
||||
• settings.profile.update_success
|
||||
```
|
||||
|
||||
**Resolution:**
|
||||
1. Ensure that translations for those keys are present in en-US.json .
|
||||
2. Run `pnpm generate-translations` to have Lingo.dev generate the missing translations
|
||||
3. OR manually add the keys to `apps/web/locales/en-US.json`:
|
||||
```json
|
||||
{
|
||||
"auth": {
|
||||
"signup": {
|
||||
"email_required": "Email is required"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"profile": {
|
||||
"update_success": "Profile updated successfully"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
3. Run `pnpm scan-translations` to verify
|
||||
4. Commit the changes
|
||||
|
||||
#### Unused Keys
|
||||
|
||||
If you encounter unused key errors:
|
||||
|
||||
```
|
||||
⚠️ UNUSED KEYS (1):
|
||||
|
||||
These keys exist in translation files but are not used in code:
|
||||
|
||||
• old.deprecated.key
|
||||
```
|
||||
|
||||
**Resolution:**
|
||||
1. If the key is truly unused, remove it from all translation files
|
||||
2. If the key should be used, add it to your code using `t("old.deprecated.key")`
|
||||
3. Run `pnpm scan-translations` to verify
|
||||
4. Commit the changes
|
||||
|
||||
#### Incomplete Translations
|
||||
|
||||
If you encounter incomplete translation errors:
|
||||
|
||||
```
|
||||
⚠️ INCOMPLETE TRANSLATIONS:
|
||||
|
||||
Some keys from en-US are missing in target languages:
|
||||
|
||||
📝 de-DE (5 missing keys):
|
||||
• auth.new_feature.title
|
||||
• auth.new_feature.description
|
||||
• settings.advanced.option
|
||||
... and 2 more
|
||||
```
|
||||
|
||||
**Resolution:**
|
||||
1. **Recommended:** Run `pnpm generate-translations` to have Lingo.dev automatically translate the missing keys
|
||||
2. **Manual:** Add the missing keys to the target language files:
|
||||
```bash
|
||||
# Copy the structure from en-US.json and translate the values
|
||||
# For example, in de-DE.json:
|
||||
{
|
||||
"auth": {
|
||||
"new_feature": {
|
||||
"title": "Neues Feature",
|
||||
"description": "Beschreibung des neuen Features"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
3. Run `pnpm scan-translations` to verify all translations are complete
|
||||
4. Commit the changes
|
||||
|
||||
## Pre-commit Hook Behavior
|
||||
|
||||
The pre-commit hook will:
|
||||
|
||||
1. Run `lint-staged` for code formatting
|
||||
2. If `LINGODOTDEV_API_KEY` is set:
|
||||
- Generate translations using Lingo.dev
|
||||
- Validate translation keys
|
||||
- Auto-add updated locale files to the commit
|
||||
- **Block the commit** if validation fails
|
||||
3. If `LINGODOTDEV_API_KEY` is not set:
|
||||
- Skip translation validation (for community contributors)
|
||||
- Show a warning message
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### LINGODOTDEV_API_KEY
|
||||
|
||||
This is the API key for Lingo.dev integration.
|
||||
|
||||
**For Core Team:**
|
||||
- Add to your local `.env` file
|
||||
- Required for running translation generation
|
||||
|
||||
**For Community Contributors:**
|
||||
- Not required for local development
|
||||
- Translation validation will be skipped
|
||||
- The CI will still validate translations
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Keep Keys Organized
|
||||
|
||||
Group related keys together:
|
||||
```json
|
||||
{
|
||||
"auth": {
|
||||
"login": { ... },
|
||||
"signup": { ... },
|
||||
"forgot_password": { ... }
|
||||
},
|
||||
"dashboard": {
|
||||
"header": { ... },
|
||||
"sidebar": { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Avoid Hardcoded Strings
|
||||
|
||||
**❌ Bad:**
|
||||
```tsx
|
||||
<button>Click here</button>
|
||||
```
|
||||
|
||||
**✅ Good:**
|
||||
```tsx
|
||||
<button>{t("common.click_here")}</button>
|
||||
```
|
||||
|
||||
### 3. Use Interpolation for Dynamic Content
|
||||
|
||||
**❌ Bad:**
|
||||
```tsx
|
||||
{t("welcome")} {userName}!
|
||||
```
|
||||
|
||||
**✅ Good:**
|
||||
```tsx
|
||||
{t("auth.welcome_message", { userName })}
|
||||
```
|
||||
|
||||
With translation:
|
||||
```json
|
||||
{
|
||||
"auth": {
|
||||
"welcome_message": "Welcome, {userName}!"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Avoid Dynamic Key Construction
|
||||
|
||||
**❌ Bad:**
|
||||
```tsx
|
||||
const key = `errors.${errorCode}`;
|
||||
t(key);
|
||||
```
|
||||
|
||||
**✅ Good:**
|
||||
```tsx
|
||||
switch (errorCode) {
|
||||
case "401":
|
||||
return t("errors.unauthorized");
|
||||
case "404":
|
||||
return t("errors.not_found");
|
||||
default:
|
||||
return t("errors.unknown");
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Test Translation Keys
|
||||
|
||||
When adding new features:
|
||||
1. Add translation keys
|
||||
2. Test in multiple languages using the language switcher
|
||||
3. Ensure text doesn't overflow in longer translations (German, French)
|
||||
4. Run `pnpm scan-translations` before committing
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: Pre-commit hook fails with validation errors
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Run the full i18n workflow
|
||||
pnpm i18n
|
||||
|
||||
# Fix any missing or unused keys
|
||||
# Then commit again
|
||||
git add .
|
||||
git commit -m "your message"
|
||||
```
|
||||
|
||||
### Issue: Translation validation passes locally but fails in CI
|
||||
|
||||
**Solution:**
|
||||
- Ensure all translation files are committed
|
||||
- Check that `scan-translations.ts` hasn't been modified
|
||||
- Verify that locale files are properly formatted JSON
|
||||
|
||||
### Issue: Cannot commit because of missing translations
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# If you have LINGODOTDEV_API_KEY:
|
||||
pnpm generate-translations
|
||||
|
||||
# If you don't have the API key (community contributor):
|
||||
# Manually add the missing keys to en-US.json
|
||||
# Then run validation:
|
||||
pnpm scan-translations
|
||||
```
|
||||
|
||||
### Issue: Getting "unused keys" for keys that are used
|
||||
|
||||
**Solution:**
|
||||
- The script scans `.ts` and `.tsx` files only
|
||||
- If keys are used in other file types, they may be flagged
|
||||
- Verify the key is actually used with `grep -r "your.key" apps/web/`
|
||||
- If it's a false positive, consider updating the scanning patterns in `scan-translations.ts`
|
||||
|
||||
## AI Assistant Guidelines
|
||||
|
||||
When assisting with i18n-related tasks, always:
|
||||
|
||||
1. **Use the `t()` function** for all user-facing text
|
||||
2. **Follow key naming conventions** (lowercase, dots for nesting)
|
||||
3. **Run validation** after making changes: `pnpm scan-translations`
|
||||
4. **Fix missing keys** by adding them to `en-US.json`
|
||||
5. **Remove unused keys** from all translation files
|
||||
6. **Test the pre-commit hook** if making changes to translation workflow
|
||||
7. **Update this rule file** if translation workflow changes
|
||||
|
||||
### Fixing Missing Translation Keys
|
||||
|
||||
When the AI encounters missing translation key errors:
|
||||
|
||||
1. Identify the missing keys from the error output
|
||||
2. Determine the appropriate section and naming for each key
|
||||
3. Add the keys to `apps/web/locales/en-US.json` with meaningful English text
|
||||
4. Ensure proper JSON structure and nesting
|
||||
5. Run `pnpm scan-translations` to verify
|
||||
6. Inform the user that other language files will be updated via Lingo.dev
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// Error: Missing key "settings.api.rate_limit_exceeded"
|
||||
|
||||
// Add to en-US.json:
|
||||
{
|
||||
"settings": {
|
||||
"api": {
|
||||
"rate_limit_exceeded": "API rate limit exceeded. Please try again later."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Removing Unused Translation Keys
|
||||
|
||||
When the AI encounters unused translation key errors:
|
||||
|
||||
1. Verify the keys are truly unused by searching the codebase
|
||||
2. Remove the keys from `apps/web/locales/en-US.json`
|
||||
3. Note that removal from other language files can be handled via Lingo.dev
|
||||
4. Run `pnpm scan-translations` to verify
|
||||
|
||||
## Migration Notes
|
||||
|
||||
This project previously used Tolgee for translations. As of this migration:
|
||||
|
||||
- **Old scripts**: `tolgee-pull` is deprecated (kept for reference)
|
||||
- **New scripts**: Use `pnpm i18n` or `pnpm generate-translations`
|
||||
- **Old workflows**: `tolgee.yml` and `tolgee-missing-key-check.yml` removed
|
||||
- **New workflow**: `translation-check.yml` handles all validation
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** October 14, 2025
|
||||
**Related Files:**
|
||||
- `scan-translations.ts` - Translation validation script
|
||||
- `.husky/pre-commit` - Pre-commit hook with i18n validation
|
||||
- `.github/workflows/translation-check.yml` - CI workflow for translation validation
|
||||
- `apps/web/locales/*.json` - Translation files
|
||||
@@ -1,52 +0,0 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# React Context & Provider Patterns
|
||||
|
||||
## Context Provider Best Practices
|
||||
|
||||
### Provider Implementation
|
||||
- Use TypeScript interfaces for provider props with optional `initialCount` for testing
|
||||
- Implement proper cleanup in `useEffect` to avoid React hooks warnings
|
||||
- Reference: [apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/ResponseCountProvider.tsx](mdc:apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/ResponseCountProvider.tsx)
|
||||
|
||||
### Cleanup Pattern for Refs
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
const currentPendingRequests = pendingRequests.current;
|
||||
const currentAbortController = abortController.current;
|
||||
|
||||
return () => {
|
||||
if (currentAbortController) {
|
||||
currentAbortController.abort();
|
||||
}
|
||||
currentPendingRequests.clear();
|
||||
};
|
||||
}, []);
|
||||
```
|
||||
|
||||
### Testing Context Providers
|
||||
- Always wrap components using context in the provider during tests
|
||||
- Use `initialCount` prop for predictable test scenarios
|
||||
- Mock context dependencies like `useParams`, `useResponseFilter`
|
||||
- Example from [apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.test.tsx](mdc:apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.test.tsx):
|
||||
|
||||
```typescript
|
||||
render(
|
||||
<ResponseCountProvider survey={dummySurvey} initialCount={5}>
|
||||
<ComponentUnderTest />
|
||||
</ResponseCountProvider>
|
||||
);
|
||||
```
|
||||
|
||||
### Required Mocks for Context Testing
|
||||
- Mock `next/navigation` with `useParams` returning environment and survey IDs
|
||||
- Mock response filter context and actions
|
||||
- Mock API actions that the provider depends on
|
||||
|
||||
### Context Hook Usage
|
||||
- Create custom hooks like `useResponseCountContext()` for consuming context
|
||||
- Provide meaningful error messages when context is used outside provider
|
||||
- Use context for shared state that multiple components need to access
|
||||
@@ -1,179 +0,0 @@
|
||||
---
|
||||
description: Apply these quality standards before finalizing code changes to ensure DRY principles, React best practices, TypeScript conventions, and maintainable code.
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Review & Refine
|
||||
|
||||
Before finalizing any code changes, review your implementation against these quality standards:
|
||||
|
||||
## Core Principles
|
||||
|
||||
### DRY (Don't Repeat Yourself)
|
||||
|
||||
- Extract duplicated logic into reusable functions or hooks
|
||||
- If the same code appears in multiple places, consolidate it
|
||||
- Create helper functions at appropriate scope (component-level, module-level, or utility files)
|
||||
- Avoid copy-pasting code blocks
|
||||
|
||||
### Code Reduction
|
||||
|
||||
- Remove unnecessary code, comments, and abstractions
|
||||
- Prefer built-in solutions over custom implementations
|
||||
- Consolidate similar logic
|
||||
- Remove dead code and unused imports
|
||||
- Question if every line of code is truly needed
|
||||
|
||||
## React Best Practices
|
||||
|
||||
### Component Design
|
||||
|
||||
- Keep components focused on a single responsibility
|
||||
- Extract complex logic into custom hooks
|
||||
- Prefer composition over prop drilling
|
||||
- Use children props and render props when appropriate
|
||||
- Keep component files under 300 lines when possible
|
||||
|
||||
### Hooks Usage
|
||||
|
||||
- Follow Rules of Hooks (only call at top level, only in React functions)
|
||||
- Extract complex `useEffect` logic into custom hooks
|
||||
- Use `useMemo` and `useCallback` only when you have a measured performance issue
|
||||
- Declare dependencies arrays correctly - don't ignore exhaustive-deps warnings
|
||||
- Keep `useEffect` focused on a single concern
|
||||
|
||||
### State Management
|
||||
|
||||
- Colocate state as close as possible to where it's used
|
||||
- Lift state only when necessary
|
||||
- Use `useReducer` for complex state logic with multiple sub-values
|
||||
- Avoid derived state - compute values during render instead
|
||||
- Don't store values in state that can be computed from props
|
||||
|
||||
### Event Handlers
|
||||
|
||||
- Name event handlers with `handle` prefix (e.g., `handleClick`, `handleSubmit`)
|
||||
- Extract complex event handler logic into separate functions
|
||||
- Avoid inline arrow functions in JSX when they contain complex logic
|
||||
|
||||
## TypeScript Best Practices
|
||||
|
||||
### Type Safety
|
||||
|
||||
- Prefer type inference over explicit types when possible
|
||||
- Use `const` assertions for literal types
|
||||
- Avoid `any` - use `unknown` if type is truly unknown
|
||||
- Use discriminated unions for complex conditional logic
|
||||
- Leverage type guards and narrowing
|
||||
|
||||
### Interface & Type Usage
|
||||
|
||||
- Use existing types from `@formbricks/types` - don't recreate them
|
||||
- Prefer `interface` for object shapes that might be extended
|
||||
- Prefer `type` for unions, intersections, and mapped types
|
||||
- Define types close to where they're used unless they're shared
|
||||
- Export types from index files for shared types
|
||||
|
||||
### Type Assertions
|
||||
|
||||
- Avoid type assertions (`as`) when possible
|
||||
- Use type guards instead of assertions
|
||||
- Only assert when you have more information than TypeScript
|
||||
|
||||
## Code Organization
|
||||
|
||||
### Separation of Concerns
|
||||
|
||||
- Separate business logic from UI rendering
|
||||
- Extract API calls into separate functions or modules
|
||||
- Keep data transformation separate from component logic
|
||||
- Use custom hooks for stateful logic that doesn't render UI
|
||||
|
||||
### Function Clarity
|
||||
|
||||
- Functions should do one thing well
|
||||
- Name functions clearly and descriptively
|
||||
- Keep functions small (aim for under 20 lines)
|
||||
- Extract complex conditionals into named boolean variables or functions
|
||||
- Avoid deep nesting (max 3 levels)
|
||||
|
||||
### File Structure
|
||||
|
||||
- Group related functions together
|
||||
- Order declarations logically (types → hooks → helpers → component)
|
||||
- Keep imports organized (external → internal → relative)
|
||||
- Consider splitting large files by concern
|
||||
|
||||
## Additional Quality Checks
|
||||
|
||||
### Performance
|
||||
|
||||
- Don't optimize prematurely - measure first
|
||||
- Avoid creating new objects/arrays/functions in render unnecessarily
|
||||
- Use keys properly in lists (stable, unique identifiers)
|
||||
- Lazy load heavy components when appropriate
|
||||
|
||||
### Accessibility
|
||||
|
||||
- Use semantic HTML elements
|
||||
- Include ARIA labels where needed
|
||||
- Ensure keyboard navigation works
|
||||
- Check color contrast and focus states
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Handle error states in components
|
||||
- Provide user feedback for failed operations
|
||||
- Use error boundaries for component errors
|
||||
- Log errors appropriately (avoid swallowing errors silently)
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- Use descriptive names (avoid abbreviations unless very common)
|
||||
- Boolean variables/props should sound like yes/no questions (`isLoading`, `hasError`, `canEdit`)
|
||||
- Arrays should be plural (`users`, `choices`, `items`)
|
||||
- Event handlers: `handleX` in components, `onX` for props
|
||||
- Constants in UPPER_SNAKE_CASE only for true constants
|
||||
|
||||
### Code Readability
|
||||
|
||||
- Prefer early returns to reduce nesting
|
||||
- Use destructuring to make code clearer
|
||||
- Break complex expressions into named variables
|
||||
- Add comments only when code can't be made self-explanatory
|
||||
- Use whitespace to group related code
|
||||
|
||||
### Testing Considerations
|
||||
|
||||
- Write code that's easy to test (pure functions, clear inputs/outputs)
|
||||
- Avoid hard-to-mock dependencies when possible
|
||||
- Keep side effects at the edges of your code
|
||||
|
||||
## Review Checklist
|
||||
|
||||
Before submitting your changes, ask yourself:
|
||||
|
||||
1. **DRY**: Is there any duplicated logic I can extract?
|
||||
2. **Clarity**: Would another developer understand this code easily?
|
||||
3. **Simplicity**: Is this the simplest solution that works?
|
||||
4. **Types**: Am I using TypeScript effectively?
|
||||
5. **React**: Am I following React idioms and best practices?
|
||||
6. **Performance**: Are there obvious performance issues?
|
||||
7. **Separation**: Are concerns properly separated?
|
||||
8. **Testing**: Is this code testable?
|
||||
9. **Maintenance**: Will this be easy to change in 6 months?
|
||||
10. **Deletion**: Can I remove any code and still accomplish the goal?
|
||||
|
||||
## When to Apply This Rule
|
||||
|
||||
Apply this rule:
|
||||
|
||||
- After implementing a feature but before marking it complete
|
||||
- When you notice your code feels "messy" or complex
|
||||
- Before requesting code review
|
||||
- When you see yourself copy-pasting code
|
||||
- After receiving feedback about code quality
|
||||
|
||||
Don't let perfect be the enemy of good, but always strive for:
|
||||
**Simple, readable, maintainable code that does one thing well.**
|
||||
@@ -1,216 +0,0 @@
|
||||
---
|
||||
description: Migrate deprecated UI components to a unified component
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
# Component Migration Automation Rule
|
||||
|
||||
## Overview
|
||||
This rule automates the migration of deprecated components to new component systems in React/TypeScript codebases.
|
||||
|
||||
## Trigger
|
||||
When the user requests component migration (e.g., "migrate [DeprecatedComponent] to [NewComponent]" or "component migration").
|
||||
|
||||
## Process
|
||||
|
||||
### Step 1: Discovery and Planning
|
||||
1. **Identify migration parameters:**
|
||||
- Ask user for deprecated component name (e.g., "Modal")
|
||||
- Ask user for new component name(s) (e.g., "Dialog")
|
||||
- Ask for any components to exclude (e.g., "ModalWithTabs")
|
||||
- Ask for specific import paths if needed
|
||||
|
||||
2. **Scan codebase** for deprecated components:
|
||||
- Search for `import.*[DeprecatedComponent]` patterns
|
||||
- Exclude specified components that should not be migrated
|
||||
- List all found components with file paths
|
||||
- Present numbered list to user for confirmation
|
||||
|
||||
### Step 2: Component-by-Component Migration
|
||||
For each component, follow this exact sequence:
|
||||
|
||||
#### 2.1 Component Migration
|
||||
- **Import changes:**
|
||||
- Ask user to provide the new import structure
|
||||
- Example transformation pattern:
|
||||
```typescript
|
||||
// FROM:
|
||||
import { [DeprecatedComponent] } from "@/components/ui/[DeprecatedComponent]"
|
||||
|
||||
// TO:
|
||||
import {
|
||||
[NewComponent],
|
||||
[NewComponentPart1],
|
||||
[NewComponentPart2],
|
||||
// ... other parts
|
||||
} from "@/components/ui/[NewComponent]"
|
||||
```
|
||||
|
||||
- **Props transformation:**
|
||||
- Ask user for prop mapping rules (e.g., `open` → `open`, `setOpen` → `onOpenChange`)
|
||||
- Ask for props to remove (e.g., `noPadding`, `closeOnOutsideClick`, `size`)
|
||||
- Apply transformations based on user specifications
|
||||
|
||||
- **Structure transformation:**
|
||||
- Ask user for the new component structure pattern
|
||||
- Apply the transformation maintaining all functionality
|
||||
- Preserve all existing logic, state management, and event handlers
|
||||
|
||||
#### 2.2 Wait for User Approval
|
||||
- Present the migration changes
|
||||
- Wait for explicit user approval before proceeding
|
||||
- If rejected, ask for specific feedback and iterate
|
||||
#### 2.3 Re-read and Apply Additional Changes
|
||||
- Re-read the component file to capture any user modifications
|
||||
- Apply any additional improvements the user made
|
||||
- Ensure all changes are incorporated
|
||||
|
||||
#### 2.4 Test File Updates
|
||||
- **Find corresponding test file** (same name with `.test.tsx` or `.test.ts`)
|
||||
- **Update test mocks:**
|
||||
- Ask user for new component mock structure
|
||||
- Replace old component mocks with new ones
|
||||
- Example pattern:
|
||||
```typescript
|
||||
// Add to test setup:
|
||||
jest.mock("@/components/ui/[NewComponent]", () => ({
|
||||
[NewComponent]: ({ children, [props] }: any) => ([mock implementation]),
|
||||
[NewComponentPart1]: ({ children }: any) => <div data-testid="[new-component-part1]">{children}</div>,
|
||||
[NewComponentPart2]: ({ children }: any) => <div data-testid="[new-component-part2]">{children}</div>,
|
||||
// ... other parts
|
||||
}));
|
||||
```
|
||||
- **Update test expectations:**
|
||||
- Change test IDs from old component to new component
|
||||
- Update any component-specific assertions
|
||||
- Ensure all new component parts used in the component are mocked
|
||||
|
||||
#### 2.5 Run Tests and Optimize
|
||||
- Execute `Node package manager test -- ComponentName.test.tsx`
|
||||
- Fix any failing tests
|
||||
- Optimize code quality (imports, formatting, etc.)
|
||||
- Re-run tests until all pass
|
||||
- **Maximum 3 iterations** - if still failing, ask user for guidance
|
||||
|
||||
#### 2.6 Wait for Final Approval
|
||||
- Present test results and any optimizations made
|
||||
- Wait for user approval of the complete migration
|
||||
- If rejected, iterate based on feedback
|
||||
|
||||
#### 2.7 Git Commit
|
||||
- Run: `git add .`
|
||||
- Run: `git commit -m "migrate [ComponentName] from [DeprecatedComponent] to [NewComponent]"`
|
||||
- Confirm commit was successful
|
||||
|
||||
### Step 3: Final Report Generation
|
||||
After all components are migrated, generate a comprehensive GitHub PR report:
|
||||
|
||||
#### PR Title
|
||||
```
|
||||
feat: migrate [DeprecatedComponent] components to [NewComponent] system
|
||||
```
|
||||
|
||||
#### PR Description Template
|
||||
```markdown
|
||||
## 🔄 [DeprecatedComponent] to [NewComponent] Migration
|
||||
|
||||
### Overview
|
||||
Migrated [X] [DeprecatedComponent] components to the new [NewComponent] component system to modernize the UI architecture and improve consistency.
|
||||
|
||||
### Components Migrated
|
||||
[List each component with file path]
|
||||
|
||||
### Technical Changes
|
||||
- **Imports:** Replaced `[DeprecatedComponent]` with `[NewComponent], [NewComponentParts...]`
|
||||
- **Props:** [List prop transformations]
|
||||
- **Structure:** Implemented proper [NewComponent] component hierarchy
|
||||
- **Styling:** [Describe styling changes]
|
||||
- **Tests:** Updated all test mocks and expectations
|
||||
|
||||
### Migration Pattern
|
||||
```typescript
|
||||
// Before
|
||||
<[DeprecatedComponent] [oldProps]>
|
||||
[oldStructure]
|
||||
</[DeprecatedComponent]>
|
||||
|
||||
// After
|
||||
<[NewComponent] [newProps]>
|
||||
[newStructure]
|
||||
</[NewComponent]>
|
||||
```
|
||||
|
||||
### Testing
|
||||
- ✅ All existing tests updated and passing
|
||||
- ✅ Component functionality preserved
|
||||
- ✅ UI/UX behavior maintained
|
||||
|
||||
### How to Test This PR
|
||||
1. **Functional Testing:**
|
||||
- Navigate to each migrated component's usage
|
||||
- Verify [component] opens and closes correctly
|
||||
- Test all interactive elements within [components]
|
||||
- Confirm styling and layout are preserved
|
||||
|
||||
2. **Automated Testing:**
|
||||
```bash
|
||||
Node package manager test
|
||||
```
|
||||
|
||||
3. **Visual Testing:**
|
||||
- Check that all [components] maintain proper styling
|
||||
- Verify responsive behavior
|
||||
- Test keyboard navigation and accessibility
|
||||
|
||||
### Breaking Changes
|
||||
[List any breaking changes or state "None - this is a drop-in replacement maintaining all existing functionality."]
|
||||
|
||||
### Notes
|
||||
- [Any excluded components] were preserved as they already use [NewComponent] internally
|
||||
- All form validation and complex state management preserved
|
||||
- Enhanced code quality with better imports and formatting
|
||||
```
|
||||
|
||||
## Special Considerations
|
||||
|
||||
### Excluded Components
|
||||
- **DO NOT MIGRATE** components specified by user as exclusions
|
||||
- They may already use the new component internally or have other reasons
|
||||
- Inform user these are skipped and why
|
||||
|
||||
### Complex Components
|
||||
- Preserve all existing functionality (forms, validation, state management)
|
||||
- Maintain prop interfaces
|
||||
- Keep all event handlers and callbacks
|
||||
- Preserve accessibility features
|
||||
|
||||
### Test Coverage
|
||||
- Ensure all new component parts are mocked when used
|
||||
- Mock all new component parts that appear in the component
|
||||
- Update test IDs from old component to new component
|
||||
- Maintain all existing test scenarios
|
||||
|
||||
### Error Handling
|
||||
- If tests fail after 3 iterations, stop and ask user for guidance
|
||||
- If component is too complex, ask user for specific guidance
|
||||
- If unsure about functionality preservation, ask for clarification
|
||||
|
||||
### Migration Patterns
|
||||
- Always ask user for specific migration patterns before starting
|
||||
- Confirm import structures, prop mappings, and component hierarchies
|
||||
- Adapt to different component architectures (simple replacements, complex restructuring, etc.)
|
||||
|
||||
## Success Criteria
|
||||
- All deprecated components successfully migrated to new components
|
||||
- All tests passing
|
||||
- No functionality lost
|
||||
- Code quality maintained or improved
|
||||
- User approval on each component
|
||||
- Successful git commits for each migration
|
||||
- Comprehensive PR report generated
|
||||
|
||||
## Usage Examples
|
||||
- "migrate Modal to Dialog"
|
||||
- "migrate Button to NewButton"
|
||||
- "migrate Card to ModernCard"
|
||||
- "component migration" (will prompt for details)
|
||||
@@ -1,177 +0,0 @@
|
||||
---
|
||||
description: Create a story in Storybook for a given component
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Formbricks Storybook Stories
|
||||
|
||||
## When generating Storybook stories for Formbricks components:
|
||||
|
||||
### 1. **File Structure**
|
||||
- Create `stories.tsx` (not `.stories.tsx`) in component directory
|
||||
- Use exact import: `import { Meta, StoryObj } from "@storybook/react-vite";`
|
||||
- Import component from `"./index"`
|
||||
|
||||
### 2. **Story Structure Template**
|
||||
```tsx
|
||||
import { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ComponentName } from "./index";
|
||||
|
||||
// For complex components with configurable options
|
||||
// consider this as an example the options need to reflect the props types
|
||||
interface StoryOptions {
|
||||
showIcon: boolean;
|
||||
numberOfElements: number;
|
||||
customLabels: string[];
|
||||
}
|
||||
|
||||
type StoryProps = React.ComponentProps<typeof ComponentName> & StoryOptions;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI/ComponentName",
|
||||
component: ComponentName,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
controls: { sort: "alpha", exclude: [] },
|
||||
docs: {
|
||||
description: {
|
||||
component: "The **ComponentName** component provides [description].",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
// Organize in exactly these categories: Behavior, Appearance, Content
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ComponentName> & { args: StoryOptions };
|
||||
```
|
||||
|
||||
### 3. **ArgTypes Organization**
|
||||
Organize ALL argTypes into exactly three categories:
|
||||
- **Behavior**: disabled, variant, onChange, etc.
|
||||
- **Appearance**: size, color, layout, styling, etc.
|
||||
- **Content**: text, icons, numberOfElements, etc.
|
||||
|
||||
Format:
|
||||
```tsx
|
||||
argTypes: {
|
||||
propName: {
|
||||
control: "select" | "boolean" | "text" | "number",
|
||||
options: ["option1", "option2"], // for select
|
||||
description: "Clear description",
|
||||
table: {
|
||||
category: "Behavior" | "Appearance" | "Content",
|
||||
type: { summary: "string" },
|
||||
defaultValue: { summary: "default" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 4. **Required Stories**
|
||||
Every component must include:
|
||||
- `Default`: Most common use case
|
||||
- `Disabled`: If component supports disabled state
|
||||
- `WithIcon`: If component supports icons
|
||||
- Variant stories for each variant (Primary, Secondary, Error, etc.)
|
||||
- Edge case stories (ManyElements, LongText, CustomStyling)
|
||||
|
||||
### 5. **Story Format**
|
||||
```tsx
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
// Props with realistic values
|
||||
},
|
||||
};
|
||||
|
||||
export const EdgeCase: Story = {
|
||||
args: { /* ... */ },
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use this when [specific scenario].",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 6. **Dynamic Content Pattern**
|
||||
For components with dynamic content, create render function:
|
||||
```tsx
|
||||
const renderComponent = (args: StoryProps) => {
|
||||
const { numberOfElements, showIcon, customLabels } = args;
|
||||
|
||||
// Generate dynamic content
|
||||
const elements = Array.from({ length: numberOfElements }, (_, i) => ({
|
||||
id: `element-${i}`,
|
||||
label: customLabels[i] || `Element ${i + 1}`,
|
||||
icon: showIcon ? <IconComponent /> : undefined,
|
||||
}));
|
||||
|
||||
return <ComponentName {...args} elements={elements} />;
|
||||
};
|
||||
|
||||
export const Dynamic: Story = {
|
||||
render: renderComponent,
|
||||
args: {
|
||||
numberOfElements: 3,
|
||||
showIcon: true,
|
||||
customLabels: ["First", "Second", "Third"],
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 7. **State Management**
|
||||
For interactive components:
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
|
||||
const ComponentWithState = (args: any) => {
|
||||
const [value, setValue] = useState(args.defaultValue);
|
||||
|
||||
return (
|
||||
<ComponentName
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
setValue(newValue);
|
||||
args.onChange?.(newValue);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Interactive: Story = {
|
||||
render: ComponentWithState,
|
||||
args: { defaultValue: "initial" },
|
||||
};
|
||||
```
|
||||
|
||||
### 8. **Quality Requirements**
|
||||
- Include component description in parameters.docs
|
||||
- Add story documentation for non-obvious use cases
|
||||
- Test edge cases (overflow, empty states, many elements)
|
||||
- Ensure no TypeScript errors
|
||||
- Use realistic prop values
|
||||
- Include at least 3-5 story variants
|
||||
- Example values need to be in the context of survey application
|
||||
|
||||
### 9. **Naming Conventions**
|
||||
- **Story titles**: "UI/ComponentName"
|
||||
- **Story exports**: PascalCase (Default, WithIcon, ManyElements)
|
||||
- **Categories**: "Behavior", "Appearance", "Content" (exact spelling)
|
||||
- **Props**: camelCase matching component props
|
||||
|
||||
### 10. **Special Cases**
|
||||
- **Generic components**: Remove `component` from meta if type conflicts
|
||||
- **Form components**: Include Invalid, WithValue stories
|
||||
- **Navigation**: Include ManyItems stories
|
||||
- **Modals, Dropdowns and Popups **: Include trigger and content structure
|
||||
|
||||
## Generate stories that are comprehensive, well-documented, and reflect all component states and edge cases.
|
||||
@@ -111,27 +111,21 @@ jobs:
|
||||
const additions = ${{ steps.check-size.outputs.total_additions }};
|
||||
const deletions = ${{ steps.check-size.outputs.total_deletions }};
|
||||
|
||||
const body = `## 🚨 PR Size Warning
|
||||
|
||||
This PR has approximately **${totalChanges} lines** of changes (${additions} additions, ${deletions} deletions across ${countedFiles} files).
|
||||
|
||||
Large PRs (>800 lines) are significantly harder to review and increase the chance of merge conflicts. Consider splitting this into smaller, self-contained PRs.
|
||||
|
||||
### 💡 Suggestions:
|
||||
- **Split by feature or module** - Break down into logical, independent pieces
|
||||
- **Create a sequence of PRs** - Each building on the previous one
|
||||
- **Branch off PR branches** - Don't wait for reviews to continue dependent work
|
||||
|
||||
### 📊 What was counted:
|
||||
- ✅ Source files, stylesheets, configuration files
|
||||
- ❌ Excluded ${excludedFiles} files (tests, locales, locks, generated files)
|
||||
|
||||
### 📚 Guidelines:
|
||||
- **Ideal:** 300-500 lines per PR
|
||||
- **Warning:** 500-800 lines
|
||||
- **Critical:** 800+ lines ⚠️
|
||||
|
||||
If this large PR is unavoidable (e.g., migration, dependency update, major refactor), please explain in the PR description why it couldn't be split.`;
|
||||
const body = '## 🚨 PR Size Warning\n\n' +
|
||||
'This PR has approximately **' + totalChanges + ' lines** of changes (' + additions + ' additions, ' + deletions + ' deletions across ' + countedFiles + ' files).\n\n' +
|
||||
'Large PRs (>800 lines) are significantly harder to review and increase the chance of merge conflicts. Consider splitting this into smaller, self-contained PRs.\n\n' +
|
||||
'### 💡 Suggestions:\n' +
|
||||
'- **Split by feature or module** - Break down into logical, independent pieces\n' +
|
||||
'- **Create a sequence of PRs** - Each building on the previous one\n' +
|
||||
'- **Branch off PR branches** - Don\'t wait for reviews to continue dependent work\n\n' +
|
||||
'### 📊 What was counted:\n' +
|
||||
'- ✅ Source files, stylesheets, configuration files\n' +
|
||||
'- ❌ Excluded ' + excludedFiles + ' files (tests, locales, locks, generated files)\n\n' +
|
||||
'### 📚 Guidelines:\n' +
|
||||
'- **Ideal:** 300-500 lines per PR\n' +
|
||||
'- **Warning:** 500-800 lines\n' +
|
||||
'- **Critical:** 800+ lines ⚠️\n\n' +
|
||||
'If this large PR is unavoidable (e.g., migration, dependency update, major refactor), please explain in the PR description why it couldn\'t be split.';
|
||||
|
||||
// Check if we already commented
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
|
||||
@@ -62,3 +62,4 @@ branch.json
|
||||
packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdata
|
||||
.cursorrules
|
||||
i18n.cache
|
||||
stats.html
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
# Load environment variables from .env files
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
|
||||
@@ -18,11 +18,65 @@ Formbricks runs as a pnpm/turbo monorepo. `apps/web` is the Next.js product surf
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
TypeScript, React, and Prisma are the primary languages. Use the shared ESLint presets (`@formbricks/eslint-config`) and Prettier preset (110-char width, semicolons, double quotes, sorted import groups). Two-space indentation is standard; prefer `PascalCase` for React components and folders under `modules/`, `camelCase` for functions/variables, and `SCREAMING_SNAKE_CASE` only for constants. When adding mocks, place them inside `__mocks__` so import ordering stays stable.
|
||||
We are using SonarQube to identify code smells and security hotspots.
|
||||
|
||||
## Architecture & Patterns
|
||||
|
||||
- Next.js app router lives in `apps/web/app` with route groups like `(app)` and `(auth)`. Services live in `apps/web/lib`, feature modules in `apps/web/modules`.
|
||||
- Server actions wrap service calls and return `{ data }` or `{ error }` consistently.
|
||||
- Context providers should guard against missing provider usage and use cleanup patterns that snapshot refs inside `useEffect` to avoid React hooks warnings
|
||||
|
||||
## Caching
|
||||
|
||||
- Use React `cache()` for request-level dedupe and `cache.withCache()` or explicit Redis for expensive data.
|
||||
- Do not use Next.js `unstable_cache()`.
|
||||
- Always use `createCacheKey.*` utilities for cache keys.
|
||||
|
||||
## i18n (Internationalization)
|
||||
|
||||
- All user-facing text must use the `t()` function from `react-i18next`.
|
||||
- Key naming: use lowercase with dots for nesting (e.g., `common.welcome`).
|
||||
- Translations are in `apps/web/locales/`. Default is `en-US.json`.
|
||||
- Lingo.dev is automatically translating strings from en-US into other languages on commit. Run `pnpm i18n` to generate missing translations and validate keys.
|
||||
|
||||
## Database & Prisma Performance
|
||||
|
||||
- Multi-tenancy: All data must be scoped by Organization or Environment.
|
||||
- Soft Deletion: Check for `isActive` or `deletedAt` fields; use proper filtering.
|
||||
- Never use `skip`/`offset` with `prisma.response.count()`; only use `where`.
|
||||
- Separate count and data queries and run in parallel (`Promise.all`).
|
||||
- Prefer cursor pagination for large datasets.
|
||||
- When filtering by `createdAt`, include indexed fields (e.g., `surveyId` + `createdAt`).
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
Prefer Vitest with Testing Library for logic in `.ts` files, keeping specs colocated with the code they exercise (`utility.test.ts`). Do not write tests for `.tsx` files—React components are covered by Playwright E2E tests instead. Mock network and storage boundaries through helpers from `@formbricks/*`. Run `pnpm test` before opening a PR and `pnpm test:coverage` when touching critical flows; keep coverage from regressing. End-to-end scenarios belong in `apps/web/playwright`, using descriptive filenames (`billing.spec.ts`) and tagging slow suites with `@slow` when necessary.
|
||||
|
||||
## Documentation (apps/docs)
|
||||
|
||||
- Add frontmatter with `title`, `description`, and `icon` at the top of the MDX file.
|
||||
- Do not start with an H1; use Camel Case headings (only capitalize the feature name).
|
||||
- Use Mintlify components for steps and callouts.
|
||||
- If Enterprise-only, add the Enterprise note block described in docs.
|
||||
|
||||
## Storybook
|
||||
|
||||
- Stories live in `stories.tsx` in the component folder and import from `"./index"`.
|
||||
- Use `@storybook/react-vite` and organize argTypes into `Behavior`, `Appearance`, `Content`.
|
||||
- Include Default, Disabled (if supported), WithIcon (if supported), all variants, and edge cases.
|
||||
|
||||
## GitHub Actions
|
||||
|
||||
- Always set minimal `permissions` for `GITHUB_TOKEN`.
|
||||
- On `ubuntu-latest`, add `step-security/harden-runner` as the first step.
|
||||
|
||||
## Quality Checklist
|
||||
|
||||
- Keep code DRY and small; remove dead code and unused imports.
|
||||
- Follow React hooks rules, keep effects focused, and avoid unnecessary `useMemo`/`useCallback`.
|
||||
- Prefer type inference, avoid `any`, and use shared types from `@formbricks/types`.
|
||||
- Keep components focused, avoid deep nesting, and ensure basic accessibility.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
Commits follow a lightweight Conventional Commit format (`fix:`, `chore:`, `feat:`) and usually append the PR number, e.g. `fix: update OpenAPI schema (#6617)`. Keep commits scoped and lint-clean. Pull requests should outline the problem, summarize the solution, and link to issues or product specs. Attach screenshots or gifs for UI-facing work, list any migrations or env changes, and paste the output of relevant commands (`pnpm test`, `pnpm lint`, `pnpm db:migrate:dev`) so reviewers can verify readiness.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -11,24 +11,24 @@
|
||||
"clean": "rimraf .turbo node_modules dist storybook-static"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formbricks/survey-ui": "workspace:*",
|
||||
"eslint-plugin-react-refresh": "0.4.24"
|
||||
"@formbricks/survey-ui": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^4.1.3",
|
||||
"@storybook/addon-a11y": "10.0.8",
|
||||
"@storybook/addon-links": "10.0.8",
|
||||
"@storybook/addon-onboarding": "10.0.8",
|
||||
"@storybook/react-vite": "10.0.8",
|
||||
"@typescript-eslint/eslint-plugin": "8.48.0",
|
||||
"@tailwindcss/vite": "4.1.17",
|
||||
"@typescript-eslint/parser": "8.48.0",
|
||||
"@vitejs/plugin-react": "5.1.1",
|
||||
"esbuild": "0.27.0",
|
||||
"eslint-plugin-storybook": "10.0.8",
|
||||
"@chromatic-com/storybook": "^5.0.0",
|
||||
"@storybook/addon-a11y": "10.1.11",
|
||||
"@storybook/addon-links": "10.1.11",
|
||||
"@storybook/addon-onboarding": "10.1.11",
|
||||
"@storybook/react-vite": "10.1.11",
|
||||
"@typescript-eslint/eslint-plugin": "8.53.0",
|
||||
"@tailwindcss/vite": "4.1.18",
|
||||
"@typescript-eslint/parser": "8.53.0",
|
||||
"@vitejs/plugin-react": "5.1.2",
|
||||
"esbuild": "0.27.2",
|
||||
"eslint-plugin-react-refresh": "0.4.26",
|
||||
"eslint-plugin-storybook": "10.1.11",
|
||||
"prop-types": "15.8.1",
|
||||
"storybook": "10.0.8",
|
||||
"vite": "7.2.4",
|
||||
"@storybook/addon-docs": "10.0.8"
|
||||
"storybook": "10.1.11",
|
||||
"vite": "7.3.1",
|
||||
"@storybook/addon-docs": "10.1.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
.next/
|
||||
public/
|
||||
playwright/
|
||||
dist/
|
||||
coverage/
|
||||
vendor/
|
||||
@@ -104,6 +104,9 @@ RUN chown -R nextjs:nextjs ./apps/web/.next/static && chmod -R 755 ./apps/web/.n
|
||||
COPY --from=installer /app/apps/web/public ./apps/web/public
|
||||
RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
|
||||
|
||||
# Create packages/database directory structure with proper ownership for runtime migrations
|
||||
RUN mkdir -p ./packages/database/migrations && chown -R nextjs:nextjs ./packages/database
|
||||
|
||||
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
|
||||
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -209,7 +209,7 @@ export const OrganizationBreadcrumb = ({
|
||||
)}
|
||||
{!isLoadingOrganizations && !loadError && (
|
||||
<>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
|
||||
{organizations.map((org) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={org.id}
|
||||
|
||||
@@ -234,7 +234,7 @@ export const ProjectBreadcrumb = ({
|
||||
)}
|
||||
{!isLoadingProjects && !loadError && (
|
||||
<>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
|
||||
{projects.map((proj) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={proj.id}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { ShieldCheckIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export const SecurityListTip = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="max-w-4xl">
|
||||
<div className="flex items-center space-x-3 rounded-lg border border-blue-100 bg-blue-50 p-4 text-sm text-blue-900 shadow-sm">
|
||||
<ShieldCheckIcon className="h-5 w-5 flex-shrink-0 text-blue-400" />
|
||||
<p className="text-sm">
|
||||
{t("environments.settings.general.security_list_tip")}{" "}
|
||||
<Link
|
||||
href="https://formbricks.com/security#stay-informed-with-formbricks-security-updates"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:text-blue-700">
|
||||
{t("environments.settings.general.security_list_tip_link")}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -12,6 +12,7 @@ import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { SettingsCard } from "../../components/SettingsCard";
|
||||
import { DeleteOrganization } from "./components/DeleteOrganization";
|
||||
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
|
||||
import { SecurityListTip } from "./components/SecurityListTip";
|
||||
|
||||
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
const params = await props.params;
|
||||
@@ -48,6 +49,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
{!IS_FORMBRICKS_CLOUD && <SecurityListTip />}
|
||||
<SettingsCard
|
||||
title={t("environments.settings.general.organization_name")}
|
||||
description={t("environments.settings.general.organization_name_description")}>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
ListOrderedIcon,
|
||||
MessageSquareTextIcon,
|
||||
MousePointerClickIcon,
|
||||
NetworkIcon,
|
||||
PieChartIcon,
|
||||
Rows3Icon,
|
||||
SmartphoneIcon,
|
||||
@@ -99,6 +100,7 @@ const elementIcons = {
|
||||
action: MousePointerClickIcon,
|
||||
country: FlagIcon,
|
||||
url: LinkIcon,
|
||||
ipAddress: NetworkIcon,
|
||||
|
||||
// others
|
||||
Language: LanguagesIcon,
|
||||
@@ -190,7 +192,7 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
placeholder={open ? `${t("common.search")}...` : t("common.select_filter")}
|
||||
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none outline-none ring-offset-transparent focus:border-none focus:shadow-none focus:outline-none focus:ring-offset-0"
|
||||
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none ring-offset-transparent outline-none focus:border-none focus:shadow-none focus:ring-offset-0 focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
|
||||
@@ -82,6 +82,7 @@ const mockPipelineInput = {
|
||||
},
|
||||
country: "USA",
|
||||
action: "Action Name",
|
||||
ipAddress: "203.0.113.7",
|
||||
} as TResponseMeta,
|
||||
personAttributes: {},
|
||||
singleUseId: null,
|
||||
@@ -346,7 +347,7 @@ describe("handleIntegrations", () => {
|
||||
expect(airtableWriteData).toHaveBeenCalledTimes(1);
|
||||
// Adjust expectations for metadata and recalled question
|
||||
const expectedMetadataString =
|
||||
"Source: web\nURL: http://example.com\nBrowser: Chrome\nOS: Mac OS\nDevice: Desktop\nCountry: USA\nAction: Action Name";
|
||||
"Source: web\nURL: http://example.com\nBrowser: Chrome\nOS: Mac OS\nDevice: Desktop\nCountry: USA\nAction: Action Name\nIP Address: 203.0.113.7";
|
||||
expect(airtableWriteData).toHaveBeenCalledWith(
|
||||
mockAirtableIntegration.config.key,
|
||||
mockAirtableIntegration.config.data[0],
|
||||
|
||||
@@ -31,6 +31,7 @@ const convertMetaObjectToString = (metadata: TResponseMeta): string => {
|
||||
if (metadata.userAgent?.device) result.push(`Device: ${metadata.userAgent.device}`);
|
||||
if (metadata.country) result.push(`Country: ${metadata.country}`);
|
||||
if (metadata.action) result.push(`Action: ${metadata.action}`);
|
||||
if (metadata.ipAddress) result.push(`IP Address: ${metadata.ipAddress}`);
|
||||
|
||||
// Join all the elements in the result array with a newline for formatting
|
||||
return result.join("\n");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PipelineTriggers, Webhook } from "@prisma/client";
|
||||
import { headers } from "next/headers";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
@@ -8,6 +9,7 @@ import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { CRON_SECRET } from "@/lib/constants";
|
||||
import { generateStandardWebhookSignature } from "@/lib/crypto";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
@@ -90,28 +92,50 @@ export const POST = async (request: Request) => {
|
||||
]);
|
||||
};
|
||||
|
||||
const webhookPromises = webhooks.map((webhook) =>
|
||||
fetchWithTimeout(webhook.url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
webhookId: webhook.id,
|
||||
event,
|
||||
data: {
|
||||
...response,
|
||||
survey: {
|
||||
title: survey.name,
|
||||
type: survey.type,
|
||||
status: survey.status,
|
||||
createdAt: survey.createdAt,
|
||||
updatedAt: survey.updatedAt,
|
||||
},
|
||||
const webhookPromises = webhooks.map((webhook) => {
|
||||
const body = JSON.stringify({
|
||||
webhookId: webhook.id,
|
||||
event,
|
||||
data: {
|
||||
...response,
|
||||
survey: {
|
||||
title: survey.name,
|
||||
type: survey.type,
|
||||
status: survey.status,
|
||||
createdAt: survey.createdAt,
|
||||
updatedAt: survey.updatedAt,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// Generate Standard Webhooks headers
|
||||
const webhookMessageId = uuidv7();
|
||||
const webhookTimestamp = Math.floor(Date.now() / 1000);
|
||||
|
||||
const requestHeaders: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
"webhook-id": webhookMessageId,
|
||||
"webhook-timestamp": webhookTimestamp.toString(),
|
||||
};
|
||||
|
||||
// Add signature if webhook has a secret configured
|
||||
if (webhook.secret) {
|
||||
requestHeaders["webhook-signature"] = generateStandardWebhookSignature(
|
||||
webhookMessageId,
|
||||
webhookTimestamp,
|
||||
body,
|
||||
webhook.secret
|
||||
);
|
||||
}
|
||||
|
||||
return fetchWithTimeout(webhook.url, {
|
||||
method: "POST",
|
||||
headers: requestHeaders,
|
||||
body,
|
||||
}).catch((error) => {
|
||||
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
if (event === "responseFinished") {
|
||||
// Fetch integrations and responseCount in parallel
|
||||
|
||||
@@ -11,6 +11,7 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||
import { validateFileUploads } from "@/modules/storage/utils";
|
||||
@@ -136,6 +137,13 @@ export const POST = withV1ApiWrapper({
|
||||
action: responseInputData?.meta?.action,
|
||||
};
|
||||
|
||||
// Capture IP address if the survey has IP capture enabled
|
||||
// Server-derived IP always overwrites any client-provided value
|
||||
if (survey.isCaptureIpEnabled) {
|
||||
const ipAddress = await getClientIpFromHeaders();
|
||||
meta.ipAddress = ipAddress;
|
||||
}
|
||||
|
||||
response = await createResponseWithQuotaEvaluation({
|
||||
...responseInputData,
|
||||
meta,
|
||||
|
||||
@@ -19,6 +19,10 @@ vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
generateWebhookSecret: vi.fn(() => "whsec_test_secret_1234567890"),
|
||||
}));
|
||||
|
||||
describe("createWebhook", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -59,6 +63,7 @@ describe("createWebhook", () => {
|
||||
source: webhookInput.source,
|
||||
surveyIds: webhookInput.surveyIds,
|
||||
triggers: webhookInput.triggers,
|
||||
secret: "whsec_test_secret_1234567890",
|
||||
environment: {
|
||||
connect: {
|
||||
id: webhookInput.environmentId,
|
||||
@@ -144,6 +149,7 @@ describe("createWebhook", () => {
|
||||
source: webhookInput.source,
|
||||
surveyIds: webhookInput.surveyIds,
|
||||
triggers: webhookInput.triggers,
|
||||
secret: "whsec_test_secret_1234567890",
|
||||
environment: {
|
||||
connect: {
|
||||
id: webhookInput.environmentId,
|
||||
|
||||
@@ -4,12 +4,15 @@ import { ZId, ZOptionalNumber } from "@formbricks/types/common";
|
||||
import { DatabaseError, InvalidInputError } from "@formbricks/types/errors";
|
||||
import { TWebhookInput, ZWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
|
||||
import { ITEMS_PER_PAGE } from "@/lib/constants";
|
||||
import { generateWebhookSecret } from "@/lib/crypto";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
|
||||
export const createWebhook = async (webhookInput: TWebhookInput): Promise<Webhook> => {
|
||||
validateInputs([webhookInput, ZWebhookInput]);
|
||||
|
||||
try {
|
||||
const secret = generateWebhookSecret();
|
||||
|
||||
const createdWebhook = await prisma.webhook.create({
|
||||
data: {
|
||||
url: webhookInput.url,
|
||||
@@ -17,6 +20,7 @@ export const createWebhook = async (webhookInput: TWebhookInput): Promise<Webhoo
|
||||
source: webhookInput.source,
|
||||
surveyIds: webhookInput.surveyIds || [],
|
||||
triggers: webhookInput.triggers || [],
|
||||
secret,
|
||||
environment: {
|
||||
connect: {
|
||||
id: webhookInput.environmentId,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
|
||||
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
|
||||
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
|
||||
@@ -119,6 +120,13 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
||||
action: responseInputData?.meta?.action,
|
||||
};
|
||||
|
||||
// Capture IP address if the survey has IP capture enabled
|
||||
// Server-derived IP always overwrites any client-provided value
|
||||
if (survey.isCaptureIpEnabled) {
|
||||
const ipAddress = await getClientIpFromHeaders();
|
||||
meta.ipAddress = ipAddress;
|
||||
}
|
||||
|
||||
response = await createResponseWithQuotaEvaluation({
|
||||
...responseInputData,
|
||||
meta,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { createI18nString } from "@/lib/i18n/utils";
|
||||
import { buildBlock } from "./survey-block-builder";
|
||||
|
||||
const mockT = vi.fn((key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"common.next": "Next",
|
||||
"common.back": "Back",
|
||||
"": "",
|
||||
};
|
||||
return translations[key] || key;
|
||||
}) as unknown as TFunction;
|
||||
|
||||
describe("survey-block-builder", () => {
|
||||
describe("buildBlock", () => {
|
||||
const mockElements = [
|
||||
{
|
||||
id: "element-1",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: createI18nString("Test Question", []),
|
||||
required: false,
|
||||
inputType: "text",
|
||||
longAnswer: false,
|
||||
charLimit: { enabled: false },
|
||||
},
|
||||
];
|
||||
|
||||
test("should use getDefaultButtonLabel when buttonLabel is provided", () => {
|
||||
const result = buildBlock({
|
||||
name: "Test Block",
|
||||
elements: mockElements,
|
||||
buttonLabel: "Custom Next",
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(result.buttonLabel).toEqual({
|
||||
default: "Custom Next",
|
||||
});
|
||||
});
|
||||
|
||||
test("should use createI18nString with empty translation when buttonLabel is not provided", () => {
|
||||
const result = buildBlock({
|
||||
name: "Test Block",
|
||||
elements: mockElements,
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(result.buttonLabel).toEqual({
|
||||
default: "",
|
||||
});
|
||||
});
|
||||
|
||||
test("should use getDefaultBackButtonLabel when backButtonLabel is provided", () => {
|
||||
const result = buildBlock({
|
||||
name: "Test Block",
|
||||
elements: mockElements,
|
||||
backButtonLabel: "Custom Back",
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(result.backButtonLabel).toEqual({
|
||||
default: "Custom Back",
|
||||
});
|
||||
});
|
||||
|
||||
test("should use createI18nString with empty translation when backButtonLabel is not provided", () => {
|
||||
const result = buildBlock({
|
||||
name: "Test Block",
|
||||
elements: mockElements,
|
||||
t: mockT,
|
||||
});
|
||||
|
||||
expect(result.backButtonLabel).toEqual({
|
||||
default: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -302,7 +302,9 @@ export const buildBlock = ({
|
||||
elements,
|
||||
logic,
|
||||
logicFallback,
|
||||
buttonLabel: buttonLabel ? getDefaultButtonLabel(buttonLabel, t) : undefined,
|
||||
backButtonLabel: backButtonLabel ? getDefaultBackButtonLabel(backButtonLabel, t) : undefined,
|
||||
buttonLabel: buttonLabel ? getDefaultButtonLabel(buttonLabel, t) : createI18nString(t(""), []),
|
||||
backButtonLabel: backButtonLabel
|
||||
? getDefaultBackButtonLabel(backButtonLabel, t)
|
||||
: createI18nString(t(""), []),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4913,6 +4913,7 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
|
||||
showLanguageSwitch: false,
|
||||
followUps: [],
|
||||
isBackButtonHidden: false,
|
||||
isCaptureIpEnabled: false,
|
||||
metadata: {},
|
||||
questions: [], // Required for build-time type checking (Zod defaults to [] at runtime)
|
||||
slug: null,
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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 |
|
After Width: | Height: | Size: 7.1 KiB |
@@ -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 |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 53 KiB |
@@ -1,8 +1,11 @@
|
||||
import * as crypto from "crypto";
|
||||
import * as crypto from "node:crypto";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
// Import after unmocking
|
||||
import {
|
||||
generateStandardWebhookSignature,
|
||||
generateWebhookSecret,
|
||||
getWebhookSecretBytes,
|
||||
hashSecret,
|
||||
hashSha256,
|
||||
parseApiKeyV2,
|
||||
@@ -283,6 +286,133 @@ describe("Crypto Utils", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Webhook Signature Functions", () => {
|
||||
describe("generateWebhookSecret", () => {
|
||||
test("should generate a secret with whsec_ prefix", () => {
|
||||
const secret = generateWebhookSecret();
|
||||
expect(secret.startsWith("whsec_")).toBe(true);
|
||||
});
|
||||
|
||||
test("should generate base64-encoded content after prefix", () => {
|
||||
const secret = generateWebhookSecret();
|
||||
const base64Part = secret.slice(6); // Remove "whsec_"
|
||||
|
||||
// Should be valid base64
|
||||
expect(() => Buffer.from(base64Part, "base64")).not.toThrow();
|
||||
|
||||
// Should decode to 32 bytes (256 bits)
|
||||
const decoded = Buffer.from(base64Part, "base64");
|
||||
expect(decoded.length).toBe(32);
|
||||
});
|
||||
|
||||
test("should generate unique secrets each time", () => {
|
||||
const secret1 = generateWebhookSecret();
|
||||
const secret2 = generateWebhookSecret();
|
||||
expect(secret1).not.toBe(secret2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getWebhookSecretBytes", () => {
|
||||
test("should decode whsec_ prefixed secret to bytes", () => {
|
||||
const secret = generateWebhookSecret();
|
||||
const bytes = getWebhookSecretBytes(secret);
|
||||
|
||||
expect(Buffer.isBuffer(bytes)).toBe(true);
|
||||
expect(bytes.length).toBe(32);
|
||||
});
|
||||
|
||||
test("should handle secret without whsec_ prefix", () => {
|
||||
const base64Secret = Buffer.from("test-secret-bytes-32-characters!").toString("base64");
|
||||
const bytes = getWebhookSecretBytes(base64Secret);
|
||||
|
||||
expect(Buffer.isBuffer(bytes)).toBe(true);
|
||||
expect(bytes.toString()).toBe("test-secret-bytes-32-characters!");
|
||||
});
|
||||
|
||||
test("should correctly decode a known secret", () => {
|
||||
// Create a known secret
|
||||
const knownBytes = Buffer.from("known-test-secret-for-testing!!");
|
||||
const secret = `whsec_${knownBytes.toString("base64")}`;
|
||||
|
||||
const decoded = getWebhookSecretBytes(secret);
|
||||
expect(decoded.toString()).toBe("known-test-secret-for-testing!!");
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateStandardWebhookSignature", () => {
|
||||
test("should generate signature in v1,{base64} format", () => {
|
||||
const secret = generateWebhookSecret();
|
||||
const signature = generateStandardWebhookSignature("msg_123", 1704547200, '{"test":"data"}', secret);
|
||||
|
||||
expect(signature.startsWith("v1,")).toBe(true);
|
||||
const base64Part = signature.slice(3);
|
||||
expect(() => Buffer.from(base64Part, "base64")).not.toThrow();
|
||||
});
|
||||
|
||||
test("should generate deterministic signatures for same inputs", () => {
|
||||
const secret = "whsec_" + Buffer.from("test-secret-32-bytes-exactly!!!").toString("base64");
|
||||
const webhookId = "msg_test123";
|
||||
const timestamp = 1704547200;
|
||||
const payload = '{"event":"test"}';
|
||||
|
||||
const sig1 = generateStandardWebhookSignature(webhookId, timestamp, payload, secret);
|
||||
const sig2 = generateStandardWebhookSignature(webhookId, timestamp, payload, secret);
|
||||
|
||||
expect(sig1).toBe(sig2);
|
||||
});
|
||||
|
||||
test("should generate different signatures for different payloads", () => {
|
||||
const secret = "whsec_" + Buffer.from("test-secret-32-bytes-exactly!!!").toString("base64");
|
||||
const webhookId = "msg_test123";
|
||||
const timestamp = 1704547200;
|
||||
|
||||
const sig1 = generateStandardWebhookSignature(webhookId, timestamp, '{"event":"a"}', secret);
|
||||
const sig2 = generateStandardWebhookSignature(webhookId, timestamp, '{"event":"b"}', secret);
|
||||
|
||||
expect(sig1).not.toBe(sig2);
|
||||
});
|
||||
|
||||
test("should generate different signatures for different timestamps", () => {
|
||||
const secret = "whsec_" + Buffer.from("test-secret-32-bytes-exactly!!!").toString("base64");
|
||||
const webhookId = "msg_test123";
|
||||
const payload = '{"event":"test"}';
|
||||
|
||||
const sig1 = generateStandardWebhookSignature(webhookId, 1704547200, payload, secret);
|
||||
const sig2 = generateStandardWebhookSignature(webhookId, 1704547201, payload, secret);
|
||||
|
||||
expect(sig1).not.toBe(sig2);
|
||||
});
|
||||
|
||||
test("should generate different signatures for different webhook IDs", () => {
|
||||
const secret = "whsec_" + Buffer.from("test-secret-32-bytes-exactly!!!").toString("base64");
|
||||
const timestamp = 1704547200;
|
||||
const payload = '{"event":"test"}';
|
||||
|
||||
const sig1 = generateStandardWebhookSignature("msg_1", timestamp, payload, secret);
|
||||
const sig2 = generateStandardWebhookSignature("msg_2", timestamp, payload, secret);
|
||||
|
||||
expect(sig1).not.toBe(sig2);
|
||||
});
|
||||
|
||||
test("should produce verifiable signatures", () => {
|
||||
// This test verifies the signature can be verified using the same algorithm
|
||||
const secretBytes = Buffer.from("test-secret-32-bytes-exactly!!!");
|
||||
const secret = `whsec_${secretBytes.toString("base64")}`;
|
||||
const webhookId = "msg_verify";
|
||||
const timestamp = 1704547200;
|
||||
const payload = '{"event":"verify"}';
|
||||
|
||||
const signature = generateStandardWebhookSignature(webhookId, timestamp, payload, secret);
|
||||
|
||||
// Manually compute the expected signature
|
||||
const signedContent = `${webhookId}.${timestamp}.${payload}`;
|
||||
const expectedSig = crypto.createHmac("sha256", secretBytes).update(signedContent).digest("base64");
|
||||
|
||||
expect(signature).toBe(`v1,${expectedSig}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("GCM decryption failure logging", () => {
|
||||
// Test key - 32 bytes for AES-256
|
||||
const testKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
@@ -314,11 +444,11 @@ describe("Crypto Utils", () => {
|
||||
expect(() => symmetricDecrypt(corruptedPayload, testKey)).toThrow();
|
||||
|
||||
// Verify logger.warn was called with the correct format (object first, message second)
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
"AES-GCM decryption failed; refusing to fall back to insecure CBC"
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
const [firstArg, secondArg] = vi.mocked(logger.warn).mock.calls[0];
|
||||
expect(firstArg).toHaveProperty("err");
|
||||
expect(firstArg.err).toHaveProperty("message");
|
||||
expect(secondArg).toBe("AES-GCM decryption failed; refusing to fall back to insecure CBC");
|
||||
});
|
||||
|
||||
test("logs warning and throws when GCM decryption fails with corrupted encrypted data", () => {
|
||||
@@ -342,11 +472,11 @@ describe("Crypto Utils", () => {
|
||||
expect(() => symmetricDecrypt(corruptedPayload, testKey)).toThrow();
|
||||
|
||||
// Verify logger.warn was called
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
"AES-GCM decryption failed; refusing to fall back to insecure CBC"
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
const [firstArg, secondArg] = vi.mocked(logger.warn).mock.calls[0];
|
||||
expect(firstArg).toHaveProperty("err");
|
||||
expect(firstArg.err).toHaveProperty("message");
|
||||
expect(secondArg).toBe("AES-GCM decryption failed; refusing to fall back to insecure CBC");
|
||||
});
|
||||
|
||||
test("logs warning and throws when GCM decryption fails with wrong key", () => {
|
||||
@@ -366,11 +496,11 @@ describe("Crypto Utils", () => {
|
||||
expect(() => symmetricDecrypt(payload, wrongKey)).toThrow();
|
||||
|
||||
// Verify logger.warn was called
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
"AES-GCM decryption failed; refusing to fall back to insecure CBC"
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
const [firstArg, secondArg] = vi.mocked(logger.warn).mock.calls[0];
|
||||
expect(firstArg).toHaveProperty("err");
|
||||
expect(firstArg.err).toHaveProperty("message");
|
||||
expect(secondArg).toBe("AES-GCM decryption failed; refusing to fall back to insecure CBC");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { compare, hash } from "bcryptjs";
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto";
|
||||
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "node:crypto";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ENCRYPTION_KEY } from "@/lib/constants";
|
||||
|
||||
@@ -141,3 +141,54 @@ export const parseApiKeyV2 = (key: string): { secret: string } | null => {
|
||||
|
||||
return { secret };
|
||||
};
|
||||
|
||||
// Standard Webhooks secret prefix
|
||||
const WEBHOOK_SECRET_PREFIX = "whsec_";
|
||||
|
||||
/**
|
||||
* Generate a Standard Webhooks compliant secret
|
||||
* Following: https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md
|
||||
*
|
||||
* Format: whsec_ + base64(32 random bytes)
|
||||
* @returns A webhook secret in format "whsec_{base64_encoded_random_bytes}"
|
||||
*/
|
||||
export const generateWebhookSecret = (): string => {
|
||||
const secretBytes = randomBytes(32); // 256 bits of entropy
|
||||
return `${WEBHOOK_SECRET_PREFIX}${secretBytes.toString("base64")}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Decode a Standard Webhooks secret to get the raw bytes
|
||||
* Strips the whsec_ prefix and base64 decodes the rest
|
||||
*
|
||||
* @param secret The webhook secret (with or without whsec_ prefix)
|
||||
* @returns Buffer containing the raw secret bytes
|
||||
*/
|
||||
export const getWebhookSecretBytes = (secret: string): Buffer => {
|
||||
const base64Part = secret.startsWith(WEBHOOK_SECRET_PREFIX)
|
||||
? secret.slice(WEBHOOK_SECRET_PREFIX.length)
|
||||
: secret;
|
||||
return Buffer.from(base64Part, "base64");
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate Standard Webhooks compliant signature
|
||||
* Following: https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md
|
||||
*
|
||||
* @param webhookId Unique message identifier
|
||||
* @param timestamp Unix timestamp in seconds
|
||||
* @param payload The request body as a string
|
||||
* @param secret The shared secret (whsec_ prefixed)
|
||||
* @returns The signature in format "v1,{base64_signature}"
|
||||
*/
|
||||
export const generateStandardWebhookSignature = (
|
||||
webhookId: string,
|
||||
timestamp: number,
|
||||
payload: string,
|
||||
secret: string
|
||||
): string => {
|
||||
const signedContent = `${webhookId}.${timestamp}.${payload}`;
|
||||
const secretBytes = getWebhookSecretBytes(secret);
|
||||
const signature = createHmac("sha256", secretBytes).update(signedContent).digest("base64");
|
||||
return `v1,${signature}`;
|
||||
};
|
||||
|
||||
@@ -88,7 +88,7 @@ export const getLanguageCode = (surveyLanguages: TSurveyLanguage[], languageCode
|
||||
return language?.default ? "default" : language?.language.code || "default";
|
||||
};
|
||||
|
||||
export const iso639Identifiers = iso639Languages.map((language) => language.alpha2);
|
||||
export const iso639Identifiers = iso639Languages.map((language) => language.code);
|
||||
|
||||
// Helper function to add language keys to a multi-language object (e.g. survey or question)
|
||||
// Iterates over the object recursively and adds empty strings for new language keys
|
||||
@@ -130,217 +130,78 @@ export const appLanguages = [
|
||||
code: "en-US",
|
||||
label: {
|
||||
"en-US": "English (US)",
|
||||
"de-DE": "Englisch (US)",
|
||||
"pt-BR": "Inglês (EUA)",
|
||||
"fr-FR": "Anglais (États-Unis)",
|
||||
"zh-Hant-TW": "英文 (美國)",
|
||||
"pt-PT": "Inglês (EUA)",
|
||||
"ro-RO": "Engleză (SUA)",
|
||||
"ja-JP": "英語(米国)",
|
||||
"zh-Hans-CN": "英语(美国)",
|
||||
"nl-NL": "Engels (VS)",
|
||||
"es-ES": "Inglés (EE.UU.)",
|
||||
"sv-SE": "Engelska (USA)",
|
||||
"ru-RU": "Английский (США)",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "de-DE",
|
||||
label: {
|
||||
"en-US": "German",
|
||||
"de-DE": "Deutsch",
|
||||
"pt-BR": "Alemão",
|
||||
"fr-FR": "Allemand",
|
||||
"zh-Hant-TW": "德語",
|
||||
"pt-PT": "Alemão",
|
||||
"ro-RO": "Germană",
|
||||
"ja-JP": "ドイツ語",
|
||||
"zh-Hans-CN": "德语",
|
||||
"nl-NL": "Duits",
|
||||
"es-ES": "Alemán",
|
||||
"sv-SE": "Tyska",
|
||||
"ru-RU": "Немецкий",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "pt-BR",
|
||||
label: {
|
||||
"en-US": "Portuguese (Brazil)",
|
||||
"de-DE": "Portugiesisch (Brasilien)",
|
||||
"pt-BR": "Português (Brasil)",
|
||||
"fr-FR": "Portugais (Brésil)",
|
||||
"zh-Hant-TW": "葡萄牙語 (巴西)",
|
||||
"pt-PT": "Português (Brasil)",
|
||||
"ro-RO": "Portugheză (Brazilia)",
|
||||
"ja-JP": "ポルトガル語(ブラジル)",
|
||||
"zh-Hans-CN": "葡萄牙语(巴西)",
|
||||
"nl-NL": "Portugees (Brazilië)",
|
||||
"es-ES": "Portugués (Brasil)",
|
||||
"sv-SE": "Portugisiska (Brasilien)",
|
||||
"ru-RU": "Португальский (Бразилия)",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "fr-FR",
|
||||
label: {
|
||||
"en-US": "French",
|
||||
"de-DE": "Französisch",
|
||||
"pt-BR": "Francês",
|
||||
"fr-FR": "Français",
|
||||
"zh-Hant-TW": "法語",
|
||||
"pt-PT": "Francês",
|
||||
"ro-RO": "Franceză",
|
||||
"ja-JP": "フランス語",
|
||||
"zh-Hans-CN": "法语",
|
||||
"nl-NL": "Frans",
|
||||
"es-ES": "Francés",
|
||||
"sv-SE": "Franska",
|
||||
"ru-RU": "Французский",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "zh-Hant-TW",
|
||||
label: {
|
||||
"en-US": "Chinese (Traditional)",
|
||||
"de-DE": "Chinesisch (Traditionell)",
|
||||
"pt-BR": "Chinês (Tradicional)",
|
||||
"fr-FR": "Chinois (Traditionnel)",
|
||||
"zh-Hant-TW": "繁體中文",
|
||||
"pt-PT": "Chinês (Tradicional)",
|
||||
"ro-RO": "Chineza (Tradițională)",
|
||||
"ja-JP": "中国語(繁体字)",
|
||||
"zh-Hans-CN": "繁体中文",
|
||||
"nl-NL": "Chinees (Traditioneel)",
|
||||
"es-ES": "Chino (Tradicional)",
|
||||
"sv-SE": "Kinesiska (traditionell)",
|
||||
"ru-RU": "Китайский (традиционный)",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "pt-PT",
|
||||
label: {
|
||||
"en-US": "Portuguese (Portugal)",
|
||||
"de-DE": "Portugiesisch (Portugal)",
|
||||
"pt-BR": "Português (Portugal)",
|
||||
"fr-FR": "Portugais (Portugal)",
|
||||
"zh-Hant-TW": "葡萄牙語 (葡萄牙)",
|
||||
"pt-PT": "Português (Portugal)",
|
||||
"ro-RO": "Portugheză (Portugalia)",
|
||||
"ja-JP": "ポルトガル語(ポルトガル)",
|
||||
"zh-Hans-CN": "葡萄牙语(葡萄牙)",
|
||||
"nl-NL": "Portugees (Portugal)",
|
||||
"es-ES": "Portugués (Portugal)",
|
||||
"sv-SE": "Portugisiska (Portugal)",
|
||||
"ru-RU": "Португальский (Португалия)",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "ro-RO",
|
||||
label: {
|
||||
"en-US": "Romanian",
|
||||
"de-DE": "Rumänisch",
|
||||
"pt-BR": "Romeno",
|
||||
"fr-FR": "Roumain",
|
||||
"zh-Hant-TW": "羅馬尼亞語",
|
||||
"pt-PT": "Romeno",
|
||||
"ro-RO": "Română",
|
||||
"ja-JP": "ルーマニア語",
|
||||
"zh-Hans-CN": "罗马尼亚语",
|
||||
"nl-NL": "Roemeens",
|
||||
"es-ES": "Rumano",
|
||||
"sv-SE": "Rumänska",
|
||||
"ru-RU": "Румынский",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "ja-JP",
|
||||
label: {
|
||||
"en-US": "Japanese",
|
||||
"de-DE": "Japanisch",
|
||||
"pt-BR": "Japonês",
|
||||
"fr-FR": "Japonais",
|
||||
"zh-Hant-TW": "日語",
|
||||
"pt-PT": "Japonês",
|
||||
"ro-RO": "Japoneză",
|
||||
"ja-JP": "日本語",
|
||||
"zh-Hans-CN": "日语",
|
||||
"nl-NL": "Japans",
|
||||
"es-ES": "Japonés",
|
||||
"sv-SE": "Japanska",
|
||||
"ru-RU": "Японский",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "zh-Hans-CN",
|
||||
label: {
|
||||
"en-US": "Chinese (Simplified)",
|
||||
"de-DE": "Chinesisch (Vereinfacht)",
|
||||
"pt-BR": "Chinês (Simplificado)",
|
||||
"fr-FR": "Chinois (Simplifié)",
|
||||
"zh-Hant-TW": "簡體中文",
|
||||
"pt-PT": "Chinês (Simplificado)",
|
||||
"ro-RO": "Chineza (Simplificată)",
|
||||
"ja-JP": "中国語(簡体字)",
|
||||
"zh-Hans-CN": "简体中文",
|
||||
"nl-NL": "Chinees (Vereenvoudigd)",
|
||||
"es-ES": "Chino (Simplificado)",
|
||||
"sv-SE": "Kinesiska (förenklad)",
|
||||
"ru-RU": "Китайский (упрощенный)",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "nl-NL",
|
||||
label: {
|
||||
"en-US": "Dutch",
|
||||
"de-DE": "Niederländisch",
|
||||
"pt-BR": "Holandês",
|
||||
"fr-FR": "Néerlandais",
|
||||
"zh-Hant-TW": "荷蘭語",
|
||||
"pt-PT": "Holandês",
|
||||
"ro-RO": "Olandeza",
|
||||
"ja-JP": "オランダ語",
|
||||
"zh-Hans-CN": "荷兰语",
|
||||
"nl-NL": "Nederlands",
|
||||
"es-ES": "Neerlandés",
|
||||
"sv-SE": "Nederländska",
|
||||
"ru-RU": "Голландский",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "es-ES",
|
||||
label: {
|
||||
"en-US": "Spanish",
|
||||
"de-DE": "Spanisch",
|
||||
"pt-BR": "Espanhol",
|
||||
"fr-FR": "Espagnol",
|
||||
"zh-Hant-TW": "西班牙語",
|
||||
"pt-PT": "Espanhol",
|
||||
"ro-RO": "Spaniol",
|
||||
"ja-JP": "スペイン語",
|
||||
"zh-Hans-CN": "西班牙语",
|
||||
"nl-NL": "Spaans",
|
||||
"es-ES": "Español",
|
||||
"sv-SE": "Spanska",
|
||||
"ru-RU": "Испанский",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "sv-SE",
|
||||
label: {
|
||||
"en-US": "Swedish",
|
||||
"de-DE": "Schwedisch",
|
||||
"pt-BR": "Sueco",
|
||||
"fr-FR": "Suédois",
|
||||
"zh-Hant-TW": "瑞典語",
|
||||
"pt-PT": "Sueco",
|
||||
"ro-RO": "Suedeză",
|
||||
"ja-JP": "スウェーデン語",
|
||||
"zh-Hans-CN": "瑞典语",
|
||||
"nl-NL": "Zweeds",
|
||||
"es-ES": "Sueco",
|
||||
"sv-SE": "Svenska",
|
||||
"ru-RU": "Шведский",
|
||||
},
|
||||
},
|
||||
{
|
||||
code: "ru-RU",
|
||||
label: {
|
||||
"en-US": "Russian",
|
||||
},
|
||||
},
|
||||
];
|
||||
export { iso639Languages };
|
||||
|
||||
@@ -208,6 +208,7 @@ const baseSurveyProperties = {
|
||||
},
|
||||
],
|
||||
isBackButtonHidden: false,
|
||||
isCaptureIpEnabled: false,
|
||||
endings: [
|
||||
{
|
||||
id: "umyknohldc7w26ocjdhaa62c",
|
||||
|
||||
@@ -308,6 +308,10 @@ describe("Tests for updateSurvey", () => {
|
||||
const updatedSurvey = await updateSurvey(updateSurveyInput);
|
||||
expect(updatedSurvey).toEqual(mockTransformedSurveyOutput);
|
||||
});
|
||||
|
||||
// Note: Language handling tests (for languages.length > 0 fix) are covered in
|
||||
// apps/web/modules/survey/editor/lib/survey.test.ts where we have better control
|
||||
// over the test mocks. The key fix ensures languages.length > 0 (not > 1) is used.
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
|
||||
@@ -56,6 +56,7 @@ export const selectSurvey = {
|
||||
isVerifyEmailEnabled: true,
|
||||
isSingleResponsePerEmailEnabled: true,
|
||||
isBackButtonHidden: true,
|
||||
isCaptureIpEnabled: true,
|
||||
redirectUrl: true,
|
||||
projectOverwrites: true,
|
||||
styling: true,
|
||||
@@ -328,7 +329,7 @@ export const updateSurveyInternal = async (
|
||||
? currentSurvey.languages.map((l) => l.language.id)
|
||||
: [];
|
||||
const updatedLanguageIds =
|
||||
languages.length > 1 ? updatedSurvey.languages.map((l) => l.language.id) : [];
|
||||
languages.length > 0 ? updatedSurvey.languages.map((l) => l.language.id) : [];
|
||||
const enabledLanguageIds = languages.map((language) => {
|
||||
if (language.enabled) return language.language.id;
|
||||
});
|
||||
|
||||
@@ -90,11 +90,10 @@ describe("locale", () => {
|
||||
// Verify sv-SE is in AVAILABLE_LOCALES
|
||||
expect(AVAILABLE_LOCALES).toContain("sv-SE");
|
||||
|
||||
// Verify Swedish has a language entry with proper labels
|
||||
// Verify Swedish has a language entry with proper label
|
||||
const swedishLanguage = appLanguages.find((lang) => lang.code === "sv-SE");
|
||||
expect(swedishLanguage).toBeDefined();
|
||||
expect(swedishLanguage?.label["en-US"]).toBe("Swedish");
|
||||
expect(swedishLanguage?.label["sv-SE"]).toBe("Svenska");
|
||||
|
||||
// Verify the locale can be matched from Accept-Language header
|
||||
vi.mocked(nextHeaders.headers).mockReturnValue({
|
||||
|
||||
@@ -75,6 +75,10 @@
|
||||
"password_validation_uppercase_and_lowercase": "Mix aus Groß- und Kleinbuchstaben",
|
||||
"please_verify_captcha": "Bitte bestätige reCAPTCHA",
|
||||
"privacy_policy": "Datenschutzerklärung",
|
||||
"product_updates_description": "Monatliche Produktneuigkeiten und Feature-Updates, es gilt die Datenschutzerklärung.",
|
||||
"product_updates_title": "Produkt-Updates",
|
||||
"security_updates_description": "Nur sicherheitsrelevante Informationen, es gilt die Datenschutzerklärung.",
|
||||
"security_updates_title": "Sicherheits-Updates",
|
||||
"terms_of_service": "Nutzungsbedingungen",
|
||||
"title": "Erstelle dein Formbricks-Konto"
|
||||
},
|
||||
@@ -197,6 +201,7 @@
|
||||
"docs": "Dokumentation",
|
||||
"documentation": "Dokumentation",
|
||||
"domain": "Domain",
|
||||
"done": "Fertig",
|
||||
"download": "Herunterladen",
|
||||
"draft": "Entwurf",
|
||||
"duplicate": "Duplikat",
|
||||
@@ -783,20 +788,26 @@
|
||||
"add_webhook": "Webhook hinzufügen",
|
||||
"add_webhook_description": "Sende Umfragedaten an einen benutzerdefinierten Endpunkt",
|
||||
"all_current_and_new_surveys": "Alle aktuellen und neuen Umfragen",
|
||||
"copy_secret_now": "Signierungsschlüssel kopieren",
|
||||
"created_by_third_party": "Erstellt von einer dritten Partei",
|
||||
"discord_webhook_not_supported": "Discord-Webhooks werden derzeit nicht unterstützt.",
|
||||
"empty_webhook_message": "Deine Webhooks werden hier angezeigt, sobald Du sie hinzufügst ⏲️",
|
||||
"endpoint_pinged": "Juhu! Wir können den Webhook anpingen!",
|
||||
"endpoint_pinged_error": "Kann den Webhook nicht anpingen!",
|
||||
"learn_to_verify": "Erfahren Sie, wie Sie Webhook-Signaturen verifizieren",
|
||||
"please_check_console": "Bitte überprüfe die Konsole für weitere Details",
|
||||
"please_enter_a_url": "Bitte gib eine URL ein",
|
||||
"response_created": "Antwort erstellt",
|
||||
"response_finished": "Antwort abgeschlossen",
|
||||
"response_updated": "Antwort aktualisiert",
|
||||
"secret_copy_warning": "Bewahren Sie diesen Schlüssel sicher auf. Sie können ihn erneut in den Webhook-Einstellungen einsehen.",
|
||||
"secret_description": "Verwenden Sie diesen Schlüssel, um Webhook-Anfragen zu verifizieren. Siehe Dokumentation zur Signaturverifizierung.",
|
||||
"signing_secret": "Signierungsschlüssel",
|
||||
"source": "Quelle",
|
||||
"test_endpoint": "Test-Endpunkt",
|
||||
"triggers": "Auslöser",
|
||||
"webhook_added_successfully": "Webhook wurde erfolgreich hinzugefügt",
|
||||
"webhook_created": "Webhook erstellt",
|
||||
"webhook_delete_confirmation": "Bist Du sicher, dass Du diesen Webhook löschen möchtest? Dadurch werden dir keine weiteren Benachrichtigungen mehr gesendet.",
|
||||
"webhook_deleted_successfully": "Webhook erfolgreich gelöscht",
|
||||
"webhook_name_placeholder": "Optional: Benenne deinen Webhook zur einfachen Identifizierung",
|
||||
@@ -1008,6 +1019,8 @@
|
||||
"remove_logo": "Logo entfernen",
|
||||
"replace_logo": "Logo ersetzen",
|
||||
"resend_invitation_email": "Einladungsemail erneut senden",
|
||||
"security_list_tip": "Haben Sie sich für unsere Sicherheitsliste angemeldet? Bleiben Sie informiert, um Ihre Instanz sicher zu halten!",
|
||||
"security_list_tip_link": "Hier registrieren.",
|
||||
"share_invite_link": "Einladungslink teilen",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Teile diesen Link, damit dein Organisationsmitglied deiner Organisation beitreten kann:",
|
||||
"test_email_sent_successfully": "Test-E-Mail erfolgreich gesendet",
|
||||
@@ -1169,6 +1182,9 @@
|
||||
"assign": "Zuweisen =",
|
||||
"audience": "Publikum",
|
||||
"auto_close_on_inactivity": "Automatisches Schließen bei Inaktivität",
|
||||
"auto_save_disabled": "Automatisches Speichern deaktiviert",
|
||||
"auto_save_disabled_tooltip": "Ihre Umfrage wird nur im Entwurfsmodus automatisch gespeichert. So wird sichergestellt, dass öffentliche Umfragen nicht unbeabsichtigt aktualisiert werden.",
|
||||
"auto_save_on": "Automatisches Speichern an",
|
||||
"automatically_close_survey_after": "Umfrage automatisch schließen nach",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Schließe die Umfrage automatisch nach einer bestimmten Anzahl von Antworten.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Schließe die Umfrage automatisch, wenn der Benutzer nach einer bestimmten Anzahl von Sekunden nicht antwortet.",
|
||||
@@ -1190,6 +1206,8 @@
|
||||
"cal_username": "Cal.com Benutzername oder Benutzername/Ereignis",
|
||||
"calculate": "Berechnen",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Erfasse eine neue Aktion, um eine Umfrage auszulösen.",
|
||||
"capture_ip_address": "IP-Adresse erfassen",
|
||||
"capture_ip_address_description": "Speichern Sie die IP-Adresse des Befragten in den Antwort-Metadaten zur Duplikaterkennung und für Sicherheitszwecke",
|
||||
"capture_new_action": "Neue Aktion erfassen",
|
||||
"card_arrangement_for_survey_type_derived": "Kartenanordnung für {surveyTypeDerived} Umfragen",
|
||||
"card_background_color": "Hintergrundfarbe der Karte",
|
||||
@@ -1448,6 +1466,7 @@
|
||||
"please_specify": "Bitte angeben",
|
||||
"prevent_double_submission": "Doppeltes Anbschicken verhindern",
|
||||
"prevent_double_submission_description": "Nur eine Antwort pro E-Mail-Adresse zulassen (beta)",
|
||||
"progress_saved": "Fortschritt gespeichert",
|
||||
"protect_survey_with_pin": "Umfrage mit einer PIN schützen",
|
||||
"protect_survey_with_pin_description": "Nur Benutzer, die die PIN haben, können auf die Umfrage zugreifen.",
|
||||
"publish": "Veröffentlichen",
|
||||
@@ -1646,6 +1665,7 @@
|
||||
"error_downloading_responses": "Beim Herunterladen der Antworten ist ein Fehler aufgetreten",
|
||||
"first_name": "Vorname",
|
||||
"how_to_identify_users": "Wie man Benutzer identifiziert",
|
||||
"ip_address": "IP-Adresse",
|
||||
"last_name": "Nachname",
|
||||
"not_completed": "Nicht abgeschlossen ⏳",
|
||||
"os": "Betriebssystem",
|
||||
|
||||
@@ -75,6 +75,10 @@
|
||||
"password_validation_uppercase_and_lowercase": "Mezcla de mayúsculas y minúsculas",
|
||||
"please_verify_captcha": "Por favor, verifica el reCAPTCHA",
|
||||
"privacy_policy": "Política de privacidad",
|
||||
"product_updates_description": "Noticias mensuales del producto y actualizaciones de funciones, se aplica la política de privacidad.",
|
||||
"product_updates_title": "Actualizaciones del producto",
|
||||
"security_updates_description": "Solo información relevante sobre seguridad, se aplica la política de privacidad.",
|
||||
"security_updates_title": "Actualizaciones de seguridad",
|
||||
"terms_of_service": "Términos de servicio",
|
||||
"title": "Crea tu cuenta de Formbricks"
|
||||
},
|
||||
@@ -197,6 +201,7 @@
|
||||
"docs": "Documentación",
|
||||
"documentation": "Documentación",
|
||||
"domain": "Dominio",
|
||||
"done": "Hecho",
|
||||
"download": "Descargar",
|
||||
"draft": "Borrador",
|
||||
"duplicate": "Duplicar",
|
||||
@@ -783,20 +788,26 @@
|
||||
"add_webhook": "Añadir webhook",
|
||||
"add_webhook_description": "Envía datos de respuestas de encuestas a un endpoint personalizado",
|
||||
"all_current_and_new_surveys": "Todas las encuestas actuales y nuevas",
|
||||
"copy_secret_now": "Copia tu secreto de firma",
|
||||
"created_by_third_party": "Creado por un tercero",
|
||||
"discord_webhook_not_supported": "Los webhooks de Discord no son compatibles actualmente.",
|
||||
"empty_webhook_message": "Tus webhooks aparecerán aquí tan pronto como los añadas. ⏲️",
|
||||
"endpoint_pinged": "¡Genial! ¡Podemos hacer ping al webhook!",
|
||||
"endpoint_pinged_error": "¡No se puede hacer ping al webhook!",
|
||||
"learn_to_verify": "Aprende a verificar las firmas de webhook",
|
||||
"please_check_console": "Por favor, consulta la consola para más detalles",
|
||||
"please_enter_a_url": "Por favor, introduce una URL",
|
||||
"response_created": "Respuesta creada",
|
||||
"response_finished": "Respuesta finalizada",
|
||||
"response_updated": "Respuesta actualizada",
|
||||
"secret_copy_warning": "Almacena este secreto de forma segura. Puedes verlo de nuevo en la configuración del webhook.",
|
||||
"secret_description": "Usa este secreto para verificar las solicitudes del webhook. Consulta la documentación para la verificación de firma.",
|
||||
"signing_secret": "Secreto de firma",
|
||||
"source": "Origen",
|
||||
"test_endpoint": "Probar endpoint",
|
||||
"triggers": "Disparadores",
|
||||
"webhook_added_successfully": "Webhook añadido correctamente",
|
||||
"webhook_created": "Webhook creado",
|
||||
"webhook_delete_confirmation": "¿Estás seguro de que quieres eliminar este webhook? Esto detendrá el envío de futuras notificaciones.",
|
||||
"webhook_deleted_successfully": "Webhook eliminado correctamente",
|
||||
"webhook_name_placeholder": "Opcional: Etiqueta tu webhook para identificarlo fácilmente",
|
||||
@@ -1008,6 +1019,8 @@
|
||||
"remove_logo": "Eliminar logotipo",
|
||||
"replace_logo": "Reemplazar logotipo",
|
||||
"resend_invitation_email": "Reenviar correo electrónico de invitación",
|
||||
"security_list_tip": "¿Estás suscrito a nuestra lista de seguridad? ¡Mantente informado para mantener tu instancia segura!",
|
||||
"security_list_tip_link": "Regístrate aquí.",
|
||||
"share_invite_link": "Compartir enlace de invitación",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Comparte este enlace para permitir que los miembros de tu organización se unan a tu organización:",
|
||||
"test_email_sent_successfully": "Correo electrónico de prueba enviado correctamente",
|
||||
@@ -1169,6 +1182,9 @@
|
||||
"assign": "Asignar =",
|
||||
"audience": "Audiencia",
|
||||
"auto_close_on_inactivity": "Cierre automático por inactividad",
|
||||
"auto_save_disabled": "Guardado automático desactivado",
|
||||
"auto_save_disabled_tooltip": "Su encuesta solo se guarda automáticamente cuando está en borrador. Esto asegura que las encuestas públicas no se actualicen involuntariamente.",
|
||||
"auto_save_on": "Guardado automático activado",
|
||||
"automatically_close_survey_after": "Cerrar automáticamente la encuesta después de",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Cerrar automáticamente la encuesta después de un cierto número de respuestas.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Cerrar automáticamente la encuesta si el usuario no responde después de cierto número de segundos.",
|
||||
@@ -1190,6 +1206,8 @@
|
||||
"cal_username": "Nombre de usuario de Cal.com o nombre de usuario/evento",
|
||||
"calculate": "Calcular",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Captura una nueva acción para activar una encuesta.",
|
||||
"capture_ip_address": "Capturar dirección IP",
|
||||
"capture_ip_address_description": "Almacenar la dirección IP del encuestado en los metadatos de respuesta para la detección de duplicados y fines de seguridad",
|
||||
"capture_new_action": "Capturar nueva acción",
|
||||
"card_arrangement_for_survey_type_derived": "Disposición de tarjetas para encuestas de tipo {surveyTypeDerived}",
|
||||
"card_background_color": "Color de fondo de la tarjeta",
|
||||
@@ -1448,6 +1466,7 @@
|
||||
"please_specify": "Por favor, especifica",
|
||||
"prevent_double_submission": "Evitar envío duplicado",
|
||||
"prevent_double_submission_description": "Permitir solo 1 respuesta por dirección de correo electrónico",
|
||||
"progress_saved": "Progreso guardado",
|
||||
"protect_survey_with_pin": "Proteger encuesta con un PIN",
|
||||
"protect_survey_with_pin_description": "Solo los usuarios que tengan el PIN pueden acceder a la encuesta.",
|
||||
"publish": "Publicar",
|
||||
@@ -1646,6 +1665,7 @@
|
||||
"error_downloading_responses": "Se produjo un error al descargar las respuestas",
|
||||
"first_name": "Nombre",
|
||||
"how_to_identify_users": "Cómo identificar a los usuarios",
|
||||
"ip_address": "Dirección IP",
|
||||
"last_name": "Apellido",
|
||||
"not_completed": "No completado ⏳",
|
||||
"os": "Sistema operativo",
|
||||
|
||||
@@ -75,6 +75,10 @@
|
||||
"password_validation_uppercase_and_lowercase": "Mélange de majuscules et de minuscules",
|
||||
"please_verify_captcha": "Veuillez vérifier reCAPTCHA",
|
||||
"privacy_policy": "Politique de confidentialité",
|
||||
"product_updates_description": "Actualités mensuelles du produit et mises à jour des fonctionnalités, la politique de confidentialité s'applique.",
|
||||
"product_updates_title": "Mises à jour du produit",
|
||||
"security_updates_description": "Informations relatives à la sécurité uniquement, la politique de confidentialité s'applique.",
|
||||
"security_updates_title": "Mises à jour de sécurité",
|
||||
"terms_of_service": "Conditions d'utilisation",
|
||||
"title": "Créez votre compte Formbricks"
|
||||
},
|
||||
@@ -197,6 +201,7 @@
|
||||
"docs": "Documentation",
|
||||
"documentation": "Documentation",
|
||||
"domain": "Domaine",
|
||||
"done": "Terminé",
|
||||
"download": "Télécharger",
|
||||
"draft": "Brouillon",
|
||||
"duplicate": "Dupliquer",
|
||||
@@ -783,20 +788,26 @@
|
||||
"add_webhook": "Ajouter un Webhook",
|
||||
"add_webhook_description": "Envoyer les données de réponse à l'enquête à un point de terminaison personnalisé",
|
||||
"all_current_and_new_surveys": "Tous les sondages actuels et nouveaux",
|
||||
"copy_secret_now": "Copiez votre secret de signature",
|
||||
"created_by_third_party": "Créé par un tiers",
|
||||
"discord_webhook_not_supported": "Les webhooks Discord ne sont actuellement pas pris en charge.",
|
||||
"empty_webhook_message": "Vos webhooks apparaîtront ici dès que vous les ajouterez. ⏲️",
|
||||
"endpoint_pinged": "Yay ! Nous pouvons pinger le webhook !",
|
||||
"endpoint_pinged_error": "Impossible de pinger le webhook !",
|
||||
"learn_to_verify": "Découvrez comment vérifier les signatures de webhook",
|
||||
"please_check_console": "Veuillez vérifier la console pour plus de détails.",
|
||||
"please_enter_a_url": "Veuillez entrer une URL.",
|
||||
"response_created": "Réponse créée",
|
||||
"response_finished": "Réponse terminée",
|
||||
"response_updated": "Réponse mise à jour",
|
||||
"secret_copy_warning": "Conservez ce secret en lieu sûr. Vous pourrez le consulter à nouveau dans les paramètres du webhook.",
|
||||
"secret_description": "Utilisez ce secret pour vérifier les requêtes webhook. Consultez la documentation pour la vérification de signature.",
|
||||
"signing_secret": "Secret de signature",
|
||||
"source": "Source",
|
||||
"test_endpoint": "Point de test",
|
||||
"triggers": "Déclencheurs",
|
||||
"webhook_added_successfully": "Webhook ajouté avec succès",
|
||||
"webhook_created": "Webhook créé",
|
||||
"webhook_delete_confirmation": "Êtes-vous sûr de vouloir supprimer ce Webhook ? Cela arrêtera l'envoi de toute notification future.",
|
||||
"webhook_deleted_successfully": "Webhook supprimé avec succès",
|
||||
"webhook_name_placeholder": "Optionnel : Étiquetez votre webhook pour une identification facile",
|
||||
@@ -1008,6 +1019,8 @@
|
||||
"remove_logo": "Supprimer le logo",
|
||||
"replace_logo": "Remplacer le logo",
|
||||
"resend_invitation_email": "Renvoyer l'e-mail d'invitation",
|
||||
"security_list_tip": "Êtes-vous inscrit à notre liste de sécurité ? Restez informé pour maintenir votre instance sécurisée !",
|
||||
"security_list_tip_link": "Inscrivez-vous ici.",
|
||||
"share_invite_link": "Partager le lien d'invitation",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Partagez ce lien pour permettre à un membre de votre organisation de rejoindre votre organisation :",
|
||||
"test_email_sent_successfully": "E-mail de test envoyé avec succès",
|
||||
@@ -1169,6 +1182,9 @@
|
||||
"assign": "Attribuer =",
|
||||
"audience": "Public",
|
||||
"auto_close_on_inactivity": "Fermeture automatique en cas d'inactivité",
|
||||
"auto_save_disabled": "Sauvegarde automatique désactivée",
|
||||
"auto_save_disabled_tooltip": "Votre sondage n'est sauvegardé automatiquement que lorsqu'il est en brouillon. Cela garantit que les sondages publics ne sont pas mis à jour involontairement.",
|
||||
"auto_save_on": "Sauvegarde automatique activée",
|
||||
"automatically_close_survey_after": "Fermer automatiquement l'enquête après",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fermer automatiquement l'enquête après un certain nombre de réponses.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Fermer automatiquement l'enquête si l'utilisateur ne répond pas après un certain nombre de secondes.",
|
||||
@@ -1190,6 +1206,8 @@
|
||||
"cal_username": "Nom d'utilisateur Cal.com ou nom d'utilisateur/événement",
|
||||
"calculate": "Calculer",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Capturez une nouvelle action pour déclencher une enquête.",
|
||||
"capture_ip_address": "Capturer l'adresse IP",
|
||||
"capture_ip_address_description": "Stocker l'adresse IP du répondant dans les métadonnées de réponse à des fins de détection des doublons et de sécurité",
|
||||
"capture_new_action": "Capturer une nouvelle action",
|
||||
"card_arrangement_for_survey_type_derived": "Disposition des cartes pour les enquêtes {surveyTypeDerived}",
|
||||
"card_background_color": "Couleur de fond de la carte",
|
||||
@@ -1448,6 +1466,7 @@
|
||||
"please_specify": "Veuillez préciser",
|
||||
"prevent_double_submission": "Empêcher la double soumission",
|
||||
"prevent_double_submission_description": "Autoriser uniquement 1 réponse par adresse e-mail",
|
||||
"progress_saved": "Progression enregistrée",
|
||||
"protect_survey_with_pin": "Protéger l'enquête par un code PIN",
|
||||
"protect_survey_with_pin_description": "Seules les personnes ayant le code PIN peuvent accéder à l'enquête.",
|
||||
"publish": "Publier",
|
||||
@@ -1646,6 +1665,7 @@
|
||||
"error_downloading_responses": "Une erreur s'est produite lors du téléchargement des réponses",
|
||||
"first_name": "Prénom",
|
||||
"how_to_identify_users": "Comment identifier les utilisateurs",
|
||||
"ip_address": "Adresse IP",
|
||||
"last_name": "Nom de famille",
|
||||
"not_completed": "Non terminé ⏳",
|
||||
"os": "Système d'exploitation",
|
||||
|
||||
@@ -75,6 +75,10 @@
|
||||
"password_validation_uppercase_and_lowercase": "大文字と小文字を混ぜる",
|
||||
"please_verify_captcha": "reCAPTCHAを認証してください",
|
||||
"privacy_policy": "プライバシーポリシー",
|
||||
"product_updates_description": "毎月の製品ニュースと機能アップデート、プライバシーポリシーが適用されます。",
|
||||
"product_updates_title": "製品アップデート",
|
||||
"security_updates_description": "セキュリティ関連情報のみ、プライバシーポリシーが適用されます。",
|
||||
"security_updates_title": "セキュリティアップデート",
|
||||
"terms_of_service": "利用規約",
|
||||
"title": "Formbricksアカウントを作成"
|
||||
},
|
||||
@@ -197,6 +201,7 @@
|
||||
"docs": "ドキュメント",
|
||||
"documentation": "ドキュメント",
|
||||
"domain": "ドメイン",
|
||||
"done": "完了",
|
||||
"download": "ダウンロード",
|
||||
"draft": "下書き",
|
||||
"duplicate": "複製",
|
||||
@@ -783,20 +788,26 @@
|
||||
"add_webhook": "Webhook を追加",
|
||||
"add_webhook_description": "フォーム回答データを任意のエンドポイントへ送信",
|
||||
"all_current_and_new_surveys": "現在および新規のすべてのフォーム",
|
||||
"copy_secret_now": "署名シークレットをコピー",
|
||||
"created_by_third_party": "サードパーティによって作成",
|
||||
"discord_webhook_not_supported": "現在、Discord Webhook はサポートしていません。",
|
||||
"empty_webhook_message": "Webhook は追加するとここに表示されます。⏲️",
|
||||
"endpoint_pinged": "成功!Webhook に ping できました。",
|
||||
"endpoint_pinged_error": "Webhook への ping に失敗しました。",
|
||||
"learn_to_verify": "Webhook署名の検証方法を学ぶ",
|
||||
"please_check_console": "詳細はコンソールを確認してください",
|
||||
"please_enter_a_url": "URL を入力してください",
|
||||
"response_created": "回答作成",
|
||||
"response_finished": "回答完了",
|
||||
"response_updated": "回答更新",
|
||||
"secret_copy_warning": "このシークレットを安全に保管してください。Webhook 設定で再度確認できます。",
|
||||
"secret_description": "このシークレットを使用して Webhook リクエストを検証します。署名検証についてはドキュメントを参照してください。",
|
||||
"signing_secret": "署名シークレット",
|
||||
"source": "ソース",
|
||||
"test_endpoint": "エンドポイントをテスト",
|
||||
"triggers": "トリガー",
|
||||
"webhook_added_successfully": "Webhook を追加しました",
|
||||
"webhook_created": "Webhook を作成しました",
|
||||
"webhook_delete_confirmation": "このWebhookを削除してもよろしいですか?以後の通知は送信されません。",
|
||||
"webhook_deleted_successfully": "Webhook を削除しました",
|
||||
"webhook_name_placeholder": "任意: 識別しやすいようWebhookにラベルを付ける",
|
||||
@@ -1008,6 +1019,8 @@
|
||||
"remove_logo": "ロゴを削除",
|
||||
"replace_logo": "ロゴを交換",
|
||||
"resend_invitation_email": "招待メールを再送信",
|
||||
"security_list_tip": "セキュリティリストに登録していますか?インスタンスを安全に保つために最新情報を入手しましょう!",
|
||||
"security_list_tip_link": "こちらからサインアップしてください。",
|
||||
"share_invite_link": "招待リンクを共有",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "このリンクを共有して、組織メンバーを招待できます:",
|
||||
"test_email_sent_successfully": "テストメールを正常に送信しました",
|
||||
@@ -1169,6 +1182,9 @@
|
||||
"assign": "割り当て =",
|
||||
"audience": "オーディエンス",
|
||||
"auto_close_on_inactivity": "非アクティブ時に自動閉鎖",
|
||||
"auto_save_disabled": "自動保存が無効",
|
||||
"auto_save_disabled_tooltip": "アンケートは下書き状態の時のみ自動保存されます。これにより、公開中のアンケートが意図せず更新されることを防ぎます。",
|
||||
"auto_save_on": "自動保存オン",
|
||||
"automatically_close_survey_after": "フォームを自動的に閉じる",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "一定の回答数に達した後にフォームを自動的に閉じます。",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "ユーザーが一定秒数応答しない場合、フォームを自動的に閉じます。",
|
||||
@@ -1190,6 +1206,8 @@
|
||||
"cal_username": "Cal.comのユーザー名またはユーザー名/イベント",
|
||||
"calculate": "計算",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "フォームをトリガーする新しいアクションをキャプチャします。",
|
||||
"capture_ip_address": "IPアドレスを記録",
|
||||
"capture_ip_address_description": "重複検出とセキュリティ目的で、回答者のIPアドレスを回答メタデータに保存します",
|
||||
"capture_new_action": "新しいアクションをキャプチャ",
|
||||
"card_arrangement_for_survey_type_derived": "{surveyTypeDerived} フォームのカード配置",
|
||||
"card_background_color": "カードの背景色",
|
||||
@@ -1448,6 +1466,7 @@
|
||||
"please_specify": "具体的に指定してください",
|
||||
"prevent_double_submission": "二重送信を防ぐ",
|
||||
"prevent_double_submission_description": "メールアドレスごとに1つの回答のみを許可する",
|
||||
"progress_saved": "進捗を保存しました",
|
||||
"protect_survey_with_pin": "PINでフォームを保護",
|
||||
"protect_survey_with_pin_description": "PINを持つユーザーのみがフォームにアクセスできます。",
|
||||
"publish": "公開",
|
||||
@@ -1646,6 +1665,7 @@
|
||||
"error_downloading_responses": "回答のダウンロード中にエラーが発生しました",
|
||||
"first_name": "名",
|
||||
"how_to_identify_users": "ユーザーを識別する方法",
|
||||
"ip_address": "IPアドレス",
|
||||
"last_name": "姓",
|
||||
"not_completed": "未完了 ⏳",
|
||||
"os": "OS",
|
||||
|
||||
@@ -75,6 +75,10 @@
|
||||
"password_validation_uppercase_and_lowercase": "Mix van hoofdletters en kleine letters",
|
||||
"please_verify_captcha": "Controleer reCAPTCHA",
|
||||
"privacy_policy": "Privacybeleid",
|
||||
"product_updates_description": "Maandelijks productnieuws en feature-updates, privacybeleid is van toepassing.",
|
||||
"product_updates_title": "Product-updates",
|
||||
"security_updates_description": "Alleen beveiligingsrelevante informatie, privacybeleid is van toepassing.",
|
||||
"security_updates_title": "Beveiligingsupdates",
|
||||
"terms_of_service": "Servicevoorwaarden",
|
||||
"title": "Maak uw Formbricks-account aan"
|
||||
},
|
||||
@@ -197,6 +201,7 @@
|
||||
"docs": "Documentatie",
|
||||
"documentation": "Documentatie",
|
||||
"domain": "Domein",
|
||||
"done": "Klaar",
|
||||
"download": "Downloaden",
|
||||
"draft": "Voorlopige versie",
|
||||
"duplicate": "Duplicaat",
|
||||
@@ -783,20 +788,26 @@
|
||||
"add_webhook": "Webhook toevoegen",
|
||||
"add_webhook_description": "Stuur enquêtereactiegegevens naar een aangepast eindpunt",
|
||||
"all_current_and_new_surveys": "Alle huidige en nieuwe onderzoeken",
|
||||
"copy_secret_now": "Kopieer je ondertekeningsgeheim",
|
||||
"created_by_third_party": "Gemaakt door een derde partij",
|
||||
"discord_webhook_not_supported": "Discord-webhooks worden momenteel niet ondersteund.",
|
||||
"empty_webhook_message": "Uw webhooks verschijnen hier zodra u ze toevoegt. ⏲️",
|
||||
"endpoint_pinged": "Jawel! We kunnen de webhook pingen!",
|
||||
"endpoint_pinged_error": "Kan de webhook niet pingen!",
|
||||
"learn_to_verify": "Leer hoe je webhook-handtekeningen kunt verifiëren",
|
||||
"please_check_console": "Controleer de console voor meer details",
|
||||
"please_enter_a_url": "Voer een URL in",
|
||||
"response_created": "Reactie gemaakt",
|
||||
"response_finished": "Reactie voltooid",
|
||||
"response_updated": "Reactie bijgewerkt",
|
||||
"secret_copy_warning": "Bewaar dit geheim veilig. Je kunt het opnieuw bekijken in de webhook-instellingen.",
|
||||
"secret_description": "Gebruik dit geheim om webhook-verzoeken te verifiëren. Zie de documentatie voor handtekeningverificatie.",
|
||||
"signing_secret": "Ondertekeningsgeheim",
|
||||
"source": "Bron",
|
||||
"test_endpoint": "Eindpunt testen",
|
||||
"triggers": "Triggers",
|
||||
"webhook_added_successfully": "Webhook succesvol toegevoegd",
|
||||
"webhook_created": "Webhook aangemaakt",
|
||||
"webhook_delete_confirmation": "Weet u zeker dat u deze webhook wilt verwijderen? Hierdoor worden er geen verdere meldingen meer verzonden.",
|
||||
"webhook_deleted_successfully": "Webhook is succesvol verwijderd",
|
||||
"webhook_name_placeholder": "Optioneel: Label uw webhook voor gemakkelijke identificatie",
|
||||
@@ -1008,6 +1019,8 @@
|
||||
"remove_logo": "Logo verwijderen",
|
||||
"replace_logo": "Logo vervangen",
|
||||
"resend_invitation_email": "Uitnodigings-e-mail opnieuw verzenden",
|
||||
"security_list_tip": "Ben je aangemeld voor onze beveiligingslijst? Blijf op de hoogte om je instantie veilig te houden!",
|
||||
"security_list_tip_link": "Meld je hier aan.",
|
||||
"share_invite_link": "Deel de uitnodigingslink",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Deel deze link om uw organisatielid lid te laten worden van uw organisatie:",
|
||||
"test_email_sent_successfully": "Test-e-mail succesvol verzonden",
|
||||
@@ -1169,6 +1182,9 @@
|
||||
"assign": "Toewijzen =",
|
||||
"audience": "Publiek",
|
||||
"auto_close_on_inactivity": "Automatisch sluiten bij inactiviteit",
|
||||
"auto_save_disabled": "Automatisch opslaan uitgeschakeld",
|
||||
"auto_save_disabled_tooltip": "Uw enquête wordt alleen automatisch opgeslagen wanneer deze een concept is. Dit zorgt ervoor dat openbare enquêtes niet onbedoeld worden bijgewerkt.",
|
||||
"auto_save_on": "Automatisch opslaan aan",
|
||||
"automatically_close_survey_after": "Sluit de enquête daarna automatisch af",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Sluit de enquête automatisch af na een bepaald aantal reacties.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Sluit de enquête automatisch af als de gebruiker na een bepaald aantal seconden niet reageert.",
|
||||
@@ -1190,6 +1206,8 @@
|
||||
"cal_username": "Cal.com-gebruikersnaam of gebruikersnaam/evenement",
|
||||
"calculate": "Berekenen",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Leg een nieuwe actie vast om een enquête over te activeren.",
|
||||
"capture_ip_address": "IP-adres vastleggen",
|
||||
"capture_ip_address_description": "Sla het IP-adres van de respondent op in de metadata van het antwoord voor detectie van duplicaten en beveiligingsdoeleinden",
|
||||
"capture_new_action": "Leg nieuwe actie vast",
|
||||
"card_arrangement_for_survey_type_derived": "Kaartarrangement voor {surveyTypeDerived} enquêtes",
|
||||
"card_background_color": "Achtergrondkleur van de kaart",
|
||||
@@ -1448,6 +1466,7 @@
|
||||
"please_specify": "Gelieve te specificeren",
|
||||
"prevent_double_submission": "Voorkom dubbele indiening",
|
||||
"prevent_double_submission_description": "Er is slechts 1 reactie per e-mailadres toegestaan",
|
||||
"progress_saved": "Voortgang opgeslagen",
|
||||
"protect_survey_with_pin": "Beveilig onderzoek met een pincode",
|
||||
"protect_survey_with_pin_description": "Alleen gebruikers die de pincode hebben, hebben toegang tot de enquête.",
|
||||
"publish": "Publiceren",
|
||||
@@ -1646,6 +1665,7 @@
|
||||
"error_downloading_responses": "Er is een fout opgetreden bij het downloaden van de antwoorden",
|
||||
"first_name": "Voornaam",
|
||||
"how_to_identify_users": "Hoe gebruikers te identificeren",
|
||||
"ip_address": "IP-adres",
|
||||
"last_name": "Achternaam",
|
||||
"not_completed": "Niet voltooid ⏳",
|
||||
"os": "Besturingssysteem",
|
||||
|
||||
@@ -75,6 +75,10 @@
|
||||
"password_validation_uppercase_and_lowercase": "mistura de maiúsculas e minúsculas",
|
||||
"please_verify_captcha": "Por favor, verifique o reCAPTCHA",
|
||||
"privacy_policy": "Política de Privacidade",
|
||||
"product_updates_description": "Novidades mensais do produto e atualizações de recursos, a Política de Privacidade se aplica.",
|
||||
"product_updates_title": "Atualizações do produto",
|
||||
"security_updates_description": "Apenas informações relevantes sobre segurança, a Política de Privacidade se aplica.",
|
||||
"security_updates_title": "Atualizações de segurança",
|
||||
"terms_of_service": "Termos de Serviço",
|
||||
"title": "Crie sua conta no Formbricks"
|
||||
},
|
||||
@@ -197,6 +201,7 @@
|
||||
"docs": "Documentação",
|
||||
"documentation": "Documentação",
|
||||
"domain": "Domínio",
|
||||
"done": "Concluído",
|
||||
"download": "baixar",
|
||||
"draft": "Rascunho",
|
||||
"duplicate": "Duplicar",
|
||||
@@ -783,20 +788,26 @@
|
||||
"add_webhook": "Adicionar Webhook",
|
||||
"add_webhook_description": "Enviar dados das respostas da pesquisa para um endpoint personalizado",
|
||||
"all_current_and_new_surveys": "Todas as pesquisas atuais e novas",
|
||||
"copy_secret_now": "Copie seu segredo de assinatura",
|
||||
"created_by_third_party": "Criado por um Terceiro",
|
||||
"discord_webhook_not_supported": "Webhooks do Discord não são suportados no momento.",
|
||||
"empty_webhook_message": "Seus webhooks vão aparecer aqui assim que você adicioná-los. ⏲️",
|
||||
"endpoint_pinged": "Uhul! Conseguimos pingar o webhook!",
|
||||
"endpoint_pinged_error": "Não consegui pingar o webhook!",
|
||||
"learn_to_verify": "Aprenda como verificar assinaturas de webhook",
|
||||
"please_check_console": "Por favor, verifica o console para mais detalhes",
|
||||
"please_enter_a_url": "Por favor, insira uma URL",
|
||||
"response_created": "Resposta Criada",
|
||||
"response_finished": "Resposta Finalizada",
|
||||
"response_updated": "Resposta Atualizada",
|
||||
"secret_copy_warning": "Armazene este segredo com segurança. Você pode visualizá-lo novamente nas configurações do webhook.",
|
||||
"secret_description": "Use este segredo para verificar requisições de webhook. Consulte a documentação para verificação de assinatura.",
|
||||
"signing_secret": "Segredo de assinatura",
|
||||
"source": "fonte",
|
||||
"test_endpoint": "Testar Ponto de Extremidade",
|
||||
"triggers": "gatilhos",
|
||||
"webhook_added_successfully": "Webhook adicionado com sucesso",
|
||||
"webhook_created": "Webhook criado",
|
||||
"webhook_delete_confirmation": "Tem certeza de que quer deletar esse Webhook? Isso vai parar de te enviar qualquer notificação.",
|
||||
"webhook_deleted_successfully": "Webhook deletado com sucesso",
|
||||
"webhook_name_placeholder": "Opcional: Dê um nome ao seu webhook para facilitar a identificação",
|
||||
@@ -1008,6 +1019,8 @@
|
||||
"remove_logo": "Remover logo",
|
||||
"replace_logo": "Substituir logo",
|
||||
"resend_invitation_email": "Reenviar E-mail de Convite",
|
||||
"security_list_tip": "Você está inscrito na nossa Lista de Segurança? Mantenha-se informado para manter sua instância segura!",
|
||||
"security_list_tip_link": "Cadastre-se aqui.",
|
||||
"share_invite_link": "Compartilhar Link de Convite",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Compartilhe esse link para que o membro da sua organização possa entrar na sua organização:",
|
||||
"test_email_sent_successfully": "E-mail de teste enviado com sucesso",
|
||||
@@ -1169,6 +1182,9 @@
|
||||
"assign": "atribuir =",
|
||||
"audience": "Público",
|
||||
"auto_close_on_inactivity": "Fechar automaticamente por inatividade",
|
||||
"auto_save_disabled": "Salvamento automático desativado",
|
||||
"auto_save_disabled_tooltip": "Sua pesquisa só é salva automaticamente quando está em rascunho. Isso garante que pesquisas públicas não sejam atualizadas involuntariamente.",
|
||||
"auto_save_on": "Salvamento automático ativado",
|
||||
"automatically_close_survey_after": "Fechar pesquisa automaticamente após",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fechar automaticamente a pesquisa depois de um certo número de respostas.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Feche automaticamente a pesquisa se o usuário não responder depois de alguns segundos.",
|
||||
@@ -1190,6 +1206,8 @@
|
||||
"cal_username": "Nome de usuário do Cal.com ou nome de usuário/evento",
|
||||
"calculate": "Calcular",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Captura uma nova ação pra disparar uma pesquisa.",
|
||||
"capture_ip_address": "Capturar endereço IP",
|
||||
"capture_ip_address_description": "Armazenar o endereço IP do respondente nos metadados da resposta para fins de detecção de duplicatas e segurança",
|
||||
"capture_new_action": "Capturar nova ação",
|
||||
"card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Pesquisas {surveyTypeDerived}",
|
||||
"card_background_color": "Cor de fundo do cartão",
|
||||
@@ -1448,6 +1466,7 @@
|
||||
"please_specify": "Por favor, especifique",
|
||||
"prevent_double_submission": "Evitar envio duplicado",
|
||||
"prevent_double_submission_description": "Permitir apenas 1 resposta por endereço de email",
|
||||
"progress_saved": "Progresso salvo",
|
||||
"protect_survey_with_pin": "Proteger pesquisa com um PIN",
|
||||
"protect_survey_with_pin_description": "Somente usuários que têm o PIN podem acessar a pesquisa.",
|
||||
"publish": "Publicar",
|
||||
@@ -1646,6 +1665,7 @@
|
||||
"error_downloading_responses": "Ocorreu um erro ao baixar as respostas",
|
||||
"first_name": "Primeiro Nome",
|
||||
"how_to_identify_users": "Como identificar usuários",
|
||||
"ip_address": "Endereço IP",
|
||||
"last_name": "Sobrenome",
|
||||
"not_completed": "Não Concluído ⏳",
|
||||
"os": "sistema operacional",
|
||||
|
||||
@@ -75,6 +75,10 @@
|
||||
"password_validation_uppercase_and_lowercase": "Mistura de maiúsculas e minúsculas",
|
||||
"please_verify_captcha": "Por favor, verifique o reCAPTCHA",
|
||||
"privacy_policy": "Política de Privacidade",
|
||||
"product_updates_description": "Notícias mensais sobre o produto e atualizações de funcionalidades, aplica-se a Política de Privacidade.",
|
||||
"product_updates_title": "Atualizações do produto",
|
||||
"security_updates_description": "Apenas informações relevantes sobre segurança, aplica-se a Política de Privacidade.",
|
||||
"security_updates_title": "Atualizações de segurança",
|
||||
"terms_of_service": "Termos de Serviço",
|
||||
"title": "Crie a sua conta Formbricks"
|
||||
},
|
||||
@@ -197,6 +201,7 @@
|
||||
"docs": "Documentação",
|
||||
"documentation": "Documentação",
|
||||
"domain": "Domínio",
|
||||
"done": "Concluído",
|
||||
"download": "Transferir",
|
||||
"draft": "Rascunho",
|
||||
"duplicate": "Duplicar",
|
||||
@@ -783,20 +788,26 @@
|
||||
"add_webhook": "Adicionar Webhook",
|
||||
"add_webhook_description": "Enviar dados de resposta do inquérito para um endpoint personalizado",
|
||||
"all_current_and_new_surveys": "Todos os inquéritos atuais e novos",
|
||||
"copy_secret_now": "Copiar o seu segredo de assinatura",
|
||||
"created_by_third_party": "Criado por um Terceiro",
|
||||
"discord_webhook_not_supported": "Os webhooks do Discord não são atualmente suportados.",
|
||||
"empty_webhook_message": "Os seus webhooks aparecerão aqui assim que os adicionar. ⏲️",
|
||||
"endpoint_pinged": "Yay! Conseguimos aceder ao webhook!",
|
||||
"endpoint_pinged_error": "Não foi possível aceder ao webhook!",
|
||||
"learn_to_verify": "Aprenda a verificar assinaturas de webhook",
|
||||
"please_check_console": "Por favor, verifique a consola para mais detalhes",
|
||||
"please_enter_a_url": "Por favor, insira um URL",
|
||||
"response_created": "Resposta Criada",
|
||||
"response_finished": "Resposta Concluída",
|
||||
"response_updated": "Resposta Atualizada",
|
||||
"secret_copy_warning": "Armazene este segredo de forma segura. Pode visualizá-lo novamente nas definições do webhook.",
|
||||
"secret_description": "Use este segredo para verificar os pedidos do webhook. Consulte a documentação para verificação de assinatura.",
|
||||
"signing_secret": "Segredo de assinatura",
|
||||
"source": "Fonte",
|
||||
"test_endpoint": "Testar Endpoint",
|
||||
"triggers": "Disparadores",
|
||||
"webhook_added_successfully": "Webhook adicionado com sucesso",
|
||||
"webhook_created": "Webhook criado",
|
||||
"webhook_delete_confirmation": "Tem a certeza de que deseja eliminar este Webhook? Isto irá parar de lhe enviar quaisquer notificações futuras.",
|
||||
"webhook_deleted_successfully": "Webhook eliminado com sucesso",
|
||||
"webhook_name_placeholder": "Opcional: Rotule o seu webhook para fácil identificação",
|
||||
@@ -1008,6 +1019,8 @@
|
||||
"remove_logo": "Remover logótipo",
|
||||
"replace_logo": "Substituir logotipo",
|
||||
"resend_invitation_email": "Reenviar Email de Convite",
|
||||
"security_list_tip": "Está inscrito na nossa Lista de Segurança? Mantenha-se informado para manter a sua instância segura!",
|
||||
"security_list_tip_link": "Inscreva-se aqui.",
|
||||
"share_invite_link": "Partilhar Link de Convite",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Partilhe este link para permitir que o membro da sua organização se junte à sua organização:",
|
||||
"test_email_sent_successfully": "Email de teste enviado com sucesso",
|
||||
@@ -1169,6 +1182,9 @@
|
||||
"assign": "Atribuir =",
|
||||
"audience": "Público",
|
||||
"auto_close_on_inactivity": "Fechar automaticamente por inatividade",
|
||||
"auto_save_disabled": "Guardar automático desativado",
|
||||
"auto_save_disabled_tooltip": "O seu inquérito só é guardado automaticamente quando está em rascunho. Isto garante que os inquéritos públicos não sejam atualizados involuntariamente.",
|
||||
"auto_save_on": "Guardar automático ativado",
|
||||
"automatically_close_survey_after": "Fechar automaticamente o inquérito após",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Fechar automaticamente o inquérito após um certo número de respostas",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Fechar automaticamente o inquérito se o utilizador não responder após um certo número de segundos.",
|
||||
@@ -1190,6 +1206,8 @@
|
||||
"cal_username": "Nome de utilizador do Cal.com ou nome de utilizador/evento",
|
||||
"calculate": "Calcular",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Capturar uma nova ação para desencadear um inquérito.",
|
||||
"capture_ip_address": "Capturar endereço IP",
|
||||
"capture_ip_address_description": "Armazenar o endereço IP do inquirido nos metadados da resposta para deteção de duplicados e fins de segurança",
|
||||
"capture_new_action": "Capturar nova ação",
|
||||
"card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Inquéritos {surveyTypeDerived}",
|
||||
"card_background_color": "Cor de fundo do cartão",
|
||||
@@ -1448,6 +1466,7 @@
|
||||
"please_specify": "Por favor, especifique",
|
||||
"prevent_double_submission": "Impedir submissão dupla",
|
||||
"prevent_double_submission_description": "Permitir apenas 1 resposta por endereço de email",
|
||||
"progress_saved": "Progresso guardado",
|
||||
"protect_survey_with_pin": "Proteger inquérito com um PIN",
|
||||
"protect_survey_with_pin_description": "Apenas utilizadores com o PIN podem aceder ao inquérito.",
|
||||
"publish": "Publicar",
|
||||
@@ -1646,6 +1665,7 @@
|
||||
"error_downloading_responses": "Ocorreu um erro ao transferir as respostas",
|
||||
"first_name": "Primeiro Nome",
|
||||
"how_to_identify_users": "Como identificar utilizadores",
|
||||
"ip_address": "Endereço IP",
|
||||
"last_name": "Apelido",
|
||||
"not_completed": "Não Concluído ⏳",
|
||||
"os": "SO",
|
||||
|
||||
@@ -75,6 +75,10 @@
|
||||
"password_validation_uppercase_and_lowercase": "Amestec de majuscule și minuscule",
|
||||
"please_verify_captcha": "Vă rugăm să verificați CAPTCHA",
|
||||
"privacy_policy": "Politica de confidențialitate",
|
||||
"product_updates_description": "Noutăți lunare despre produse și actualizări de funcționalități; se aplică Politica de confidențialitate.",
|
||||
"product_updates_title": "Actualizări de produs",
|
||||
"security_updates_description": "Doar informații relevante pentru securitate; se aplică Politica de confidențialitate.",
|
||||
"security_updates_title": "Actualizări de securitate",
|
||||
"terms_of_service": "Termeni de utilizare a serviciului",
|
||||
"title": "Creați-vă contul Formbricks"
|
||||
},
|
||||
@@ -197,6 +201,7 @@
|
||||
"docs": "Documentație",
|
||||
"documentation": "Documentație",
|
||||
"domain": "Domeniu",
|
||||
"done": "Gata",
|
||||
"download": "Descărcare",
|
||||
"draft": "Schiță",
|
||||
"duplicate": "Duplicități",
|
||||
@@ -783,20 +788,26 @@
|
||||
"add_webhook": "Adaugă Webhook",
|
||||
"add_webhook_description": "Trimite datele de răspuns ale chestionarului la un punct final personalizat",
|
||||
"all_current_and_new_surveys": "Toate chestionarele curente și noi",
|
||||
"copy_secret_now": "Copiază secretul de semnare",
|
||||
"created_by_third_party": "Creat de o Parte Terță",
|
||||
"discord_webhook_not_supported": "Webhook-urile Discord nu sunt în prezent suportate.",
|
||||
"empty_webhook_message": "Webhook-urile tale vor apărea aici de îndată ce le vei adăuga. ⏲️",
|
||||
"endpoint_pinged": "Grozav! Am reușit să ping-ui webhooks-ul!",
|
||||
"endpoint_pinged_error": "Nu pot să ping-ui webhooks-ul!",
|
||||
"learn_to_verify": "Află cum să verifici semnăturile webhook",
|
||||
"please_check_console": "Vă rugăm să verificați consola pentru mai multe detalii",
|
||||
"please_enter_a_url": "Vă rugăm să introduceți un URL",
|
||||
"response_created": "Răspuns creat",
|
||||
"response_finished": "Răspuns finalizat",
|
||||
"response_updated": "Răspuns actualizat",
|
||||
"secret_copy_warning": "Păstrează acest secret în siguranță. Îl poți vizualiza din nou în setările webhook-ului.",
|
||||
"secret_description": "Folosește acest secret pentru a verifica cererile webhook. Vezi documentația pentru verificarea semnăturii.",
|
||||
"signing_secret": "Secret de semnare",
|
||||
"source": "Sursă",
|
||||
"test_endpoint": "Punct final de test",
|
||||
"triggers": "Declanșatori",
|
||||
"webhook_added_successfully": "Webhook adăugat cu succes",
|
||||
"webhook_created": "Webhook creat",
|
||||
"webhook_delete_confirmation": "Sigur doriți să ștergeți acest Webhook? Acest lucru va opri trimiterea oricăror notificări viitoare.",
|
||||
"webhook_deleted_successfully": "Webhook șters cu succes",
|
||||
"webhook_name_placeholder": "Opțional: Etichetează webhook-ul pentru identificare ușoară",
|
||||
@@ -1008,6 +1019,8 @@
|
||||
"remove_logo": "Înlătură siglă",
|
||||
"replace_logo": "Înlocuiește sigla",
|
||||
"resend_invitation_email": "Retrimite emailul de invitație",
|
||||
"security_list_tip": "Ești abonat la lista noastră de securitate? Rămâi informat pentru a-ți menține instanța în siguranță!",
|
||||
"security_list_tip_link": "Înscrie-te aici.",
|
||||
"share_invite_link": "Distribuie link-ul de invitație",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Distribuie acest link pentru a permite membrului organizației să se alăture organizației tale:",
|
||||
"test_email_sent_successfully": "Email de test trimis cu succes",
|
||||
@@ -1169,6 +1182,9 @@
|
||||
"assign": "Atribuire =",
|
||||
"audience": "Public",
|
||||
"auto_close_on_inactivity": "Închidere automată la inactivitate",
|
||||
"auto_save_disabled": "Salvare automată dezactivată",
|
||||
"auto_save_disabled_tooltip": "Chestionarul dvs. este salvat automat doar când este în ciornă. Acest lucru asigură că sondajele publice nu sunt actualizate neintenționat.",
|
||||
"auto_save_on": "Salvare automată activată",
|
||||
"automatically_close_survey_after": "Închideți automat sondajul după",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Închideți automat sondajul după un număr anumit de răspunsuri.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Închideți automat sondajul dacă utilizatorul nu răspunde după un anumit număr de secunde.",
|
||||
@@ -1190,6 +1206,8 @@
|
||||
"cal_username": "Utilizator Cal.com sau utilizator/eveniment",
|
||||
"calculate": "Calculați",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Capturează o acțiune nouă pentru a declanșa un sondaj.",
|
||||
"capture_ip_address": "Capturare adresă IP",
|
||||
"capture_ip_address_description": "Stochează adresa IP a respondentului în metadatele răspunsului pentru detectarea duplicatelor și în scopuri de securitate",
|
||||
"capture_new_action": "Capturați acțiune nouă",
|
||||
"card_arrangement_for_survey_type_derived": "Aranjament de carduri pentru sondaje de tip {surveyTypeDerived}",
|
||||
"card_background_color": "Culoarea de fundal a cardului",
|
||||
@@ -1448,6 +1466,7 @@
|
||||
"please_specify": "Vă rugăm să specificați",
|
||||
"prevent_double_submission": "Prevenire trimitere dublă",
|
||||
"prevent_double_submission_description": "Permite doar 1 răspuns per adresă de email.",
|
||||
"progress_saved": "Progres salvat",
|
||||
"protect_survey_with_pin": "Protejați sondajul cu un PIN",
|
||||
"protect_survey_with_pin_description": "Doar utilizatorii care cunosc PIN-ul pot accesa sondajul.",
|
||||
"publish": "Publică",
|
||||
@@ -1646,6 +1665,7 @@
|
||||
"error_downloading_responses": "A apărut o eroare la descărcarea răspunsurilor",
|
||||
"first_name": "Prenume",
|
||||
"how_to_identify_users": "Cum să identifici utilizatorii",
|
||||
"ip_address": "Adresă IP",
|
||||
"last_name": "Nume de familie",
|
||||
"not_completed": "Necompletat ⏳",
|
||||
"os": "SO",
|
||||
|
||||
@@ -75,6 +75,10 @@
|
||||
"password_validation_uppercase_and_lowercase": "Сочетание заглавных и строчных букв",
|
||||
"please_verify_captcha": "Пожалуйста, подтвердите reCAPTCHA",
|
||||
"privacy_policy": "Политика конфиденциальности",
|
||||
"product_updates_description": "Ежемесячные новости о продукте и обновления функций. Применяется Политика конфиденциальности.",
|
||||
"product_updates_title": "Обновления продукта",
|
||||
"security_updates_description": "Только важная информация по безопасности. Применяется Политика конфиденциальности.",
|
||||
"security_updates_title": "Обновления безопасности",
|
||||
"terms_of_service": "Условия использования",
|
||||
"title": "Создайте аккаунт Formbricks"
|
||||
},
|
||||
@@ -197,6 +201,7 @@
|
||||
"docs": "Документация",
|
||||
"documentation": "Документация",
|
||||
"domain": "Домен",
|
||||
"done": "Готово",
|
||||
"download": "Скачать",
|
||||
"draft": "Черновик",
|
||||
"duplicate": "Дублировать",
|
||||
@@ -783,20 +788,26 @@
|
||||
"add_webhook": "Добавить webhook",
|
||||
"add_webhook_description": "Отправляйте данные ответов на опрос на пользовательский endpoint",
|
||||
"all_current_and_new_surveys": "Все текущие и новые опросы",
|
||||
"copy_secret_now": "Скопируйте ваш секрет подписи",
|
||||
"created_by_third_party": "Создано сторонней организацией",
|
||||
"discord_webhook_not_supported": "В настоящее время webhooks Discord не поддерживаются.",
|
||||
"empty_webhook_message": "Ваши webhooks появятся здесь, как только вы их добавите. ⏲️",
|
||||
"endpoint_pinged": "Ура! Нам удалось отправить ping на webhook!",
|
||||
"endpoint_pinged_error": "Не удалось отправить ping на webhook!",
|
||||
"learn_to_verify": "Узнайте, как проверить подписи вебхуков",
|
||||
"please_check_console": "Пожалуйста, проверьте консоль для получения подробностей",
|
||||
"please_enter_a_url": "Пожалуйста, введите URL",
|
||||
"response_created": "Ответ создан",
|
||||
"response_finished": "Ответ завершён",
|
||||
"response_updated": "Ответ обновлён",
|
||||
"secret_copy_warning": "Храните этот секрет в надёжном месте. Вы сможете просмотреть его снова в настройках webhook.",
|
||||
"secret_description": "Используйте этот секрет для проверки запросов webhook. Подробнее о проверке подписи — в документации.",
|
||||
"signing_secret": "Секрет подписи",
|
||||
"source": "Источник",
|
||||
"test_endpoint": "Тестировать endpoint",
|
||||
"triggers": "Триггеры",
|
||||
"webhook_added_successfully": "Webhook успешно добавлен",
|
||||
"webhook_created": "Webhook создан",
|
||||
"webhook_delete_confirmation": "Вы уверены, что хотите удалить этот webhook? Это прекратит отправку вам любых дальнейших уведомлений.",
|
||||
"webhook_deleted_successfully": "Webhook успешно удалён",
|
||||
"webhook_name_placeholder": "Необязательно: дайте метку вашему webhook для удобной идентификации",
|
||||
@@ -1008,6 +1019,8 @@
|
||||
"remove_logo": "Удалить логотип",
|
||||
"replace_logo": "Заменить логотип",
|
||||
"resend_invitation_email": "Отправить приглашение повторно",
|
||||
"security_list_tip": "Вы подписаны на нашу рассылку по безопасности? Будьте в курсе, чтобы обезопасить свой экземпляр!",
|
||||
"security_list_tip_link": "Зарегистрируйтесь здесь.",
|
||||
"share_invite_link": "Поделиться ссылкой-приглашением",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Поделитесь этой ссылкой, чтобы участник вашей организации мог присоединиться к ней:",
|
||||
"test_email_sent_successfully": "Тестовое письмо успешно отправлено",
|
||||
@@ -1169,6 +1182,9 @@
|
||||
"assign": "Назначить =",
|
||||
"audience": "Аудитория",
|
||||
"auto_close_on_inactivity": "Автоматически закрывать при бездействии",
|
||||
"auto_save_disabled": "Автосохранение отключено",
|
||||
"auto_save_disabled_tooltip": "Ваш опрос автоматически сохраняется только в режиме черновика. Это гарантирует, что публичные опросы не будут случайно обновлены.",
|
||||
"auto_save_on": "Автосохранение включено",
|
||||
"automatically_close_survey_after": "Автоматически закрыть опрос через",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Автоматически закрывать опрос после определённого количества ответов.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Автоматически закрывать опрос, если пользователь не ответил за определённое количество секунд.",
|
||||
@@ -1190,6 +1206,8 @@
|
||||
"cal_username": "Имя пользователя Cal.com или username/event",
|
||||
"calculate": "Вычислить",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Захватить новое действие для запуска опроса.",
|
||||
"capture_ip_address": "Сохранять IP-адрес",
|
||||
"capture_ip_address_description": "Сохранять IP-адрес респондента в метаданных ответа для обнаружения дубликатов и обеспечения безопасности",
|
||||
"capture_new_action": "Захватить новое действие",
|
||||
"card_arrangement_for_survey_type_derived": "Расположение карточек для опросов типа {surveyTypeDerived}",
|
||||
"card_background_color": "Цвет фона карточки",
|
||||
@@ -1448,6 +1466,7 @@
|
||||
"please_specify": "Пожалуйста, уточните",
|
||||
"prevent_double_submission": "Предотвратить повторную отправку",
|
||||
"prevent_double_submission_description": "Разрешить только 1 ответ на один адрес электронной почты",
|
||||
"progress_saved": "Прогресс сохранён",
|
||||
"protect_survey_with_pin": "Защитить опрос с помощью PIN-кода",
|
||||
"protect_survey_with_pin_description": "Только пользователи, у которых есть PIN-код, могут получить доступ к опросу.",
|
||||
"publish": "Опубликовать",
|
||||
@@ -1646,6 +1665,7 @@
|
||||
"error_downloading_responses": "Произошла ошибка при загрузке ответов",
|
||||
"first_name": "Имя",
|
||||
"how_to_identify_users": "Как идентифицировать пользователей",
|
||||
"ip_address": "IP-адрес",
|
||||
"last_name": "Фамилия",
|
||||
"not_completed": "Не завершено ⏳",
|
||||
"os": "ОС",
|
||||
|
||||
@@ -75,6 +75,10 @@
|
||||
"password_validation_uppercase_and_lowercase": "Blandning av stora och små bokstäver",
|
||||
"please_verify_captcha": "Vänligen verifiera reCAPTCHA",
|
||||
"privacy_policy": "Integritetspolicy",
|
||||
"product_updates_description": "Månatliga produktnyheter och funktionsuppdateringar. Integritetspolicyn gäller.",
|
||||
"product_updates_title": "Produktuppdateringar",
|
||||
"security_updates_description": "Endast säkerhetsrelaterad information. Integritetspolicyn gäller.",
|
||||
"security_updates_title": "Säkerhetsuppdateringar",
|
||||
"terms_of_service": "Användarvillkor",
|
||||
"title": "Skapa ditt Formbricks-konto"
|
||||
},
|
||||
@@ -197,6 +201,7 @@
|
||||
"docs": "Dokumentation",
|
||||
"documentation": "Dokumentation",
|
||||
"domain": "Domän",
|
||||
"done": "Klar",
|
||||
"download": "Ladda ner",
|
||||
"draft": "Utkast",
|
||||
"duplicate": "Duplicera",
|
||||
@@ -783,20 +788,26 @@
|
||||
"add_webhook": "Lägg till webhook",
|
||||
"add_webhook_description": "Skicka enkätsvardata till en anpassad endpoint",
|
||||
"all_current_and_new_surveys": "Alla nuvarande och nya enkäter",
|
||||
"copy_secret_now": "Kopiera din signeringsnyckel",
|
||||
"created_by_third_party": "Skapad av tredje part",
|
||||
"discord_webhook_not_supported": "Discord-webhooks stöds för närvarande inte.",
|
||||
"empty_webhook_message": "Dina webhooks visas här så snart du lägger till dem. ⏲️",
|
||||
"endpoint_pinged": "Ja! Vi kan nå webhooken!",
|
||||
"endpoint_pinged_error": "Kunde inte nå webhooken!",
|
||||
"learn_to_verify": "Lär dig hur du verifierar webhook-signaturer",
|
||||
"please_check_console": "Vänligen kontrollera konsolen för mer information",
|
||||
"please_enter_a_url": "Vänligen ange en URL",
|
||||
"response_created": "Svar skapat",
|
||||
"response_finished": "Svar slutfört",
|
||||
"response_updated": "Svar uppdaterat",
|
||||
"secret_copy_warning": "Förvara denna nyckel säkert. Du kan visa den igen i webhook-inställningarna.",
|
||||
"secret_description": "Använd denna nyckel för att verifiera webhook-förfrågningar. Se dokumentationen för signaturverifiering.",
|
||||
"signing_secret": "Signeringsnyckel",
|
||||
"source": "Källa",
|
||||
"test_endpoint": "Testa endpoint",
|
||||
"triggers": "Utlösare",
|
||||
"webhook_added_successfully": "Webhook tillagd",
|
||||
"webhook_created": "Webhook skapad",
|
||||
"webhook_delete_confirmation": "Är du säker på att du vill ta bort denna webhook? Detta kommer att stoppa alla ytterligare notifieringar.",
|
||||
"webhook_deleted_successfully": "Webhook borttagen",
|
||||
"webhook_name_placeholder": "Valfritt: Namnge din webhook för enkel identifiering",
|
||||
@@ -1008,6 +1019,8 @@
|
||||
"remove_logo": "Ta bort logotyp",
|
||||
"replace_logo": "Ersätt logotyp",
|
||||
"resend_invitation_email": "Skicka inbjudningsmejl igen",
|
||||
"security_list_tip": "Är du med på vår säkerhetslista? Håll dig informerad för att skydda din instans!",
|
||||
"security_list_tip_link": "Registrera dig här.",
|
||||
"share_invite_link": "Dela inbjudningslänk",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "Dela denna länk för att låta din organisationsmedlem gå med i din organisation:",
|
||||
"test_email_sent_successfully": "Test-e-post skickat",
|
||||
@@ -1169,6 +1182,9 @@
|
||||
"assign": "Tilldela =",
|
||||
"audience": "Målgrupp",
|
||||
"auto_close_on_inactivity": "Stäng automatiskt vid inaktivitet",
|
||||
"auto_save_disabled": "Automatisk sparning inaktiverad",
|
||||
"auto_save_disabled_tooltip": "Din enkät sparas endast automatiskt när den är ett utkast. Detta säkerställer att publika enkäter inte uppdateras oavsiktligt.",
|
||||
"auto_save_on": "Automatisk sparning på",
|
||||
"automatically_close_survey_after": "Stäng enkäten automatiskt efter",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "Stäng enkäten automatiskt efter ett visst antal svar.",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "Stäng enkäten automatiskt om användaren inte svarar efter ett visst antal sekunder.",
|
||||
@@ -1190,6 +1206,8 @@
|
||||
"cal_username": "Cal.com-användarnamn eller användarnamn/händelse",
|
||||
"calculate": "Beräkna",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "Fånga en ny åtgärd att utlösa en enkät på.",
|
||||
"capture_ip_address": "Registrera IP-adress",
|
||||
"capture_ip_address_description": "Spara respondentens IP-adress i svarsmetadatan för att upptäcka dubbletter och av säkerhetsskäl",
|
||||
"capture_new_action": "Fånga ny åtgärd",
|
||||
"card_arrangement_for_survey_type_derived": "Kortarrangemang för {surveyTypeDerived}-enkäter",
|
||||
"card_background_color": "Kortets bakgrundsfärg",
|
||||
@@ -1448,6 +1466,7 @@
|
||||
"please_specify": "Vänligen specificera",
|
||||
"prevent_double_submission": "Förhindra dubbelinskickning",
|
||||
"prevent_double_submission_description": "Tillåt endast 1 svar per e-postadress",
|
||||
"progress_saved": "Framsteg sparade",
|
||||
"protect_survey_with_pin": "Skydda enkäten med en PIN",
|
||||
"protect_survey_with_pin_description": "Endast användare som har PIN-koden kan komma åt enkäten.",
|
||||
"publish": "Publicera",
|
||||
@@ -1646,6 +1665,7 @@
|
||||
"error_downloading_responses": "Ett fel uppstod vid nedladdning av svar",
|
||||
"first_name": "Förnamn",
|
||||
"how_to_identify_users": "Hur man identifierar användare",
|
||||
"ip_address": "IP-adress",
|
||||
"last_name": "Efternamn",
|
||||
"not_completed": "Inte slutförd ⏳",
|
||||
"os": "OS",
|
||||
|
||||
@@ -75,6 +75,10 @@
|
||||
"password_validation_uppercase_and_lowercase": "大小写混合",
|
||||
"please_verify_captcha": "请 验证 reCAPTCHA",
|
||||
"privacy_policy": "隐私政策",
|
||||
"product_updates_description": "每月产品新闻和功能更新,适用隐私政策。",
|
||||
"product_updates_title": "产品更新",
|
||||
"security_updates_description": "仅限安全相关信息,适用隐私政策。",
|
||||
"security_updates_title": "安全更新",
|
||||
"terms_of_service": "服务条款",
|
||||
"title": "创建你的 Formbricks 账户"
|
||||
},
|
||||
@@ -197,6 +201,7 @@
|
||||
"docs": "文档",
|
||||
"documentation": "文档",
|
||||
"domain": "域名",
|
||||
"done": "完成",
|
||||
"download": "下载",
|
||||
"draft": "草稿",
|
||||
"duplicate": "复制",
|
||||
@@ -783,20 +788,26 @@
|
||||
"add_webhook": "添加 Webhook",
|
||||
"add_webhook_description": "发送 调查 响应 数据 到 自定义 端点",
|
||||
"all_current_and_new_surveys": "所有 当前 和 新的 调查",
|
||||
"copy_secret_now": "复制您的签名密钥",
|
||||
"created_by_third_party": "由 第三方 创建",
|
||||
"discord_webhook_not_supported": "Discord webhooks 目前不 支持。",
|
||||
"empty_webhook_message": "您的 Webhooks 会在您 添加 后 出现在这里。 ⏲️",
|
||||
"endpoint_pinged": "太好了! 我们能 ping 该 webhook!",
|
||||
"endpoint_pinged_error": "无法 ping 该 webhook!",
|
||||
"learn_to_verify": "了解如何验证 webhook 签名",
|
||||
"please_check_console": "请查看控制台以获取更多详情",
|
||||
"please_enter_a_url": "请输入一个 URL",
|
||||
"response_created": "创建 响应",
|
||||
"response_finished": "响应 完成",
|
||||
"response_updated": "更新 响应",
|
||||
"secret_copy_warning": "请妥善保存此密钥。您可以在 Webhook 设置中再次查看。",
|
||||
"secret_description": "使用此密钥验证 Webhook 请求。有关签名验证,请参阅文档。",
|
||||
"signing_secret": "签名密钥",
|
||||
"source": "来源",
|
||||
"test_endpoint": "测试 端点",
|
||||
"triggers": "触发器",
|
||||
"webhook_added_successfully": "Webhook 添加成功",
|
||||
"webhook_created": "Webhook 已创建",
|
||||
"webhook_delete_confirmation": "您 确定 要 删除 此 Webhook 吗?这 将 停止 向 您 发送 更多 通知 。",
|
||||
"webhook_deleted_successfully": "Webhook 删除 成功",
|
||||
"webhook_name_placeholder": "可选 : 为 您的 Webhook 标注 标签 以 便于 识别",
|
||||
@@ -1008,6 +1019,8 @@
|
||||
"remove_logo": "移除 logo",
|
||||
"replace_logo": "替换 logo",
|
||||
"resend_invitation_email": "重新发送邀请邮件",
|
||||
"security_list_tip": "您已订阅我们的安全列表了吗?保持关注,保障您的实例安全!",
|
||||
"security_list_tip_link": "点击此处注册。",
|
||||
"share_invite_link": "分享邀请链接",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "分享 这个 链接 以 让 你的 组织 成员 加入 你的 组织:",
|
||||
"test_email_sent_successfully": "测试 邮件 发送 成功",
|
||||
@@ -1169,6 +1182,9 @@
|
||||
"assign": "指派 =",
|
||||
"audience": "受众",
|
||||
"auto_close_on_inactivity": "自动关闭 在 无活动时",
|
||||
"auto_save_disabled": "自动保存已禁用",
|
||||
"auto_save_disabled_tooltip": "您的调查仅在草稿状态时自动保存。这确保公开的调查不会被意外更新。",
|
||||
"auto_save_on": "自动保存已启用",
|
||||
"automatically_close_survey_after": "自动 关闭 调查 后",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "自动 关闭 调查 在 达到 一定数量 的 回应 后",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "用户未在一定秒数内应答时 自动关闭 问卷",
|
||||
@@ -1190,6 +1206,8 @@
|
||||
"cal_username": "Cal.com 用户名 或 用户名/事件",
|
||||
"calculate": "计算",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "捕获一个新动作以触发调查。",
|
||||
"capture_ip_address": "记录IP地址",
|
||||
"capture_ip_address_description": "将答题者的IP地址存储在响应元数据中,用于重复检测和安全目的",
|
||||
"capture_new_action": "捕获 新动作",
|
||||
"card_arrangement_for_survey_type_derived": "{surveyTypeDerived} 调查 的 卡片 布局",
|
||||
"card_background_color": "卡片 的 背景 颜色",
|
||||
@@ -1448,6 +1466,7 @@
|
||||
"please_specify": "请 指定",
|
||||
"prevent_double_submission": "防止 重复 提交",
|
||||
"prevent_double_submission_description": "只允许每个 email 地址提供 1 个回复",
|
||||
"progress_saved": "进度已保存",
|
||||
"protect_survey_with_pin": "使用 PIN 保护 调查",
|
||||
"protect_survey_with_pin_description": "只有 拥有 PIN 的 用户 可以 访问 调查。",
|
||||
"publish": "发布",
|
||||
@@ -1646,6 +1665,7 @@
|
||||
"error_downloading_responses": "下载答复时发生错误",
|
||||
"first_name": "名字",
|
||||
"how_to_identify_users": "如何 识别 用户",
|
||||
"ip_address": "IP地址",
|
||||
"last_name": "姓",
|
||||
"not_completed": "未完成 ⏳",
|
||||
"os": "操作系统",
|
||||
|
||||
@@ -75,6 +75,10 @@
|
||||
"password_validation_uppercase_and_lowercase": "混合使用大小寫字母",
|
||||
"please_verify_captcha": "請驗證 reCAPTCHA",
|
||||
"privacy_policy": "隱私權政策",
|
||||
"product_updates_description": "每月產品新聞與功能更新,適用隱私權政策。",
|
||||
"product_updates_title": "產品更新",
|
||||
"security_updates_description": "僅限安全相關資訊,適用隱私權政策。",
|
||||
"security_updates_title": "安全更新",
|
||||
"terms_of_service": "服務條款",
|
||||
"title": "建立您的 Formbricks 帳戶"
|
||||
},
|
||||
@@ -197,6 +201,7 @@
|
||||
"docs": "文件",
|
||||
"documentation": "文件",
|
||||
"domain": "網域",
|
||||
"done": "完成",
|
||||
"download": "下載",
|
||||
"draft": "草稿",
|
||||
"duplicate": "複製",
|
||||
@@ -783,20 +788,26 @@
|
||||
"add_webhook": "新增 Webhook",
|
||||
"add_webhook_description": "將問卷回應資料傳送至自訂端點",
|
||||
"all_current_and_new_surveys": "所有目前和新的問卷",
|
||||
"copy_secret_now": "複製您的簽章密鑰",
|
||||
"created_by_third_party": "由第三方建立",
|
||||
"discord_webhook_not_supported": "目前不支援 Discord webhooks。",
|
||||
"empty_webhook_message": "您的 Webhook 將在您新增後立即顯示在此處。⏲️",
|
||||
"endpoint_pinged": "耶!我們能夠 ping Webhook!",
|
||||
"endpoint_pinged_error": "無法 ping Webhook!",
|
||||
"learn_to_verify": "了解如何驗證 webhook 簽章",
|
||||
"please_check_console": "請檢查主控台以取得更多詳細資料",
|
||||
"please_enter_a_url": "請輸入網址",
|
||||
"response_created": "已建立回應",
|
||||
"response_finished": "已完成回應",
|
||||
"response_updated": "已更新回應",
|
||||
"secret_copy_warning": "請妥善保存此密鑰。您可以在 Webhook 設定中再次查看。",
|
||||
"secret_description": "使用此密鑰來驗證 Webhook 請求。請參閱文件以了解簽章驗證方式。",
|
||||
"signing_secret": "簽章密鑰",
|
||||
"source": "來源",
|
||||
"test_endpoint": "測試端點",
|
||||
"triggers": "觸發器",
|
||||
"webhook_added_successfully": "Webhook 已成功新增",
|
||||
"webhook_created": "Webhook 已建立",
|
||||
"webhook_delete_confirmation": "您確定要刪除此 Webhook 嗎?這將停止向您發送任何進一步的通知。",
|
||||
"webhook_deleted_successfully": "Webhook 已成功刪除",
|
||||
"webhook_name_placeholder": "選填:為您的 Webhook 加上標籤以便於識別",
|
||||
@@ -1008,6 +1019,8 @@
|
||||
"remove_logo": "移除標誌",
|
||||
"replace_logo": "取代標誌",
|
||||
"resend_invitation_email": "重新發送邀請電子郵件",
|
||||
"security_list_tip": "您已訂閱我們的安全名單了嗎?保持關注,確保您的實例安全!",
|
||||
"security_list_tip_link": "請在此註冊。",
|
||||
"share_invite_link": "分享邀請連結",
|
||||
"share_this_link_to_let_your_organization_member_join_your_organization": "分享此連結以讓您的組織成員加入您的組織:",
|
||||
"test_email_sent_successfully": "測試電子郵件已成功發送",
|
||||
@@ -1169,6 +1182,9 @@
|
||||
"assign": "等於 =",
|
||||
"audience": "受眾",
|
||||
"auto_close_on_inactivity": "非活動時自動關閉",
|
||||
"auto_save_disabled": "自動儲存已停用",
|
||||
"auto_save_disabled_tooltip": "您的問卷僅在草稿狀態時自動儲存。這確保公開的問卷不會被意外更新。",
|
||||
"auto_save_on": "自動儲存已啟用",
|
||||
"automatically_close_survey_after": "在指定時間自動關閉問卷",
|
||||
"automatically_close_the_survey_after_a_certain_number_of_responses": "在收到一定數量的回覆後自動關閉問卷。",
|
||||
"automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds": "如果用戶在特定秒數後未回應,則自動關閉問卷。",
|
||||
@@ -1190,6 +1206,8 @@
|
||||
"cal_username": "Cal.com 使用者名稱或使用者名稱/事件",
|
||||
"calculate": "計算",
|
||||
"capture_a_new_action_to_trigger_a_survey_on": "擷取新的操作以觸發問卷。",
|
||||
"capture_ip_address": "擷取 IP 位址",
|
||||
"capture_ip_address_description": "將受訪者的 IP 位址儲存在回應中繼資料中,以便進行重複檢測與安全性用途",
|
||||
"capture_new_action": "擷取新操作",
|
||||
"card_arrangement_for_survey_type_derived": "'{'surveyTypeDerived'}' 問卷的卡片排列",
|
||||
"card_background_color": "卡片背景顏色",
|
||||
@@ -1448,6 +1466,7 @@
|
||||
"please_specify": "請指定",
|
||||
"prevent_double_submission": "防止重複提交",
|
||||
"prevent_double_submission_description": "每個電子郵件地址僅允許 1 個回應",
|
||||
"progress_saved": "進度已儲存",
|
||||
"protect_survey_with_pin": "使用 PIN 碼保護問卷",
|
||||
"protect_survey_with_pin_description": "只有擁有 PIN 碼的使用者才能存取問卷。",
|
||||
"publish": "發布",
|
||||
@@ -1646,6 +1665,7 @@
|
||||
"error_downloading_responses": "下載回應時發生錯誤",
|
||||
"first_name": "名字",
|
||||
"how_to_identify_users": "如何識別使用者",
|
||||
"ip_address": "IP 位址",
|
||||
"last_name": "姓氏",
|
||||
"not_completed": "未完成 ⏳",
|
||||
"os": "作業系統",
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Languages } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { getLanguageLabel } from "@formbricks/i18n-utils/src/utils";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { getEnabledLanguages } from "@/lib/i18n/utils";
|
||||
import { useClickOutside } from "@/lib/utils/hooks/useClickOutside";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
|
||||
interface LanguageDropdownProps {
|
||||
survey: TSurvey;
|
||||
@@ -14,38 +18,31 @@ interface LanguageDropdownProps {
|
||||
}
|
||||
|
||||
export const LanguageDropdown = ({ survey, setLanguage, locale }: LanguageDropdownProps) => {
|
||||
const [showLanguageSelect, setShowLanguageSelect] = useState(false);
|
||||
const containerRef = useRef(null);
|
||||
const enabledLanguages = getEnabledLanguages(survey.languages ?? []);
|
||||
|
||||
useClickOutside(containerRef, () => setShowLanguageSelect(false));
|
||||
if (enabledLanguages.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
enabledLanguages.length > 1 && (
|
||||
<div className="relative" ref={containerRef}>
|
||||
{showLanguageSelect && (
|
||||
<div className="absolute top-12 z-30 max-h-64 max-w-48 overflow-auto rounded-lg border bg-slate-900 p-1 text-sm text-white">
|
||||
{enabledLanguages.map((surveyLanguage) => (
|
||||
<button
|
||||
key={surveyLanguage.language.code}
|
||||
className="w-full truncate rounded-md p-2 text-start hover:cursor-pointer hover:bg-slate-700"
|
||||
onClick={() => {
|
||||
setLanguage(surveyLanguage.language.code);
|
||||
setShowLanguageSelect(false);
|
||||
}}>
|
||||
{getLanguageLabel(surveyLanguage.language.code, locale)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
title="Select Language"
|
||||
aria-label="Select Language"
|
||||
onClick={() => setShowLanguageSelect(!showLanguageSelect)}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="secondary" title="Select Language" aria-label="Select Language">
|
||||
<Languages className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="max-h-64 max-w-48 overflow-auto bg-slate-900 p-1 text-sm text-white"
|
||||
align="start">
|
||||
{enabledLanguages.map((surveyLanguage) => (
|
||||
<DropdownMenuItem
|
||||
key={surveyLanguage.language.code}
|
||||
className="w-full truncate rounded-md p-2 text-start text-white hover:cursor-pointer hover:bg-slate-700 focus:bg-slate-700"
|
||||
onSelect={() => setLanguage(surveyLanguage.language.code)}>
|
||||
{getLanguageLabel(surveyLanguage.language.code, locale)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -123,6 +123,11 @@ export const SingleResponseCardMetadata = ({ response, locale }: SingleResponseC
|
||||
{t("environments.surveys.responses.country")}: {response.meta.country}
|
||||
</p>
|
||||
)}
|
||||
{response.meta.ipAddress && (
|
||||
<p className="truncate" title={`IP Address: ${response.meta.ipAddress}`}>
|
||||
{t("environments.surveys.responses.ip_address")}: {response.meta.ipAddress}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { buildCommonFilterQuery } from "./utils";
|
||||
|
||||
describe("buildCommonFilterQuery", () => {
|
||||
// Test for line 32: spread existing date filter when adding startDate
|
||||
it("should preserve existing date filter when adding startDate", () => {
|
||||
test("should preserve existing date filter when adding startDate", () => {
|
||||
const query: Prisma.ResponseFindManyArgs = {
|
||||
where: {
|
||||
createdAt: {
|
||||
@@ -23,7 +23,7 @@ describe("buildCommonFilterQuery", () => {
|
||||
});
|
||||
|
||||
// Test for line 45: spread existing date filter when adding endDate
|
||||
it("should preserve existing date filter when adding endDate", () => {
|
||||
test("should preserve existing date filter when adding endDate", () => {
|
||||
const query: Prisma.ResponseFindManyArgs = {
|
||||
where: {
|
||||
createdAt: {
|
||||
|
||||
@@ -21,6 +21,7 @@ export const ZWebhookUpdateSchema = ZWebhook.omit({
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
environmentId: true,
|
||||
secret: true,
|
||||
}).openapi({
|
||||
ref: "webhookUpdate",
|
||||
description: "A webhook to update.",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Prisma, Webhook } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
import { generateWebhookSecret } from "@/lib/crypto";
|
||||
import { getWebhooksQuery } from "@/modules/api/v2/management/webhooks/lib/utils";
|
||||
import { TGetWebhooksFilter, TWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
@@ -49,6 +50,8 @@ export const createWebhook = async (webhook: TWebhookInput): Promise<Result<Webh
|
||||
const { environmentId, name, url, source, triggers, surveyIds } = webhook;
|
||||
|
||||
try {
|
||||
const secret = generateWebhookSecret();
|
||||
|
||||
const prismaData: Prisma.WebhookCreateInput = {
|
||||
environment: {
|
||||
connect: {
|
||||
@@ -60,6 +63,7 @@ export const createWebhook = async (webhook: TWebhookInput): Promise<Result<Webh
|
||||
source,
|
||||
triggers,
|
||||
surveyIds,
|
||||
secret,
|
||||
};
|
||||
|
||||
const createdWebhook = await prisma.webhook.create({
|
||||
|
||||
@@ -18,6 +18,7 @@ import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { subscribeUserToMailingList } from "@/modules/ee/mailing/lib/mailing-subscription";
|
||||
import { sendInviteAcceptedEmail, sendVerificationEmail } from "@/modules/email";
|
||||
|
||||
const ZCreatedUser = ZUser.pick({
|
||||
@@ -44,6 +45,9 @@ const ZCreateUserAction = z.object({
|
||||
(token) => !IS_TURNSTILE_CONFIGURED || (IS_TURNSTILE_CONFIGURED && token),
|
||||
"CAPTCHA verification required"
|
||||
),
|
||||
isFormbricksCloud: z.boolean(),
|
||||
subscribeToSecurityUpdates: z.boolean().optional(),
|
||||
subscribeToProductUpdates: z.boolean().optional(),
|
||||
});
|
||||
|
||||
async function verifyTurnstileIfConfigured(turnstileToken: string | undefined): Promise<void> {
|
||||
@@ -191,6 +195,13 @@ export const createUserAction = actionClient.schema(ZCreateUserAction).action(
|
||||
parsedInput.inviteToken,
|
||||
parsedInput.emailVerificationDisabled
|
||||
);
|
||||
|
||||
await subscribeUserToMailingList({
|
||||
email: user.email,
|
||||
isFormbricksCloud: parsedInput.isFormbricksCloud,
|
||||
subscribeToSecurityUpdates: parsedInput.subscribeToSecurityUpdates,
|
||||
subscribeToProductUpdates: parsedInput.subscribeToProductUpdates,
|
||||
});
|
||||
}
|
||||
|
||||
if (user) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { createUserAction } from "@/modules/auth/signup/actions";
|
||||
import { TermsPrivacyLinks } from "@/modules/auth/signup/components/terms-privacy-links";
|
||||
import { SSOOptions } from "@/modules/ee/sso/components/sso-options";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Checkbox } from "@/modules/ui/components/checkbox";
|
||||
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { PasswordInput } from "@/modules/ui/components/password-input";
|
||||
@@ -48,6 +49,7 @@ interface SignupFormProps {
|
||||
samlTenant: string;
|
||||
samlProduct: string;
|
||||
turnstileSiteKey?: string;
|
||||
isFormbricksCloud: boolean;
|
||||
}
|
||||
|
||||
export const SignupForm = ({
|
||||
@@ -69,6 +71,7 @@ export const SignupForm = ({
|
||||
samlTenant,
|
||||
samlProduct,
|
||||
turnstileSiteKey,
|
||||
isFormbricksCloud,
|
||||
}: SignupFormProps) => {
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
const searchParams = useSearchParams();
|
||||
@@ -76,6 +79,8 @@ export const SignupForm = ({
|
||||
const inviteToken = searchParams?.get("inviteToken");
|
||||
const router = useRouter();
|
||||
const [turnstileToken, setTurnstileToken] = useState<string>();
|
||||
const [subscribeToSecurityUpdates, setSubscribeToSecurityUpdates] = useState(false);
|
||||
const [subscribeToProductUpdates, setSubscribeToProductUpdates] = useState(false);
|
||||
|
||||
const turnstile = useTurnstile();
|
||||
|
||||
@@ -110,6 +115,9 @@ export const SignupForm = ({
|
||||
inviteToken: inviteToken ?? "",
|
||||
emailVerificationDisabled,
|
||||
turnstileToken,
|
||||
isFormbricksCloud,
|
||||
subscribeToSecurityUpdates,
|
||||
subscribeToProductUpdates,
|
||||
});
|
||||
|
||||
const emailTokenActionResponse = await createEmailTokenAction({ email: data.email });
|
||||
@@ -239,6 +247,43 @@ export const SignupForm = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
{showLogin &&
|
||||
(isFormbricksCloud ? (
|
||||
<label
|
||||
htmlFor="product-updates"
|
||||
className="my-4 flex cursor-pointer space-x-2 rounded-md border border-slate-200 bg-slate-100 p-2 text-left">
|
||||
<Checkbox
|
||||
id="product-updates"
|
||||
checked={subscribeToProductUpdates}
|
||||
onCheckedChange={(checked) => setSubscribeToProductUpdates(checked === true)}
|
||||
className="mt-0.5 h-4 w-4"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-slate-700">
|
||||
{t("auth.signup.product_updates_title")}
|
||||
</span>
|
||||
<p className="text-xs text-slate-500">{t("auth.signup.product_updates_description")}</p>
|
||||
</div>
|
||||
</label>
|
||||
) : (
|
||||
<label
|
||||
htmlFor="security-updates"
|
||||
className="my-4 flex cursor-pointer space-x-2 rounded-md border border-slate-200 bg-slate-100 p-2 text-left">
|
||||
<Checkbox
|
||||
id="security-updates"
|
||||
checked={subscribeToSecurityUpdates}
|
||||
onCheckedChange={(checked) => setSubscribeToSecurityUpdates(checked === true)}
|
||||
className="mt-0.5 h-4 w-4"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-slate-700">
|
||||
{t("auth.signup.security_updates_title")}
|
||||
</span>
|
||||
<p className="text-xs text-slate-500">{t("auth.signup.security_updates_description")}</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
|
||||
{showLogin && (
|
||||
<Button
|
||||
data-testid="signup-submit"
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
EMAIL_VERIFICATION_DISABLED,
|
||||
GITHUB_OAUTH_ENABLED,
|
||||
GOOGLE_OAUTH_ENABLED,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
IS_TURNSTILE_CONFIGURED,
|
||||
OIDC_DISPLAY_NAME,
|
||||
OIDC_OAUTH_ENABLED,
|
||||
@@ -55,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}
|
||||
@@ -76,6 +77,7 @@ export const SignupPage = async ({ searchParams: searchParamsProps }) => {
|
||||
samlTenant={SAML_TENANT}
|
||||
samlProduct={SAML_PRODUCT}
|
||||
turnstileSiteKey={TURNSTILE_SITE_KEY}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
/>
|
||||
</FormWrapper>
|
||||
</div>
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -30,6 +30,7 @@ const CONFIG = {
|
||||
env.ENVIRONMENT === "staging"
|
||||
? "https://staging.ee.formbricks.com/api/licenses/check"
|
||||
: "https://ee.formbricks.com/api/licenses/check",
|
||||
// ENDPOINT: "https://localhost:8080/api/licenses/check",
|
||||
TIMEOUT_MS: 5000,
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { subscribeToMailingList, subscribeUserToMailingList } from "./mailing-subscription";
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
|
||||
globalThis.fetch = vi.fn();
|
||||
|
||||
describe("subscribeToMailingList", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("should successfully subscribe to security mailing list", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||
|
||||
const result = await subscribeToMailingList({
|
||||
email: "test@example.com",
|
||||
listId: "security",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"https://ee.formbricks.com/api/v1/public/mailing/security/subscriptions",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "test@example.com" }),
|
||||
})
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
{ listId: "security" },
|
||||
"Successfully subscribed to security mailing list"
|
||||
);
|
||||
});
|
||||
|
||||
test("should successfully subscribe to product-updates mailing list", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||
|
||||
const result = await subscribeToMailingList({
|
||||
email: "test@example.com",
|
||||
listId: "product-updates",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"https://ee.formbricks.com/api/v1/public/mailing/product-updates/subscriptions",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: "test@example.com" }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should return error when API returns non-ok response", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(
|
||||
new Response("Bad Request", { status: 400, statusText: "Bad Request" })
|
||||
);
|
||||
|
||||
const result = await subscribeToMailingList({
|
||||
email: "test@example.com",
|
||||
listId: "security",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: false, error: "Failed to subscribe: 400" });
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ status: 400, error: "Bad Request" },
|
||||
"Failed to subscribe to security mailing list"
|
||||
);
|
||||
});
|
||||
|
||||
test("should return error when fetch throws an error", async () => {
|
||||
vi.mocked(globalThis.fetch).mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
const result = await subscribeToMailingList({
|
||||
email: "test@example.com",
|
||||
listId: "security",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: false, error: "Failed to subscribe to mailing list" });
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
"Error subscribing to security mailing list"
|
||||
);
|
||||
});
|
||||
|
||||
test("should return timeout error when request times out", async () => {
|
||||
const abortError = new Error("Aborted");
|
||||
abortError.name = "AbortError";
|
||||
vi.mocked(globalThis.fetch).mockRejectedValueOnce(abortError);
|
||||
|
||||
const result = await subscribeToMailingList({
|
||||
email: "test@example.com",
|
||||
listId: "security",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: false, error: "Request timed out" });
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
{ listId: "security" },
|
||||
"Mailing subscription request timed out"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("subscribeUserToMailingList", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should subscribe to product-updates when isFormbricksCloud is true and subscribeToProductUpdates is true", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||
|
||||
await subscribeUserToMailingList({
|
||||
email: "test@example.com",
|
||||
isFormbricksCloud: true,
|
||||
subscribeToProductUpdates: true,
|
||||
subscribeToSecurityUpdates: false,
|
||||
});
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"https://ee.formbricks.com/api/v1/public/mailing/product-updates/subscriptions",
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
test("should not subscribe when isFormbricksCloud is true but subscribeToProductUpdates is false", async () => {
|
||||
await subscribeUserToMailingList({
|
||||
email: "test@example.com",
|
||||
isFormbricksCloud: true,
|
||||
subscribeToProductUpdates: false,
|
||||
subscribeToSecurityUpdates: true,
|
||||
});
|
||||
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should subscribe to security when isFormbricksCloud is false and subscribeToSecurityUpdates is true", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||
|
||||
await subscribeUserToMailingList({
|
||||
email: "test@example.com",
|
||||
isFormbricksCloud: false,
|
||||
subscribeToSecurityUpdates: true,
|
||||
subscribeToProductUpdates: false,
|
||||
});
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"https://ee.formbricks.com/api/v1/public/mailing/security/subscriptions",
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
test("should not subscribe when isFormbricksCloud is false but subscribeToSecurityUpdates is false", async () => {
|
||||
await subscribeUserToMailingList({
|
||||
email: "test@example.com",
|
||||
isFormbricksCloud: false,
|
||||
subscribeToSecurityUpdates: false,
|
||||
subscribeToProductUpdates: true,
|
||||
});
|
||||
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should not subscribe when both subscription flags are undefined", async () => {
|
||||
await subscribeUserToMailingList({
|
||||
email: "test@example.com",
|
||||
isFormbricksCloud: true,
|
||||
});
|
||||
|
||||
expect(globalThis.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should prioritize product-updates for cloud users even if security is also true", async () => {
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(new Response(null, { status: 200 }));
|
||||
|
||||
await subscribeUserToMailingList({
|
||||
email: "test@example.com",
|
||||
isFormbricksCloud: true,
|
||||
subscribeToProductUpdates: true,
|
||||
subscribeToSecurityUpdates: true,
|
||||
});
|
||||
|
||||
// Should only call product-updates endpoint for cloud users
|
||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(
|
||||
"https://ee.formbricks.com/api/v1/public/mailing/product-updates/subscriptions",
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
"use server";
|
||||
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TUserEmail, ZUserEmail } from "@formbricks/types/user";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
|
||||
export type TMailingListId = "security" | "product-updates";
|
||||
|
||||
const MAILING_LIST_ENDPOINTS: Record<TMailingListId, string> = {
|
||||
security: "https://ee.formbricks.com/api/v1/public/mailing/security/subscriptions",
|
||||
"product-updates": "https://ee.formbricks.com/api/v1/public/mailing/product-updates/subscriptions",
|
||||
} as const;
|
||||
|
||||
const EE_SERVER_TIMEOUT_MS = 5000;
|
||||
|
||||
interface TSubscribeToMailingListParams {
|
||||
email: TUserEmail;
|
||||
listId: TMailingListId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe a user to a mailing list via the EE server
|
||||
* @param email - The user's email address
|
||||
* @param listId - The mailing list ID ("security" or "product-updates")
|
||||
*/
|
||||
export const subscribeToMailingList = async ({
|
||||
email,
|
||||
listId,
|
||||
}: TSubscribeToMailingListParams): Promise<{ success: boolean; error?: string }> => {
|
||||
validateInputs([email, ZUserEmail.toLowerCase()]);
|
||||
|
||||
const endpoint = MAILING_LIST_ENDPOINTS[listId];
|
||||
if (!endpoint) {
|
||||
logger.error({ listId }, "Invalid mailing list ID");
|
||||
return { success: false, error: "Invalid mailing list ID" };
|
||||
}
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), EE_SERVER_TIMEOUT_MS);
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ email }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error(
|
||||
{ status: response.status, error: errorText },
|
||||
`Failed to subscribe to ${listId} mailing list`
|
||||
);
|
||||
return { success: false, error: `Failed to subscribe: ${response.status}` };
|
||||
}
|
||||
|
||||
logger.info({ listId }, `Successfully subscribed to ${listId} mailing list`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
logger.error({ listId }, "Mailing subscription request timed out");
|
||||
return { success: false, error: "Request timed out" };
|
||||
}
|
||||
|
||||
logger.error(error, `Error subscribing to ${listId} mailing list`);
|
||||
return { success: false, error: "Failed to subscribe to mailing list" };
|
||||
}
|
||||
};
|
||||
|
||||
export const subscribeUserToMailingList = async ({
|
||||
email,
|
||||
isFormbricksCloud,
|
||||
subscribeToSecurityUpdates,
|
||||
subscribeToProductUpdates,
|
||||
}: {
|
||||
email: TUserEmail;
|
||||
isFormbricksCloud: boolean;
|
||||
subscribeToSecurityUpdates?: boolean;
|
||||
subscribeToProductUpdates?: boolean;
|
||||
}): Promise<void> => {
|
||||
if (isFormbricksCloud && subscribeToProductUpdates) {
|
||||
await subscribeToMailingList({ email, listId: "product-updates" });
|
||||
} else if (!isFormbricksCloud && subscribeToSecurityUpdates) {
|
||||
await subscribeToMailingList({ email, listId: "security" });
|
||||
}
|
||||
};
|
||||
@@ -67,7 +67,7 @@ const validateLanguages = (languages: Language[], t: TFunction) => {
|
||||
// (e.g. alias "nl" pointing to a non-Dutch language) which later breaks the
|
||||
// dropdowns that rely on ISO identifiers.
|
||||
for (const alias of languageAliases) {
|
||||
if (iso639Languages.some((language) => language.alpha2 === alias && !languageCodes.includes(alias))) {
|
||||
if (iso639Languages.some((language) => language.code === alias && !languageCodes.includes(alias))) {
|
||||
toast.error(
|
||||
t("environments.workspace.languages.conflict_between_selected_alias_and_another_language"),
|
||||
{
|
||||
|
||||
@@ -22,7 +22,7 @@ export function LanguageSelect({ language, onLanguageChange, disabled, locale }:
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedOption, setSelectedOption] = useState(
|
||||
iso639Languages.find((isoLang) => isoLang.alpha2 === language.code)
|
||||
iso639Languages.find((isoLang) => isoLang.code === language.code)
|
||||
);
|
||||
const items = iso639Languages;
|
||||
|
||||
@@ -39,7 +39,7 @@ export function LanguageSelect({ language, onLanguageChange, disabled, locale }:
|
||||
|
||||
const handleOptionSelect = (option: TIso639Language) => {
|
||||
setSelectedOption(option);
|
||||
onLanguageChange({ ...language, code: option.alpha2 || "" });
|
||||
onLanguageChange({ ...language, code: option.code || "" });
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
@@ -87,7 +87,7 @@ export function LanguageSelect({ language, onLanguageChange, disabled, locale }:
|
||||
{filteredItems.map((item) => (
|
||||
<button
|
||||
className="block w-full cursor-pointer rounded-md px-4 py-2 text-left text-slate-700 hover:bg-slate-100 active:bg-blue-100"
|
||||
key={item.alpha2}
|
||||
key={item.code}
|
||||
onClick={() => {
|
||||
handleOptionSelect(item);
|
||||
}}>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { PipelineTriggers } from "@prisma/client";
|
||||
import { PipelineTriggers, Webhook } from "@prisma/client";
|
||||
import clsx from "clsx";
|
||||
import { Webhook } from "lucide-react";
|
||||
import { Webhook as WebhookIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -12,6 +12,7 @@ import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { SurveyCheckboxGroup } from "@/modules/integrations/webhooks/components/survey-checkbox-group";
|
||||
import { TriggerCheckboxGroup } from "@/modules/integrations/webhooks/components/trigger-checkbox-group";
|
||||
import { WebhookCreatedModal } from "@/modules/integrations/webhooks/components/webhook-created-modal";
|
||||
import { isDiscordWebhook, validWebHookURL } from "@/modules/integrations/webhooks/lib/utils";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
@@ -51,6 +52,7 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
|
||||
const [selectedSurveys, setSelectedSurveys] = useState<string[]>([]);
|
||||
const [selectedAllSurveys, setSelectedAllSurveys] = useState(false);
|
||||
const [creatingWebhook, setCreatingWebhook] = useState(false);
|
||||
const [createdWebhook, setCreatedWebhook] = useState<Webhook | null>(null);
|
||||
|
||||
const handleTestEndpoint = async (sendSuccessToast: boolean) => {
|
||||
try {
|
||||
@@ -142,7 +144,7 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
|
||||
});
|
||||
if (createWebhookActionResult?.data) {
|
||||
router.refresh();
|
||||
setOpenWithStates(false);
|
||||
setCreatedWebhook(createWebhookActionResult.data);
|
||||
toast.success(t("environments.integrations.webhooks.webhook_added_successfully"));
|
||||
} else {
|
||||
const errorMessage = getFormattedErrorMessage(createWebhookActionResult);
|
||||
@@ -156,21 +158,27 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
|
||||
}
|
||||
};
|
||||
|
||||
const setOpenWithStates = (isOpen: boolean) => {
|
||||
setOpen(isOpen);
|
||||
const resetAndClose = () => {
|
||||
setOpen(false);
|
||||
reset();
|
||||
setTestEndpointInput("");
|
||||
setEndpointAccessible(undefined);
|
||||
setSelectedSurveys([]);
|
||||
setSelectedTriggers([]);
|
||||
setSelectedAllSurveys(false);
|
||||
setCreatedWebhook(null);
|
||||
};
|
||||
|
||||
// Show success dialog with secret after webhook creation
|
||||
if (createdWebhook) {
|
||||
return <WebhookCreatedModal open={open} webhook={createdWebhook} onClose={resetAndClose} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpenWithStates}>
|
||||
<Dialog open={open} onOpenChange={resetAndClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<Webhook />
|
||||
<WebhookIcon />
|
||||
<DialogTitle>{t("environments.integrations.webhooks.add_webhook")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("environments.integrations.webhooks.add_webhook_description")}
|
||||
@@ -249,12 +257,7 @@ export const AddWebhookModal = ({ environmentId, surveys, open, setOpen }: AddWe
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setOpenWithStates(false);
|
||||
}}>
|
||||
<Button type="button" variant="secondary" onClick={resetAndClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={creatingWebhook}>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { Webhook } from "@prisma/client";
|
||||
import { CheckIcon, CopyIcon, ExternalLinkIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
|
||||
interface WebhookCreatedModalProps {
|
||||
open: boolean;
|
||||
webhook: Webhook;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const WebhookCreatedModal = ({ open, webhook, onClose }: WebhookCreatedModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
toast.success(t("common.copied_to_clipboard"));
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<CheckIcon className="h-6 w-6 text-green-500" />
|
||||
<DialogTitle>{t("environments.integrations.webhooks.webhook_created")}</DialogTitle>
|
||||
<DialogDescription>{t("environments.integrations.webhooks.copy_secret_now")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody className="space-y-4 pb-4">
|
||||
<div className="col-span-1">
|
||||
<Label>{t("environments.integrations.webhooks.signing_secret")}</Label>
|
||||
<div className="mt-1 flex">
|
||||
<Input type="text" readOnly value={webhook.secret ?? ""} className="font-mono text-sm" />
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="ml-2 whitespace-nowrap"
|
||||
onClick={() => copyToClipboard(webhook.secret ?? "")}>
|
||||
{copied ? (
|
||||
<>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
{t("common.copied")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
{t("common.copy")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-slate-500">
|
||||
{t("environments.integrations.webhooks.secret_copy_warning")}
|
||||
</p>
|
||||
<Link
|
||||
href="https://formbricks.com/docs/xm-and-surveys/core-features/integrations/webhooks#webhook-security-with-standard-webhooks"
|
||||
target="_blank"
|
||||
className="mt-2 inline-flex items-center gap-1 text-xs text-slate-600 underline hover:text-slate-800">
|
||||
{t("environments.integrations.webhooks.learn_to_verify")}
|
||||
<ExternalLinkIcon className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" onClick={onClose}>
|
||||
{t("common.done")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { PipelineTriggers, Webhook } from "@prisma/client";
|
||||
import clsx from "clsx";
|
||||
import { TrashIcon } from "lucide-react";
|
||||
import { CheckIcon, CopyIcon, ExternalLinkIcon, EyeIcon, EyeOff, TrashIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
@@ -48,6 +48,15 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: We
|
||||
const [endpointAccessible, setEndpointAccessible] = useState<boolean>();
|
||||
const [hittingEndpoint, setHittingEndpoint] = useState<boolean>(false);
|
||||
const [selectedAllSurveys, setSelectedAllSurveys] = useState(webhook.surveyIds.length === 0);
|
||||
const [showSecret, setShowSecret] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
toast.success(t("common.copied_to_clipboard"));
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleTestEndpoint = async (sendSuccessToast: boolean) => {
|
||||
try {
|
||||
@@ -113,6 +122,7 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: We
|
||||
toast.error(t("common.please_select_at_least_one_survey"));
|
||||
return;
|
||||
}
|
||||
|
||||
const endpointHitSuccessfully = await handleTestEndpoint(false);
|
||||
if (!endpointHitSuccessfully) {
|
||||
return;
|
||||
@@ -196,6 +206,60 @@ export const WebhookSettingsTab = ({ webhook, surveys, setOpen, isReadOnly }: We
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{webhook.secret && (
|
||||
<div className="col-span-1">
|
||||
<Label htmlFor="secret">{t("environments.integrations.webhooks.signing_secret")}</Label>
|
||||
<div className="mt-1 flex">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
type={showSecret ? "text" : "password"}
|
||||
id="secret"
|
||||
readOnly
|
||||
value={webhook.secret}
|
||||
className="pr-10 font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-1/2 right-3 -translate-y-1/2 transform"
|
||||
onClick={() => setShowSecret(!showSecret)}>
|
||||
{showSecret ? (
|
||||
<EyeOff className="h-5 w-5 text-slate-400" />
|
||||
) : (
|
||||
<EyeIcon className="h-5 w-5 text-slate-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="ml-2 whitespace-nowrap"
|
||||
onClick={() => copyToClipboard(webhook.secret ?? "")}>
|
||||
{copied ? (
|
||||
<>
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
{t("common.copied")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
{t("common.copy")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
{t("environments.integrations.webhooks.secret_description")}
|
||||
</p>
|
||||
<Link
|
||||
href="https://formbricks.com/docs/xm-and-surveys/core-features/integrations/webhooks#webhook-security-with-standard-webhooks"
|
||||
target="_blank"
|
||||
className="mt-1 inline-flex items-center gap-1 text-xs text-slate-600 underline hover:text-slate-800">
|
||||
{t("environments.integrations.webhooks.learn_to_verify")}
|
||||
<ExternalLinkIcon className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="Triggers">{t("environments.integrations.webhooks.triggers")}</Label>
|
||||
<TriggerCheckboxGroup
|
||||
|
||||
@@ -35,6 +35,7 @@ export const WebhookTable = ({
|
||||
surveyIds: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
secret: null,
|
||||
});
|
||||
|
||||
const handleOpenWebhookDetailModalClick = (e, webhook: Webhook) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Prisma, Webhook } from "@prisma/client";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
ResourceNotFoundError,
|
||||
UnknownError,
|
||||
} from "@formbricks/types/errors";
|
||||
import { generateStandardWebhookSignature, generateWebhookSecret } from "@/lib/crypto";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { isDiscordWebhook } from "@/modules/integrations/webhooks/lib/utils";
|
||||
import { TWebhookInput } from "../types/webhooks";
|
||||
@@ -59,15 +61,19 @@ export const deleteWebhook = async (id: string): Promise<boolean> => {
|
||||
}
|
||||
};
|
||||
|
||||
export const createWebhook = async (environmentId: string, webhookInput: TWebhookInput): Promise<boolean> => {
|
||||
export const createWebhook = async (environmentId: string, webhookInput: TWebhookInput): Promise<Webhook> => {
|
||||
try {
|
||||
if (isDiscordWebhook(webhookInput.url)) {
|
||||
throw new UnknownError("Discord webhooks are currently not supported.");
|
||||
}
|
||||
await prisma.webhook.create({
|
||||
|
||||
const secret = generateWebhookSecret();
|
||||
|
||||
const webhook = await prisma.webhook.create({
|
||||
data: {
|
||||
...webhookInput,
|
||||
surveyIds: webhookInput.surveyIds || [],
|
||||
secret,
|
||||
environment: {
|
||||
connect: {
|
||||
id: environmentId,
|
||||
@@ -76,7 +82,7 @@ export const createWebhook = async (environmentId: string, webhookInput: TWebhoo
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
return webhook;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
@@ -121,13 +127,22 @@ export const testEndpoint = async (url: string): Promise<boolean> => {
|
||||
throw new UnknownError("Discord webhooks are currently not supported.");
|
||||
}
|
||||
|
||||
const webhookMessageId = uuidv7();
|
||||
const webhookTimestamp = Math.floor(Date.now() / 1000);
|
||||
const body = JSON.stringify({ event: "testEndpoint" });
|
||||
|
||||
// Generate a temporary test secret and signature for consistency with actual webhooks
|
||||
const testSecret = generateWebhookSecret();
|
||||
const signature = generateStandardWebhookSignature(webhookMessageId, webhookTimestamp, body, testSecret);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
event: "testEndpoint",
|
||||
}),
|
||||
body,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"webhook-id": webhookMessageId,
|
||||
"webhook-timestamp": webhookTimestamp.toString(),
|
||||
"webhook-signature": signature,
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
@@ -255,7 +255,7 @@ export const AddApiKeyModal = ({
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="min-w-[8rem]">
|
||||
<DropdownMenuContent className="max-h-[300px] min-w-[8rem] overflow-y-auto">
|
||||
{projectOptions.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.id}
|
||||
@@ -286,7 +286,7 @@ export const AddApiKeyModal = ({
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="min-w-[8rem] capitalize">
|
||||
<DropdownMenuContent className="max-h-[300px] min-w-[8rem] overflow-y-auto capitalize">
|
||||
{getEnvironmentOptionsForProject(permission.projectId).map((env) => (
|
||||
<DropdownMenuItem
|
||||
key={env.id}
|
||||
|
||||
@@ -18,9 +18,9 @@ export const APIKeysPage = async (props) => {
|
||||
|
||||
const projects = await getProjectsByOrganizationId(organization.id);
|
||||
|
||||
const isNotOwner = currentUserMembership.role !== "owner";
|
||||
const canAccessApiKeys = currentUserMembership.role === "owner" || currentUserMembership.role === "manager";
|
||||
|
||||
if (isNotOwner) throw new Error(t("common.not_authorized"));
|
||||
if (!canAccessApiKeys) throw new Error(t("common.not_authorized"));
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
@@ -38,7 +38,7 @@ export const APIKeysPage = async (props) => {
|
||||
<ApiKeyList
|
||||
organizationId={organization.id}
|
||||
locale={locale}
|
||||
isReadOnly={isNotOwner}
|
||||
isReadOnly={!canAccessApiKeys}
|
||||
projects={projects}
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
EMAIL_VERIFICATION_DISABLED,
|
||||
GITHUB_OAUTH_ENABLED,
|
||||
GOOGLE_OAUTH_ENABLED,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
IS_TURNSTILE_CONFIGURED,
|
||||
OIDC_DISPLAY_NAME,
|
||||
OIDC_OAUTH_ENABLED,
|
||||
@@ -57,6 +58,7 @@ export const SignupPage = async () => {
|
||||
samlTenant={SAML_TENANT}
|
||||
samlProduct={SAML_PRODUCT}
|
||||
turnstileSiteKey={TURNSTILE_SITE_KEY}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
|
||||
|
||||
interface AutoSaveIndicatorProps {
|
||||
isDraft: boolean;
|
||||
lastSaved: Date | null;
|
||||
}
|
||||
|
||||
export const AutoSaveIndicator = ({ isDraft, lastSaved }: AutoSaveIndicatorProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [showSaved, setShowSaved] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastSaved) {
|
||||
setShowSaved(true);
|
||||
const timer = setTimeout(() => {
|
||||
setShowSaved(false);
|
||||
}, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [lastSaved]);
|
||||
|
||||
const isSavedState = isDraft && showSaved;
|
||||
|
||||
const text = useMemo(() => {
|
||||
if (!isDraft) {
|
||||
return t("environments.surveys.edit.auto_save_disabled");
|
||||
}
|
||||
|
||||
if (showSaved) {
|
||||
return t("environments.surveys.edit.progress_saved");
|
||||
}
|
||||
|
||||
return t("environments.surveys.edit.auto_save_on");
|
||||
}, [isDraft, showSaved, t]);
|
||||
|
||||
const badge = (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex cursor-default items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors duration-300",
|
||||
isSavedState
|
||||
? "border-green-600 bg-green-50 text-green-800"
|
||||
: "border-slate-200 bg-slate-100 text-slate-600"
|
||||
)}>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<TooltipRenderer
|
||||
shouldRender={!isDraft}
|
||||
tooltipContent={t("environments.surveys.edit.auto_save_disabled_tooltip")}
|
||||
className="max-w-64 text-center">
|
||||
{badge}
|
||||
</TooltipRenderer>
|
||||
);
|
||||
};
|
||||
@@ -33,9 +33,10 @@ export const ResponseOptionsCard = ({
|
||||
const [surveyClosedMessageToggle, setSurveyClosedMessageToggle] = useState(false);
|
||||
const [verifyEmailToggle, setVerifyEmailToggle] = useState(localSurvey.isVerifyEmailEnabled);
|
||||
const [recaptchaToggle, setRecaptchaToggle] = useState(localSurvey.recaptcha?.enabled ?? false);
|
||||
const [isSingleResponsePerEmailEnabledToggle, setIsSingleResponsePerEmailToggle] = useState(
|
||||
const [singleResponsePerEmailToggle, setSingleResponsePerEmailToggle] = useState(
|
||||
localSurvey.isSingleResponsePerEmailEnabled
|
||||
);
|
||||
const [captureIpToggle, setCaptureIpToggle] = useState(localSurvey.isCaptureIpEnabled);
|
||||
|
||||
const [surveyClosedMessage, setSurveyClosedMessage] = useState({
|
||||
heading: t("environments.surveys.edit.survey_completed_heading"),
|
||||
@@ -90,7 +91,7 @@ export const ResponseOptionsCard = ({
|
||||
};
|
||||
|
||||
const handleSingleResponsePerEmailToggle = () => {
|
||||
setIsSingleResponsePerEmailToggle(!isSingleResponsePerEmailEnabledToggle);
|
||||
setSingleResponsePerEmailToggle(!singleResponsePerEmailToggle);
|
||||
setLocalSurvey({
|
||||
...localSurvey,
|
||||
isSingleResponsePerEmailEnabled: !localSurvey.isSingleResponsePerEmailEnabled,
|
||||
@@ -117,6 +118,11 @@ export const ResponseOptionsCard = ({
|
||||
setLocalSurvey({ ...localSurvey, isBackButtonHidden: !localSurvey.isBackButtonHidden });
|
||||
};
|
||||
|
||||
const handleCaptureIpToggle = () => {
|
||||
setCaptureIpToggle(!captureIpToggle);
|
||||
setLocalSurvey({ ...localSurvey, isCaptureIpEnabled: !localSurvey.isCaptureIpEnabled });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!!localSurvey.surveyClosedMessage) {
|
||||
setSurveyClosedMessage({
|
||||
@@ -199,7 +205,7 @@ export const ResponseOptionsCard = ({
|
||||
)}>
|
||||
<Collapsible.CollapsibleTrigger asChild className="h-full w-full cursor-pointer">
|
||||
<div className="inline-flex px-4 py-4">
|
||||
<div className="flex items-center pl-2 pr-5">
|
||||
<div className="flex items-center pr-5 pl-2">
|
||||
<CheckIcon
|
||||
strokeWidth={3}
|
||||
className="h-7 w-7 rounded-full border border-green-300 bg-green-100 p-1.5 text-green-600"
|
||||
@@ -237,7 +243,7 @@ export const ResponseOptionsCard = ({
|
||||
value={localSurvey.autoComplete?.toString()}
|
||||
onChange={handleInputResponse}
|
||||
onBlur={handleInputResponseBlur}
|
||||
className="ml-2 mr-2 inline w-20 bg-white text-center text-sm"
|
||||
className="mr-2 ml-2 inline w-20 bg-white text-center text-sm"
|
||||
/>
|
||||
{t("environments.surveys.edit.completed_responses")}
|
||||
</p>
|
||||
@@ -304,7 +310,7 @@ export const ResponseOptionsCard = ({
|
||||
<Input
|
||||
autoFocus
|
||||
id="heading"
|
||||
className="mb-4 mt-2 bg-white"
|
||||
className="mt-2 mb-4 bg-white"
|
||||
name="heading"
|
||||
defaultValue={surveyClosedMessage.heading}
|
||||
onChange={(e) => handleClosedSurveyMessageChange({ heading: e.target.value })}
|
||||
@@ -333,7 +339,7 @@ export const ResponseOptionsCard = ({
|
||||
<div className="m-1">
|
||||
<AdvancedOptionToggle
|
||||
htmlId="preventDoubleSubmission"
|
||||
isChecked={isSingleResponsePerEmailEnabledToggle}
|
||||
isChecked={singleResponsePerEmailToggle}
|
||||
onToggle={handleSingleResponsePerEmailToggle}
|
||||
title={t("environments.surveys.edit.prevent_double_submission")}
|
||||
description={t("environments.surveys.edit.prevent_double_submission_description")}
|
||||
@@ -380,6 +386,13 @@ export const ResponseOptionsCard = ({
|
||||
title={t("environments.surveys.edit.hide_back_button")}
|
||||
description={t("environments.surveys.edit.hide_back_button_description")}
|
||||
/>
|
||||
<AdvancedOptionToggle
|
||||
htmlId="captureIp"
|
||||
isChecked={captureIpToggle}
|
||||
onToggle={handleCaptureIpToggle}
|
||||
title={t("environments.surveys.edit.capture_ip_address")}
|
||||
description={t("environments.surveys.edit.capture_ip_address_description")}
|
||||
/>
|
||||
</div>
|
||||
</Collapsible.CollapsibleContent>
|
||||
</Collapsible.Root>
|
||||
|
||||
@@ -26,6 +26,7 @@ import { Button } from "@/modules/ui/components/button";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { updateSurveyAction, updateSurveyDraftAction } from "../actions";
|
||||
import { isSurveyValid } from "../lib/validation";
|
||||
import { AutoSaveIndicator } from "./auto-save-indicator";
|
||||
|
||||
interface SurveyMenuBarProps {
|
||||
localSurvey: TSurvey;
|
||||
@@ -68,7 +69,14 @@ export const SurveyMenuBar = ({
|
||||
const [isConfirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
||||
const [isSurveyPublishing, setIsSurveyPublishing] = useState(false);
|
||||
const [isSurveySaving, setIsSurveySaving] = useState(false);
|
||||
const [lastAutoSaved, setLastAutoSaved] = useState<Date | null>(null);
|
||||
const isSuccessfullySavedRef = useRef(false);
|
||||
const isAutoSavingRef = useRef(false);
|
||||
|
||||
// Refs for interval-based auto-save (to access current values without re-creating interval)
|
||||
const localSurveyRef = useRef(localSurvey);
|
||||
const surveyRef = useRef(survey);
|
||||
const isSurveySavingRef = useRef(isSurveySaving);
|
||||
|
||||
useEffect(() => {
|
||||
if (audiencePrompt && activeId === "settings") {
|
||||
@@ -80,6 +88,19 @@ export const SurveyMenuBar = ({
|
||||
setIsLinkSurvey(localSurvey.type === "link");
|
||||
}, [localSurvey.type]);
|
||||
|
||||
// Keep refs updated for interval-based auto-save
|
||||
useEffect(() => {
|
||||
localSurveyRef.current = localSurvey;
|
||||
}, [localSurvey]);
|
||||
|
||||
useEffect(() => {
|
||||
surveyRef.current = survey;
|
||||
}, [survey]);
|
||||
|
||||
useEffect(() => {
|
||||
isSurveySavingRef.current = isSurveySaving;
|
||||
}, [isSurveySaving]);
|
||||
|
||||
// Reset the successfully saved flag when survey prop updates (page refresh complete)
|
||||
useEffect(() => {
|
||||
if (isSuccessfullySavedRef.current) {
|
||||
@@ -228,6 +249,52 @@ export const SurveyMenuBar = ({
|
||||
return true;
|
||||
};
|
||||
|
||||
// Interval-based auto-save for draft surveys (every 10 seconds)
|
||||
useEffect(() => {
|
||||
// Only set up interval for draft surveys
|
||||
if (localSurvey.status !== "draft") return;
|
||||
|
||||
const intervalId = setInterval(async () => {
|
||||
// Skip if tab is not visible (no computation, no API calls for background tabs)
|
||||
if (document.hidden) return;
|
||||
|
||||
// Skip if already saving (manual or auto)
|
||||
if (isAutoSavingRef.current || isSurveySavingRef.current) return;
|
||||
|
||||
// Check for changes using refs (avoids re-creating interval on every change)
|
||||
const { updatedAt: localUpdatedAt, ...localSurveyRest } = localSurveyRef.current;
|
||||
const { updatedAt: surveyUpdatedAt, ...surveyRest } = surveyRef.current;
|
||||
|
||||
// Skip if no changes
|
||||
if (isEqual(localSurveyRest, surveyRest)) return;
|
||||
|
||||
isAutoSavingRef.current = true;
|
||||
|
||||
try {
|
||||
const currentSurvey = localSurveyRef.current;
|
||||
const updatedSurveyResponse = await updateSurveyDraftAction({
|
||||
...currentSurvey,
|
||||
segment: currentSurvey.segment?.id === "temp" ? null : currentSurvey.segment,
|
||||
} as unknown as TSurveyDraft);
|
||||
|
||||
if (updatedSurveyResponse?.data) {
|
||||
// Update surveyRef (not localSurvey state) to prevent re-renders during auto-save.
|
||||
// This keeps the UI stable while still tracking that changes have been saved.
|
||||
// The comparison uses refs, so this prevents unnecessary re-saves.
|
||||
surveyRef.current = { ...updatedSurveyResponse.data };
|
||||
isSuccessfullySavedRef.current = true;
|
||||
setLastAutoSaved(new Date());
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
isAutoSavingRef.current = false;
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [localSurvey.status]);
|
||||
|
||||
// Add new handler after handleSurveySave
|
||||
const handleSurveySaveDraft = async (): Promise<boolean> => {
|
||||
setIsSurveySaving(true);
|
||||
@@ -401,6 +468,7 @@ export const SurveyMenuBar = ({
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center gap-2 sm:mt-0 sm:ml-4">
|
||||
<AutoSaveIndicator isDraft={localSurvey.status === "draft"} lastSaved={lastAutoSaved} />
|
||||
{!isStorageConfigured && (
|
||||
<div>
|
||||
<Alert variant="warning" size="small">
|
||||
@@ -427,6 +495,7 @@ export const SurveyMenuBar = ({
|
||||
)}
|
||||
{!isCxMode && (
|
||||
<Button
|
||||
data-save-button
|
||||
disabled={disableSave}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
|
||||
@@ -168,7 +168,7 @@ describe("Survey Editor Library Tests", () => {
|
||||
vi.mocked(getOrganizationAIKeys).mockResolvedValue(mockOrganization as any);
|
||||
});
|
||||
|
||||
test("should handle languages update", async () => {
|
||||
test("should handle languages update with multiple languages", async () => {
|
||||
const updatedSurvey: TSurvey = {
|
||||
...mockSurvey,
|
||||
languages: [
|
||||
@@ -219,6 +219,60 @@ describe("Survey Editor Library Tests", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle languages update with single default language", async () => {
|
||||
// This tests the fix for the bug where languages.length === 1 would incorrectly
|
||||
// set updatedLanguageIds to [] causing the default language to be removed
|
||||
const updatedSurvey: TSurvey = {
|
||||
...mockSurvey,
|
||||
languages: [
|
||||
{
|
||||
language: {
|
||||
id: "en",
|
||||
code: "en",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
alias: null,
|
||||
projectId: "project1",
|
||||
},
|
||||
default: true,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await updateSurvey(updatedSurvey);
|
||||
|
||||
// Verify that prisma.survey.update was called
|
||||
expect(prisma.survey.update).toHaveBeenCalled();
|
||||
|
||||
const updateCall = vi.mocked(prisma.survey.update).mock.calls[0][0];
|
||||
|
||||
// The key test: when languages.length === 1, we should still process language updates
|
||||
// and NOT delete the language. Before the fix, languages.length > 1 would fail this case.
|
||||
expect(updateCall).toBeDefined();
|
||||
expect(updateCall.where).toEqual({ id: "survey123" });
|
||||
expect(updateCall.data).toBeDefined();
|
||||
});
|
||||
|
||||
test("should remove all languages when empty array is passed", async () => {
|
||||
const updatedSurvey: TSurvey = {
|
||||
...mockSurvey,
|
||||
languages: [],
|
||||
};
|
||||
|
||||
await updateSurvey(updatedSurvey);
|
||||
|
||||
// Verify that prisma.survey.update was called
|
||||
expect(prisma.survey.update).toHaveBeenCalled();
|
||||
|
||||
const updateCall = vi.mocked(prisma.survey.update).mock.calls[0][0];
|
||||
|
||||
// When languages is empty array, all existing languages should be removed
|
||||
expect(updateCall).toBeDefined();
|
||||
expect(updateCall.where).toEqual({ id: "survey123" });
|
||||
expect(updateCall.data).toBeDefined();
|
||||
});
|
||||
|
||||
test("should delete private segment for non-app type surveys", async () => {
|
||||
const mockSegment: TSegment = {
|
||||
id: "segment1",
|
||||
|
||||
@@ -43,7 +43,7 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
|
||||
? currentSurvey.languages.map((l) => l.language.id)
|
||||
: [];
|
||||
const updatedLanguageIds =
|
||||
languages.length > 1 ? updatedSurvey.languages.map((l) => l.language.id) : [];
|
||||
languages.length > 0 ? updatedSurvey.languages.map((l) => l.language.id) : [];
|
||||
const enabledLanguageIds = languages.map((language) => {
|
||||
if (language.enabled) return language.language.id;
|
||||
});
|
||||
|
||||