Compare commits

..

56 Commits

Author SHA1 Message Date
Johannes c6cbcd8280 fix unit test 2026-02-03 11:52:09 +01:00
Johannes 23ef11d228 Merge branch 'main' of https://github.com/formbricks/formbricks into fix-lang-attribute 2026-02-03 11:49:58 +01:00
devin-ai-integration[bot] 5f05f8d36b chore: remove unused icon components (#7170)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Johannes <johannes@formbricks.com>
2026-02-03 08:40:15 +00:00
Balázs Úr f7558a7497 feat: Add Hungarian language support (#7175)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-02-03 08:22:41 +00:00
Dhruwang Jariwala 009beba866 feat: dropdown ui for multi select (#7191) 2026-02-03 05:16:03 +00:00
Bhagya Amarasinghe c3ec5ddc3a fix: optimize license check flow to prevent Redis hammering and OOM crashes (#7180) 2026-02-02 13:50:35 +00:00
Matti Nannt 9573ae19e6 fix(security): upgrade next and lodash to fix vulnerabilities (#7179)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 06:51:37 +00:00
Matti Nannt 7b3f841c5e fix(security): upgrade qs to fix DoS vulnerability (#7178)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 05:53:59 +00:00
Johannes 6c07e71b47 add translations 2026-01-27 14:59:39 +04:00
Dhruwang Jariwala 8f7d225d6a fix: jerky animation behaviour (#7158) 2026-01-23 12:26:57 +00:00
Anshuman Pandey 094b6dedba fix: fixes response card UI for cta question (#7157) 2026-01-23 10:29:01 +00:00
Anshuman Pandey 36f0be07c4 fix: handle server errors in survey publish flow (#7156) 2026-01-23 08:54:11 +00:00
Bhagya Amarasinghe e079055a43 fix(helm): DB migration job (#7152) 2026-01-23 07:58:54 +00:00
Bhagya Amarasinghe 9ae9a3a9fc fix(helm): update ExternalSecret API version to v1 (#7153) 2026-01-23 07:03:50 +00:00
Dhruwang Jariwala b4606c0113 fix: nps & rating rtl UI (#7154) 2026-01-23 06:46:41 +00:00
Dhruwang Jariwala 6be654ab60 fix: language variants not working for app surveys (#7151) 2026-01-23 06:46:21 +00:00
dependabot[bot] 95c2e24416 chore(deps): bump the npm_and_yarn group across 2 directories with 1 update (#7149)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matti Nannt <matti@formbricks.com>
2026-01-22 12:06:12 +00:00
Theodór Tómas 5b86dd3a8f feat: question delete dialog (#7144) 2026-01-22 09:41:54 +00:00
Dhruwang Jariwala 0da083a214 fix: billing checks (#7137) 2026-01-22 09:24:13 +00:00
Dhruwang Jariwala 379a86cf46 fix: survey card animation issue (#7150) 2026-01-22 07:58:18 +00:00
Johannes bed78716f0 fix: add validation for variable name conflicts with hidden fields (#7148) 2026-01-22 07:36:09 +00:00
Johannes 6167c3d9e6 fix: make redirect wait for successful response completion (#7146) 2026-01-22 06:55:54 +00:00
Johannes 242b003048 feat: add language attribute and improve attributes section rendering
- Introduced a new "Language" attribute in the environment service and database seeding.
- Refactored the attributes section to use a reusable AttributeRow component for better maintainability.
- Updated localization for attribute key terminology from "Key" to "Attribute".
- Enhanced delete logic in the edit contact attributes modal to prevent deletion of essential attributes.
2026-01-21 16:47:23 +00:00
Dhruwang Jariwala 1db1271e7f feat: validation rules (#7140) 2026-01-21 15:23:09 +00:00
Matti Nannt 9ec1964106 fix(security): upgrade react-email packages to fix transitive next.js vulnerability (#7145) 2026-01-21 16:00:33 +01:00
Dhruwang Jariwala d5a70796dd chore: tweaked validation of ending card url (#7139)
Co-authored-by: Johannes <johannes@formbricks.com>
2026-01-21 14:41:36 +00:00
Dhruwang Jariwala 246351b3e6 fix: quotas not working for multi lang surveys (#7141) 2026-01-21 14:23:16 +00:00
Dhruwang Jariwala 22ea7302bb fix: removed validation from button labels (#7138) 2026-01-21 14:14:22 +00:00
Dhruwang Jariwala 8d47ab9709 fix: rtl tweaks (#7136) 2026-01-21 07:08:22 +00:00
Matti Nannt 8f6d27c1ef fix: upgrade next.js and preact to fix high-severity vulnerabilities (#7134) 2026-01-20 11:22:01 +00:00
Dhruwang Jariwala a37815b831 fix: breaking email embed preview for single select question (#7133) 2026-01-20 06:42:15 +00:00
Dhruwang Jariwala 2b526a87ca fix: email locale in invite accepted email (#7124) 2026-01-19 13:32:01 +00:00
Dhruwang Jariwala 047750967c fix: console warnings in survey ui package (#7130) 2026-01-19 07:19:13 +00:00
Johannes a54356c3b0 docs: add CSAT and update Survey Cooldown (#7128)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-01-19 07:06:16 +00:00
Matti Nannt 38ea5ed6ae perf: remove redundant database indexes (#7104) 2026-01-16 10:17:05 +00:00
Dhruwang Jariwala 6e19de32f7 fix: org managers not able to access api keys (#7123) 2026-01-16 09:54:54 +00:00
Johannes 957a4432f4 feat: introduce language variations (#7082)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-01-16 08:51:20 +00:00
Matti Nannt 22a5d4bb7d chore: consolidate agent instructions and remove Cursor rules (#7096) 2026-01-16 08:20:23 +00:00
Matti Nannt 226dff0344 fix: upgrade storybook to v10.1.11 (#7120) 2026-01-16 07:19:18 +00:00
Dhruwang Jariwala d474a94a21 fix: multi lang button label issue (#7117) 2026-01-15 17:57:50 +00:00
dependabot[bot] c1a4cc308b chore(deps): bump the npm_and_yarn group across 2 directories with 1 update (#7081)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Matti Nannt <matti@formbricks.com>
2026-01-15 15:10:33 +01:00
Dhruwang Jariwala 210da98b69 fix: scrolling in project breadcrumb dropdown (#7118) 2026-01-15 11:59:17 +00:00
Matti Nannt 2fc183d384 chore: update pre-commit hook to address husky warning (#7106) 2026-01-15 07:42:37 +00:00
Dhruwang Jariwala 78fb111610 fix: syntax issue in pr check size github action (#7116) 2026-01-15 06:43:59 +00:00
Bhagya Amarasinghe 11c0cb4b61 fix: add required WEBAPP_URL/NEXTAUTH_URL config and improve helm chart (#7107) 2026-01-14 18:26:40 +00:00
Johannes 95831f7c7f feat: add auto-save for draft surveys and Cmd+S hotkey (#7087)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-01-14 17:23:34 +00:00
Anshuman Pandey a31e7bfaa5 feat: security signup ui (#7088)
Co-authored-by: Johannes <johannes@formbricks.com>
2026-01-14 16:45:21 +00:00
Matti Nannt 6e35fc1769 fix: update systeminformation to 5.27.14 (#7105) 2026-01-14 11:04:43 +00:00
Theodór Tómas 48cded1646 perf: decouple constants from zod and add bundle analyzer (#7101) 2026-01-14 09:50:05 +00:00
Dhruwang Jariwala db752cee15 feat: add support for mp3 file extension and corresponding MIME type (#7103)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-01-13 12:19:22 +00:00
Dhruwang Jariwala b33aae0a73 fix: missing Russian langauge in language select dropdown (#7099) 2026-01-13 10:08:50 +00:00
Matti Nannt 72126ad736 fix: required label not being translated (#7092) 2026-01-13 10:05:11 +00:00
Theodór Tómas 4a2eeac90b perf: reduce bundle size (#7094) 2026-01-12 16:57:12 +00:00
Anshuman Pandey 46be3e7d70 feat: webhook secret (#7084) 2026-01-09 12:31:29 +00:00
Dhruwang Jariwala 6d140532a7 feat: add IP address capture functionality to surveys (#7079) 2026-01-09 11:28:05 +00:00
Dhruwang Jariwala 8c4a7f1518 fix: remove subheader field from survey element presets (#7078) 2026-01-09 08:28:48 +00:00
381 changed files with 16640 additions and 10175 deletions
-61
View File
@@ -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
-415
View File
@@ -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)
-41
View File
@@ -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
-105
View File
@@ -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.
-28
View File
@@ -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>
-332
View File
@@ -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
-232
View File
@@ -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)
-457
View File
@@ -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
-52
View File
@@ -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
-179
View File
@@ -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.
+15 -21
View File
@@ -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({
+1
View File
@@ -62,3 +62,4 @@ branch.json
packages/ios/FormbricksSDK/FormbricksSDK.xcodeproj/project.xcworkspace/xcuserdata
.cursorrules
i18n.cache
stats.html
-3
View File
@@ -1,6 +1,3 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
# Load environment variables from .env files
if [ -f .env ]; then
set -a
+54
View File
@@ -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.
+16 -16
View File
@@ -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.25.12",
"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"
}
}
+7
View File
@@ -0,0 +1,7 @@
node_modules/
.next/
public/
playwright/
dist/
coverage/
vendor/
+6
View File
@@ -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
@@ -122,6 +125,9 @@ RUN chown nextjs:nextjs ./prisma_version.txt && chmod 644 ./prisma_version.txt
COPY --from=installer /app/node_modules/@paralleldrive/cuid2 ./node_modules/@paralleldrive/cuid2
RUN chmod -R 755 ./node_modules/@paralleldrive/cuid2
COPY --from=installer /app/node_modules/uuid ./node_modules/uuid
RUN chmod -R 755 ./node_modules/uuid
COPY --from=installer /app/node_modules/@noble/hashes ./node_modules/@noble/hashes
RUN chmod -R 755 ./node_modules/@noble/hashes
@@ -36,7 +36,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
// Calculate derived values (no queries)
const { isMember, isOwner, isManager } = getAccessFlags(membership.role);
const { features, lastChecked, isPendingDowngrade, active } = license;
const { features, lastChecked, isPendingDowngrade, active, status } = license;
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
const isOwnerOrManager = isOwner || isManager;
@@ -63,6 +63,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
active={active}
environmentId={environment.id}
locale={user.locale}
status={status}
/>
<div className="flex h-full">
@@ -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}
@@ -58,7 +58,7 @@ async function handleEmailUpdate({
payload.email = inputEmail;
await updateBrevoCustomer({ id: ctx.user.id, email: inputEmail });
} else {
await sendVerificationNewEmail(ctx.user.id, inputEmail);
await sendVerificationNewEmail(ctx.user.id, inputEmail, ctx.user.locale);
}
return payload;
}
@@ -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")}>
@@ -58,6 +58,7 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
ctx.user.email,
emailHtml,
survey.environmentId,
ctx.user.locale,
organizationLogoUrl || ""
);
});
@@ -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");
+51 -20
View File
@@ -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
@@ -191,7 +215,14 @@ export const POST = async (request: Request) => {
}
const emailPromises = usersWithNotifications.map((user) =>
sendResponseFinishedEmail(user.email, environmentId, survey, response, responseCount).catch((error) => {
sendResponseFinishedEmail(
user.email,
user.locale,
environmentId,
survey,
response,
responseCount
).catch((error) => {
logger.error(
{ error, url: request.url, userEmail: user.email },
`Failed to send email to ${user.email}`
@@ -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,
@@ -8,6 +8,10 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
import { sendToPipeline } from "@/app/lib/pipelines";
import { deleteResponse, getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import {
formatValidationErrorsForV1Api,
validateResponseData,
} from "@/modules/api/v2/management/responses/lib/validation";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./lib/response";
@@ -140,6 +144,24 @@ export const PUT = withV1ApiWrapper({
};
}
// Validate response data against validation rules
const validationErrors = validateResponseData(
result.survey.blocks,
responseUpdate.data,
responseUpdate.language ?? "en",
result.survey.questions
);
if (validationErrors) {
return {
response: responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
true
),
};
}
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
if (!inputValidation.success) {
return {
@@ -7,6 +7,10 @@ import { transformErrorToDetails } from "@/app/lib/api/validator";
import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import {
formatValidationErrorsForV1Api,
validateResponseData,
} from "@/modules/api/v2/management/responses/lib/validation";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import {
@@ -149,6 +153,24 @@ export const POST = withV1ApiWrapper({
};
}
// Validate response data against validation rules
const validationErrors = validateResponseData(
surveyResult.survey.blocks,
responseInput.data,
responseInput.language ?? "en",
surveyResult.survey.questions
);
if (validationErrors) {
return {
response: responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
true
),
};
}
if (responseInput.createdAt && !responseInput.updatedAt) {
responseInput.updatedAt = responseInput.createdAt;
}
@@ -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: "",
});
});
});
});
+4 -2
View File
@@ -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(""), []),
};
};
+1
View File
@@ -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,
+6 -5
View File
@@ -9,17 +9,18 @@
"source": "en-US",
"targets": [
"de-DE",
"es-ES",
"fr-FR",
"hu-HU",
"ja-JP",
"nl-NL",
"pt-BR",
"pt-PT",
"ro-RO",
"zh-Hans-CN",
"zh-Hant-TW",
"nl-NL",
"es-ES",
"ru-RU",
"sv-SE",
"ru-RU"
"zh-Hans-CN",
"zh-Hant-TW"
]
},
"version": 1.8
+65 -17
View File
@@ -61,6 +61,10 @@ checksums:
auth/signup/password_validation_uppercase_and_lowercase: ae98b485024dbff1022f6048e22443cd
auth/signup/please_verify_captcha: 12938ca7ca13e3f933737dd5436fa1c0
auth/signup/privacy_policy: 7459744a63ef8af4e517a09024bd7c08
auth/signup/product_updates_description: f20eedb2cf42d2235b1fe0294086695b
auth/signup/product_updates_title: 31e099ba18abb0a49f8a75fece1f1791
auth/signup/security_updates_description: 4643df07f13cec619e7fd91c8f14d93b
auth/signup/security_updates_title: de5127f5847cdd412906607e1402f48d
auth/signup/terms_of_service: 5add91f519e39025708e54a7eb7a9fc5
auth/signup/title: 96addc349f834eaa5d14c786d5478b1c
auth/signup_without_verification_success/user_successfully_created: ff849ebedc5dacb36493d7894f16edc7
@@ -170,6 +174,7 @@ checksums:
common/docs: 1563fcb5ddb5037b0709ccd3dd384a92
common/documentation: 1563fcb5ddb5037b0709ccd3dd384a92
common/domain: 402d46965eacc3af4c5df92e53e95712
common/done: ffd408fa29d5bc9039ef8ea1b9b699bb
common/download: 56b7d0834952b39ee394b44bd8179178
common/draft: e8a92958ad300aacfe46c2bf6644927e
common/duplicate: 27756566785c2b8463e21582c4bb619b
@@ -222,6 +227,7 @@ checksums:
common/label: a5c71bf158481233f8215dbd38cc196b
common/language: 277fd1a41cc237a437cd1d5e4a80463b
common/learn_more: e598091d132f890c37a6d4ed94f6d794
common/license_expired: 7af13535e320e4197989472c01387d2c
common/light_overlay: 0499907ea7b8405f4267b117998b5a78
common/limits_reached: 98879ae8c2be281ad6d283d7029d0148
common/link: 60f0aa33e70ca6df3b6659802b16f433
@@ -291,7 +297,7 @@ checksums:
common/placeholder: 88c2c168aff12ca70148fcb5f6b4c7b1
common/please_select_at_least_one_survey: fb1cbeb670480115305e23444c347e50
common/please_select_at_least_one_trigger: e88e64a1010a039745e80ed2e30951fe
common/please_upgrade_your_plan: bfe98d41cd7383ad42169785d8c818fc
common/please_upgrade_your_plan: 03d54a21ecd27723c72a13644837e5ed
common/preview: 3173ee1f0f1d4e50665ca4a84c38e15d
common/preview_survey: 7409e9c118e3e5d5f2a86201c2b354f2
common/privacy: 7459744a63ef8af4e517a09024bd7c08
@@ -429,6 +435,7 @@ checksums:
common/you_have_reached_your_monthly_miu_limit_of: ded62fc6842c707f62622386ca34f71a
common/you_have_reached_your_monthly_response_limit_of: 3824db23ecc3dcd2b1787b98ccfdd5f9
common/you_will_be_downgraded_to_the_community_edition_on_date: bff35b54c13e2c205dc4c19056261cc0
common/your_license_has_expired_please_renew: 3f21ae4a7deab351b143b407ece58254
emails/accept: f8cc1de4f5e3c850cfdbbc0ec831ade7
emails/click_or_drag_to_upload_files: 64f59bc339568d52b8464b82546b70ea
emails/email_customization_preview_email_heading: 8b798cb8438b3dd356c02dab33b4c897
@@ -572,7 +579,7 @@ checksums:
environments/contacts/attribute_created_successfully: e9f90d366d817f2f1c81fb819c0e2f05
environments/contacts/attribute_description: e17686a22ffad04cc7bb70524ed4478b
environments/contacts/attribute_description_placeholder: 05af83e4cfc6328476ef9719581e47af
environments/contacts/attribute_key: 3d1065ab98a1c2f1210507fd5c7bf515
environments/contacts/attribute_key: cc92f647873ba9e17cff57d4a59737bd
environments/contacts/attribute_key_cannot_be_changed: 0ced703e77a8e620276c1fa21fcc8900
environments/contacts/attribute_key_hint: 1a68c6f91e1a5cf9eff811e2e54e92b8
environments/contacts/attribute_key_placeholder: 31702e553b3f138a623dbaa42b6f878f
@@ -733,20 +740,26 @@ checksums:
environments/integrations/webhooks/add_webhook: 20ba6e981d4237490d9da86dade7f7d2
environments/integrations/webhooks/add_webhook_description: 85466a73d6a55476319c0c980b6f2aff
environments/integrations/webhooks/all_current_and_new_surveys: 4c0e0e94bf2dea0cf58568d11cfbb71d
environments/integrations/webhooks/copy_secret_now: 23d6da66a541e7c22eb06729b6eac376
environments/integrations/webhooks/created_by_third_party: b40197eabbbce500b80b44268b8b1ee9
environments/integrations/webhooks/discord_webhook_not_supported: 23432534f908b2ba63a517fb1f9bbe0e
environments/integrations/webhooks/empty_webhook_message: 4c4d8709576a38cb8eb59866331d2405
environments/integrations/webhooks/endpoint_pinged: 3b1fce00e61d4b9d2bdca390649c58b6
environments/integrations/webhooks/endpoint_pinged_error: 96c312fe8214757c4a934cdfbe177027
environments/integrations/webhooks/learn_to_verify: 25b2a035e2109170b28f4e16db76ad39
environments/integrations/webhooks/please_check_console: 7b1787e82a0d762df02c011ebb1650ea
environments/integrations/webhooks/please_enter_a_url: c24c74d0ce7ed3a6b858aadbc82108fe
environments/integrations/webhooks/response_created: 8c43b1b6d748f6096f6f8d9232a3c469
environments/integrations/webhooks/response_finished: 71764de45369a08aacc290af629fa298
environments/integrations/webhooks/response_updated: 0b178ffeb39b615db0db036a685f118b
environments/integrations/webhooks/secret_copy_warning: 55ac31fc9ee192a66093ba4b6ccd0a91
environments/integrations/webhooks/secret_description: e9ab6e0fd78d49c3e25ee649c62061bd
environments/integrations/webhooks/signing_secret: 91594fa8588e4232e155a65d07419bf7
environments/integrations/webhooks/source: 6e87903ef260da661b2bf6d858ba68ca
environments/integrations/webhooks/test_endpoint: 9ce47af3f982224071e16d5a17190a60
environments/integrations/webhooks/triggers: 66488f38662a4199fb8a18967239c992
environments/integrations/webhooks/webhook_added_successfully: 2d8e8d7a158ea8e4b65e67900363527b
environments/integrations/webhooks/webhook_created: ffb4449a8d50bb83097485ddabb73562
environments/integrations/webhooks/webhook_delete_confirmation: b5bae9856effd32053669c0e0a22479f
environments/integrations/webhooks/webhook_deleted_successfully: fcefd247ec76a372002d2cffac3c5b0f
environments/integrations/webhooks/webhook_name_placeholder: ffa3274cf83d8dc05c882fbf61c48f8f
@@ -944,6 +957,8 @@ checksums:
environments/settings/general/remove_logo: f60f1803e6fc8017b1eae7c30089107f
environments/settings/general/replace_logo: e3c8bec7574a670607e88771164e272f
environments/settings/general/resend_invitation_email: 6305d1ffa015c377ef59fe9c2661cf02
environments/settings/general/security_list_tip: 0bbed89fa5265da7e07767087f87c736
environments/settings/general/security_list_tip_link: ccdb1a21610ebf5a626d813b155be4ba
environments/settings/general/share_invite_link: b40b7ffbcf02d7464be52fb562df5e3a
environments/settings/general/share_this_link_to_let_your_organization_member_join_your_organization: 6eb43d5b1c855572b7ab35f527ba953c
environments/settings/general/test_email_sent_successfully: aa68214f5e0707c9615e01343640ab32
@@ -1085,7 +1100,6 @@ checksums:
environments/surveys/edit/adjust_survey_closed_message_description: e906aebd9af6451a2a39c73287927299
environments/surveys/edit/adjust_the_theme_in_the: bccdafda8af5871513266f668b55d690
environments/surveys/edit/all_other_answers_will_continue_to: 9a5d09eea42ff5fd1c18cc58a14dcabd
environments/surveys/edit/allow_file_type: ec4f1e0c5b764990c3b1560d0d8dc2af
environments/surveys/edit/allow_multi_select: 7b4b83f7a0205e2a0a8971671a69a174
environments/surveys/edit/allow_multiple_files: dbd99f9d1026e4f7c5a5d03f71ba379d
environments/surveys/edit/allow_users_to_select_more_than_one_image: d683e0b538d1366400292a771f3fbd08
@@ -1095,6 +1109,9 @@ checksums:
environments/surveys/edit/assign: e80715ab64bf7cf463abb3a9fd1ad516
environments/surveys/edit/audience: a4d9fab4214a641e2d358fbb28f010e0
environments/surveys/edit/auto_close_on_inactivity: 093db516799315ccd4242a3675693012
environments/surveys/edit/auto_save_disabled: f7411fb0dcfb8f7b19b85f0be54f2231
environments/surveys/edit/auto_save_disabled_tooltip: 77322e1e866b7d29f7641a88bbd3b681
environments/surveys/edit/auto_save_on: 1524d466830b00c5d727c701db404963
environments/surveys/edit/automatically_close_survey_after: 3e1c400a4b226c875dc8337e3b204d85
environments/surveys/edit/automatically_close_the_survey_after_a_certain_number_of_responses: 2beee129dca506f041e5d1e6a1688310
environments/surveys/edit/automatically_close_the_survey_if_the_user_does_not_respond_after_certain_number_of_seconds: 1be3819ffa1db67385357ae933d69a7b
@@ -1116,6 +1133,8 @@ checksums:
environments/surveys/edit/cal_username: a4a9c739af909d975beb1bc4998feae9
environments/surveys/edit/calculate: c5fcf8d3a38706ae2071b6f78339ec68
environments/surveys/edit/capture_a_new_action_to_trigger_a_survey_on: 73410e9665a37bc4a9747db5d683d36c
environments/surveys/edit/capture_ip_address: e950f924f1c0b52f8c5b06ca118e049f
environments/surveys/edit/capture_ip_address_description: 932d1b4ad68594d06d4eaf0212f9570c
environments/surveys/edit/capture_new_action: 0aa2a3c399b62b1a52307deedf4922e8
environments/surveys/edit/card_arrangement_for_survey_type_derived: c06b9aaebcc11bc16e57a445b62361fc
environments/surveys/edit/card_background_color: acd5d023e1d1a4471b053dce504c7a83
@@ -1165,7 +1184,6 @@ checksums:
environments/surveys/edit/contact_fields: 0d4e3f4d2eb3481aabe3ac60a692fa74
environments/surveys/edit/contains: 41c8c25407527a5336404313f4c8d650
environments/surveys/edit/continue_to_settings: b9853a7eedb3ae295088268fe5a44824
environments/surveys/edit/control_which_file_types_can_be_uploaded: 97144e65d91e2ca0114af923ba5924f4
environments/surveys/edit/convert_to_multiple_choice: e5396019ae897f6ec4c4295394c115e3
environments/surveys/edit/convert_to_single_choice: 8ecabfcb9276f29e6ac962ffcbc1ba64
environments/surveys/edit/country: 73581fc33a1e83e6a56db73558e7b5c6
@@ -1178,11 +1196,13 @@ checksums:
environments/surveys/edit/darken_or_lighten_background_of_your_choice: 304a64a8050ebf501d195e948cd25b6f
environments/surveys/edit/date_format: e95dfc41ac944874868487457ddc057a
environments/surveys/edit/days_before_showing_this_survey_again: 9ee757e5c3a07844b12ceb406dc65b04
environments/surveys/edit/delete_anyways: cc8683ab625280eefc9776bd381dc2e8
environments/surveys/edit/delete_block: c00617cb0724557e486304276063807a
environments/surveys/edit/delete_choice: fd750208d414b9ad8c980c161a0199e1
environments/surveys/edit/disable_the_visibility_of_survey_progress: 2af631010114307ac2a91612559c9618
environments/surveys/edit/display_an_estimate_of_completion_time_for_survey: 03f0a816569399c1c61d08dbc913de06
environments/surveys/edit/display_number_of_responses_for_survey: 06294567ecba9aba2cce337c669577f6
environments/surveys/edit/display_type: 68c2deaca48289119f1a988ede39dbad
environments/surveys/edit/divide: ca443836e15d0a1cbde5f03cc8edba78
environments/surveys/edit/does_not_contain: d618eb0f854f7efa0d7c644e6628fa42
environments/surveys/edit/does_not_end_with: 885c4c1981b97a4bfa213e185b78b6c4
@@ -1190,6 +1210,7 @@ checksums:
environments/surveys/edit/does_not_include_all_of: c18c1a71e6d96c681a3e95c7bd6c9482
environments/surveys/edit/does_not_include_one_of: 91090d2e0667faf654f6a81d9857440f
environments/surveys/edit/does_not_start_with: 9395869b54cdfb353a51a7e0864f4fd7
environments/surveys/edit/dropdown: b4069d14e572b53ca4156d25b0a970c1
environments/surveys/edit/duplicate_block: d4ea4afb5fc5b18a81cbe0302fa05997
environments/surveys/edit/duplicate_question: 910751de01fdd327165968214717711b
environments/surveys/edit/edit_link: 40ba9e15beac77a46c5baf30be84ac54
@@ -1319,10 +1340,10 @@ checksums:
environments/surveys/edit/key: 3d1065ab98a1c2f1210507fd5c7bf515
environments/surveys/edit/last_name: 2c9a7de7738ca007ba9023c385149c26
environments/surveys/edit/let_people_upload_up_to_25_files_at_the_same_time: 44110eeba2b63049a84d69927846ea3c
environments/surveys/edit/limit_file_types: 2ee563bc98c65f565014945d6fef389c
environments/surveys/edit/limit_the_maximum_file_size: f3f8682de34eaae30351d570805ba172
environments/surveys/edit/limit_the_maximum_file_size: 6ae5944fe490b9acdaaee92b30381ec0
environments/surveys/edit/limit_upload_file_size_to: 949c48d25ae45259cc19464e95752d29
environments/surveys/edit/link_survey_description: f45569b5e6b78be6bc02bc6a46da948b
environments/surveys/edit/list: 94f13e7ef909a4de9db7abaa1f9f0b61
environments/surveys/edit/load_segment: 5341d3de37ff10f7526152e38e25e3c5
environments/surveys/edit/logic_error_warning: 542fbb918ffdb29e6f9a4a6196ffb558
environments/surveys/edit/logic_error_warning_text: f2afad8852a95ed169a39959efbf592c
@@ -1366,12 +1387,12 @@ checksums:
environments/surveys/edit/picture_idx: 55e053ad1ade5d17c582406706036028
environments/surveys/edit/pin_can_only_contain_numbers: 417c854d44620a7229ebd9ab8cbb3613
environments/surveys/edit/pin_must_be_a_four_digit_number: 9f9c8c55d99f7b24fbcf6e7e377b726f
environments/surveys/edit/please_enter_a_file_extension: 60ad12bce720593482809c002a542a97
environments/surveys/edit/please_enter_a_valid_url: 25d43dfb802c31cb59dc88453ea72fc4
environments/surveys/edit/please_set_a_survey_trigger: 0358142df37dd1724f629008a1db453a
environments/surveys/edit/please_specify: e1faa6cd085144f7339c7e74dc6fb366
environments/surveys/edit/prevent_double_submission: afc502baa2da81d9c9618da1c3b5a57a
environments/surveys/edit/prevent_double_submission_description: ef7d2aa22d43bdc6ccebb076c6aa9ce5
environments/surveys/edit/progress_saved: d7bfc189571f08bbb4d0240cb9363ffa
environments/surveys/edit/protect_survey_with_pin: 16d1925b6a5770f7423772d6d9a8291a
environments/surveys/edit/protect_survey_with_pin_description: 0e55d19b6f3578b1024e03606172a5d2
environments/surveys/edit/publish: 4aa95ba4793bb293e771bd73b4f87c0f
@@ -1380,7 +1401,8 @@ checksums:
environments/surveys/edit/question_deleted: ecdeb22b81ae2d732656a7742c1eec7b
environments/surveys/edit/question_duplicated: 3f02439fd0a8b818bc84c1b1b473898c
environments/surveys/edit/question_id_updated: e8d94dbefcbad00c7464b3d1fb0ee81a
environments/surveys/edit/question_used_in_logic: cd1fab1a4ccdea83c6d630a59cdc9931
environments/surveys/edit/question_used_in_logic_warning_text: ec78767a7cf335222d41b98cb5baa6be
environments/surveys/edit/question_used_in_logic_warning_title: 4bb8528cdc3b8649c194487067737f6d
environments/surveys/edit/question_used_in_quota: 311b93fcecd68a65fdefbea13bec7350
environments/surveys/edit/question_used_in_recall: 00d74a1ede4e75e32d50fe87b85d5a8b
environments/surveys/edit/question_used_in_recall_ending_card: ab5b0dc296cecd160a6406cbfab42695
@@ -1442,6 +1464,7 @@ checksums:
environments/surveys/edit/search_for_images: 8b1bc3561d126cc49a1ee185c07e7aaf
environments/surveys/edit/seconds_after_trigger_the_survey_will_be_closed_if_no_response: 3584be059fe152e93895ef9885f8e8a7
environments/surveys/edit/seconds_before_showing_the_survey: 4b03756dd5f06df732bf62b2c7968b82
environments/surveys/edit/select_field: 45665a44f7d5707506364f17f28db3bf
environments/surveys/edit/select_or_type_value: a99c307b2cc3f9f6f893babd546d7296
environments/surveys/edit/select_ordering: c8f632a17fe78d8b7f87e82df9351ff9
environments/surveys/edit/select_saved_action: de31ab9cbb2bb67a050df717de7cdde4
@@ -1489,8 +1512,6 @@ checksums:
environments/surveys/edit/the_survey_will_be_shown_once_even_if_person_doesnt_respond: 6062aaa5cf8e58e79b75b6b588ae9598
environments/surveys/edit/then: 5e941fb7dd51a18651fcfb865edd5ba6
environments/surveys/edit/this_action_will_remove_all_the_translations_from_this_survey: 3340c89696f10bdc01b9a1047ff0b987
environments/surveys/edit/this_extension_is_already_added: 201d636539836c95958e28cecd8f3240
environments/surveys/edit/this_file_type_is_not_supported: f365b9a2e05aa062ab0bc1af61f642e2
environments/surveys/edit/three_points: d7f299aec752d7d690ef0ab6373327ae
environments/surveys/edit/times: 5ab156c13df6bfd75c0b17ad0a92c78a
environments/surveys/edit/to_keep_the_placement_over_all_surveys_consistent_you_can: 7a078e6a39d4c30b465137d2b6ef3e67
@@ -1511,23 +1532,49 @@ checksums:
environments/surveys/edit/upper_label: 1fa48bce3fade6ffc1a52d9fdddf9e17
environments/surveys/edit/url_filters: e524879d2eb74463d7fd06a7e0f53421
environments/surveys/edit/url_not_supported: af8a753467c617b596aadef1aaaed664
environments/surveys/edit/validation/characters: f62970e214bd04fd1959e2759ee1ec48
environments/surveys/edit/validation/add_validation_rule: e0c3208977140e5475df3e9b08927dbf
environments/surveys/edit/validation/answer_all_rows: 5ca73b038ac41922a09802fef4b5afc0
environments/surveys/edit/validation/characters: e26d6bb531181ec1ed551e264bc86259
environments/surveys/edit/validation/contains: 41c8c25407527a5336404313f4c8d650
environments/surveys/edit/validation/delete_validation_rule: cc92081eda4dcffd9f746c5628fa2636
environments/surveys/edit/validation/does_not_contain: d618eb0f854f7efa0d7c644e6628fa42
environments/surveys/edit/validation/email: a481cd9fba3e145252458ee1eaa9bd3b
environments/surveys/edit/validation/max_length: dad68e07f6ee06ed11ec6bda2e896c68
environments/surveys/edit/validation/end_date: acbea5a9fd7a6fadf5aa1b4f47188203
environments/surveys/edit/validation/file_extension_is: c102e4962dd7b8b17faec31ecda6c9bd
environments/surveys/edit/validation/file_extension_is_not: e5067a8ad6b89cd979651c9d8ee7c614
environments/surveys/edit/validation/is: 1940eeb4f6f0189788fde5403c6e9e9a
environments/surveys/edit/validation/is_between: 5721c877c60f0005dc4ce78d4c0d3fdc
environments/surveys/edit/validation/is_earlier_than: 3829d0a060cfc2c7f5f0281a55759612
environments/surveys/edit/validation/is_greater_than: b9542ab0e0ea0ee18e82931b160b1385
environments/surveys/edit/validation/is_later_than: 315eba60c6b8ca4cb3dd95c564ada456
environments/surveys/edit/validation/is_less_than: 6109d595ba21497c59b1c91d7fd09a13
environments/surveys/edit/validation/is_not: 8c7817ecdb08e6fa92fdf3487e0c8c9d
environments/surveys/edit/validation/is_not_between: 4579a41b4e74d940eb036e13b3c63258
environments/surveys/edit/validation/kb: 476c6cddd277e93a1bb7af4a763e95dc
environments/surveys/edit/validation/max_length: 6edf9e1149c3893da102d9464138da22
environments/surveys/edit/validation/max_selections: 6edf9e1149c3893da102d9464138da22
environments/surveys/edit/validation/max_value: 6109d595ba21497c59b1c91d7fd09a13
environments/surveys/edit/validation/min_length: ad5c57a937565826794fb865522962e8
environments/surveys/edit/validation/max_value: 6edf9e1149c3893da102d9464138da22
environments/surveys/edit/validation/mb: dbcf612f2d898197a764a442747b5c06
environments/surveys/edit/validation/min_length: 204dbf1f1b3aa34c8b981642b1694262
environments/surveys/edit/validation/min_selections: 204dbf1f1b3aa34c8b981642b1694262
environments/surveys/edit/validation/min_value: b9542ab0e0ea0ee18e82931b160b1385
environments/surveys/edit/validation/options_selected: 088309b017c07c01494447dba82b2621
environments/surveys/edit/validation/min_value: 204dbf1f1b3aa34c8b981642b1694262
environments/surveys/edit/validation/minimum_options_ranked: 2dca1fb216c977a044987c65a0ca95c9
environments/surveys/edit/validation/minimum_rows_answered: a8766a986cd73db0bb9daff49b271ed6
environments/surveys/edit/validation/options_selected: a7f72a7059a49a2a6d5b90f7a2a8aa44
environments/surveys/edit/validation/pattern: c6f01d7bc9baa21a40ea38fa986bd5a0
environments/surveys/edit/validation/phone: bcd7bd37a475ab1f80ea4c5b4d4d0bb5
environments/surveys/edit/validation/required: b6c231d5d1a8dfe37615d1efd38ed8e0
environments/surveys/edit/validation/rank_all_options: a885523e9d7820c9b0529bca37e48ccc
environments/surveys/edit/validation/select_file_extensions: 208ccb7bd4dde20b0d79bdd1fa763076
environments/surveys/edit/validation/select_option: 53ba37697cca1f6c7d57ecca53ea063e
environments/surveys/edit/validation/start_date: 881de78c79b56f5ceb9b7103bf23cb2c
environments/surveys/edit/validation/url: 4006a4d8dfac013758f0053f6aa67cdd
environments/surveys/edit/validation_logic_and: 83bb027b15e28b3dc1d6e16c7fc86056
environments/surveys/edit/validation_logic_or: 32c9f3998984fd32a2b5bc53f2d97429
environments/surveys/edit/validation_rules: 0cd99f02684d633196c8b249e857d207
environments/surveys/edit/validation_rules_description: a0a7cee05e18efd462148698e3a93399
environments/surveys/edit/variable_is_used_in_logic_of_question_please_remove_it_from_logic_first: bd9d9c7cf0be671c4e8cf67e2ae6659e
environments/surveys/edit/variable_is_used_in_quota_please_remove_it_from_quota_first: 0d36e5b2713f5450fe346e0af0aaa29c
environments/surveys/edit/variable_name_conflicts_with_hidden_field: fe2f6a711d5b663790bdd5780ad77bf2
environments/surveys/edit/variable_name_is_already_taken_please_choose_another: 6da42fe8733c6379158bce9a176f76d7
environments/surveys/edit/variable_name_must_start_with_a_letter: f7abbdecf1ba7b822ccabb16981ebcb5
environments/surveys/edit/variable_used_in_recall: 1c9c354a1233408cc42922eefaa8ce23
@@ -1579,6 +1626,7 @@ checksums:
environments/surveys/responses/error_downloading_responses: 97a79108cfc854834d09cf14c300a291
environments/surveys/responses/first_name: cf040a5d6a9fd696be400380cc99f54b
environments/surveys/responses/how_to_identify_users: c886035d9d9a0cfc3fa9703972001044
environments/surveys/responses/ip_address: 8f2b4d42a165a4c165eca4d7639ce57e
environments/surveys/responses/last_name: 2c9a7de7738ca007ba9023c385149c26
environments/surveys/responses/not_completed: df34eab65a6291f2c5e15a0e349c4eba
environments/surveys/responses/os: a4c753bb2c004a58d02faeed6b4da476
+8 -7
View File
@@ -165,19 +165,20 @@ export const MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT = 150;
export const DEFAULT_LOCALE = "en-US";
export const AVAILABLE_LOCALES: TUserLocale[] = [
"en-US",
"de-DE",
"pt-BR",
"en-US",
"es-ES",
"fr-FR",
"hu-HU",
"ja-JP",
"nl-NL",
"zh-Hant-TW",
"pt-BR",
"pt-PT",
"ro-RO",
"ja-JP",
"zh-Hans-CN",
"es-ES",
"sv-SE",
"ru-RU",
"sv-SE",
"zh-Hans-CN",
"zh-Hant-TW",
];
// Billing constants
+143 -13
View File
@@ -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");
});
});
});
+52 -1
View File
@@ -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}`;
};
+6
View File
@@ -167,6 +167,12 @@ export const createEnvironment = async (
description: "Your contact's last name",
type: "default",
},
{
key: "language",
name: "Language",
description: "The language preference of a contact",
type: "default",
},
],
},
},
+63 -196
View File
@@ -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
@@ -126,221 +126,88 @@ export const addMultiLanguageLabels = (object: unknown, languageSymbols: string[
};
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",
code: "en-US",
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": "Голландский",
"en-US": "English (US)",
},
},
{
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: "fr-FR",
label: {
"en-US": "French",
},
},
{
code: "hu-HU",
label: {
"en-US": "Hungarian",
},
},
{
code: "ja-JP",
label: {
"en-US": "Japanese",
},
},
{
code: "nl-NL",
label: {
"en-US": "Dutch",
},
},
{
code: "pt-BR",
label: {
"en-US": "Portuguese (Brazil)",
},
},
{
code: "pt-PT",
label: {
"en-US": "Portuguese (Portugal)",
},
},
{
code: "ro-RO",
label: {
"en-US": "Romanian",
},
},
{
code: "ru-RU",
label: {
"en-US": "Russian",
},
},
{
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: "zh-Hans-CN",
label: {
"en-US": "Chinese (Simplified)",
},
},
{
code: "zh-Hant-TW",
label: {
"en-US": "Chinese (Traditional)",
},
},
];
export { iso639Languages };
@@ -208,6 +208,7 @@ const baseSurveyProperties = {
},
],
isBackButtonHidden: false,
isCaptureIpEnabled: false,
endings: [
{
id: "umyknohldc7w26ocjdhaa62c",
+4
View File
@@ -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", () => {
+2 -1
View File
@@ -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;
});
+15 -13
View File
@@ -1,5 +1,5 @@
import { formatDistance, intlFormat } from "date-fns";
import { de, enUS, es, fr, ja, nl, pt, ptBR, ro, ru, sv, zhCN, zhTW } from "date-fns/locale";
import { de, enUS, es, fr, hu, ja, nl, pt, ptBR, ro, ru, sv, zhCN, zhTW } from "date-fns/locale";
import { TUserLocale } from "@formbricks/types/user";
export const convertDateString = (dateString: string | null) => {
@@ -87,28 +87,30 @@ const getLocaleForTimeSince = (locale: TUserLocale) => {
return de;
case "en-US":
return enUS;
case "pt-BR":
return ptBR;
case "es-ES":
return es;
case "fr-FR":
return fr;
case "hu-HU":
return hu;
case "ja-JP":
return ja;
case "nl-NL":
return nl;
case "sv-SE":
return sv;
case "zh-Hant-TW":
return zhTW;
case "pt-BR":
return ptBR;
case "pt-PT":
return pt;
case "ro-RO":
return ro;
case "ja-JP":
return ja;
case "zh-Hans-CN":
return zhCN;
case "es-ES":
return es;
case "ru-RU":
return ru;
case "sv-SE":
return sv;
case "zh-Hans-CN":
return zhCN;
case "zh-Hant-TW":
return zhTW;
}
};
+1 -2
View File
@@ -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({
+17 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, test, vi } from "vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { getLocale } from "@/lingodotdev/language";
import { getTranslate } from "./server";
@@ -11,6 +11,10 @@ vi.mock("@/lingodotdev/shared", () => ({
}));
describe("lingodotdev server", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("should get translate", async () => {
vi.mocked(getLocale).mockResolvedValue("en-US");
const translate = await getTranslate();
@@ -22,4 +26,16 @@ describe("lingodotdev server", () => {
const translate = await getTranslate();
expect(translate).toBeDefined();
});
test("should use provided locale instead of calling getLocale", async () => {
const translate = await getTranslate("de-DE");
expect(getLocale).not.toHaveBeenCalled();
expect(translate).toBeDefined();
});
test("should call getLocale when locale is not provided", async () => {
vi.mocked(getLocale).mockResolvedValue("fr-FR");
await getTranslate();
expect(getLocale).toHaveBeenCalled();
});
});
+5 -4
View File
@@ -2,6 +2,7 @@ import { createInstance } from "i18next";
import ICU from "i18next-icu";
import resourcesToBackend from "i18next-resources-to-backend";
import { initReactI18next } from "react-i18next/initReactI18next";
import { TUserLocale } from "@formbricks/types/user";
import { DEFAULT_LOCALE } from "@/lib/constants";
import { getLocale } from "@/lingodotdev/language";
@@ -21,9 +22,9 @@ const initI18next = async (lng: string) => {
return i18nInstance;
};
export async function getTranslate() {
const locale = await getLocale();
export async function getTranslate(locale?: TUserLocale) {
const resolvedLocale = locale ?? (await getLocale());
const i18nextInstance = await initI18next(locale);
return i18nextInstance.getFixedT(locale);
const i18nextInstance = await initI18next(resolvedLocale);
return i18nextInstance.getFixedT(resolvedLocale);
}
+68 -20
View File
@@ -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",
@@ -249,6 +254,7 @@
"label": "Bezeichnung",
"language": "Sprache",
"learn_more": "Mehr erfahren",
"license_expired": "License Expired",
"light_overlay": "Helle Überlagerung",
"limits_reached": "Limits erreicht",
"link": "Link",
@@ -318,7 +324,7 @@
"placeholder": "Platzhalter",
"please_select_at_least_one_survey": "Bitte wähle mindestens eine Umfrage aus",
"please_select_at_least_one_trigger": "Bitte wähle mindestens einen Auslöser aus",
"please_upgrade_your_plan": "Bitte upgrade deinen Plan.",
"please_upgrade_your_plan": "Bitte aktualisieren Sie Ihren Plan",
"preview": "Vorschau",
"preview_survey": "Umfragevorschau",
"privacy": "Datenschutz",
@@ -455,7 +461,8 @@
"you_have_reached_your_limit_of_workspace_limit": "Sie haben Ihr Limit von {projectLimit} Workspaces erreicht.",
"you_have_reached_your_monthly_miu_limit_of": "Du hast dein monatliches MIU-Limit erreicht",
"you_have_reached_your_monthly_response_limit_of": "Du hast dein monatliches Antwortlimit erreicht",
"you_will_be_downgraded_to_the_community_edition_on_date": "Du wirst am {date} auf die Community Edition herabgestuft."
"you_will_be_downgraded_to_the_community_edition_on_date": "Du wirst am {date} auf die Community Edition herabgestuft.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
},
"emails": {
"accept": "Annehmen",
@@ -608,7 +615,7 @@
"attribute_created_successfully": "Attribut erfolgreich erstellt",
"attribute_description": "Beschreibung",
"attribute_description_placeholder": "Kurze Beschreibung",
"attribute_key": "Schlüssel",
"attribute_key": "Attribut",
"attribute_key_cannot_be_changed": "Schlüssel kann nach der Erstellung nicht geändert werden",
"attribute_key_hint": "Nur Kleinbuchstaben, Zahlen und Unterstriche. Muss mit einem Buchstaben beginnen.",
"attribute_key_placeholder": "z. B. geburtsdatum",
@@ -780,20 +787,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",
@@ -1005,6 +1018,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",
@@ -1156,7 +1171,6 @@
"adjust_survey_closed_message_description": "Ändere die Nachricht, die Besucher sehen, wenn die Umfrage geschlossen ist.",
"adjust_the_theme_in_the": "Passe das Thema an in den",
"all_other_answers_will_continue_to": "Alle anderen Antworten werden weiterhin",
"allow_file_type": "Dateityp begrenzen",
"allow_multi_select": "Mehrfachauswahl erlauben",
"allow_multiple_files": "Mehrere Dateien zulassen",
"allow_users_to_select_more_than_one_image": "Erlaube Nutzern, mehr als ein Bild auszuwählen",
@@ -1166,6 +1180,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.",
@@ -1187,6 +1204,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",
@@ -1236,7 +1255,6 @@
"contact_fields": "Kontaktfelder",
"contains": "enthält",
"continue_to_settings": "Weiter zu den Einstellungen",
"control_which_file_types_can_be_uploaded": "Steuere, welche Dateitypen hochgeladen werden können.",
"convert_to_multiple_choice": "In Multiple-Choice umwandeln",
"convert_to_single_choice": "In Einzelauswahl umwandeln",
"country": "Land",
@@ -1249,11 +1267,13 @@
"darken_or_lighten_background_of_your_choice": "Hintergrund deiner Wahl abdunkeln oder aufhellen.",
"date_format": "Datumsformat",
"days_before_showing_this_survey_again": "oder mehr Tage müssen zwischen der zuletzt angezeigten Umfrage und der Anzeige dieser Umfrage vergehen.",
"delete_anyways": "Trotzdem löschen",
"delete_block": "Block löschen",
"delete_choice": "Auswahl löschen",
"disable_the_visibility_of_survey_progress": "Deaktiviere die Sichtbarkeit des Umfragefortschritts.",
"display_an_estimate_of_completion_time_for_survey": "Zeige eine Schätzung der Fertigstellungszeit für die Umfrage an",
"display_number_of_responses_for_survey": "Anzahl der Antworten für Umfrage anzeigen",
"display_type": "Anzeigetyp",
"divide": "Teilen /",
"does_not_contain": "Enthält nicht",
"does_not_end_with": "Endet nicht mit",
@@ -1261,6 +1281,7 @@
"does_not_include_all_of": "Enthält nicht alle von",
"does_not_include_one_of": "Enthält nicht eines von",
"does_not_start_with": "Fängt nicht an mit",
"dropdown": "Dropdown",
"duplicate_block": "Block duplizieren",
"duplicate_question": "Frage duplizieren",
"edit_link": "Bearbeitungslink",
@@ -1353,7 +1374,7 @@
"hide_question_settings": "Frageeinstellungen ausblenden",
"hostname": "Hostname",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Wie funky sollen deine Karten in {surveyTypeDerived} Umfragen sein",
"if_you_need_more_please": "Wenn Du mehr brauchst, bitte",
"if_you_need_more_please": "Wenn Sie mehr benötigen, bitte",
"if_you_really_want_that_answer_ask_until_you_get_it": "Weiterhin anzeigen, wenn ausgelöst, bis eine Antwort abgegeben wird.",
"ignore_global_waiting_time": "Abkühlphase ignorieren",
"ignore_global_waiting_time_description": "Diese Umfrage kann angezeigt werden, wenn ihre Bedingungen erfüllt sind, auch wenn kürzlich eine andere Umfrage angezeigt wurde.",
@@ -1390,10 +1411,10 @@
"key": "Schlüssel",
"last_name": "Nachname",
"let_people_upload_up_to_25_files_at_the_same_time": "Erlaube bis zu 25 Dateien gleichzeitig hochzuladen.",
"limit_file_types": "Dateitypen einschränken",
"limit_the_maximum_file_size": "Maximale Dateigröße begrenzen",
"limit_upload_file_size_to": "Maximale Dateigröße für Uploads",
"limit_the_maximum_file_size": "Begrenzen Sie die maximale Dateigröße für Uploads.",
"limit_upload_file_size_to": "Upload-Dateigröße begrenzen auf",
"link_survey_description": "Teile einen Link zu einer Umfrageseite oder bette ihn in eine Webseite oder E-Mail ein.",
"list": "Liste",
"load_segment": "Segment laden",
"logic_error_warning": "Änderungen werden zu Logikfehlern führen",
"logic_error_warning_text": "Das Ändern des Fragetypen entfernt die Logikbedingungen von dieser Frage",
@@ -1404,8 +1425,8 @@
"manage_languages": "Sprachen verwalten",
"matrix_all_fields": "Alle Felder",
"matrix_rows": "Zeilen",
"max_file_size": "Max. Dateigröße",
"max_file_size_limit_is": "Max. Dateigröße ist",
"max_file_size": "Maximale Dateigröße",
"max_file_size_limit_is": "Die maximale Dateigrößenbeschränkung beträgt",
"move_question_to_block": "Frage in Block verschieben",
"multiply": "Multiplizieren *",
"needed_for_self_hosted_cal_com_instance": "Benötigt für eine selbstgehostete Cal.com-Instanz",
@@ -1437,12 +1458,12 @@
"picture_idx": "Bild {idx}",
"pin_can_only_contain_numbers": "PIN darf nur Zahlen enthalten.",
"pin_must_be_a_four_digit_number": "Die PIN muss eine vierstellige Zahl sein.",
"please_enter_a_file_extension": "Bitte gib eine Dateierweiterung ein.",
"please_enter_a_valid_url": "Bitte geben Sie eine gültige URL ein (z. B. https://beispiel.de)",
"please_set_a_survey_trigger": "Bitte richte einen Umfrage-Trigger ein",
"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",
@@ -1451,7 +1472,8 @@
"question_deleted": "Frage gelöscht.",
"question_duplicated": "Frage dupliziert.",
"question_id_updated": "Frage-ID aktualisiert",
"question_used_in_logic": "Diese Frage wird in der Logik der Frage {questionIndex} verwendet.",
"question_used_in_logic_warning_text": "Elemente aus diesem Block werden in einer Logikregel verwendet. Möchten Sie ihn wirklich löschen?",
"question_used_in_logic_warning_title": "Logikinkonsistenz",
"question_used_in_quota": "Diese Frage wird in der \"{quotaName}\" Quote verwendet",
"question_used_in_recall": "Diese Frage wird in Frage {questionIndex} abgerufen.",
"question_used_in_recall_ending_card": "Diese Frage wird in der Abschlusskarte abgerufen.",
@@ -1515,6 +1537,7 @@
"search_for_images": "Nach Bildern suchen",
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "Sekunden nach dem Auslösen wird die Umfrage geschlossen, wenn keine Antwort erfolgt.",
"seconds_before_showing_the_survey": "Sekunden, bevor die Umfrage angezeigt wird.",
"select_field": "Feld auswählen",
"select_or_type_value": "Auswählen oder Wert eingeben",
"select_ordering": "Anordnung auswählen",
"select_saved_action": "Gespeicherte Aktion auswählen",
@@ -1562,8 +1585,6 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Einmal anzeigen, auch wenn sie nicht antworten.",
"then": "dann",
"this_action_will_remove_all_the_translations_from_this_survey": "Diese Aktion entfernt alle Übersetzungen aus dieser Umfrage.",
"this_extension_is_already_added": "Diese Erweiterung ist bereits hinzugefügt.",
"this_file_type_is_not_supported": "Dieser Dateityp wird nicht unterstützt.",
"three_points": "3 Punkte",
"times": "Zeiten",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Um die Platzierung über alle Umfragen hinweg konsistent zu halten, kannst du",
@@ -1585,24 +1606,50 @@
"url_filters": "URL-Filter",
"url_not_supported": "URL nicht unterstützt",
"validation": {
"add_validation_rule": "Validierungsregel hinzufügen",
"answer_all_rows": "Alle Zeilen beantworten",
"characters": "Zeichen",
"contains": "enthält",
"delete_validation_rule": "Validierungsregel löschen",
"does_not_contain": "enthält nicht",
"email": "Ist gültige E-Mail",
"max_length": "Ist kürzer als",
"end_date": "Enddatum",
"file_extension_is": "Dateierweiterung ist",
"file_extension_is_not": "Dateierweiterung ist nicht",
"is": "ist",
"is_between": "ist zwischen",
"is_earlier_than": "ist früher als",
"is_greater_than": "ist größer als",
"is_later_than": "ist später als",
"is_less_than": "ist weniger als",
"is_not": "ist nicht",
"is_not_between": "ist nicht zwischen",
"kb": "KB",
"max_length": "Höchstens",
"max_selections": "Höchstens",
"max_value": "Ist weniger als",
"min_length": "Ist länger als",
"max_value": "Höchstens",
"mb": "MB",
"min_length": "Mindestens",
"min_selections": "Mindestens",
"min_value": "Ist größer als",
"min_value": "Mindestens",
"minimum_options_ranked": "Mindestanzahl bewerteter Optionen",
"minimum_rows_answered": "Mindestanzahl beantworteter Zeilen",
"options_selected": "Optionen ausgewählt",
"pattern": "Entspricht Regex-Muster",
"phone": "Ist gültige Telefonnummer",
"required": "Ist erforderlich",
"rank_all_options": "Alle Optionen bewerten",
"select_file_extensions": "Dateierweiterungen auswählen...",
"select_option": "Option auswählen",
"start_date": "Startdatum",
"url": "Ist gültige URL"
},
"validation_logic_and": "Alle sind wahr",
"validation_logic_or": "mindestens eine ist wahr",
"validation_rules": "Validierungsregeln",
"validation_rules_description": "Nur Antworten akzeptieren, die die folgenden Kriterien erfüllen",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne es zuerst aus der Logik.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variable \"{variableName}\" wird in der \"{quotaName}\" Quote verwendet",
"variable_name_conflicts_with_hidden_field": "Der Variablenname steht im Konflikt mit einer vorhandenen Hidden-Field-ID.",
"variable_name_is_already_taken_please_choose_another": "Variablenname ist bereits vergeben, bitte wähle einen anderen.",
"variable_name_must_start_with_a_letter": "Variablenname muss mit einem Buchstaben beginnen.",
"variable_used_in_recall": "Variable \"{variable}\" wird in Frage {questionIndex} abgerufen.",
@@ -1658,6 +1705,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",
+67 -19
View File
@@ -75,6 +75,10 @@
"password_validation_uppercase_and_lowercase": "Mix of uppercase and lowercase",
"please_verify_captcha": "Please verify reCAPTCHA",
"privacy_policy": "Privacy Policy",
"product_updates_description": "Monthly product news and feature updates, Privacy Policy applies.",
"product_updates_title": "Product updates",
"security_updates_description": "Security relevant information only, Privacy Policy applies.",
"security_updates_title": "Security updates",
"terms_of_service": "Terms of Service",
"title": "Create your Formbricks account"
},
@@ -197,6 +201,7 @@
"docs": "Documentation",
"documentation": "Documentation",
"domain": "Domain",
"done": "Done",
"download": "Download",
"draft": "Draft",
"duplicate": "Duplicate",
@@ -249,6 +254,7 @@
"label": "Label",
"language": "Language",
"learn_more": "Learn more",
"license_expired": "License Expired",
"light_overlay": "Light overlay",
"limits_reached": "Limits Reached",
"link": "Link",
@@ -318,7 +324,7 @@
"placeholder": "Placeholder",
"please_select_at_least_one_survey": "Please select at least one survey",
"please_select_at_least_one_trigger": "Please select at least one trigger",
"please_upgrade_your_plan": "Please upgrade your plan.",
"please_upgrade_your_plan": "Please upgrade your plan",
"preview": "Preview",
"preview_survey": "Preview Survey",
"privacy": "Privacy Policy",
@@ -455,7 +461,8 @@
"you_have_reached_your_limit_of_workspace_limit": "You have reached your limit of {projectLimit} workspaces.",
"you_have_reached_your_monthly_miu_limit_of": "You have reached your monthly MIU limit of",
"you_have_reached_your_monthly_response_limit_of": "You have reached your monthly response limit of",
"you_will_be_downgraded_to_the_community_edition_on_date": "You will be downgraded to the Community Edition on {date}."
"you_will_be_downgraded_to_the_community_edition_on_date": "You will be downgraded to the Community Edition on {date}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
},
"emails": {
"accept": "Accept",
@@ -608,7 +615,7 @@
"attribute_created_successfully": "Attribute created successfully",
"attribute_description": "Description",
"attribute_description_placeholder": "Short description",
"attribute_key": "Key",
"attribute_key": "Attribute",
"attribute_key_cannot_be_changed": "Key cannot be changed after creation",
"attribute_key_hint": "Only lowercase letters, numbers, and underscores. Must start with a letter.",
"attribute_key_placeholder": "e.g. date_of_birth",
@@ -780,20 +787,26 @@
"add_webhook": "Add Webhook",
"add_webhook_description": "Send survey response data to a custom endpoint",
"all_current_and_new_surveys": "All current and new surveys",
"copy_secret_now": "Copy your signing secret",
"created_by_third_party": "Created by a Third Party",
"discord_webhook_not_supported": "Discord webhooks are currently not supported.",
"empty_webhook_message": "Your webhooks will appear here as soon as you add them. ⏲️",
"endpoint_pinged": "Yay! We are able to ping the webhook!",
"endpoint_pinged_error": "Unable to ping the webhook!",
"learn_to_verify": "Learn how to verify webhook signatures",
"please_check_console": "Please check the console for more details",
"please_enter_a_url": "Please enter a URL",
"response_created": "Response Created",
"response_finished": "Response Finished",
"response_updated": "Response Updated",
"secret_copy_warning": "Store this secret securely. You can view it again in webhook settings.",
"secret_description": "Use this secret to verify webhook requests. See documentation for signature verification.",
"signing_secret": "Signing Secret",
"source": "Source",
"test_endpoint": "Test Endpoint",
"triggers": "Triggers",
"webhook_added_successfully": "Webhook added successfully",
"webhook_created": "Webhook Created",
"webhook_delete_confirmation": "Are you sure you want to delete this Webhook? This will stop sending you any further notifications.",
"webhook_deleted_successfully": "Webhook deleted successfully",
"webhook_name_placeholder": "Optional: Label your webhook for easy identification",
@@ -1005,6 +1018,8 @@
"remove_logo": "Remove logo",
"replace_logo": "Replace logo",
"resend_invitation_email": "Resend Invitation Email",
"security_list_tip": "Are you signed up for our Security List? Stay informed to keep your instance secure!",
"security_list_tip_link": "Sign up here.",
"share_invite_link": "Share Invite Link",
"share_this_link_to_let_your_organization_member_join_your_organization": "Share this link to let your organization member join your organization:",
"test_email_sent_successfully": "Test email sent successfully",
@@ -1156,7 +1171,6 @@
"adjust_survey_closed_message_description": "Change the message visitors see when the survey is closed.",
"adjust_the_theme_in_the": "Adjust the theme in the",
"all_other_answers_will_continue_to": "All other answers will continue to",
"allow_file_type": "Allow file type",
"allow_multi_select": "Allow multi-select",
"allow_multiple_files": "Allow multiple files",
"allow_users_to_select_more_than_one_image": "Allow users to select more than one image",
@@ -1166,6 +1180,9 @@
"assign": "Assign =",
"audience": "Audience",
"auto_close_on_inactivity": "Auto close on inactivity",
"auto_save_disabled": "Auto-save disabled",
"auto_save_disabled_tooltip": "Your survey is only auto-saved when in draft. This assures public surveys are not unintentionally updated.",
"auto_save_on": "Auto-save on",
"automatically_close_survey_after": "Automatically close survey after",
"automatically_close_the_survey_after_a_certain_number_of_responses": "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": "Automatically close the survey if the user does not respond after certain number of seconds.",
@@ -1187,6 +1204,8 @@
"cal_username": "Cal.com username or username/event",
"calculate": "Calculate",
"capture_a_new_action_to_trigger_a_survey_on": "Capture a new action to trigger a survey on.",
"capture_ip_address": "Capture IP address",
"capture_ip_address_description": "Store the respondent's IP address in response metadata for duplicate detection and security purposes",
"capture_new_action": "Capture new action",
"card_arrangement_for_survey_type_derived": "Card Arrangement for {surveyTypeDerived} Surveys",
"card_background_color": "Card background color",
@@ -1236,7 +1255,6 @@
"contact_fields": "Contact Fields",
"contains": "Contains",
"continue_to_settings": "Continue to Settings",
"control_which_file_types_can_be_uploaded": "Control which file types can be uploaded.",
"convert_to_multiple_choice": "Convert to Multi-select",
"convert_to_single_choice": "Convert to Single-select",
"country": "Country",
@@ -1249,11 +1267,13 @@
"darken_or_lighten_background_of_your_choice": "Darken or lighten background of your choice.",
"date_format": "Date format",
"days_before_showing_this_survey_again": "or more days to pass between the last shown survey and showing this survey.",
"delete_anyways": "Delete anyways",
"delete_block": "Delete block",
"delete_choice": "Delete choice",
"disable_the_visibility_of_survey_progress": "Disable the visibility of survey progress.",
"display_an_estimate_of_completion_time_for_survey": "Display an estimate of completion time for survey",
"display_number_of_responses_for_survey": "Display number of responses for survey",
"display_type": "Display type",
"divide": "Divide /",
"does_not_contain": "Does not contain",
"does_not_end_with": "Does not end with",
@@ -1261,6 +1281,7 @@
"does_not_include_all_of": "Does not include all of",
"does_not_include_one_of": "Does not include one of",
"does_not_start_with": "Does not start with",
"dropdown": "Dropdown",
"duplicate_block": "Duplicate block",
"duplicate_question": "Duplicate question",
"edit_link": "Edit link",
@@ -1390,10 +1411,10 @@
"key": "Key",
"last_name": "Last Name",
"let_people_upload_up_to_25_files_at_the_same_time": "Let people upload up to 25 files at the same time.",
"limit_file_types": "Limit file types",
"limit_the_maximum_file_size": "Limit the maximum file size",
"limit_the_maximum_file_size": "Limit the maximum file size for uploads.",
"limit_upload_file_size_to": "Limit upload file size to",
"link_survey_description": "Share a link to a survey page or embed it in a web page or email.",
"list": "List",
"load_segment": "Load segment",
"logic_error_warning": "Changing will cause logic errors",
"logic_error_warning_text": "Changing the question type will remove the logic conditions from this question",
@@ -1437,12 +1458,12 @@
"picture_idx": "Picture {idx}",
"pin_can_only_contain_numbers": "PIN can only contain numbers.",
"pin_must_be_a_four_digit_number": "PIN must be a four digit number.",
"please_enter_a_file_extension": "Please enter a file extension.",
"please_enter_a_valid_url": "Please enter a valid URL (e.g., https://example.com)",
"please_set_a_survey_trigger": "Please set a survey trigger",
"please_specify": "Please specify",
"prevent_double_submission": "Prevent double submission",
"prevent_double_submission_description": "Only allow 1 response per email address",
"progress_saved": "Progress saved",
"protect_survey_with_pin": "Protect survey with a PIN",
"protect_survey_with_pin_description": "Only users who have the PIN can access the survey.",
"publish": "Publish",
@@ -1451,7 +1472,8 @@
"question_deleted": "Question deleted.",
"question_duplicated": "Question duplicated.",
"question_id_updated": "Question ID updated",
"question_used_in_logic": "This question is used in logic of question {questionIndex}.",
"question_used_in_logic_warning_text": "Elements from this block are used in a logic rule, are you sure you want to delete it?",
"question_used_in_logic_warning_title": "Logic Inconsistency",
"question_used_in_quota": "This question is being used in \"{quotaName}\" quota",
"question_used_in_recall": "This question is being recalled in question {questionIndex}.",
"question_used_in_recall_ending_card": "This question is being recalled in Ending Card",
@@ -1515,6 +1537,7 @@
"search_for_images": "Search for images",
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "seconds after trigger the survey will be closed if no response",
"seconds_before_showing_the_survey": "seconds before showing the survey.",
"select_field": "Select field",
"select_or_type_value": "Select or type value",
"select_ordering": "Select ordering",
"select_saved_action": "Select saved action",
@@ -1562,8 +1585,6 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Show a single time, even if they don't respond.",
"then": "Then",
"this_action_will_remove_all_the_translations_from_this_survey": "This action will remove all the translations from this survey.",
"this_extension_is_already_added": "This extension is already added.",
"this_file_type_is_not_supported": "This file type is not supported.",
"three_points": "3 points",
"times": "times",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "To keep the placement over all surveys consistent, you can",
@@ -1585,24 +1606,50 @@
"url_filters": "URL Filters",
"url_not_supported": "URL not supported",
"validation": {
"characters": "characters",
"add_validation_rule": "Add validation rule",
"answer_all_rows": "Answer all rows",
"characters": "Characters",
"contains": "Contains",
"delete_validation_rule": "Delete validation rule",
"does_not_contain": "Does not contain",
"email": "Is valid email",
"max_length": "Is shorter than",
"end_date": "End date",
"file_extension_is": "File extension is",
"file_extension_is_not": "File extension is not",
"is": "Is",
"is_between": "Is between",
"is_earlier_than": "Is earlier than",
"is_greater_than": "Is greater than",
"is_later_than": "Is later than",
"is_less_than": "Is less than",
"is_not": "Is not",
"is_not_between": "Is not between",
"kb": "KB",
"max_length": "At most",
"max_selections": "At most",
"max_value": "Is less than",
"min_length": "Is longer than",
"max_value": "At most",
"mb": "MB",
"min_length": "At least",
"min_selections": "At least",
"min_value": "Is greater than",
"options_selected": "options selected",
"min_value": "At least",
"minimum_options_ranked": "Minimum options ranked",
"minimum_rows_answered": "Minimum rows answered",
"options_selected": "Options selected",
"pattern": "Matches regex pattern",
"phone": "Is valid phone",
"required": "Is required",
"rank_all_options": "Rank all options",
"select_file_extensions": "Select file extensions...",
"select_option": "Select option",
"start_date": "Start date",
"url": "Is valid URL"
},
"validation_logic_and": "All are true",
"validation_logic_or": "any is true",
"validation_rules": "Validation rules",
"validation_rules_description": "Only accept responses that meet the following criteria",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} is used in logic of question {questionIndex}. Please remove it from logic first.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variable \"{variableName}\" is being used in \"{quotaName}\" quota",
"variable_name_conflicts_with_hidden_field": "Variable name conflicts with an existing hidden field ID.",
"variable_name_is_already_taken_please_choose_another": "Variable name is already taken, please choose another.",
"variable_name_must_start_with_a_letter": "Variable name must start with a letter.",
"variable_used_in_recall": "Variable \"{variable}\" is being recalled in question {questionIndex}.",
@@ -1658,6 +1705,7 @@
"error_downloading_responses": "An error occurred while downloading responses",
"first_name": "First Name",
"how_to_identify_users": "How to identify users",
"ip_address": "IP Address",
"last_name": "Last Name",
"not_completed": "Not Completed ⏳",
"os": "OS",
@@ -3046,4 +3094,4 @@
"usability_rating_description": "Measure perceived usability by asking users to rate their experience with your product using a standardized 10-question survey.",
"usability_score_name": "System Usability Score (SUS)"
}
}
}
+67 -19
View File
@@ -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",
@@ -249,6 +254,7 @@
"label": "Etiqueta",
"language": "Idioma",
"learn_more": "Saber más",
"license_expired": "License Expired",
"light_overlay": "Superposición clara",
"limits_reached": "Límites alcanzados",
"link": "Enlace",
@@ -318,7 +324,7 @@
"placeholder": "Marcador de posición",
"please_select_at_least_one_survey": "Por favor, selecciona al menos una encuesta",
"please_select_at_least_one_trigger": "Por favor, selecciona al menos un disparador",
"please_upgrade_your_plan": "Por favor, actualiza tu plan.",
"please_upgrade_your_plan": "Por favor, actualiza tu plan",
"preview": "Vista previa",
"preview_survey": "Vista previa de la encuesta",
"privacy": "Política de privacidad",
@@ -455,7 +461,8 @@
"you_have_reached_your_limit_of_workspace_limit": "Has alcanzado tu límite de {projectLimit} espacios de trabajo.",
"you_have_reached_your_monthly_miu_limit_of": "Has alcanzado tu límite mensual de MIU de",
"you_have_reached_your_monthly_response_limit_of": "Has alcanzado tu límite mensual de respuestas de",
"you_will_be_downgraded_to_the_community_edition_on_date": "Serás degradado a la edición Community el {date}."
"you_will_be_downgraded_to_the_community_edition_on_date": "Serás degradado a la edición Community el {date}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
},
"emails": {
"accept": "Aceptar",
@@ -608,7 +615,7 @@
"attribute_created_successfully": "Atributo creado con éxito",
"attribute_description": "Descripción",
"attribute_description_placeholder": "Descripción breve",
"attribute_key": "Clave",
"attribute_key": "Atributo",
"attribute_key_cannot_be_changed": "La clave no se puede cambiar después de la creación",
"attribute_key_hint": "Solo letras minúsculas, números y guiones bajos. Debe empezar con una letra.",
"attribute_key_placeholder": "p. ej. fecha_de_nacimiento",
@@ -780,20 +787,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",
@@ -1005,6 +1018,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",
@@ -1156,7 +1171,6 @@
"adjust_survey_closed_message_description": "Cambiar el mensaje que ven los visitantes cuando la encuesta está cerrada.",
"adjust_the_theme_in_the": "Ajustar el tema en el",
"all_other_answers_will_continue_to": "Todas las demás respuestas continuarán",
"allow_file_type": "Permitir tipo de archivo",
"allow_multi_select": "Permitir selección múltiple",
"allow_multiple_files": "Permitir múltiples archivos",
"allow_users_to_select_more_than_one_image": "Permitir a los usuarios seleccionar más de una imagen",
@@ -1166,6 +1180,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.",
@@ -1187,6 +1204,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",
@@ -1236,7 +1255,6 @@
"contact_fields": "Campos de contacto",
"contains": "Contiene",
"continue_to_settings": "Continuar a ajustes",
"control_which_file_types_can_be_uploaded": "Controla qué tipos de archivos se pueden subir.",
"convert_to_multiple_choice": "Convertir a selección múltiple",
"convert_to_single_choice": "Convertir a selección única",
"country": "País",
@@ -1249,11 +1267,13 @@
"darken_or_lighten_background_of_your_choice": "Oscurece o aclara el fondo de tu elección.",
"date_format": "Formato de fecha",
"days_before_showing_this_survey_again": "o más días deben transcurrir entre la última encuesta mostrada y la visualización de esta encuesta.",
"delete_anyways": "Eliminar de todos modos",
"delete_block": "Eliminar bloque",
"delete_choice": "Eliminar opción",
"disable_the_visibility_of_survey_progress": "Desactivar la visibilidad del progreso de la encuesta.",
"display_an_estimate_of_completion_time_for_survey": "Mostrar una estimación del tiempo de finalización de la encuesta",
"display_number_of_responses_for_survey": "Mostrar número de respuestas para la encuesta",
"display_type": "Tipo de visualización",
"divide": "Dividir /",
"does_not_contain": "No contiene",
"does_not_end_with": "No termina con",
@@ -1261,6 +1281,7 @@
"does_not_include_all_of": "No incluye todos los",
"does_not_include_one_of": "No incluye uno de",
"does_not_start_with": "No comienza con",
"dropdown": "Desplegable",
"duplicate_block": "Duplicar bloque",
"duplicate_question": "Duplicar pregunta",
"edit_link": "Editar enlace",
@@ -1390,10 +1411,10 @@
"key": "Clave",
"last_name": "Apellido",
"let_people_upload_up_to_25_files_at_the_same_time": "Permitir que las personas suban hasta 25 archivos al mismo tiempo.",
"limit_file_types": "Limitar tipos de archivo",
"limit_the_maximum_file_size": "Limitar el tamaño máximo de archivo",
"limit_upload_file_size_to": "Limitar tamaño de subida de archivos a",
"limit_the_maximum_file_size": "Limita el tamaño máximo de archivo para las subidas.",
"limit_upload_file_size_to": "Limitar el tamaño de archivo de subida a",
"link_survey_description": "Comparte un enlace a una página de encuesta o incrústala en una página web o correo electrónico.",
"list": "Lista",
"load_segment": "Cargar segmento",
"logic_error_warning": "El cambio causará errores lógicos",
"logic_error_warning_text": "Cambiar el tipo de pregunta eliminará las condiciones lógicas de esta pregunta",
@@ -1437,12 +1458,12 @@
"picture_idx": "Imagen {idx}",
"pin_can_only_contain_numbers": "El PIN solo puede contener números.",
"pin_must_be_a_four_digit_number": "El PIN debe ser un número de cuatro dígitos.",
"please_enter_a_file_extension": "Por favor, introduce una extensión de archivo.",
"please_enter_a_valid_url": "Por favor, introduce una URL válida (p. ej., https://example.com)",
"please_set_a_survey_trigger": "Establece un disparador de encuesta",
"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",
@@ -1451,7 +1472,8 @@
"question_deleted": "Pregunta eliminada.",
"question_duplicated": "Pregunta duplicada.",
"question_id_updated": "ID de pregunta actualizado",
"question_used_in_logic": "Esta pregunta se utiliza en la lógica de la pregunta {questionIndex}.",
"question_used_in_logic_warning_text": "Los elementos de este bloque se usan en una regla de lógica, ¿estás seguro de que quieres eliminarlo?",
"question_used_in_logic_warning_title": "Inconsistencia de lógica",
"question_used_in_quota": "Esta pregunta se está utilizando en la cuota \"{quotaName}\"",
"question_used_in_recall": "Esta pregunta se está recordando en la pregunta {questionIndex}.",
"question_used_in_recall_ending_card": "Esta pregunta se está recordando en la Tarjeta Final",
@@ -1515,6 +1537,7 @@
"search_for_images": "Buscar imágenes",
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "segundos después de activarse, la encuesta se cerrará si no hay respuesta",
"seconds_before_showing_the_survey": "segundos antes de mostrar la encuesta.",
"select_field": "Seleccionar campo",
"select_or_type_value": "Selecciona o escribe un valor",
"select_ordering": "Seleccionar ordenación",
"select_saved_action": "Seleccionar acción guardada",
@@ -1562,8 +1585,6 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Mostrar una sola vez, incluso si no responden.",
"then": "Entonces",
"this_action_will_remove_all_the_translations_from_this_survey": "Esta acción eliminará todas las traducciones de esta encuesta.",
"this_extension_is_already_added": "Esta extensión ya está añadida.",
"this_file_type_is_not_supported": "Este tipo de archivo no es compatible.",
"three_points": "3 puntos",
"times": "veces",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Para mantener la ubicación coherente en todas las encuestas, puedes",
@@ -1585,24 +1606,50 @@
"url_filters": "Filtros de URL",
"url_not_supported": "URL no compatible",
"validation": {
"characters": "caracteres",
"add_validation_rule": "Añadir regla de validación",
"answer_all_rows": "Responde todas las filas",
"characters": "Caracteres",
"contains": "Contiene",
"delete_validation_rule": "Eliminar regla de validación",
"does_not_contain": "No contiene",
"email": "Es un correo electrónico válido",
"max_length": "Es más corto que",
"end_date": "Fecha de finalización",
"file_extension_is": "La extensión del archivo es",
"file_extension_is_not": "La extensión del archivo no es",
"is": "Es",
"is_between": "Está entre",
"is_earlier_than": "Es anterior a",
"is_greater_than": "Es mayor que",
"is_later_than": "Es posterior a",
"is_less_than": "Es menor que",
"is_not": "No es",
"is_not_between": "No está entre",
"kb": "KB",
"max_length": "Como máximo",
"max_selections": "Como máximo",
"max_value": "Es menor que",
"min_length": "Es más largo que",
"max_value": "Como máximo",
"mb": "MB",
"min_length": "Al menos",
"min_selections": "Al menos",
"min_value": "Es mayor que",
"options_selected": "opciones seleccionadas",
"min_value": "Al menos",
"minimum_options_ranked": "Opciones mínimas clasificadas",
"minimum_rows_answered": "Filas mínimas respondidas",
"options_selected": "Opciones seleccionadas",
"pattern": "Coincide con el patrón regex",
"phone": "Es un teléfono válido",
"required": "Es obligatorio",
"rank_all_options": "Clasificar todas las opciones",
"select_file_extensions": "Selecciona extensiones de archivo...",
"select_option": "Seleccionar opción",
"start_date": "Fecha de inicio",
"url": "Es una URL válida"
},
"validation_logic_and": "Todas son verdaderas",
"validation_logic_or": "alguna es verdadera",
"validation_rules": "Reglas de validación",
"validation_rules_description": "Solo aceptar respuestas que cumplan los siguientes criterios",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} se usa en la lógica de la pregunta {questionIndex}. Por favor, elimínala primero de la lógica.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "La variable \"{variableName}\" se está utilizando en la cuota \"{quotaName}\"",
"variable_name_conflicts_with_hidden_field": "El nombre de la variable entra en conflicto con un ID de campo oculto existente.",
"variable_name_is_already_taken_please_choose_another": "El nombre de la variable ya está en uso, por favor elige otro.",
"variable_name_must_start_with_a_letter": "El nombre de la variable debe comenzar con una letra.",
"variable_used_in_recall": "La variable \"{variable}\" se está recuperando en la pregunta {questionIndex}.",
@@ -1658,6 +1705,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",
+69 -21
View File
@@ -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",
@@ -249,6 +254,7 @@
"label": "Étiquette",
"language": "Langue",
"learn_more": "En savoir plus",
"license_expired": "License Expired",
"light_overlay": "Claire",
"limits_reached": "Limites atteints",
"link": "Lien",
@@ -318,7 +324,7 @@
"placeholder": "Remplaçant",
"please_select_at_least_one_survey": "Veuillez sélectionner au moins une enquête.",
"please_select_at_least_one_trigger": "Veuillez sélectionner au moins un déclencheur.",
"please_upgrade_your_plan": "Veuillez mettre à niveau votre plan.",
"please_upgrade_your_plan": "Veuillez mettre à niveau votre plan",
"preview": "Aperçu",
"preview_survey": "Aperçu de l'enquête",
"privacy": "Politique de confidentialité",
@@ -455,7 +461,8 @@
"you_have_reached_your_limit_of_workspace_limit": "Vous avez atteint votre limite de {projectLimit} espaces de travail.",
"you_have_reached_your_monthly_miu_limit_of": "Vous avez atteint votre limite mensuelle de MIU de",
"you_have_reached_your_monthly_response_limit_of": "Vous avez atteint votre limite de réponses mensuelle de",
"you_will_be_downgraded_to_the_community_edition_on_date": "Vous serez rétrogradé à l'édition communautaire le {date}."
"you_will_be_downgraded_to_the_community_edition_on_date": "Vous serez rétrogradé à l'édition communautaire le {date}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
},
"emails": {
"accept": "Accepter",
@@ -608,7 +615,7 @@
"attribute_created_successfully": "Attribut créé avec succès",
"attribute_description": "Description",
"attribute_description_placeholder": "Brève description",
"attribute_key": "Clé",
"attribute_key": "Attribut",
"attribute_key_cannot_be_changed": "La clé ne peut pas être modifiée après la création",
"attribute_key_hint": "Uniquement des lettres minuscules, des chiffres et des underscores. Doit commencer par une lettre.",
"attribute_key_placeholder": "ex. date_de_naissance",
@@ -780,20 +787,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",
@@ -1005,6 +1018,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",
@@ -1156,7 +1171,6 @@
"adjust_survey_closed_message_description": "Modifiez le message que les visiteurs voient lorsque l'enquête est fermée.",
"adjust_the_theme_in_the": "Ajustez le thème dans le",
"all_other_answers_will_continue_to": "Toutes les autres réponses continueront à",
"allow_file_type": "Autoriser le type de fichier",
"allow_multi_select": "Autoriser la sélection multiple",
"allow_multiple_files": "Autoriser plusieurs fichiers",
"allow_users_to_select_more_than_one_image": "Permettre aux utilisateurs de sélectionner plusieurs images",
@@ -1166,6 +1180,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.",
@@ -1187,6 +1204,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",
@@ -1236,7 +1255,6 @@
"contact_fields": "Champs de contact",
"contains": "Contient",
"continue_to_settings": "Continuer vers les paramètres",
"control_which_file_types_can_be_uploaded": "Contrôlez quels types de fichiers peuvent être téléchargés.",
"convert_to_multiple_choice": "Convertir en choix multiples",
"convert_to_single_choice": "Convertir en choix unique",
"country": "Pays",
@@ -1249,11 +1267,13 @@
"darken_or_lighten_background_of_your_choice": "Assombrir ou éclaircir l'arrière-plan de votre choix.",
"date_format": "Format de date",
"days_before_showing_this_survey_again": "ou plus de jours doivent s'écouler entre le dernier sondage affiché et l'affichage de ce sondage.",
"delete_anyways": "Supprimer quand même",
"delete_block": "Supprimer le bloc",
"delete_choice": "Supprimer l'option",
"disable_the_visibility_of_survey_progress": "Désactiver la visibilité de la progression du sondage.",
"display_an_estimate_of_completion_time_for_survey": "Afficher une estimation du temps de complétion pour l'enquête.",
"display_number_of_responses_for_survey": "Afficher le nombre de réponses pour l'enquête",
"display_type": "Type d'affichage",
"divide": "Diviser /",
"does_not_contain": "Ne contient pas",
"does_not_end_with": "Ne se termine pas par",
@@ -1261,6 +1281,7 @@
"does_not_include_all_of": "n'inclut pas tout",
"does_not_include_one_of": "n'inclut pas un de",
"does_not_start_with": "Ne commence pas par",
"dropdown": "Menu déroulant",
"duplicate_block": "Dupliquer le bloc",
"duplicate_question": "Dupliquer la question",
"edit_link": "Modifier le lien",
@@ -1353,7 +1374,7 @@
"hide_question_settings": "Masquer les paramètres de la question",
"hostname": "Nom d'hôte",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "À quel point voulez-vous que vos cartes soient funky dans les enquêtes {surveyTypeDerived}",
"if_you_need_more_please": "Si vous en avez besoin de plus, s'il vous plaît",
"if_you_need_more_please": "Si vous avez besoin de plus, veuillez",
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuer à afficher à chaque déclenchement jusqu'à ce qu'une réponse soit soumise.",
"ignore_global_waiting_time": "Ignorer la période de refroidissement",
"ignore_global_waiting_time_description": "Cette enquête peut s'afficher chaque fois que ses conditions sont remplies, même si une autre enquête a été affichée récemment.",
@@ -1390,10 +1411,10 @@
"key": "Clé",
"last_name": "Nom de famille",
"let_people_upload_up_to_25_files_at_the_same_time": "Permettre aux utilisateurs de télécharger jusqu'à 25 fichiers en même temps.",
"limit_file_types": "Limiter les types de fichiers",
"limit_the_maximum_file_size": "Limiter la taille maximale du fichier",
"limit_upload_file_size_to": "Limiter la taille des fichiers téléchargés à",
"limit_the_maximum_file_size": "Limiter la taille maximale des fichiers pour les téléversements.",
"limit_upload_file_size_to": "Limiter la taille de téléversement des fichiers à",
"link_survey_description": "Partagez un lien vers une page d'enquête ou intégrez-le dans une page web ou un e-mail.",
"list": "Liste",
"load_segment": "Segment de chargement",
"logic_error_warning": "Changer causera des erreurs logiques",
"logic_error_warning_text": "Changer le type de question supprimera les conditions logiques de cette question.",
@@ -1405,7 +1426,7 @@
"matrix_all_fields": "Tous les champs",
"matrix_rows": "Lignes",
"max_file_size": "Taille maximale du fichier",
"max_file_size_limit_is": "La taille maximale du fichier est",
"max_file_size_limit_is": "La limite de taille maximale du fichier est",
"move_question_to_block": "Déplacer la question vers le bloc",
"multiply": "Multiplier *",
"needed_for_self_hosted_cal_com_instance": "Nécessaire pour une instance Cal.com auto-hébergée",
@@ -1437,12 +1458,12 @@
"picture_idx": "Image {idx}",
"pin_can_only_contain_numbers": "Le code PIN ne peut contenir que des chiffres.",
"pin_must_be_a_four_digit_number": "Le code PIN doit être un numéro à quatre chiffres.",
"please_enter_a_file_extension": "Veuillez entrer une extension de fichier.",
"please_enter_a_valid_url": "Veuillez entrer une URL valide (par exemple, https://example.com)",
"please_set_a_survey_trigger": "Veuillez définir un déclencheur d'enquête.",
"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",
@@ -1451,7 +1472,8 @@
"question_deleted": "Question supprimée.",
"question_duplicated": "Question dupliquée.",
"question_id_updated": "ID de la question mis à jour",
"question_used_in_logic": "Cette question est utilisée dans la logique de la question '{'questionIndex'}'.",
"question_used_in_logic_warning_text": "Des éléments de ce bloc sont utilisés dans une règle logique, êtes-vous sûr de vouloir le supprimer?",
"question_used_in_logic_warning_title": "Incohérence de logique",
"question_used_in_quota": "Cette question est utilisée dans le quota \"{quotaName}\"",
"question_used_in_recall": "Cette question est rappelée dans la question {questionIndex}.",
"question_used_in_recall_ending_card": "Cette question est rappelée dans la carte de fin.",
@@ -1515,6 +1537,7 @@
"search_for_images": "Rechercher des images",
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "Les secondes après le déclenchement, l'enquête sera fermée si aucune réponse n'est donnée.",
"seconds_before_showing_the_survey": "secondes avant de montrer l'enquête.",
"select_field": "Sélectionner un champ",
"select_or_type_value": "Sélectionnez ou saisissez une valeur",
"select_ordering": "Choisir l'ordre",
"select_saved_action": "Sélectionner une action enregistrée",
@@ -1562,8 +1585,6 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Afficher une seule fois, même si la personne ne répond pas.",
"then": "Alors",
"this_action_will_remove_all_the_translations_from_this_survey": "Cette action supprimera toutes les traductions de cette enquête.",
"this_extension_is_already_added": "Cette extension est déjà ajoutée.",
"this_file_type_is_not_supported": "Ce type de fichier n'est pas pris en charge.",
"three_points": "3 points",
"times": "fois",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Pour maintenir la cohérence du placement sur tous les sondages, vous pouvez",
@@ -1585,24 +1606,50 @@
"url_filters": "Filtres d'URL",
"url_not_supported": "URL non supportée",
"validation": {
"characters": "caractères",
"add_validation_rule": "Ajouter une règle de validation",
"answer_all_rows": "Répondre à toutes les lignes",
"characters": "Caractères",
"contains": "Contient",
"delete_validation_rule": "Supprimer la règle de validation",
"does_not_contain": "Ne contient pas",
"email": "Est un e-mail valide",
"max_length": "Est plus court que",
"end_date": "Date de fin",
"file_extension_is": "L'extension de fichier est",
"file_extension_is_not": "L'extension de fichier n'est pas",
"is": "Est",
"is_between": "Est entre",
"is_earlier_than": "Est antérieur à",
"is_greater_than": "Est supérieur à",
"is_later_than": "Est postérieur à",
"is_less_than": "Est inférieur à",
"is_not": "N'est pas",
"is_not_between": "N'est pas entre",
"kb": "Ko",
"max_length": "Au maximum",
"max_selections": "Au maximum",
"max_value": "Est inférieur à",
"min_length": "Est plus long que",
"max_value": "Au maximum",
"mb": "Mo",
"min_length": "Au moins",
"min_selections": "Au moins",
"min_value": "Est supérieur à",
"options_selected": "options sélectionnées",
"min_value": "Au moins",
"minimum_options_ranked": "Nombre minimum d'options classées",
"minimum_rows_answered": "Nombre minimum de lignes répondues",
"options_selected": "Options sélectionnées",
"pattern": "Correspond au modèle d'expression régulière",
"phone": "Est un numéro de téléphone valide",
"required": "Est requis",
"rank_all_options": "Classer toutes les options",
"select_file_extensions": "Sélectionner les extensions de fichier...",
"select_option": "Sélectionner une option",
"start_date": "Date de début",
"url": "Est une URL valide"
},
"validation_logic_and": "Toutes sont vraies",
"validation_logic_or": "au moins une est vraie",
"validation_rules": "Règles de validation",
"validation_rules_description": "Accepter uniquement les réponses qui répondent aux critères suivants",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} est utilisé dans la logique de la question {questionIndex}. Veuillez d'abord le supprimer de la logique.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "La variable \"{variableName}\" est utilisée dans le quota \"{quotaName}\"",
"variable_name_conflicts_with_hidden_field": "Le nom de la variable est en conflit avec un ID de champ masqué existant.",
"variable_name_is_already_taken_please_choose_another": "Le nom de la variable est déjà pris, veuillez en choisir un autre.",
"variable_name_must_start_with_a_letter": "Le nom de la variable doit commencer par une lettre.",
"variable_used_in_recall": "La variable \"{variable}\" est rappelée dans la question {questionIndex}.",
@@ -1658,6 +1705,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",
File diff suppressed because it is too large Load Diff
+67 -19
View File
@@ -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": "複製",
@@ -249,6 +254,7 @@
"label": "ラベル",
"language": "言語",
"learn_more": "詳細を見る",
"license_expired": "License Expired",
"light_overlay": "明るいオーバーレイ",
"limits_reached": "上限に達しました",
"link": "リンク",
@@ -318,7 +324,7 @@
"placeholder": "プレースホルダー",
"please_select_at_least_one_survey": "少なくとも1つのフォームを選択してください",
"please_select_at_least_one_trigger": "少なくとも1つのトリガーを選択してください",
"please_upgrade_your_plan": "プランをアップグレードしてください",
"please_upgrade_your_plan": "プランをアップグレードしてください",
"preview": "プレビュー",
"preview_survey": "フォームをプレビュー",
"privacy": "プライバシーポリシー",
@@ -455,7 +461,8 @@
"you_have_reached_your_limit_of_workspace_limit": "ワークスペースの上限である{projectLimit}件に達しました。",
"you_have_reached_your_monthly_miu_limit_of": "月間MIU(月間アクティブユーザー)の上限に達しました",
"you_have_reached_your_monthly_response_limit_of": "月間回答数の上限に達しました",
"you_will_be_downgraded_to_the_community_edition_on_date": "コミュニティ版へのダウングレードは {date} に行われます。"
"you_will_be_downgraded_to_the_community_edition_on_date": "コミュニティ版へのダウングレードは {date} に行われます。",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
},
"emails": {
"accept": "承認",
@@ -608,7 +615,7 @@
"attribute_created_successfully": "属性を作成しました",
"attribute_description": "説明",
"attribute_description_placeholder": "簡単な説明",
"attribute_key": "キー",
"attribute_key": "属性",
"attribute_key_cannot_be_changed": "キーは作成後に変更できません",
"attribute_key_hint": "小文字のアルファベット、数字、アンダースコアのみ使用可能です。アルファベットで始める必要があります。",
"attribute_key_placeholder": "例: date_of_birth",
@@ -780,20 +787,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にラベルを付ける",
@@ -1005,6 +1018,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": "テストメールを正常に送信しました",
@@ -1156,7 +1171,6 @@
"adjust_survey_closed_message_description": "フォームがクローズしたときに訪問者が見るメッセージを変更します。",
"adjust_the_theme_in_the": "テーマを",
"all_other_answers_will_continue_to": "他のすべての回答は引き続き",
"allow_file_type": "ファイルタイプを許可",
"allow_multi_select": "複数選択を許可",
"allow_multiple_files": "複数のファイルを許可",
"allow_users_to_select_more_than_one_image": "ユーザーが複数の画像を選択できるようにする",
@@ -1166,6 +1180,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": "ユーザーが一定秒数応答しない場合、フォームを自動的に閉じます。",
@@ -1187,6 +1204,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": "カードの背景色",
@@ -1236,7 +1255,6 @@
"contact_fields": "連絡先フィールド",
"contains": "を含む",
"continue_to_settings": "設定に進む",
"control_which_file_types_can_be_uploaded": "アップロードできるファイルの種類を制御します。",
"convert_to_multiple_choice": "複数選択に変換",
"convert_to_single_choice": "単一選択に変換",
"country": "国",
@@ -1249,11 +1267,13 @@
"darken_or_lighten_background_of_your_choice": "お好みの背景を暗くしたり明るくしたりします。",
"date_format": "日付形式",
"days_before_showing_this_survey_again": "最後に表示されたアンケートとこのアンケートを表示するまでに、この日数以上の期間を空ける必要があります。",
"delete_anyways": "削除する",
"delete_block": "ブロックを削除",
"delete_choice": "選択肢を削除",
"disable_the_visibility_of_survey_progress": "フォームの進捗状況の表示を無効にする。",
"display_an_estimate_of_completion_time_for_survey": "フォームの完了時間の目安を表示",
"display_number_of_responses_for_survey": "フォームの回答数を表示",
"display_type": "表示タイプ",
"divide": "除算 /",
"does_not_contain": "を含まない",
"does_not_end_with": "で終わらない",
@@ -1261,6 +1281,7 @@
"does_not_include_all_of": "のすべてを含まない",
"does_not_include_one_of": "のいずれも含まない",
"does_not_start_with": "で始まらない",
"dropdown": "ドロップダウン",
"duplicate_block": "ブロックを複製",
"duplicate_question": "質問を複製",
"edit_link": "編集 リンク",
@@ -1390,10 +1411,10 @@
"key": "キー",
"last_name": "姓",
"let_people_upload_up_to_25_files_at_the_same_time": "一度に最大25個のファイルをアップロードできるようにする。",
"limit_file_types": "ファイルタイプを制限",
"limit_the_maximum_file_size": "最大ファイルサイズを制限",
"limit_upload_file_size_to": "アップロードファイルサイズを以下に制限",
"limit_the_maximum_file_size": "アップロードの最大ファイルサイズを制限します。",
"limit_upload_file_size_to": "アップロードファイルサイズの上限",
"link_survey_description": "フォームページへのリンクを共有するか、ウェブページやメールに埋め込みます。",
"list": "リスト",
"load_segment": "セグメントを読み込み",
"logic_error_warning": "変更するとロジックエラーが発生します",
"logic_error_warning_text": "質問の種類を変更すると、この質問のロジック条件が削除されます",
@@ -1437,12 +1458,12 @@
"picture_idx": "写真 {idx}",
"pin_can_only_contain_numbers": "PINは数字のみでなければなりません。",
"pin_must_be_a_four_digit_number": "PINは4桁の数字でなければなりません。",
"please_enter_a_file_extension": "ファイル拡張子を入力してください。",
"please_enter_a_valid_url": "有効な URL を入力してください (例:https://example.com)",
"please_set_a_survey_trigger": "フォームのトリガーを設定してください",
"please_specify": "具体的に指定してください",
"prevent_double_submission": "二重送信を防ぐ",
"prevent_double_submission_description": "メールアドレスごとに1つの回答のみを許可する",
"progress_saved": "進捗を保存しました",
"protect_survey_with_pin": "PINでフォームを保護",
"protect_survey_with_pin_description": "PINを持つユーザーのみがフォームにアクセスできます。",
"publish": "公開",
@@ -1451,7 +1472,8 @@
"question_deleted": "質問を削除しました。",
"question_duplicated": "質問を複製しました。",
"question_id_updated": "質問IDを更新しました",
"question_used_in_logic": "この質問は質問 {questionIndex} のロジックで使用されています",
"question_used_in_logic_warning_text": "このブロックの要素はロジックルールで使用されていますが、本当に削除しますか?",
"question_used_in_logic_warning_title": "ロジックの不整合",
"question_used_in_quota": "この 質問 は \"{quotaName}\" の クオータ に使用されています",
"question_used_in_recall": "この 質問 は 質問 {questionIndex} で 呼び出され て います 。",
"question_used_in_recall_ending_card": "この 質問 は エンディング カード で 呼び出され て います。",
@@ -1515,6 +1537,7 @@
"search_for_images": "画像を検索",
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "トリガーから数秒後に回答がない場合、フォームは閉じられます",
"seconds_before_showing_the_survey": "秒後にフォームを表示します。",
"select_field": "フィールドを選択",
"select_or_type_value": "値を選択または入力",
"select_ordering": "順序を選択",
"select_saved_action": "保存済みのアクションを選択",
@@ -1562,8 +1585,6 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "回答がなくても1回だけ表示します。",
"then": "その後",
"this_action_will_remove_all_the_translations_from_this_survey": "このアクションは、このフォームからすべての翻訳を削除します。",
"this_extension_is_already_added": "この拡張機能はすでに追加されています。",
"this_file_type_is_not_supported": "このファイルタイプはサポートされていません。",
"three_points": "3点",
"times": "回",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "すべてのフォームの配置を一貫させるために、",
@@ -1585,24 +1606,50 @@
"url_filters": "URLフィルター",
"url_not_supported": "URLはサポートされていません",
"validation": {
"characters": "文字",
"add_validation_rule": "検証ルールを追加",
"answer_all_rows": "すべての行に回答してください",
"characters": "文字数",
"contains": "を含む",
"delete_validation_rule": "検証ルールを削除",
"does_not_contain": "を含まない",
"email": "有効なメールアドレスである",
"max_length": "より短い",
"end_date": "終了日",
"file_extension_is": "ファイル拡張子が次と一致",
"file_extension_is_not": "ファイル拡張子が次と一致しない",
"is": "である",
"is_between": "の間である",
"is_earlier_than": "より前である",
"is_greater_than": "より大きい",
"is_later_than": "より後である",
"is_less_than": "より小さい",
"is_not": "ではない",
"is_not_between": "の間ではない",
"kb": "KB",
"max_length": "最大",
"max_selections": "最大",
"max_value": "より小さい",
"min_length": "より長い",
"max_value": "最大",
"mb": "MB",
"min_length": "最小",
"min_selections": "最小",
"min_value": "より大きい",
"options_selected": "個のオプションが選択されている",
"min_value": "最小",
"minimum_options_ranked": "ランク付けされた最小オプション数",
"minimum_rows_answered": "回答された最小行数",
"options_selected": "選択されたオプション",
"pattern": "正規表現パターンに一致する",
"phone": "有効な電話番号である",
"required": "必須である",
"rank_all_options": "すべてのオプションをランク付け",
"select_file_extensions": "ファイル拡張子を選択...",
"select_option": "オプションを選択",
"start_date": "開始日",
"url": "有効なURLである"
},
"validation_logic_and": "すべてが真である",
"validation_logic_or": "いずれかが真",
"validation_rules": "検証ルール",
"validation_rules_description": "次の条件を満たす回答のみを受け付ける",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} は質問 {questionIndex} のロジックで使用されています。まず、ロジックから削除してください。",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "変数 \"{variableName}\" は \"{quotaName}\" クォータ で使用されています",
"variable_name_conflicts_with_hidden_field": "変数名が既存の非表示フィールドIDと競合しています。",
"variable_name_is_already_taken_please_choose_another": "変数名はすでに使用されています。別の名前を選択してください。",
"variable_name_must_start_with_a_letter": "変数名はアルファベットで始まらなければなりません。",
"variable_used_in_recall": "変数 \"{variable}\" が 質問 {questionIndex} で 呼び出され て います 。",
@@ -1658,6 +1705,7 @@
"error_downloading_responses": "回答のダウンロード中にエラーが発生しました",
"first_name": "名",
"how_to_identify_users": "ユーザーを識別する方法",
"ip_address": "IPアドレス",
"last_name": "姓",
"not_completed": "未完了 ⏳",
"os": "OS",
+69 -21
View File
@@ -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",
@@ -249,6 +254,7 @@
"label": "Label",
"language": "Taal",
"learn_more": "Meer informatie",
"license_expired": "License Expired",
"light_overlay": "Lichte overlay",
"limits_reached": "Grenzen bereikt",
"link": "Link",
@@ -318,7 +324,7 @@
"placeholder": "Tijdelijke aanduiding",
"please_select_at_least_one_survey": "Selecteer ten minste één enquête",
"please_select_at_least_one_trigger": "Selecteer ten minste één trigger",
"please_upgrade_your_plan": "Upgrade uw abonnement.",
"please_upgrade_your_plan": "Upgrade je abonnement",
"preview": "Voorbeeld",
"preview_survey": "Voorbeeld van enquête",
"privacy": "Privacybeleid",
@@ -455,7 +461,8 @@
"you_have_reached_your_limit_of_workspace_limit": "Je hebt je limiet van {projectLimit} werkruimtes bereikt.",
"you_have_reached_your_monthly_miu_limit_of": "U heeft uw maandelijkse MIU-limiet van bereikt",
"you_have_reached_your_monthly_response_limit_of": "U heeft uw maandelijkse responslimiet bereikt van",
"you_will_be_downgraded_to_the_community_edition_on_date": "Je wordt gedowngraded naar de Community-editie op {date}."
"you_will_be_downgraded_to_the_community_edition_on_date": "Je wordt gedowngraded naar de Community-editie op {date}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
},
"emails": {
"accept": "Accepteren",
@@ -608,7 +615,7 @@
"attribute_created_successfully": "Attribuut succesvol aangemaakt",
"attribute_description": "Beschrijving",
"attribute_description_placeholder": "Korte beschrijving",
"attribute_key": "Sleutel",
"attribute_key": "Kenmerk",
"attribute_key_cannot_be_changed": "Sleutel kan niet worden gewijzigd na aanmaak",
"attribute_key_hint": "Alleen kleine letters, cijfers en onderstrepingstekens. Moet beginnen met een letter.",
"attribute_key_placeholder": "bijv. geboortedatum",
@@ -780,20 +787,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",
@@ -1005,6 +1018,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",
@@ -1156,7 +1171,6 @@
"adjust_survey_closed_message_description": "Wijzig het bericht dat bezoekers zien wanneer de enquête wordt gesloten.",
"adjust_the_theme_in_the": "Pas het thema aan in de",
"all_other_answers_will_continue_to": "Alle andere antwoorden blijven hetzelfde",
"allow_file_type": "Bestandstype toestaan",
"allow_multi_select": "Multi-select toestaan",
"allow_multiple_files": "Meerdere bestanden toestaan",
"allow_users_to_select_more_than_one_image": "Sta gebruikers toe meer dan één afbeelding te selecteren",
@@ -1166,6 +1180,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.",
@@ -1187,6 +1204,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",
@@ -1236,7 +1255,6 @@
"contact_fields": "Contactvelden",
"contains": "Bevat",
"continue_to_settings": "Ga verder naar Instellingen",
"control_which_file_types_can_be_uploaded": "Bepaal welke bestandstypen kunnen worden geüpload.",
"convert_to_multiple_choice": "Converteren naar Multi-select",
"convert_to_single_choice": "Converteren naar Enkele selectie",
"country": "Land",
@@ -1249,11 +1267,13 @@
"darken_or_lighten_background_of_your_choice": "Maak de achtergrond naar keuze donkerder of lichter.",
"date_format": "Datumformaat",
"days_before_showing_this_survey_again": "of meer dagen moeten verstrijken tussen de laatst getoonde enquête en het tonen van deze enquête.",
"delete_anyways": "Toch verwijderen",
"delete_block": "Blok verwijderen",
"delete_choice": "Keuze verwijderen",
"disable_the_visibility_of_survey_progress": "Schakel de zichtbaarheid van de voortgang van het onderzoek uit.",
"display_an_estimate_of_completion_time_for_survey": "Geef een schatting weer van de voltooiingstijd voor het onderzoek",
"display_number_of_responses_for_survey": "Weergave aantal reacties voor enquête",
"display_type": "Weergavetype",
"divide": "Verdeling /",
"does_not_contain": "Bevat niet",
"does_not_end_with": "Eindigt niet met",
@@ -1261,6 +1281,7 @@
"does_not_include_all_of": "Omvat niet alles",
"does_not_include_one_of": "Bevat niet een van",
"does_not_start_with": "Begint niet met",
"dropdown": "Dropdown",
"duplicate_block": "Blok dupliceren",
"duplicate_question": "Vraag dupliceren",
"edit_link": "Link bewerken",
@@ -1353,7 +1374,7 @@
"hide_question_settings": "Vraaginstellingen verbergen",
"hostname": "Hostnaam",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Hoe funky wil je je kaarten hebben in {surveyTypeDerived} Enquêtes",
"if_you_need_more_please": "Als u meer nodig heeft, alstublieft",
"if_you_need_more_please": "Als je meer nodig hebt,",
"if_you_really_want_that_answer_ask_until_you_get_it": "Blijf tonen wanneer geactiveerd totdat een reactie is ingediend.",
"ignore_global_waiting_time": "Afkoelperiode negeren",
"ignore_global_waiting_time_description": "Deze enquête kan worden getoond wanneer aan de voorwaarden wordt voldaan, zelfs als er onlangs een andere enquête is getoond.",
@@ -1390,10 +1411,10 @@
"key": "Sleutel",
"last_name": "Achternaam",
"let_people_upload_up_to_25_files_at_the_same_time": "Laat mensen maximaal 25 bestanden tegelijk uploaden.",
"limit_file_types": "Beperk bestandstypen",
"limit_the_maximum_file_size": "Beperk de maximale bestandsgrootte",
"limit_upload_file_size_to": "Beperk de uploadbestandsgrootte tot",
"limit_the_maximum_file_size": "Beperk de maximale bestandsgrootte voor uploads.",
"limit_upload_file_size_to": "Beperk uploadbestandsgrootte tot",
"link_survey_description": "Deel een link naar een enquêtepagina of sluit deze in op een webpagina of e-mail.",
"list": "Lijst",
"load_segment": "Laadsegment",
"logic_error_warning": "Wijzigen zal logische fouten veroorzaken",
"logic_error_warning_text": "Als u het vraagtype wijzigt, worden de logische voorwaarden van deze vraag verwijderd",
@@ -1405,7 +1426,7 @@
"matrix_all_fields": "Alle velden",
"matrix_rows": "Rijen",
"max_file_size": "Maximale bestandsgrootte",
"max_file_size_limit_is": "De maximale bestandsgrootte is",
"max_file_size_limit_is": "Maximale bestandsgroottelimiet is",
"move_question_to_block": "Vraag naar blok verplaatsen",
"multiply": "Vermenigvuldig *",
"needed_for_self_hosted_cal_com_instance": "Nodig voor een zelf-gehoste Cal.com-instantie",
@@ -1437,12 +1458,12 @@
"picture_idx": "Afbeelding {idx}",
"pin_can_only_contain_numbers": "De pincode kan alleen cijfers bevatten.",
"pin_must_be_a_four_digit_number": "De pincode moet uit vier cijfers bestaan.",
"please_enter_a_file_extension": "Voer een bestandsextensie in.",
"please_enter_a_valid_url": "Voer een geldige URL in (bijvoorbeeld https://example.com)",
"please_set_a_survey_trigger": "Stel een enquêtetrigger in",
"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",
@@ -1451,7 +1472,8 @@
"question_deleted": "Vraag verwijderd.",
"question_duplicated": "Vraag dubbel gesteld.",
"question_id_updated": "Vraag-ID bijgewerkt",
"question_used_in_logic": "Deze vraag wordt gebruikt in de logica van vraag {questionIndex}.",
"question_used_in_logic_warning_text": "Elementen uit dit blok worden gebruikt in een logische regel, weet je zeker dat je het wilt verwijderen?",
"question_used_in_logic_warning_title": "Logica-inconsistentie",
"question_used_in_quota": "Deze vraag wordt gebruikt in het quotum '{quotaName}'",
"question_used_in_recall": "Deze vraag wordt teruggehaald in vraag {questionIndex}.",
"question_used_in_recall_ending_card": "Deze vraag wordt teruggeroepen in de Eindkaart",
@@ -1515,6 +1537,7 @@
"search_for_images": "Zoek naar afbeeldingen",
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "seconden na trigger wordt de enquête gesloten als er geen reactie is",
"seconds_before_showing_the_survey": "seconden voordat de enquête wordt weergegeven.",
"select_field": "Selecteer veld",
"select_or_type_value": "Selecteer of typ een waarde",
"select_ordering": "Selecteer bestellen",
"select_saved_action": "Selecteer opgeslagen actie",
@@ -1562,8 +1585,6 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Toon één keer, zelfs als ze niet reageren.",
"then": "Dan",
"this_action_will_remove_all_the_translations_from_this_survey": "Met deze actie worden alle vertalingen uit deze enquête verwijderd.",
"this_extension_is_already_added": "Deze extensie is al toegevoegd.",
"this_file_type_is_not_supported": "Dit bestandstype wordt niet ondersteund.",
"three_points": "3 punten",
"times": "keer",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Om de plaatsing over alle enquêtes consistent te houden, kunt u dat doen",
@@ -1585,24 +1606,50 @@
"url_filters": "URL-filters",
"url_not_supported": "URL niet ondersteund",
"validation": {
"characters": "tekens",
"add_validation_rule": "Validatieregel toevoegen",
"answer_all_rows": "Beantwoord alle rijen",
"characters": "Tekens",
"contains": "Bevat",
"delete_validation_rule": "Validatieregel verwijderen",
"does_not_contain": "Bevat niet",
"email": "Is geldig e-mailadres",
"max_length": "Is korter dan",
"end_date": "Einddatum",
"file_extension_is": "Bestandsextensie is",
"file_extension_is_not": "Bestandsextensie is niet",
"is": "Is",
"is_between": "Is tussen",
"is_earlier_than": "Is eerder dan",
"is_greater_than": "Is groter dan",
"is_later_than": "Is later dan",
"is_less_than": "Is minder dan",
"is_not": "Is niet",
"is_not_between": "Is niet tussen",
"kb": "KB",
"max_length": "Maximaal",
"max_selections": "Maximaal",
"max_value": "Is minder dan",
"min_length": "Is langer dan",
"max_value": "Maximaal",
"mb": "MB",
"min_length": "Minimaal",
"min_selections": "Minimaal",
"min_value": "Is groter dan",
"options_selected": "opties geselecteerd",
"min_value": "Minimaal",
"minimum_options_ranked": "Minimaal aantal gerangschikte opties",
"minimum_rows_answered": "Minimaal aantal beantwoorde rijen",
"options_selected": "Opties geselecteerd",
"pattern": "Komt overeen met regex-patroon",
"phone": "Is geldig telefoonnummer",
"required": "Is verplicht",
"rank_all_options": "Rangschik alle opties",
"select_file_extensions": "Selecteer bestandsextensies...",
"select_option": "Optie selecteren",
"start_date": "Startdatum",
"url": "Is geldige URL"
},
"validation_logic_and": "Alle zijn waar",
"validation_logic_or": "een is waar",
"validation_rules": "Validatieregels",
"validation_rules_description": "Accepteer alleen antwoorden die voldoen aan de volgende criteria",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} wordt gebruikt in de logica van vraag {questionIndex}. Verwijder het eerst uit de logica.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variabele \"{variableName}\" wordt gebruikt in het \"{quotaName}\" quotum",
"variable_name_conflicts_with_hidden_field": "Variabelenaam conflicteert met een bestaande verborgen veld-ID.",
"variable_name_is_already_taken_please_choose_another": "Variabelenaam is al in gebruik, kies een andere.",
"variable_name_must_start_with_a_letter": "Variabelenaam moet beginnen met een letter.",
"variable_used_in_recall": "Variabele \"{variable}\" wordt opgeroepen in vraag {questionIndex}.",
@@ -1658,6 +1705,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",
+68 -20
View File
@@ -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",
@@ -249,6 +254,7 @@
"label": "Etiqueta",
"language": "Língua",
"learn_more": "Saiba mais",
"license_expired": "License Expired",
"light_overlay": "sobreposição leve",
"limits_reached": "Limites Atingidos",
"link": "link",
@@ -318,7 +324,7 @@
"placeholder": "Espaço reservado",
"please_select_at_least_one_survey": "Por favor, selecione pelo menos uma pesquisa",
"please_select_at_least_one_trigger": "Por favor, selecione pelo menos um gatilho",
"please_upgrade_your_plan": "Por favor, atualize seu plano.",
"please_upgrade_your_plan": "Por favor, atualize seu plano",
"preview": "Prévia",
"preview_survey": "Prévia da Pesquisa",
"privacy": "Política de Privacidade",
@@ -455,7 +461,8 @@
"you_have_reached_your_limit_of_workspace_limit": "Você atingiu seu limite de {projectLimit} espaços de trabalho.",
"you_have_reached_your_monthly_miu_limit_of": "Você atingiu o seu limite mensal de MIU de",
"you_have_reached_your_monthly_response_limit_of": "Você atingiu o limite mensal de respostas de",
"you_will_be_downgraded_to_the_community_edition_on_date": "Você será rebaixado para a Edição Comunitária em {date}."
"you_will_be_downgraded_to_the_community_edition_on_date": "Você será rebaixado para a Edição Comunitária em {date}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
},
"emails": {
"accept": "Aceitar",
@@ -608,7 +615,7 @@
"attribute_created_successfully": "Atributo criado com sucesso",
"attribute_description": "Descrição",
"attribute_description_placeholder": "Descrição curta",
"attribute_key": "Chave",
"attribute_key": "atributo",
"attribute_key_cannot_be_changed": "A chave não pode ser alterada após a criação",
"attribute_key_hint": "Apenas letras minúsculas, números e underscores. Deve começar com uma letra.",
"attribute_key_placeholder": "ex: data_de_nascimento",
@@ -780,20 +787,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",
@@ -1005,6 +1018,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",
@@ -1156,7 +1171,6 @@
"adjust_survey_closed_message_description": "Mude a mensagem que os visitantes veem quando a pesquisa está fechada.",
"adjust_the_theme_in_the": "Ajuste o tema no",
"all_other_answers_will_continue_to": "Todas as outras respostas continuarão a",
"allow_file_type": "Permitir tipo de arquivo",
"allow_multi_select": "Permitir seleção múltipla",
"allow_multiple_files": "Permitir vários arquivos",
"allow_users_to_select_more_than_one_image": "Permitir que os usuários selecionem mais de uma imagem",
@@ -1166,6 +1180,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.",
@@ -1187,6 +1204,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",
@@ -1236,7 +1255,6 @@
"contact_fields": "Campos de Contato",
"contains": "contém",
"continue_to_settings": "Continuar para Configurações",
"control_which_file_types_can_be_uploaded": "Controlar quais tipos de arquivos podem ser enviados.",
"convert_to_multiple_choice": "Converter para Múltipla Escolha",
"convert_to_single_choice": "Converter para Escolha Única",
"country": "país",
@@ -1249,11 +1267,13 @@
"darken_or_lighten_background_of_your_choice": "Escureça ou clareie o fundo da sua escolha.",
"date_format": "Formato de data",
"days_before_showing_this_survey_again": "ou mais dias devem passar entre a última pesquisa exibida e a exibição desta pesquisa.",
"delete_anyways": "Excluir mesmo assim",
"delete_block": "Excluir bloco",
"delete_choice": "Deletar opção",
"disable_the_visibility_of_survey_progress": "Desativar a visibilidade do progresso da pesquisa.",
"display_an_estimate_of_completion_time_for_survey": "Mostrar uma estimativa de tempo de conclusão da pesquisa",
"display_number_of_responses_for_survey": "Mostrar número de respostas da pesquisa",
"display_type": "Tipo de exibição",
"divide": "Divida /",
"does_not_contain": "não contém",
"does_not_end_with": "Não termina com",
@@ -1261,6 +1281,7 @@
"does_not_include_all_of": "Não inclui todos de",
"does_not_include_one_of": "Não inclui um de",
"does_not_start_with": "Não começa com",
"dropdown": "Menu suspenso",
"duplicate_block": "Duplicar bloco",
"duplicate_question": "Duplicar pergunta",
"edit_link": "Editar link",
@@ -1390,10 +1411,10 @@
"key": "chave",
"last_name": "Sobrenome",
"let_people_upload_up_to_25_files_at_the_same_time": "Deixe as pessoas fazerem upload de até 25 arquivos ao mesmo tempo.",
"limit_file_types": "Limitar tipos de arquivos",
"limit_the_maximum_file_size": "Limitar o tamanho máximo do arquivo",
"limit_upload_file_size_to": "Limitar tamanho do arquivo de upload para",
"limit_the_maximum_file_size": "Limitar o tamanho máximo de arquivo para uploads.",
"limit_upload_file_size_to": "Limitar tamanho de arquivo de upload para",
"link_survey_description": "Compartilhe um link para a página da pesquisa ou incorpore-a em uma página da web ou e-mail.",
"list": "Lista",
"load_segment": "segmento de carga",
"logic_error_warning": "Mudar vai causar erros de lógica",
"logic_error_warning_text": "Mudar o tipo de pergunta vai remover as condições lógicas dessa pergunta",
@@ -1405,7 +1426,7 @@
"matrix_all_fields": "Todos os campos",
"matrix_rows": "Linhas",
"max_file_size": "Tamanho máximo do arquivo",
"max_file_size_limit_is": "Tamanho máximo do arquivo é",
"max_file_size_limit_is": "O limite de tamanho máximo do arquivo é",
"move_question_to_block": "Mover pergunta para o bloco",
"multiply": "Multiplicar *",
"needed_for_self_hosted_cal_com_instance": "Necessário para uma instância auto-hospedada do Cal.com",
@@ -1437,12 +1458,12 @@
"picture_idx": "Imagem {idx}",
"pin_can_only_contain_numbers": "O PIN só pode conter números.",
"pin_must_be_a_four_digit_number": "O PIN deve ser um número de quatro dígitos.",
"please_enter_a_file_extension": "Por favor, insira uma extensão de arquivo.",
"please_enter_a_valid_url": "Por favor, insira uma URL válida (ex.: https://example.com)",
"please_set_a_survey_trigger": "Por favor, configure um gatilho para a pesquisa",
"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",
@@ -1451,7 +1472,8 @@
"question_deleted": "Pergunta deletada.",
"question_duplicated": "Pergunta duplicada.",
"question_id_updated": "ID da pergunta atualizado",
"question_used_in_logic": "Essa pergunta é usada na lógica da pergunta {questionIndex}.",
"question_used_in_logic_warning_text": "Elementos deste bloco são usados em uma regra de lógica, tem certeza de que deseja excluí-lo?",
"question_used_in_logic_warning_title": "Inconsistência de lógica",
"question_used_in_quota": "Esta questão está sendo usada na cota \"{quotaName}\"",
"question_used_in_recall": "Esta pergunta está sendo recordada na pergunta {questionIndex}.",
"question_used_in_recall_ending_card": "Esta pergunta está sendo recordada no card de Encerramento",
@@ -1515,6 +1537,7 @@
"search_for_images": "Buscar imagens",
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "segundos após acionar, a pesquisa será encerrada se não houver resposta",
"seconds_before_showing_the_survey": "segundos antes de mostrar a pesquisa.",
"select_field": "Selecionar campo",
"select_or_type_value": "Selecionar ou digitar valor",
"select_ordering": "Selecionar pedido",
"select_saved_action": "Selecionar ação salva",
@@ -1562,8 +1585,6 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Mostrar uma única vez, mesmo que não respondam.",
"then": "Então",
"this_action_will_remove_all_the_translations_from_this_survey": "Essa ação vai remover todas as traduções dessa pesquisa.",
"this_extension_is_already_added": "Essa extensão já foi adicionada.",
"this_file_type_is_not_supported": "Esse tipo de arquivo não é suportado.",
"three_points": "3 pontos",
"times": "times",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Para manter a colocação consistente em todas as pesquisas, você pode",
@@ -1585,24 +1606,50 @@
"url_filters": "Filtros de URL",
"url_not_supported": "URL não suportada",
"validation": {
"characters": "caracteres",
"add_validation_rule": "Adicionar regra de validação",
"answer_all_rows": "Responda todas as linhas",
"characters": "Caracteres",
"contains": "Contém",
"delete_validation_rule": "Excluir regra de validação",
"does_not_contain": "Não contém",
"email": "É um e-mail válido",
"max_length": "É menor que",
"end_date": "Data final",
"file_extension_is": "A extensão do arquivo é",
"file_extension_is_not": "A extensão do arquivo não é",
"is": "É",
"is_between": "Está entre",
"is_earlier_than": "É anterior a",
"is_greater_than": "É maior que",
"is_later_than": "É posterior a",
"is_less_than": "É menor que",
"is_not": "Não é",
"is_not_between": "Não está entre",
"kb": "KB",
"max_length": "No máximo",
"max_selections": "No máximo",
"max_value": "É menor que",
"min_length": "É maior que",
"max_value": "No máximo",
"mb": "MB",
"min_length": "No mínimo",
"min_selections": "No mínimo",
"min_value": "É maior que",
"options_selected": "opções selecionadas",
"min_value": "No mínimo",
"minimum_options_ranked": "Mínimo de opções classificadas",
"minimum_rows_answered": "Mínimo de linhas respondidas",
"options_selected": "Opções selecionadas",
"pattern": "Corresponde ao padrão regex",
"phone": "É um telefone válido",
"required": "É obrigatório",
"rank_all_options": "Classificar todas as opções",
"select_file_extensions": "Selecionar extensões de arquivo...",
"select_option": "Selecionar opção",
"start_date": "Data inicial",
"url": "É uma URL válida"
},
"validation_logic_and": "Todas são verdadeiras",
"validation_logic_or": "qualquer uma é verdadeira",
"validation_rules": "Regras de validação",
"validation_rules_description": "Aceitar apenas respostas que atendam aos seguintes critérios",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} está sendo usado na lógica da pergunta {questionIndex}. Por favor, remova-o da lógica primeiro.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variável \"{variableName}\" está sendo usada na cota \"{quotaName}\"",
"variable_name_conflicts_with_hidden_field": "O nome da variável está em conflito com um ID de campo oculto existente.",
"variable_name_is_already_taken_please_choose_another": "O nome da variável já está em uso, por favor escolha outro.",
"variable_name_must_start_with_a_letter": "O nome da variável deve começar com uma letra.",
"variable_used_in_recall": "Variável \"{variable}\" está sendo recordada na pergunta {questionIndex}.",
@@ -1658,6 +1705,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",
+69 -21
View File
@@ -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",
@@ -249,6 +254,7 @@
"label": "Etiqueta",
"language": "Idioma",
"learn_more": "Saiba mais",
"license_expired": "License Expired",
"light_overlay": "Sobreposição leve",
"limits_reached": "Limites Atingidos",
"link": "Link",
@@ -318,7 +324,7 @@
"placeholder": "Espaço reservado",
"please_select_at_least_one_survey": "Por favor, selecione pelo menos um inquérito",
"please_select_at_least_one_trigger": "Por favor, selecione pelo menos um gatilho",
"please_upgrade_your_plan": "Por favor, atualize o seu plano.",
"please_upgrade_your_plan": "Por favor, atualize o seu plano",
"preview": "Pré-visualização",
"preview_survey": "Pré-visualização do inquérito",
"privacy": "Política de Privacidade",
@@ -455,7 +461,8 @@
"you_have_reached_your_limit_of_workspace_limit": "Atingiu o seu limite de {projectLimit} áreas de trabalho.",
"you_have_reached_your_monthly_miu_limit_of": "Atingiu o seu limite mensal de MIU de",
"you_have_reached_your_monthly_response_limit_of": "Atingiu o seu limite mensal de respostas de",
"you_will_be_downgraded_to_the_community_edition_on_date": "Será rebaixado para a Edição Comunitária em {date}."
"you_will_be_downgraded_to_the_community_edition_on_date": "Será rebaixado para a Edição Comunitária em {date}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
},
"emails": {
"accept": "Aceitar",
@@ -608,7 +615,7 @@
"attribute_created_successfully": "Atributo criado com sucesso",
"attribute_description": "Descrição",
"attribute_description_placeholder": "Descrição breve",
"attribute_key": "Chave",
"attribute_key": "Atributo",
"attribute_key_cannot_be_changed": "A chave não pode ser alterada após a criação",
"attribute_key_hint": "Apenas letras minúsculas, números e sublinhados. Deve começar com uma letra.",
"attribute_key_placeholder": "ex. data_de_nascimento",
@@ -780,20 +787,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",
@@ -1005,6 +1018,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",
@@ -1156,7 +1171,6 @@
"adjust_survey_closed_message_description": "Alterar a mensagem que os visitantes veem quando o inquérito está fechado.",
"adjust_the_theme_in_the": "Ajustar o tema no",
"all_other_answers_will_continue_to": "Todas as outras respostas continuarão a",
"allow_file_type": "Permitir tipo de ficheiro",
"allow_multi_select": "Permitir seleção múltipla",
"allow_multiple_files": "Permitir vários ficheiros",
"allow_users_to_select_more_than_one_image": "Permitir aos utilizadores selecionar mais do que uma imagem",
@@ -1166,6 +1180,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.",
@@ -1187,6 +1204,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",
@@ -1236,7 +1255,6 @@
"contact_fields": "Campos de Contacto",
"contains": "Contém",
"continue_to_settings": "Continuar para Definições",
"control_which_file_types_can_be_uploaded": "Controlar quais tipos de ficheiros podem ser carregados.",
"convert_to_multiple_choice": "Converter para Seleção Múltipla",
"convert_to_single_choice": "Converter para Seleção Única",
"country": "País",
@@ -1249,11 +1267,13 @@
"darken_or_lighten_background_of_your_choice": "Escurecer ou clarear o fundo da sua escolha.",
"date_format": "Formato da data",
"days_before_showing_this_survey_again": "ou mais dias a decorrer entre o último inquérito apresentado e a apresentação deste inquérito.",
"delete_anyways": "Eliminar mesmo assim",
"delete_block": "Eliminar bloco",
"delete_choice": "Eliminar escolha",
"disable_the_visibility_of_survey_progress": "Desativar a visibilidade do progresso da pesquisa.",
"display_an_estimate_of_completion_time_for_survey": "Mostrar uma estimativa do tempo de conclusão do inquérito",
"display_number_of_responses_for_survey": "Mostrar número de respostas do inquérito",
"display_type": "Tipo de exibição",
"divide": "Dividir /",
"does_not_contain": "Não contém",
"does_not_end_with": "Não termina com",
@@ -1261,6 +1281,7 @@
"does_not_include_all_of": "Não inclui todos de",
"does_not_include_one_of": "Não inclui um de",
"does_not_start_with": "Não começa com",
"dropdown": "Menu suspenso",
"duplicate_block": "Duplicar bloco",
"duplicate_question": "Duplicar pergunta",
"edit_link": "Editar link",
@@ -1390,10 +1411,10 @@
"key": "Chave",
"last_name": "Apelido",
"let_people_upload_up_to_25_files_at_the_same_time": "Permitir que as pessoas carreguem até 25 ficheiros ao mesmo tempo.",
"limit_file_types": "Limitar tipos de ficheiros",
"limit_the_maximum_file_size": "Limitar o tamanho máximo do ficheiro",
"limit_upload_file_size_to": "Limitar tamanho do ficheiro carregado a",
"limit_the_maximum_file_size": "Limitar o tamanho máximo de ficheiro para carregamentos.",
"limit_upload_file_size_to": "Limitar o tamanho de ficheiro de carregamento para",
"link_survey_description": "Partilhe um link para uma página de inquérito ou incorpore-o numa página web ou email.",
"list": "Lista",
"load_segment": "Carregar segmento",
"logic_error_warning": "A alteração causará erros de lógica",
"logic_error_warning_text": "Alterar o tipo de pergunta irá remover as condições lógicas desta pergunta",
@@ -1404,8 +1425,8 @@
"manage_languages": "Gerir Idiomas",
"matrix_all_fields": "Todos os campos",
"matrix_rows": "Linhas",
"max_file_size": "Tamanho máximo do ficheiro",
"max_file_size_limit_is": "O limite do tamanho máximo do ficheiro é",
"max_file_size": "Tamanho máximo de ficheiro",
"max_file_size_limit_is": "O limite de tamanho máximo de ficheiro é",
"move_question_to_block": "Mover pergunta para o bloco",
"multiply": "Multiplicar *",
"needed_for_self_hosted_cal_com_instance": "Necessário para uma instância auto-hospedada do Cal.com",
@@ -1437,12 +1458,12 @@
"picture_idx": "Imagem {idx}",
"pin_can_only_contain_numbers": "O PIN só pode conter números.",
"pin_must_be_a_four_digit_number": "O PIN deve ser um número de quatro dígitos.",
"please_enter_a_file_extension": "Por favor, insira uma extensão de ficheiro.",
"please_enter_a_valid_url": "Por favor, insira um URL válido (por exemplo, https://example.com)",
"please_set_a_survey_trigger": "Por favor, defina um desencadeador de inquérito",
"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",
@@ -1451,7 +1472,8 @@
"question_deleted": "Pergunta eliminada.",
"question_duplicated": "Pergunta duplicada.",
"question_id_updated": "ID da pergunta atualizado",
"question_used_in_logic": "Esta pergunta é usada na lógica da pergunta {questionIndex}.",
"question_used_in_logic_warning_text": "Os elementos deste bloco são utilizados numa regra de lógica, tem a certeza de que pretende eliminá-lo?",
"question_used_in_logic_warning_title": "Inconsistência de lógica",
"question_used_in_quota": "Esta pergunta está a ser usada na quota \"{quotaName}\"",
"question_used_in_recall": "Esta pergunta está a ser recordada na pergunta {questionIndex}.",
"question_used_in_recall_ending_card": "Esta pergunta está a ser recordada no Cartão de Conclusão",
@@ -1515,6 +1537,7 @@
"search_for_images": "Procurar imagens",
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "segundos após o acionamento o inquérito será fechado se não houver resposta",
"seconds_before_showing_the_survey": "segundos antes de mostrar o inquérito.",
"select_field": "Selecionar campo",
"select_or_type_value": "Selecionar ou digitar valor",
"select_ordering": "Selecionar ordem",
"select_saved_action": "Selecionar ação guardada",
@@ -1562,8 +1585,6 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Mostrar uma única vez, mesmo que não respondam.",
"then": "Então",
"this_action_will_remove_all_the_translations_from_this_survey": "Esta ação irá remover todas as traduções deste inquérito.",
"this_extension_is_already_added": "Esta extensão já está adicionada.",
"this_file_type_is_not_supported": "Este tipo de ficheiro não é suportado.",
"three_points": "3 pontos",
"times": "tempos",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Para manter a colocação consistente em todos os questionários, pode",
@@ -1585,24 +1606,50 @@
"url_filters": "Filtros de URL",
"url_not_supported": "URL não suportado",
"validation": {
"characters": "caracteres",
"add_validation_rule": "Adicionar regra de validação",
"answer_all_rows": "Responda a todas as linhas",
"characters": "Caracteres",
"contains": "Contém",
"delete_validation_rule": "Eliminar regra de validação",
"does_not_contain": "Não contém",
"email": "É um email válido",
"max_length": "É mais curto que",
"end_date": "Data de fim",
"file_extension_is": "A extensão do ficheiro é",
"file_extension_is_not": "A extensão do ficheiro não é",
"is": "É",
"is_between": "Está entre",
"is_earlier_than": "É anterior a",
"is_greater_than": "É maior que",
"is_later_than": "É posterior a",
"is_less_than": "É menor que",
"is_not": "Não é",
"is_not_between": "Não está entre",
"kb": "KB",
"max_length": "No máximo",
"max_selections": "No máximo",
"max_value": "É menos que",
"min_length": "É mais longo que",
"max_value": "No máximo",
"mb": "MB",
"min_length": "Pelo menos",
"min_selections": "Pelo menos",
"min_value": "É maior que",
"options_selected": "opções selecionadas",
"min_value": "Pelo menos",
"minimum_options_ranked": "Opções mínimas classificadas",
"minimum_rows_answered": "Linhas mínimas respondidas",
"options_selected": "Opções selecionadas",
"pattern": "Coincide com o padrão regex",
"phone": "É um telefone válido",
"required": "É obrigatório",
"rank_all_options": "Classificar todas as opções",
"select_file_extensions": "Selecionar extensões de ficheiro...",
"select_option": "Selecionar opção",
"start_date": "Data de início",
"url": "É um URL válido"
},
"validation_logic_and": "Todas são verdadeiras",
"validation_logic_or": "qualquer uma é verdadeira",
"validation_rules": "Regras de validação",
"validation_rules_description": "Aceitar apenas respostas que cumpram os seguintes critérios",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} é usada na lógica da pergunta {questionIndex}. Por favor, remova-a da lógica primeiro.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variável \"{variableName}\" está a ser utilizada na quota \"{quotaName}\"",
"variable_name_conflicts_with_hidden_field": "O nome da variável está em conflito com um ID de campo oculto existente.",
"variable_name_is_already_taken_please_choose_another": "O nome da variável já está em uso, por favor escolha outro.",
"variable_name_must_start_with_a_letter": "O nome da variável deve começar com uma letra.",
"variable_used_in_recall": "Variável \"{variable}\" está a ser recordada na pergunta {questionIndex}.",
@@ -1658,6 +1705,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",
+69 -21
View File
@@ -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",
@@ -249,6 +254,7 @@
"label": "Etichetă",
"language": "Limba",
"learn_more": "Află mai multe",
"license_expired": "License Expired",
"light_overlay": "Suprapunere ușoară",
"limits_reached": "Limite atinse",
"link": "Legătura",
@@ -318,7 +324,7 @@
"placeholder": "Marcaj substituent",
"please_select_at_least_one_survey": "Vă rugăm să selectați cel puțin un sondaj",
"please_select_at_least_one_trigger": "Vă rugăm să selectați cel puțin un declanșator",
"please_upgrade_your_plan": "Vă rugăm să vă actualizați planul.",
"please_upgrade_your_plan": "Vă rugăm să faceți upgrade la planul dumneavoastră",
"preview": "Previzualizare",
"preview_survey": "Previzualizare Chestionar",
"privacy": "Politica de Confidențialitate",
@@ -455,7 +461,8 @@
"you_have_reached_your_limit_of_workspace_limit": "Ați atins limita de {projectLimit} spații de lucru.",
"you_have_reached_your_monthly_miu_limit_of": "Ați atins limita lunară MIU de",
"you_have_reached_your_monthly_response_limit_of": "Ați atins limita lunară de răspunsuri de",
"you_will_be_downgraded_to_the_community_edition_on_date": "Vei fi retrogradat la ediția Community pe {date}."
"you_will_be_downgraded_to_the_community_edition_on_date": "Vei fi retrogradat la ediția Community pe {date}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
},
"emails": {
"accept": "Acceptă",
@@ -608,7 +615,7 @@
"attribute_created_successfully": "Atribut creat cu succes",
"attribute_description": "Descriere",
"attribute_description_placeholder": "Descriere scurtă",
"attribute_key": "Cheie",
"attribute_key": "Atribut",
"attribute_key_cannot_be_changed": "Cheia nu poate fi modificată după creare",
"attribute_key_hint": "Doar litere mici, cifre și caractere de subliniere. Trebuie să înceapă cu o literă.",
"attribute_key_placeholder": "ex: date_of_birth",
@@ -780,20 +787,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ă",
@@ -1005,6 +1018,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",
@@ -1156,7 +1171,6 @@
"adjust_survey_closed_message_description": "Schimbați mesajul pe care îl văd vizitatorii atunci când sondajul este închis.",
"adjust_the_theme_in_the": "Ajustați tema în",
"all_other_answers_will_continue_to": "Toate celelalte răspunsuri vor continua să",
"allow_file_type": "Permite tipul de fișier",
"allow_multi_select": "Permite selectare multiplă",
"allow_multiple_files": "Permite fișiere multiple",
"allow_users_to_select_more_than_one_image": "Permite utilizatorilor să selecteze mai mult de o imagine",
@@ -1166,6 +1180,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.",
@@ -1187,6 +1204,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",
@@ -1236,7 +1255,6 @@
"contact_fields": "Câmpuri de contact",
"contains": "Conține",
"continue_to_settings": "Continuă către Setări",
"control_which_file_types_can_be_uploaded": "Controlează ce tipuri de fișiere pot fi încărcate.",
"convert_to_multiple_choice": "Convertiți la selectare multiplă",
"convert_to_single_choice": "Convertiți la selectare unică",
"country": "Țară",
@@ -1249,11 +1267,13 @@
"darken_or_lighten_background_of_your_choice": "Întunecați sau luminați fundalul după preferințe.",
"date_format": "Format dată",
"days_before_showing_this_survey_again": "sau mai multe zile să treacă între ultima afișare a sondajului și afișarea acestui sondaj.",
"delete_anyways": "Șterge oricum",
"delete_block": "Șterge blocul",
"delete_choice": "Șterge alegerea",
"disable_the_visibility_of_survey_progress": "Dezactivați vizibilitatea progresului sondajului",
"display_an_estimate_of_completion_time_for_survey": "Afișează o estimare a timpului de finalizare pentru sondaj",
"display_number_of_responses_for_survey": "Afișează numărul de răspunsuri pentru sondaj",
"display_type": "Tip de afișare",
"divide": "Împarte /",
"does_not_contain": "Nu conține",
"does_not_end_with": "Nu se termină cu",
@@ -1261,6 +1281,7 @@
"does_not_include_all_of": "Nu include toate",
"does_not_include_one_of": "Nu include una dintre",
"does_not_start_with": "Nu începe cu",
"dropdown": "Dropdown",
"duplicate_block": "Duplicați blocul",
"duplicate_question": "Duplică întrebarea",
"edit_link": "Editare legătură",
@@ -1353,7 +1374,7 @@
"hide_question_settings": "Ascunde setările întrebării",
"hostname": "Nume gazdă",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Cât de funky doriți să fie cardurile dumneavoastră în sondajele de tip {surveyTypeDerived}",
"if_you_need_more_please": "Dacă aveți nevoie de mai multe, vă rugăm",
"if_you_need_more_please": "Dacă aveți nevoie de mai mult, vă rugăm",
"if_you_really_want_that_answer_ask_until_you_get_it": "Continuă afișarea ori de câte ori este declanșat până când se trimite un răspuns.",
"ignore_global_waiting_time": "Ignoră perioada de răcire",
"ignore_global_waiting_time_description": "Acest sondaj poate fi afișat ori de câte ori condițiile sale sunt îndeplinite, chiar dacă un alt sondaj a fost afișat recent.",
@@ -1390,10 +1411,10 @@
"key": "Cheie",
"last_name": "Nume de familie",
"let_people_upload_up_to_25_files_at_the_same_time": "Permiteți utilizatorilor să încarce până la 25 de fișiere simultan.",
"limit_file_types": "Limitare tipuri de fișiere",
"limit_the_maximum_file_size": "Limitează dimensiunea maximă a fișierului",
"limit_upload_file_size_to": "Limitați dimensiunea fișierului de încărcare la",
"limit_the_maximum_file_size": "Limitați dimensiunea maximă a fișierului pentru încărcări.",
"limit_upload_file_size_to": "Limitați dimensiunea fișierului încărcat la",
"link_survey_description": "Partajați un link către o pagină de chestionar sau încorporați-l într-o pagină web sau email.",
"list": "Listă",
"load_segment": "Încarcă segment",
"logic_error_warning": "Schimbarea va provoca erori de logică",
"logic_error_warning_text": "Schimbarea tipului de întrebare va elimina condițiile de logică din această întrebare",
@@ -1405,7 +1426,7 @@
"matrix_all_fields": "Toate câmpurile",
"matrix_rows": "Rânduri",
"max_file_size": "Dimensiune maximă fișier",
"max_file_size_limit_is": "Limita dimensiunii maxime a fișierului este",
"max_file_size_limit_is": "Limita maximă pentru dimensiunea fișierului este",
"move_question_to_block": "Mută întrebarea în bloc",
"multiply": "Multiplicare",
"needed_for_self_hosted_cal_com_instance": "Necesar pentru un exemplu autogăzduit Cal.com",
@@ -1437,12 +1458,12 @@
"picture_idx": "Poză {idx}",
"pin_can_only_contain_numbers": "PIN-ul poate conține doar numere.",
"pin_must_be_a_four_digit_number": "PIN-ul trebuie să fie un număr de patru cifre",
"please_enter_a_file_extension": "Vă rugăm să introduceți o extensie de fișier.",
"please_enter_a_valid_url": "Vă rugăm să introduceți un URL valid (de exemplu, https://example.com)",
"please_set_a_survey_trigger": "Vă rugăm să setați un declanșator sondaj",
"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ă",
@@ -1451,7 +1472,8 @@
"question_deleted": "Întrebare ștearsă.",
"question_duplicated": "Întrebare duplicată.",
"question_id_updated": "ID întrebare actualizat",
"question_used_in_logic": "Această întrebare este folosită în logica întrebării {questionIndex}.",
"question_used_in_logic_warning_text": "Elemente din acest bloc sunt folosite într-o regulă de logică. Sigur doriți să îl ștergeți?",
"question_used_in_logic_warning_title": "Inconsistență logică",
"question_used_in_quota": "Întrebarea aceasta este folosită în cota \"{quotaName}\"",
"question_used_in_recall": "Această întrebare este reamintită în întrebarea {questionIndex}.",
"question_used_in_recall_ending_card": "Această întrebare este reamintită în Cardul de Încheiere.",
@@ -1515,6 +1537,7 @@
"search_for_images": "Căutare de imagini",
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "secunde după declanșare sondajul va fi închis dacă nu există niciun răspuns",
"seconds_before_showing_the_survey": "secunde înainte de afișarea sondajului",
"select_field": "Selectează câmpul",
"select_or_type_value": "Selectați sau introduceți valoarea",
"select_ordering": "Selectează ordonarea",
"select_saved_action": "Selectați acțiunea salvată",
@@ -1562,8 +1585,6 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Afișează o singură dată, chiar dacă persoana nu răspunde.",
"then": "Apoi",
"this_action_will_remove_all_the_translations_from_this_survey": "Această acțiune va elimina toate traducerile din acest sondaj.",
"this_extension_is_already_added": "Această extensie este deja adăugată.",
"this_file_type_is_not_supported": "Acest tip de fișier nu este acceptat.",
"three_points": "3 puncte",
"times": "ori",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Pentru a menține amplasarea consecventă pentru toate sondajele, puteți",
@@ -1585,24 +1606,50 @@
"url_filters": "Filtre URL",
"url_not_supported": "URL nesuportat",
"validation": {
"characters": "caractere",
"add_validation_rule": "Adaugă regulă de validare",
"answer_all_rows": "Răspunde la toate rândurile",
"characters": "Caractere",
"contains": "Conține",
"delete_validation_rule": "Șterge regula de validare",
"does_not_contain": "Nu conține",
"email": "Este un email valid",
"max_length": "Este mai scurt de",
"end_date": "Data de sfârșit",
"file_extension_is": "Extensia fișierului este",
"file_extension_is_not": "Extensia fișierului nu este",
"is": "Este",
"is_between": "Este între",
"is_earlier_than": "Este mai devreme decât",
"is_greater_than": "Este mai mare decât",
"is_later_than": "Este mai târziu decât",
"is_less_than": "Este mai mic decât",
"is_not": "Nu este",
"is_not_between": "Nu este între",
"kb": "KB",
"max_length": "Cel mult",
"max_selections": "Cel mult",
"max_value": "Este mai mic decât",
"min_length": "Este mai lung de",
"max_value": "Cel mult",
"mb": "MB",
"min_length": "Cel puțin",
"min_selections": "Cel puțin",
"min_value": "Este mai mare decât",
"options_selected": "opțiuni selectate",
"min_value": "Cel puțin",
"minimum_options_ranked": "Număr minim de opțiuni ordonate",
"minimum_rows_answered": "Număr minim de rânduri completate",
"options_selected": "Opțiuni selectate",
"pattern": "Se potrivește cu un șablon regex",
"phone": "Este un număr de telefon valid",
"required": "Este obligatoriu",
"rank_all_options": "Ordonați toate opțiunile",
"select_file_extensions": "Selectați extensiile de fișier...",
"select_option": "Selectează opțiunea",
"start_date": "Data de început",
"url": "Este un URL valid"
},
"validation_logic_and": "Toate sunt adevărate",
"validation_logic_or": "oricare este adevărată",
"validation_rules": "Reguli de validare",
"validation_rules_description": "Acceptă doar răspunsurile care îndeplinesc următoarele criterii",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} este folosit în logica întrebării {questionIndex}. Vă rugăm să-l eliminați din logică mai întâi.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variabila \"{variableName}\" este folosită în cota \"{quotaName}\"",
"variable_name_conflicts_with_hidden_field": "Numele variabilei intră în conflict cu un ID de câmp ascuns existent.",
"variable_name_is_already_taken_please_choose_another": "Numele variabilei este deja utilizat, vă rugăm să alegeți altul.",
"variable_name_must_start_with_a_letter": "Numele variabilei trebuie să înceapă cu o literă.",
"variable_used_in_recall": "Variabila \"{variable}\" este reamintită în întrebarea {questionIndex}.",
@@ -1658,6 +1705,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",
+67 -19
View File
@@ -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": "Дублировать",
@@ -249,6 +254,7 @@
"label": "Метка",
"language": "Язык",
"learn_more": "Подробнее",
"license_expired": "License Expired",
"light_overlay": "Светлый оверлей",
"limits_reached": "Достигнуты лимиты",
"link": "Ссылка",
@@ -318,7 +324,7 @@
"placeholder": "Заполнитель",
"please_select_at_least_one_survey": "Пожалуйста, выберите хотя бы один опрос",
"please_select_at_least_one_trigger": "Пожалуйста, выберите хотя бы один триггер",
"please_upgrade_your_plan": "Пожалуйста, обновите ваш тарифный план.",
"please_upgrade_your_plan": "Пожалуйста, обновите ваш тарифный план",
"preview": "Предпросмотр",
"preview_survey": "Предпросмотр опроса",
"privacy": "Политика конфиденциальности",
@@ -455,7 +461,8 @@
"you_have_reached_your_limit_of_workspace_limit": "Вы достигли лимита в {projectLimit} рабочих пространств.",
"you_have_reached_your_monthly_miu_limit_of": "Вы достигли месячного лимита MIU:",
"you_have_reached_your_monthly_response_limit_of": "Вы достигли месячного лимита ответов:",
"you_will_be_downgraded_to_the_community_edition_on_date": "Ваша версия будет понижена до Community Edition {date}."
"you_will_be_downgraded_to_the_community_edition_on_date": "Ваша версия будет понижена до Community Edition {date}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
},
"emails": {
"accept": "Принять",
@@ -608,7 +615,7 @@
"attribute_created_successfully": "Атрибут успешно создан",
"attribute_description": "Описание",
"attribute_description_placeholder": "Краткое описание",
"attribute_key": "Ключ",
"attribute_key": "Атрибут",
"attribute_key_cannot_be_changed": "Ключ нельзя изменить после создания",
"attribute_key_hint": "Только строчные буквы, цифры и символы подчёркивания. Должен начинаться с буквы.",
"attribute_key_placeholder": "например, date_of_birth",
@@ -780,20 +787,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 для удобной идентификации",
@@ -1005,6 +1018,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": "Тестовое письмо успешно отправлено",
@@ -1156,7 +1171,6 @@
"adjust_survey_closed_message_description": "Измените сообщение, которое видят посетители, когда опрос закрыт.",
"adjust_the_theme_in_the": "Настройте тему в",
"all_other_answers_will_continue_to": "Все остальные ответы будут продолжать",
"allow_file_type": "Разрешить тип файла",
"allow_multi_select": "Разрешить множественный выбор",
"allow_multiple_files": "Разрешить несколько файлов",
"allow_users_to_select_more_than_one_image": "Разрешить пользователям выбирать более одного изображения",
@@ -1166,6 +1180,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": "Автоматически закрывать опрос, если пользователь не ответил за определённое количество секунд.",
@@ -1187,6 +1204,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": "Цвет фона карточки",
@@ -1236,7 +1255,6 @@
"contact_fields": "Поля контакта",
"contains": "Содержит",
"continue_to_settings": "Перейти к настройкам",
"control_which_file_types_can_be_uploaded": "Управляйте типами файлов, которые можно загружать.",
"convert_to_multiple_choice": "Преобразовать в мультивыбор",
"convert_to_single_choice": "Преобразовать в одиночный выбор",
"country": "Страна",
@@ -1249,11 +1267,13 @@
"darken_or_lighten_background_of_your_choice": "Затемните или осветлите выбранный фон.",
"date_format": "Формат даты",
"days_before_showing_this_survey_again": "или больше дней должно пройти между последним показом опроса и показом этого опроса.",
"delete_anyways": "Удалить в любом случае",
"delete_block": "Удалить блок",
"delete_choice": "Удалить вариант",
"disable_the_visibility_of_survey_progress": "Отключить отображение прогресса опроса.",
"display_an_estimate_of_completion_time_for_survey": "Показывать примерное время прохождения опроса",
"display_number_of_responses_for_survey": "Показывать количество ответов на опрос",
"display_type": "Тип отображения",
"divide": "Разделить /",
"does_not_contain": "Не содержит",
"does_not_end_with": "Не заканчивается на",
@@ -1261,6 +1281,7 @@
"does_not_include_all_of": "Не включает все из",
"does_not_include_one_of": "Не включает ни одного из",
"does_not_start_with": "Не начинается с",
"dropdown": "Выпадающий список",
"duplicate_block": "Дублировать блок",
"duplicate_question": "Дублировать вопрос",
"edit_link": "Редактировать ссылку",
@@ -1353,7 +1374,7 @@
"hide_question_settings": "Скрыть настройки вопроса",
"hostname": "Имя хоста",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Насколько необычными вы хотите сделать карточки в опросах типа {surveyTypeDerived}",
"if_you_need_more_please": "Если нужно больше, пожалуйста",
"if_you_need_more_please": "Если вам нужно больше, пожалуйста",
"if_you_really_want_that_answer_ask_until_you_get_it": "Показывать каждый раз при срабатывании, пока не будет получен ответ.",
"ignore_global_waiting_time": "Игнорировать период ожидания",
"ignore_global_waiting_time_description": "Этот опрос может отображаться при выполнении условий, даже если недавно уже был показан другой опрос.",
@@ -1390,10 +1411,10 @@
"key": "Ключ",
"last_name": "Фамилия",
"let_people_upload_up_to_25_files_at_the_same_time": "Разрешить загружать до 25 файлов одновременно.",
"limit_file_types": "Ограничить типы файлов",
"limit_the_maximum_file_size": "Ограничить максимальный размер файла",
"limit_the_maximum_file_size": "Ограничьте максимальный размер загружаемых файлов.",
"limit_upload_file_size_to": "Ограничить размер загружаемого файла до",
"link_survey_description": "Поделитесь ссылкой на страницу опроса или вставьте её на веб-страницу или в электронное письмо.",
"list": "Список",
"load_segment": "Загрузить сегмент",
"logic_error_warning": "Изменение приведёт к логическим ошибкам",
"logic_error_warning_text": "Изменение типа вопроса удалит логические условия из этого вопроса",
@@ -1437,12 +1458,12 @@
"picture_idx": "Изображение {idx}",
"pin_can_only_contain_numbers": "PIN-код может содержать только цифры.",
"pin_must_be_a_four_digit_number": "PIN-код должен состоять из четырёх цифр.",
"please_enter_a_file_extension": "Пожалуйста, введите расширение файла.",
"please_enter_a_valid_url": "Пожалуйста, введите корректный URL (например, https://example.com)",
"please_set_a_survey_trigger": "Пожалуйста, установите триггер опроса",
"please_specify": "Пожалуйста, уточните",
"prevent_double_submission": "Предотвратить повторную отправку",
"prevent_double_submission_description": "Разрешить только 1 ответ на один адрес электронной почты",
"progress_saved": "Прогресс сохранён",
"protect_survey_with_pin": "Защитить опрос с помощью PIN-кода",
"protect_survey_with_pin_description": "Только пользователи, у которых есть PIN-код, могут получить доступ к опросу.",
"publish": "Опубликовать",
@@ -1451,7 +1472,8 @@
"question_deleted": "Вопрос удалён.",
"question_duplicated": "Вопрос дублирован.",
"question_id_updated": "ID вопроса обновлён",
"question_used_in_logic": "Этот вопрос используется в логике вопроса {questionIndex}.",
"question_used_in_logic_warning_text": "Элементы из этого блока используются в правиле логики. Вы уверены, что хотите удалить его?",
"question_used_in_logic_warning_title": "Несогласованность логики",
"question_used_in_quota": "Этот вопрос используется в квоте \"{quotaName}\"",
"question_used_in_recall": "Этот вопрос используется в отзыве в вопросе {questionIndex}.",
"question_used_in_recall_ending_card": "Этот вопрос используется в отзыве на финальной карточке",
@@ -1515,6 +1537,7 @@
"search_for_images": "Поиск изображений",
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "секунд после запуска — опрос будет закрыт, если не будет ответа",
"seconds_before_showing_the_survey": "секунд до показа опроса.",
"select_field": "Выберите поле",
"select_or_type_value": "Выберите или введите значение",
"select_ordering": "Выберите порядок",
"select_saved_action": "Выберите сохранённое действие",
@@ -1562,8 +1585,6 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Показать один раз, даже если не будет ответа.",
"then": "Затем",
"this_action_will_remove_all_the_translations_from_this_survey": "Это действие удалит все переводы из этого опроса.",
"this_extension_is_already_added": "Это расширение уже добавлено.",
"this_file_type_is_not_supported": "Этот тип файла не поддерживается.",
"three_points": "3 балла",
"times": "раз",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "Чтобы сохранить единое расположение во всех опросах, вы можете",
@@ -1585,24 +1606,50 @@
"url_filters": "Фильтры URL",
"url_not_supported": "URL не поддерживается",
"validation": {
"characters": "символов",
"add_validation_rule": "Добавить правило проверки",
"answer_all_rows": "Ответьте на все строки",
"characters": "Символы",
"contains": "Содержит",
"delete_validation_rule": "Удалить правило проверки",
"does_not_contain": "Не содержит",
"email": "Корректный email",
"max_length": "Короче чем",
"end_date": "Дата окончания",
"file_extension_is": "Расширение файла —",
"file_extension_is_not": "Расширение файла не является",
"is": "Является",
"is_between": "Находится между",
"is_earlier_than": "Ранее чем",
"is_greater_than": "Больше чем",
"is_later_than": "Позже чем",
"is_less_than": "Меньше чем",
"is_not": "Не является",
"is_not_between": "Не находится между",
"kb": "КБ",
"max_length": "Не более",
"max_selections": "Не более",
"max_value": "Меньше чем",
"min_length": "Длиннее чем",
"max_value": "Не более",
"mb": "МБ",
"min_length": "Не менее",
"min_selections": "Не менее",
"min_value": "Больше чем",
"options_selected": "выбрано вариантов",
"min_value": "Не менее",
"minimum_options_ranked": "Минимальное количество ранжированных вариантов",
"minimum_rows_answered": "Минимальное количество заполненных строк",
"options_selected": "Выбранные опции",
"pattern": "Соответствует шаблону regex",
"phone": "Корректный телефон",
"required": "Обязательное поле",
"rank_all_options": "Ранжируйте все опции",
"select_file_extensions": "Выберите расширения файлов...",
"select_option": "Выберите вариант",
"start_date": "Дата начала",
"url": "Корректный URL"
},
"validation_logic_and": "Все условия выполняются",
"validation_logic_or": "выполняется хотя бы одно условие",
"validation_rules": "Правила валидации",
"validation_rules_description": "Принимать только ответы, соответствующие следующим критериям",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} используется в логике вопроса {questionIndex}. Пожалуйста, сначала удалите его из логики.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Переменная «{variableName}» используется в квоте «{quotaName}»",
"variable_name_conflicts_with_hidden_field": "Имя переменной конфликтует с существующим ID скрытого поля.",
"variable_name_is_already_taken_please_choose_another": "Это имя переменной уже занято, выберите другое.",
"variable_name_must_start_with_a_letter": "Имя переменной должно начинаться с буквы.",
"variable_used_in_recall": "Переменная «{variable}» используется в вопросе {questionIndex}.",
@@ -1658,6 +1705,7 @@
"error_downloading_responses": "Произошла ошибка при загрузке ответов",
"first_name": "Имя",
"how_to_identify_users": "Как идентифицировать пользователей",
"ip_address": "IP-адрес",
"last_name": "Фамилия",
"not_completed": "Не завершено ⏳",
"os": "ОС",
+69 -21
View File
@@ -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",
@@ -249,6 +254,7 @@
"label": "Etikett",
"language": "Språk",
"learn_more": "Läs mer",
"license_expired": "License Expired",
"light_overlay": "Ljust överlägg",
"limits_reached": "Gränser nådda",
"link": "Länk",
@@ -318,7 +324,7 @@
"placeholder": "Platshållare",
"please_select_at_least_one_survey": "Vänligen välj minst en enkät",
"please_select_at_least_one_trigger": "Vänligen välj minst en utlösare",
"please_upgrade_your_plan": "Vänligen uppgradera din plan.",
"please_upgrade_your_plan": "Vänligen uppgradera din plan",
"preview": "Förhandsgranska",
"preview_survey": "Förhandsgranska enkät",
"privacy": "Integritetspolicy",
@@ -455,7 +461,8 @@
"you_have_reached_your_limit_of_workspace_limit": "Du har nått din gräns på {projectLimit} arbetsytor.",
"you_have_reached_your_monthly_miu_limit_of": "Du har nått din månatliga MIU-gräns på",
"you_have_reached_your_monthly_response_limit_of": "Du har nått din månatliga svarsgräns på",
"you_will_be_downgraded_to_the_community_edition_on_date": "Du kommer att nedgraderas till Community Edition den {date}."
"you_will_be_downgraded_to_the_community_edition_on_date": "Du kommer att nedgraderas till Community Edition den {date}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
},
"emails": {
"accept": "Acceptera",
@@ -608,7 +615,7 @@
"attribute_created_successfully": "Attributet har skapats",
"attribute_description": "Beskrivning",
"attribute_description_placeholder": "Kort beskrivning",
"attribute_key": "Nyckel",
"attribute_key": "Attribut",
"attribute_key_cannot_be_changed": "Nyckeln kan inte ändras efter skapande",
"attribute_key_hint": "Endast små bokstäver, siffror och understreck. Måste börja med en bokstav.",
"attribute_key_placeholder": "t.ex. date_of_birth",
@@ -780,20 +787,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",
@@ -1005,6 +1018,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",
@@ -1156,7 +1171,6 @@
"adjust_survey_closed_message_description": "Ändra meddelandet besökare ser när enkäten är stängd.",
"adjust_the_theme_in_the": "Justera temat i",
"all_other_answers_will_continue_to": "Alla andra svar fortsätter till",
"allow_file_type": "Tillåt filtyp",
"allow_multi_select": "Tillåt flerval",
"allow_multiple_files": "Tillåt flera filer",
"allow_users_to_select_more_than_one_image": "Tillåt användare att välja mer än en bild",
@@ -1166,6 +1180,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.",
@@ -1187,6 +1204,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",
@@ -1236,7 +1255,6 @@
"contact_fields": "Kontaktfält",
"contains": "Innehåller",
"continue_to_settings": "Fortsätt till inställningar",
"control_which_file_types_can_be_uploaded": "Kontrollera vilka filtyper som kan laddas upp.",
"convert_to_multiple_choice": "Konvertera till flerval",
"convert_to_single_choice": "Konvertera till enkelval",
"country": "Land",
@@ -1249,11 +1267,13 @@
"darken_or_lighten_background_of_your_choice": "Gör bakgrunden mörkare eller ljusare efter eget val.",
"date_format": "Datumformat",
"days_before_showing_this_survey_again": "eller fler dagar måste gå mellan den senaste visade enkäten och att visa denna enkät.",
"delete_anyways": "Ta bort ändå",
"delete_block": "Ta bort block",
"delete_choice": "Ta bort val",
"disable_the_visibility_of_survey_progress": "Inaktivera synligheten av enkätens framsteg.",
"display_an_estimate_of_completion_time_for_survey": "Visa en uppskattning av tid för att slutföra enkäten",
"display_number_of_responses_for_survey": "Visa antal svar för enkäten",
"display_type": "Visningstyp",
"divide": "Dividera /",
"does_not_contain": "Innehåller inte",
"does_not_end_with": "Slutar inte med",
@@ -1261,6 +1281,7 @@
"does_not_include_all_of": "Inkluderar inte alla av",
"does_not_include_one_of": "Inkluderar inte en av",
"does_not_start_with": "Börjar inte med",
"dropdown": "Rullgardinsmeny",
"duplicate_block": "Duplicera block",
"duplicate_question": "Duplicera fråga",
"edit_link": "Redigera länk",
@@ -1353,7 +1374,7 @@
"hide_question_settings": "Dölj frågeinställningar",
"hostname": "Värdnamn",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Hur coola vill du att dina kort ska vara i {surveyTypeDerived}-enkäter",
"if_you_need_more_please": "Om du behöver fler, vänligen",
"if_you_need_more_please": "Om du behöver mer, vänligen",
"if_you_really_want_that_answer_ask_until_you_get_it": "Fortsätt visa när villkoren är uppfyllda tills ett svar skickas in.",
"ignore_global_waiting_time": "Ignorera väntetid",
"ignore_global_waiting_time_description": "Denna enkät kan visas när dess villkor är uppfyllda, även om en annan enkät nyligen visats.",
@@ -1390,10 +1411,10 @@
"key": "Nyckel",
"last_name": "Efternamn",
"let_people_upload_up_to_25_files_at_the_same_time": "Låt personer ladda upp upp till 25 filer samtidigt.",
"limit_file_types": "Begränsa filtyper",
"limit_the_maximum_file_size": "Begränsa maximal filstorlek",
"limit_upload_file_size_to": "Begränsa uppladdningsfilstorlek till",
"limit_the_maximum_file_size": "Begränsa den maximala filstorleken för uppladdningar.",
"limit_upload_file_size_to": "Begränsa uppladdad filstorlek till",
"link_survey_description": "Dela en länk till en enkätsida eller bädda in den på en webbsida eller i e-post.",
"list": "Lista",
"load_segment": "Ladda segment",
"logic_error_warning": "Ändring kommer att orsaka logikfel",
"logic_error_warning_text": "Att ändra frågetypen kommer att ta bort logikvillkoren från denna fråga",
@@ -1405,7 +1426,7 @@
"matrix_all_fields": "Alla fält",
"matrix_rows": "Rader",
"max_file_size": "Max filstorlek",
"max_file_size_limit_is": "Maxgräns för filstorlek är",
"max_file_size_limit_is": "Maximal filstorleksgräns är",
"move_question_to_block": "Flytta fråga till block",
"multiply": "Multiplicera *",
"needed_for_self_hosted_cal_com_instance": "Behövs för en självhostad Cal.com-instans",
@@ -1437,12 +1458,12 @@
"picture_idx": "Bild {idx}",
"pin_can_only_contain_numbers": "PIN kan endast innehålla siffror.",
"pin_must_be_a_four_digit_number": "PIN måste vara ett fyrsiffrigt nummer.",
"please_enter_a_file_extension": "Vänligen ange en filändelse.",
"please_enter_a_valid_url": "Vänligen ange en giltig URL (t.ex. https://example.com)",
"please_set_a_survey_trigger": "Vänligen ställ in en enkätutlösare",
"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",
@@ -1451,7 +1472,8 @@
"question_deleted": "Fråga borttagen.",
"question_duplicated": "Fråga duplicerad.",
"question_id_updated": "Fråge-ID uppdaterat",
"question_used_in_logic": "Denna fråga används i logiken för fråga {questionIndex}.",
"question_used_in_logic_warning_text": "Element från det här blocket används i en logikregel. Är du säker på att du vill ta bort det?",
"question_used_in_logic_warning_title": "Logikkonflikt",
"question_used_in_quota": "Denna fråga används i kvoten \"{quotaName}\"",
"question_used_in_recall": "Denna fråga återkallas i fråga {questionIndex}.",
"question_used_in_recall_ending_card": "Denna fråga återkallas i avslutningskortet",
@@ -1515,6 +1537,7 @@
"search_for_images": "Sök efter bilder",
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "sekunder efter utlösning stängs enkäten om inget svar",
"seconds_before_showing_the_survey": "sekunder innan enkäten visas.",
"select_field": "Välj fält",
"select_or_type_value": "Välj eller skriv värde",
"select_ordering": "Välj ordning",
"select_saved_action": "Välj sparad åtgärd",
@@ -1562,8 +1585,6 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "Visa en enda gång, även om de inte svarar.",
"then": "Sedan",
"this_action_will_remove_all_the_translations_from_this_survey": "Denna åtgärd kommer att ta bort alla översättningar från denna enkät.",
"this_extension_is_already_added": "Denna filändelse är redan tillagd.",
"this_file_type_is_not_supported": "Denna filtyp stöds inte.",
"three_points": "3 poäng",
"times": "gånger",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "För att hålla placeringen konsekvent över alla enkäter kan du",
@@ -1585,24 +1606,50 @@
"url_filters": "URL-filter",
"url_not_supported": "URL stöds inte",
"validation": {
"characters": "tecken",
"add_validation_rule": "Lägg till valideringsregel",
"answer_all_rows": "Svara på alla rader",
"characters": "Tecken",
"contains": "Innehåller",
"delete_validation_rule": "Ta bort valideringsregel",
"does_not_contain": "Innehåller inte",
"email": "Är en giltig e-postadress",
"max_length": "Är kortare än",
"end_date": "Slutdatum",
"file_extension_is": "Filändelsen är",
"file_extension_is_not": "Filändelsen är inte",
"is": "Är",
"is_between": "Är mellan",
"is_earlier_than": "Är tidigare än",
"is_greater_than": "Är större än",
"is_later_than": "Är senare än",
"is_less_than": "Är mindre än",
"is_not": "Är inte",
"is_not_between": "Är inte mellan",
"kb": "KB",
"max_length": "Högst",
"max_selections": "Högst",
"max_value": "Är mindre än",
"min_length": "Är längre än",
"max_value": "Högst",
"mb": "MB",
"min_length": "Minst",
"min_selections": "Minst",
"min_value": "Är större än",
"options_selected": "valda alternativ",
"min_value": "Minst",
"minimum_options_ranked": "Minsta antal rangordnade alternativ",
"minimum_rows_answered": "Minsta antal besvarade rader",
"options_selected": "Valda alternativ",
"pattern": "Matchar regexmönster",
"phone": "Är ett giltigt telefonnummer",
"required": "Är obligatorisk",
"rank_all_options": "Rangordna alla alternativ",
"select_file_extensions": "Välj filändelser...",
"select_option": "Välj alternativ",
"start_date": "Startdatum",
"url": "Är en giltig URL"
},
"validation_logic_and": "Alla är sanna",
"validation_logic_or": "någon är sann",
"validation_rules": "Valideringsregler",
"validation_rules_description": "Acceptera endast svar som uppfyller följande kriterier",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "{variable} används i logiken för fråga {questionIndex}. Vänligen ta bort den från logiken först.",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "Variabel \"{variableName}\" används i kvoten \"{quotaName}\"",
"variable_name_conflicts_with_hidden_field": "Variabelnamnet krockar med ett befintligt dolt fält-ID.",
"variable_name_is_already_taken_please_choose_another": "Variabelnamnet är redan taget, vänligen välj ett annat.",
"variable_name_must_start_with_a_letter": "Variabelnamnet måste börja med en bokstav.",
"variable_used_in_recall": "Variabel \"{variable}\" återkallas i fråga {questionIndex}.",
@@ -1658,6 +1705,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",
+70 -22
View File
@@ -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": "复制",
@@ -249,6 +254,7 @@
"label": "标签",
"language": "语言",
"learn_more": "了解 更多",
"license_expired": "License Expired",
"light_overlay": "浅色遮罩层",
"limits_reached": "限制 达到",
"link": "链接",
@@ -318,7 +324,7 @@
"placeholder": "占位符",
"please_select_at_least_one_survey": "请选择至少 一个调查",
"please_select_at_least_one_trigger": "请选择至少 一个触发条件",
"please_upgrade_your_plan": "请 升级 您的 计划",
"please_upgrade_your_plan": "请升级您的计划",
"preview": "预览",
"preview_survey": "预览 Survey",
"privacy": "隐私政策",
@@ -455,7 +461,8 @@
"you_have_reached_your_limit_of_workspace_limit": "您已达到 {projectLimit} 个工作区的上限。",
"you_have_reached_your_monthly_miu_limit_of": "您 已经 达到 每月 的 MIU 限制",
"you_have_reached_your_monthly_response_limit_of": "您 已经 达到 每月 的 响应 限制",
"you_will_be_downgraded_to_the_community_edition_on_date": "您将在 {date} 降级到社区版。"
"you_will_be_downgraded_to_the_community_edition_on_date": "您将在 {date} 降级到社区版。",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
},
"emails": {
"accept": "接受",
@@ -608,7 +615,7 @@
"attribute_created_successfully": "属性创建成功",
"attribute_description": "描述",
"attribute_description_placeholder": "简短描述",
"attribute_key": "",
"attribute_key": "属性",
"attribute_key_cannot_be_changed": "创建后键不可更改",
"attribute_key_hint": "仅允许小写字母、数字和下划线,且必须以字母开头。",
"attribute_key_placeholder": "例如:date_of_birth",
@@ -780,20 +787,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 标注 标签 以 便于 识别",
@@ -1005,6 +1018,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": "测试 邮件 发送 成功",
@@ -1156,7 +1171,6 @@
"adjust_survey_closed_message_description": "更改 访客 看到 调查 关闭 时 的 消息。",
"adjust_the_theme_in_the": "调整主题在",
"all_other_answers_will_continue_to": "所有其他答案将继续",
"allow_file_type": "允许 文件类型",
"allow_multi_select": "允许 多选",
"allow_multiple_files": "允许 多 个 文件",
"allow_users_to_select_more_than_one_image": "允许 用户 选择 多于 一个 图片",
@@ -1166,6 +1180,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": "用户未在一定秒数内应答时 自动关闭 问卷",
@@ -1187,6 +1204,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": "卡片 的 背景 颜色",
@@ -1236,7 +1255,6 @@
"contact_fields": "联络字段",
"contains": "包含",
"continue_to_settings": "继续 到 设置",
"control_which_file_types_can_be_uploaded": "控制 可以 上传的 文件 类型",
"convert_to_multiple_choice": "转换为 多选",
"convert_to_single_choice": "转换为 单选",
"country": "国家",
@@ -1249,11 +1267,13 @@
"darken_or_lighten_background_of_your_choice": "根据 您 的 选择 暗化 或 亮化 背景。",
"date_format": "日期格式",
"days_before_showing_this_survey_again": "距离上次显示问卷后需间隔不少于指定天数,才能再次显示此问卷。",
"delete_anyways": "仍然删除",
"delete_block": "删除区块",
"delete_choice": "删除 选择",
"disable_the_visibility_of_survey_progress": "禁用问卷 进度 的可见性。",
"display_an_estimate_of_completion_time_for_survey": "显示 调查 预计 完成 时间",
"display_number_of_responses_for_survey": "显示 调查 响应 数量",
"display_type": "显示类型",
"divide": "划分 /",
"does_not_contain": "不包含",
"does_not_end_with": "不 以 结尾",
@@ -1261,6 +1281,7 @@
"does_not_include_all_of": "不包括所有 ",
"does_not_include_one_of": "不包括一 个",
"does_not_start_with": "不 以 开头",
"dropdown": "下拉菜单",
"duplicate_block": "复制区块",
"duplicate_question": "复制问题",
"edit_link": "编辑 链接",
@@ -1353,7 +1374,7 @@
"hide_question_settings": "隐藏问题设置",
"hostname": "主 机 名",
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "在 {surveyTypeDerived} 调查 中,您 想要 卡片 多么 有趣",
"if_you_need_more_please": "如果需要更多,请",
"if_you_need_more_please": "如果需要更多,请",
"if_you_really_want_that_answer_ask_until_you_get_it": "每次触发时都会显示,直到提交回应为止。",
"ignore_global_waiting_time": "忽略冷却期",
"ignore_global_waiting_time_description": "只要满足条件,此调查即可显示,即使最近刚显示过其他调查。",
@@ -1390,10 +1411,10 @@
"key": "键",
"last_name": "姓",
"let_people_upload_up_to_25_files_at_the_same_time": "允许 人们 同时 上传 最多 25 个 文件",
"limit_file_types": "限制 文件 类型",
"limit_the_maximum_file_size": "限制 最大 文件 大小",
"limit_upload_file_size_to": "将 上传 文件 大小 限制 为",
"limit_the_maximum_file_size": "限制上传文件的最大大小。",
"limit_upload_file_size_to": "将上传文件大小限制为",
"link_survey_description": "分享 问卷 页面 链接 或 将其 嵌入 网页 或 电子邮件 中。",
"list": "列表",
"load_segment": "载入 段落",
"logic_error_warning": "更改 将 导致 逻辑 错误",
"logic_error_warning_text": "更改问题类型 会 移除 此问题 的 逻辑条件",
@@ -1404,8 +1425,8 @@
"manage_languages": "管理 语言",
"matrix_all_fields": "所有字段",
"matrix_rows": "行",
"max_file_size": "最大 文件 大小",
"max_file_size_limit_is": "最大 文件 大小 限制",
"max_file_size": "最大文件大小",
"max_file_size_limit_is": "最大文件大小限制",
"move_question_to_block": "将问题移动到区块",
"multiply": "乘 *",
"needed_for_self_hosted_cal_com_instance": "需要用于 自建 Cal.com 实例",
@@ -1437,12 +1458,12 @@
"picture_idx": "图片 {idx}",
"pin_can_only_contain_numbers": "PIN 只能包含数字。",
"pin_must_be_a_four_digit_number": "PIN 必须是 四 位数字。",
"please_enter_a_file_extension": "请输入 文件 扩展名。",
"please_enter_a_valid_url": "请输入有效的 URL(例如, https://example.com ",
"please_set_a_survey_trigger": "请 设置 一个 调查 触发",
"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": "发布",
@@ -1451,7 +1472,8 @@
"question_deleted": "问题 已删除",
"question_duplicated": "问题重复。",
"question_id_updated": "问题 ID 更新",
"question_used_in_logic": "\"这个 问题 在 问题 {questionIndex} 的 逻辑 中 使用。\"",
"question_used_in_logic_warning_text": "此区块中的元素已被用于逻辑规则,您确定要删除吗?",
"question_used_in_logic_warning_title": "逻辑不一致",
"question_used_in_quota": "此 问题 正在 被 \"{quotaName}\" 配额 使用",
"question_used_in_recall": "此问题正在召回于问题 {questionIndex}。",
"question_used_in_recall_ending_card": "此 问题 正在召回于结束 卡片。",
@@ -1515,6 +1537,7 @@
"search_for_images": "搜索 图片",
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "触发后 如果 没有 应答 将 在 几秒 后 关闭 调查",
"seconds_before_showing_the_survey": "显示问卷前 几秒",
"select_field": "选择字段",
"select_or_type_value": "选择 或 输入 值",
"select_ordering": "选择排序",
"select_saved_action": "选择 保存的 操作",
@@ -1562,8 +1585,6 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "仅显示一次,即使他们未回应。",
"then": "然后",
"this_action_will_remove_all_the_translations_from_this_survey": "此操作将删除该调查中的所有翻译。",
"this_extension_is_already_added": "此扩展已经添加。",
"this_file_type_is_not_supported": "此 文件 类型 不 支持。",
"three_points": "3 分",
"times": "次数",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "为了 保持 所有 调查 的 放置 一致,您 可以",
@@ -1585,24 +1606,50 @@
"url_filters": "URL 过滤器",
"url_not_supported": "URL 不支持",
"validation": {
"characters": "个字符",
"add_validation_rule": "添加验证规则",
"answer_all_rows": "请填写所有行",
"characters": "字符",
"contains": "包含",
"delete_validation_rule": "删除验证规则",
"does_not_contain": "不包含",
"email": "是有效的邮箱地址",
"max_length": "短于",
"end_date": "结束日期",
"file_extension_is": "文件扩展名为",
"file_extension_is_not": "文件扩展名不是",
"is": "等于",
"is_between": "介于",
"is_earlier_than": "早于",
"is_greater_than": "大于",
"is_later_than": "晚于",
"is_less_than": "小于",
"is_not": "不等于",
"is_not_between": "不介于",
"kb": "KB",
"max_length": "最多",
"max_selections": "最多",
"max_value": "小于",
"min_length": "长于",
"max_value": "最多",
"mb": "MB",
"min_length": "至少",
"min_selections": "至少",
"min_value": "大于",
"options_selected": "项已选择",
"min_value": "至少",
"minimum_options_ranked": "最少排序选项数",
"minimum_rows_answered": "最少回答行数",
"options_selected": "已选择的选项",
"pattern": "匹配正则表达式模式",
"phone": "是有效的手机号",
"required": "为必填项",
"rank_all_options": "对所有选项进行排序",
"select_file_extensions": "选择文件扩展名...",
"select_option": "选择选项",
"start_date": "开始日期",
"url": "是有效的URL"
},
"validation_logic_and": "全部为真",
"validation_logic_or": "任一为真",
"validation_rules": "校验规则",
"validation_rules_description": "仅接受符合以下条件的回复",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "\"{variable} 在 问题 {questionIndex} 的 逻辑 中 使用。请 先 从 逻辑 中 删除 它。\"",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "变量 \"{variableName}\" 正在 被 \"{quotaName}\" 配额 使用",
"variable_name_conflicts_with_hidden_field": "变量名与已有的隐藏字段 ID 冲突。",
"variable_name_is_already_taken_please_choose_another": "变量名已被占用,请选择其他。",
"variable_name_must_start_with_a_letter": "变量名 必须 以字母开头。",
"variable_used_in_recall": "变量 \"{variable}\" 正在召回于问题 {questionIndex}。",
@@ -1658,6 +1705,7 @@
"error_downloading_responses": "下载答复时发生错误",
"first_name": "名字",
"how_to_identify_users": "如何 识别 用户",
"ip_address": "IP地址",
"last_name": "姓",
"not_completed": "未完成 ⏳",
"os": "操作系统",
+67 -19
View File
@@ -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": "複製",
@@ -249,6 +254,7 @@
"label": "標籤",
"language": "語言",
"learn_more": "瞭解更多",
"license_expired": "License Expired",
"light_overlay": "淺色覆蓋",
"limits_reached": "已達上限",
"link": "連結",
@@ -318,7 +324,7 @@
"placeholder": "提示文字",
"please_select_at_least_one_survey": "請選擇至少一個問卷",
"please_select_at_least_one_trigger": "請選擇至少一個觸發器",
"please_upgrade_your_plan": "請升級您的方案",
"please_upgrade_your_plan": "請升級您的方案",
"preview": "預覽",
"preview_survey": "預覽問卷",
"privacy": "隱私權政策",
@@ -455,7 +461,8 @@
"you_have_reached_your_limit_of_workspace_limit": "您已達到 {projectLimit} 個工作區的上限。",
"you_have_reached_your_monthly_miu_limit_of": "您已達到每月 MIU 上限:",
"you_have_reached_your_monthly_response_limit_of": "您已達到每月回應上限:",
"you_will_be_downgraded_to_the_community_edition_on_date": "您將於 '{'date'}' 降級至社群版。"
"you_will_be_downgraded_to_the_community_edition_on_date": "您將於 '{'date'}' 降級至社群版。",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
},
"emails": {
"accept": "接受",
@@ -608,7 +615,7 @@
"attribute_created_successfully": "屬性建立成功",
"attribute_description": "描述",
"attribute_description_placeholder": "簡短描述",
"attribute_key": "金鑰",
"attribute_key": "屬性",
"attribute_key_cannot_be_changed": "建立後無法變更金鑰",
"attribute_key_hint": "僅限小寫字母、數字和底線,且必須以字母開頭。",
"attribute_key_placeholder": "例如:date_of_birth",
@@ -780,20 +787,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 加上標籤以便於識別",
@@ -1005,6 +1018,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": "測試電子郵件已成功發送",
@@ -1156,7 +1171,6 @@
"adjust_survey_closed_message_description": "變更訪客在問卷關閉時看到的訊息。",
"adjust_the_theme_in_the": "在",
"all_other_answers_will_continue_to": "所有其他答案將繼續",
"allow_file_type": "允許檔案類型",
"allow_multi_select": "允許多重選取",
"allow_multiple_files": "允許上傳多個檔案",
"allow_users_to_select_more_than_one_image": "允許使用者選取多張圖片",
@@ -1166,6 +1180,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": "如果用戶在特定秒數後未回應,則自動關閉問卷。",
@@ -1187,6 +1204,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": "卡片背景顏色",
@@ -1236,7 +1255,6 @@
"contact_fields": "聯絡人欄位",
"contains": "包含",
"continue_to_settings": "繼續設定",
"control_which_file_types_can_be_uploaded": "控制可以上傳哪些檔案類型。",
"convert_to_multiple_choice": "轉換為多選",
"convert_to_single_choice": "轉換為單選",
"country": "國家/地區",
@@ -1249,11 +1267,13 @@
"darken_or_lighten_background_of_your_choice": "變暗或變亮您選擇的背景。",
"date_format": "日期格式",
"days_before_showing_this_survey_again": "距離上次顯示問卷後,需間隔指定天數才能再次顯示此問卷。",
"delete_anyways": "仍要刪除",
"delete_block": "刪除區塊",
"delete_choice": "刪除選項",
"disable_the_visibility_of_survey_progress": "停用問卷進度的可見性。",
"display_an_estimate_of_completion_time_for_survey": "顯示問卷的估計完成時間",
"display_number_of_responses_for_survey": "顯示問卷的回應數",
"display_type": "顯示類型",
"divide": "除 /",
"does_not_contain": "不包含",
"does_not_end_with": "不以...結尾",
@@ -1261,6 +1281,7 @@
"does_not_include_all_of": "不包含全部",
"does_not_include_one_of": "不包含其中之一",
"does_not_start_with": "不以...開頭",
"dropdown": "下拉選單",
"duplicate_block": "複製區塊",
"duplicate_question": "複製問題",
"edit_link": "編輯 連結",
@@ -1390,10 +1411,10 @@
"key": "金鑰",
"last_name": "姓氏",
"let_people_upload_up_to_25_files_at_the_same_time": "允許使用者同時上傳最多 25 個檔案。",
"limit_file_types": "限制檔案類型",
"limit_the_maximum_file_size": "限制最大檔案大小",
"limit_upload_file_size_to": "限制上傳檔案大小為",
"limit_the_maximum_file_size": "限制上傳檔案的最大大小。",
"limit_upload_file_size_to": "將上傳檔案大小限制為",
"link_survey_description": "分享問卷頁面的連結或將其嵌入網頁或電子郵件中。",
"list": "清單",
"load_segment": "載入區隔",
"logic_error_warning": "變更將導致邏輯錯誤",
"logic_error_warning_text": "變更問題類型將會從此問題中移除邏輯條件",
@@ -1437,12 +1458,12 @@
"picture_idx": "圖片 '{'idx'}'",
"pin_can_only_contain_numbers": "PIN 碼只能包含數字。",
"pin_must_be_a_four_digit_number": "PIN 碼必須是四位數的數字。",
"please_enter_a_file_extension": "請輸入檔案副檔名。",
"please_enter_a_valid_url": "請輸入有效的 URL(例如:https://example.com",
"please_set_a_survey_trigger": "請設定問卷觸發器",
"please_specify": "請指定",
"prevent_double_submission": "防止重複提交",
"prevent_double_submission_description": "每個電子郵件地址僅允許 1 個回應",
"progress_saved": "進度已儲存",
"protect_survey_with_pin": "使用 PIN 碼保護問卷",
"protect_survey_with_pin_description": "只有擁有 PIN 碼的使用者才能存取問卷。",
"publish": "發布",
@@ -1451,7 +1472,8 @@
"question_deleted": "問題已刪除。",
"question_duplicated": "問題已複製。",
"question_id_updated": "問題 ID 已更新",
"question_used_in_logic": "此問題用於問題 '{'questionIndex'}' 的邏輯中。",
"question_used_in_logic_warning_text": "此區塊中的元素已用於邏輯規則,確定要刪除嗎?",
"question_used_in_logic_warning_title": "邏輯不一致",
"question_used_in_quota": "此問題 正被使用於 \"{quotaName}\" 配額中",
"question_used_in_recall": "此問題於問題 {questionIndex} 中被召回。",
"question_used_in_recall_ending_card": "此問題於結尾卡中被召回。",
@@ -1515,6 +1537,7 @@
"search_for_images": "搜尋圖片",
"seconds_after_trigger_the_survey_will_be_closed_if_no_response": "如果沒有回應,則在觸發後幾秒關閉問卷",
"seconds_before_showing_the_survey": "秒後顯示問卷。",
"select_field": "選擇欄位",
"select_or_type_value": "選取或輸入值",
"select_ordering": "選取排序",
"select_saved_action": "選取已儲存的操作",
@@ -1562,8 +1585,6 @@
"the_survey_will_be_shown_once_even_if_person_doesnt_respond": "僅顯示一次,即使他們未回應。",
"then": "然後",
"this_action_will_remove_all_the_translations_from_this_survey": "此操作將從此問卷中移除所有翻譯。",
"this_extension_is_already_added": "已新增此擴充功能。",
"this_file_type_is_not_supported": "不支援此檔案類型。",
"three_points": "3 分",
"times": "次",
"to_keep_the_placement_over_all_surveys_consistent_you_can": "若要保持所有問卷的位置一致,您可以",
@@ -1585,24 +1606,50 @@
"url_filters": "網址篩選器",
"url_not_supported": "不支援網址",
"validation": {
"characters": "個字元",
"add_validation_rule": "新增驗證規則",
"answer_all_rows": "請填答所有列",
"characters": "字元",
"contains": "包含",
"delete_validation_rule": "刪除驗證規則",
"does_not_contain": "不包含",
"email": "是有效的電子郵件",
"max_length": "少於",
"end_date": "結束日期",
"file_extension_is": "檔案副檔名為",
"file_extension_is_not": "檔案副檔名不是",
"is": "等於",
"is_between": "介於",
"is_earlier_than": "早於",
"is_greater_than": "大於",
"is_later_than": "晚於",
"is_less_than": "小於",
"is_not": "不等於",
"is_not_between": "不介於",
"kb": "KB",
"max_length": "最多",
"max_selections": "最多",
"max_value": "小於",
"min_length": "多於",
"max_value": "最多",
"mb": "MB",
"min_length": "至少",
"min_selections": "至少",
"min_value": "大於",
"options_selected": "個選項已選",
"min_value": "至少",
"minimum_options_ranked": "最少排序選項數",
"minimum_rows_answered": "最少作答列數",
"options_selected": "已選擇的選項",
"pattern": "符合正則表達式樣式",
"phone": "是有效的電話號碼",
"required": "為必填",
"rank_all_options": "請為所有選項排序",
"select_file_extensions": "請選擇檔案副檔名...",
"select_option": "選擇選項",
"start_date": "開始日期",
"url": "是有效的 URL"
},
"validation_logic_and": "全部為真",
"validation_logic_or": "任一為真",
"validation_rules": "驗證規則",
"validation_rules_description": "僅接受符合下列條件的回應",
"variable_is_used_in_logic_of_question_please_remove_it_from_logic_first": "'{'variable'}' 用於問題 '{'questionIndex'}' 的邏輯中。請先從邏輯中移除。",
"variable_is_used_in_quota_please_remove_it_from_quota_first": "變數 \"{variableName}\" 正被使用於 \"{quotaName}\" 配額中",
"variable_name_conflicts_with_hidden_field": "變數名稱與現有的隱藏欄位 ID 衝突。",
"variable_name_is_already_taken_please_choose_another": "已使用此變數名稱,請選擇另一個名稱。",
"variable_name_must_start_with_a_letter": "變數名稱必須以字母開頭。",
"variable_used_in_recall": "變數 \"{variable}\" 於問題 {questionIndex} 中被召回。",
@@ -1658,6 +1705,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>
);
};
@@ -3,6 +3,7 @@
import { CheckCircle2Icon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { TResponseWithQuotas } from "@formbricks/types/responses";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getTextContent } from "@formbricks/types/surveys/validation";
import { getLocalizedValue } from "@/lib/i18n/utils";
@@ -67,6 +68,16 @@ export const SingleResponseCardBody = ({
<VerifiedEmail responseData={response.data} />
)}
{elements.map((question) => {
// Skip CTA elements without external buttons only if they have no response data
// This preserves historical data from when buttonExternal was true
if (
question.type === TSurveyElementTypeEnum.CTA &&
!question.buttonExternal &&
!response.data[question.id]
) {
return null;
}
const skipped = skippedQuestions.find((skippedQuestionElement) =>
skippedQuestionElement.includes(question.id)
);
@@ -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: {
@@ -15,6 +15,7 @@ import { getSurveyQuestions } from "@/modules/api/v2/management/responses/[respo
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { formatValidationErrorsForApi, validateResponseData } from "../lib/validation";
import { ZResponseIdSchema, ZResponseUpdateSchema } from "./types/responses";
export const GET = async (request: Request, props: { params: Promise<{ responseId: string }> }) =>
@@ -192,6 +193,25 @@ export const PUT = (request: Request, props: { params: Promise<{ responseId: str
});
}
// Validate response data against validation rules
const validationErrors = validateResponseData(
questionsResponse.data.blocks,
body.data,
body.language ?? "en",
questionsResponse.data.questions
);
if (validationErrors) {
return handleApiError(
request,
{
type: "bad_request",
details: formatValidationErrorsForApi(validationErrors),
},
auditLog
);
}
const response = await updateResponseWithQuotaEvaluation(params.responseId, body);
if (!response.ok) {
@@ -0,0 +1,210 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { TResponseData } from "@formbricks/types/responses";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TValidationErrorMap } from "@formbricks/types/surveys/validation-rules";
import {
formatValidationErrorsForApi,
formatValidationErrorsForV1Api,
validateResponseData,
} from "./validation";
const mockTransformQuestionsToBlocks = vi.fn();
const mockGetElementsFromBlocks = vi.fn();
const mockValidateBlockResponses = vi.fn();
vi.mock("@/app/lib/api/survey-transformation", () => ({
transformQuestionsToBlocks: (...args: unknown[]) => mockTransformQuestionsToBlocks(...args),
}));
vi.mock("@/lib/survey/utils", () => ({
getElementsFromBlocks: (...args: unknown[]) => mockGetElementsFromBlocks(...args),
}));
vi.mock("@formbricks/surveys/validation", () => ({
validateBlockResponses: (...args: unknown[]) => mockValidateBlockResponses(...args),
}));
describe("validateResponseData", () => {
beforeEach(() => vi.clearAllMocks());
const mockBlocks: TSurveyBlock[] = [
{
id: "block1",
name: "Block 1",
elements: [
{
id: "element1",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q1" },
required: false,
inputType: "text",
charLimit: { enabled: false },
},
],
},
];
const mockQuestions: TSurveyQuestion[] = [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Q1" },
required: false,
inputType: "text",
} as unknown as TSurveyQuestion,
];
const mockResponseData: TResponseData = { element1: "test" };
const mockElements = [
{
id: "element1",
type: TSurveyElementTypeEnum.OpenText,
headline: { default: "Q1" },
required: false,
inputType: "text",
charLimit: { enabled: false },
},
];
test("should use blocks when provided", () => {
mockGetElementsFromBlocks.mockReturnValue(mockElements);
mockValidateBlockResponses.mockReturnValue({});
const result = validateResponseData(mockBlocks, mockResponseData, "en");
expect(mockGetElementsFromBlocks).toHaveBeenCalledWith(mockBlocks);
expect(mockValidateBlockResponses).toHaveBeenCalledWith(mockElements, mockResponseData, "en");
expect(result).toBeNull();
});
test("should return error map when validation fails", () => {
const errorMap: TValidationErrorMap = {
element1: [{ ruleId: "minLength", ruleType: "minLength", message: "Min length required" }],
};
mockGetElementsFromBlocks.mockReturnValue(mockElements);
mockValidateBlockResponses.mockReturnValue(errorMap);
expect(validateResponseData(mockBlocks, mockResponseData, "en")).toEqual(errorMap);
});
test("should transform questions to blocks when blocks are empty", () => {
const transformedBlocks = [{ ...mockBlocks[0] }];
mockTransformQuestionsToBlocks.mockReturnValue(transformedBlocks);
mockGetElementsFromBlocks.mockReturnValue(mockElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData([], mockResponseData, "en", mockQuestions);
expect(mockTransformQuestionsToBlocks).toHaveBeenCalledWith(mockQuestions, []);
expect(mockGetElementsFromBlocks).toHaveBeenCalledWith(transformedBlocks);
});
test("should prefer blocks over questions", () => {
mockGetElementsFromBlocks.mockReturnValue(mockElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData(mockBlocks, mockResponseData, "en", mockQuestions);
expect(mockTransformQuestionsToBlocks).not.toHaveBeenCalled();
});
test("should return null when both blocks and questions are empty", () => {
expect(validateResponseData([], mockResponseData, "en", [])).toBeNull();
expect(validateResponseData(null, mockResponseData, "en", [])).toBeNull();
expect(validateResponseData(undefined, mockResponseData, "en", null)).toBeNull();
});
test("should use default language code", () => {
mockGetElementsFromBlocks.mockReturnValue(mockElements);
mockValidateBlockResponses.mockReturnValue({});
validateResponseData(mockBlocks, mockResponseData);
expect(mockValidateBlockResponses).toHaveBeenCalledWith(mockElements, mockResponseData, "en");
});
});
describe("formatValidationErrorsForApi", () => {
test("should convert error map to V2 API format", () => {
const errorMap: TValidationErrorMap = {
element1: [{ ruleId: "minLength", ruleType: "minLength", message: "Min length required" }],
};
const result = formatValidationErrorsForApi(errorMap);
expect(result).toEqual([
{
field: "response.data.element1",
issue: "Min length required",
meta: { elementId: "element1", ruleId: "minLength", ruleType: "minLength" },
},
]);
});
test("should handle multiple errors per element", () => {
const errorMap: TValidationErrorMap = {
element1: [
{ ruleId: "minLength", ruleType: "minLength", message: "Min length" },
{ ruleId: "maxLength", ruleType: "maxLength", message: "Max length" },
],
};
const result = formatValidationErrorsForApi(errorMap);
expect(result).toHaveLength(2);
expect(result[0].field).toBe("response.data.element1");
expect(result[1].field).toBe("response.data.element1");
});
test("should handle multiple elements", () => {
const errorMap: TValidationErrorMap = {
element1: [{ ruleId: "minLength", ruleType: "minLength", message: "Min length" }],
element2: [{ ruleId: "maxLength", ruleType: "maxLength", message: "Max length" }],
};
const result = formatValidationErrorsForApi(errorMap);
expect(result).toHaveLength(2);
expect(result[0].field).toBe("response.data.element1");
expect(result[1].field).toBe("response.data.element2");
});
});
describe("formatValidationErrorsForV1Api", () => {
test("should convert error map to V1 API format", () => {
const errorMap: TValidationErrorMap = {
element1: [{ ruleId: "minLength", ruleType: "minLength", message: "Min length required" }],
};
expect(formatValidationErrorsForV1Api(errorMap)).toEqual({
"response.data.element1": "Min length required",
});
});
test("should combine multiple errors with semicolon", () => {
const errorMap: TValidationErrorMap = {
element1: [
{ ruleId: "minLength", ruleType: "minLength", message: "Min length" },
{ ruleId: "maxLength", ruleType: "maxLength", message: "Max length" },
],
};
expect(formatValidationErrorsForV1Api(errorMap)).toEqual({
"response.data.element1": "Min length; Max length",
});
});
test("should handle multiple elements", () => {
const errorMap: TValidationErrorMap = {
element1: [{ ruleId: "minLength", ruleType: "minLength", message: "Min length" }],
element2: [{ ruleId: "maxLength", ruleType: "maxLength", message: "Max length" }],
};
expect(formatValidationErrorsForV1Api(errorMap)).toEqual({
"response.data.element1": "Min length",
"response.data.element2": "Max length",
});
});
});
@@ -0,0 +1,92 @@
import "server-only";
import { validateBlockResponses } from "@formbricks/surveys/validation";
import { TResponseData } from "@formbricks/types/responses";
import { TSurveyBlock } from "@formbricks/types/surveys/blocks";
import { TSurveyQuestion } from "@formbricks/types/surveys/types";
import { TValidationErrorMap } from "@formbricks/types/surveys/validation-rules";
import { transformQuestionsToBlocks } from "@/app/lib/api/survey-transformation";
import { getElementsFromBlocks } from "@/lib/survey/utils";
import { ApiErrorDetails } from "@/modules/api/v2/types/api-error";
/**
* Validates response data against survey validation rules
*
* @param blocks - Survey blocks containing elements with validation rules (preferred)
* @param questions - Survey questions (legacy format, used as fallback if blocks are empty)
* @param responseData - Response data to validate (keyed by element ID)
* @param languageCode - Language code for error messages (defaults to "en")
* @returns Validation error map keyed by element ID, or null if validation passes
*/
export const validateResponseData = (
blocks: TSurveyBlock[] | undefined | null,
responseData: TResponseData,
languageCode: string = "en",
questions?: TSurveyQuestion[] | undefined | null
): TValidationErrorMap | null => {
// Use blocks if available, otherwise transform questions to blocks
let blocksToUse: TSurveyBlock[] = [];
if (blocks && blocks.length > 0) {
blocksToUse = blocks;
} else if (questions && questions.length > 0) {
// Transform legacy questions format to blocks for validation
blocksToUse = transformQuestionsToBlocks(questions, []);
} else {
// No blocks or questions to validate against
return null;
}
// Extract elements from blocks
const elements = getElementsFromBlocks(blocksToUse);
// Validate all elements
const errorMap = validateBlockResponses(elements, responseData, languageCode);
// Return null if no errors (validation passed), otherwise return error map
return Object.keys(errorMap).length === 0 ? null : errorMap;
};
/**
* Converts validation error map to API error response format (V2)
*
* @param errorMap - Validation error map from validateResponseData
* @returns API error response details
*/
export const formatValidationErrorsForApi = (errorMap: TValidationErrorMap) => {
const details: ApiErrorDetails = [];
for (const [elementId, errors] of Object.entries(errorMap)) {
// Include all error messages for each element
for (const error of errors) {
details.push({
field: `response.data.${elementId}`,
issue: error.message,
meta: {
elementId,
ruleId: error.ruleId,
ruleType: error.ruleType,
},
});
}
}
return details;
};
/**
* Converts validation error map to V1 API error response format
*
* @param errorMap - Validation error map from validateResponseData
* @returns V1 API error details as Record<string, string>
*/
export const formatValidationErrorsForV1Api = (errorMap: TValidationErrorMap): Record<string, string> => {
const details: Record<string, string> = {};
for (const [elementId, errors] of Object.entries(errorMap)) {
// Combine all error messages for each element
const errorMessages = errors.map((error) => error.message).join("; ");
details[`response.data.${elementId}`] = errorMessages;
}
return details;
};
@@ -13,6 +13,7 @@ import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { createResponseWithQuotaEvaluation, getResponses } from "./lib/response";
import { formatValidationErrorsForApi, validateResponseData } from "./lib/validation";
export const GET = async (request: NextRequest) =>
authenticatedApiClient({
@@ -128,6 +129,25 @@ export const POST = async (request: Request) =>
});
}
// Validate response data against validation rules
const validationErrors = validateResponseData(
surveyQuestions.data.blocks,
body.data,
body.language ?? "en",
surveyQuestions.data.questions
);
if (validationErrors) {
return handleApiError(
request,
{
type: "bad_request",
details: formatValidationErrorsForApi(validationErrors),
},
auditLog
);
}
const createResponseResult = await createResponseWithQuotaEvaluation(environmentId, body);
if (!createResponseResult.ok) {
return handleApiError(request, createResponseResult.error, auditLog);
@@ -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({
@@ -33,7 +33,7 @@ export const resetPasswordAction = actionClient.schema(ZResetPasswordAction).act
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = updatedUser;
await sendPasswordResetNotifyEmail(updatedUser);
await sendPasswordResetNotifyEmail({ email: updatedUser.email, locale: updatedUser.locale });
return { success: true };
}
)
@@ -69,6 +69,7 @@ describe("invite", () => {
creator: {
name: "Test User",
email: "test@example.com",
locale: "en-US",
},
};
@@ -89,6 +90,7 @@ describe("invite", () => {
select: {
name: true,
email: true,
locale: true,
},
},
},
@@ -46,6 +46,7 @@ export const getInvite = reactCache(async (inviteId: string): Promise<InviteWith
select: {
name: true,
email: true,
locale: true,
},
},
},
+6 -1
View File
@@ -102,7 +102,12 @@ export const InvitePage = async (props: InvitePageProps) => {
);
}
await deleteInvite(inviteId);
await sendInviteAcceptedEmail(invite.creator.name ?? "", user?.name ?? "", invite.creator.email);
await sendInviteAcceptedEmail(
invite.creator.name ?? "",
user?.name ?? "",
invite.creator.email,
invite.creator.locale
);
await updateUser(session.user.id, {
notificationSettings: {
...user.notificationSettings,
@@ -1,10 +1,12 @@
import { Invite } from "@prisma/client";
import { TUserLocale } from "@formbricks/types/user";
export interface InviteWithCreator
extends Pick<Invite, "id" | "expiresAt" | "organizationId" | "role" | "teamIds"> {
creator: {
name: string | null;
email: string;
locale: TUserLocale;
};
}
+18 -2
View File
@@ -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> {
@@ -123,7 +127,12 @@ async function handleInviteAcceptance(
},
});
await sendInviteAcceptedEmail(invite.creator.name ?? "", user.name, invite.creator.email);
await sendInviteAcceptedEmail(
invite.creator.name ?? "",
user.name,
invite.creator.email,
invite.creator.locale
);
await deleteInvite(invite.id);
}
@@ -164,7 +173,7 @@ async function handlePostUserCreation(
}
if (!emailVerificationDisabled) {
await sendVerificationEmail(user);
await sendVerificationEmail({ id: user.id, email: user.email, locale: user.locale });
}
}
@@ -191,6 +200,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"
+2
View File
@@ -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,
@@ -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>
@@ -48,8 +48,7 @@ describe("resendVerificationEmailAction", () => {
const mockUser = {
id: "user123",
email: "test@example.com",
emailVerified: null, // Not verified
name: "Test User",
locale: "en-US",
};
const mockVerifiedUser = {
@@ -32,7 +32,7 @@ export const resendVerificationEmailAction = actionClient.schema(ZResendVerifica
};
}
ctx.auditLoggingCtx.userId = user.id;
await sendVerificationEmail(user);
await sendVerificationEmail({ id: user.id, email: user.email, locale: user.locale });
return {
success: true,
};
@@ -4,6 +4,34 @@ import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attribut
import { getContact } from "@/modules/ee/contacts/lib/contacts";
import { IdBadge } from "@/modules/ui/components/id-badge";
const DEFAULT_ATTRIBUTE_KEYS = ["email", "language", "userId", "firstName", "lastName"] as const;
interface AttributeRowProps {
label: string;
value: string | undefined;
notProvidedText: string;
isIdBadge?: boolean;
}
const AttributeRow = ({ label, value, notProvidedText, isIdBadge = false }: AttributeRowProps) => {
const renderValue = () => {
if (!value) {
return <span className="text-slate-300">{notProvidedText}</span>;
}
if (isIdBadge) {
return <IdBadge id={value} />;
}
return <span>{value}</span>;
};
return (
<div>
<dt className="text-sm font-medium text-slate-500">{label}</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">{renderValue()}</dd>
</div>
);
};
export const AttributesSection = async ({ contactId }: { contactId: string }) => {
const t = await getTranslate();
const [contact, attributes] = await Promise.all([getContact(contactId), getContactAttributes(contactId)]);
@@ -14,55 +42,34 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
const responses = await getResponsesByContactId(contactId);
const numberOfResponses = responses?.length || 0;
const notProvidedText = t("environments.contacts.not_provided");
const customAttributes = Object.entries(attributes).filter(
([key]) => !DEFAULT_ATTRIBUTE_KEYS.includes(key as (typeof DEFAULT_ATTRIBUTE_KEYS)[number])
);
return (
<div className="space-y-6">
<h2 className="text-lg font-bold text-slate-700">{t("common.attributes")}</h2>
<div>
<dt className="text-sm font-medium text-slate-500">email</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{attributes.email ? (
<span>{attributes.email}</span>
) : (
<span className="text-slate-300">{t("environments.contacts.not_provided")}</span>
)}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">language</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{attributes.language ? (
<span>{attributes.language}</span>
) : (
<span className="text-slate-300">{t("environments.contacts.not_provided")}</span>
)}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-slate-500">userId</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">
{attributes.userId ? (
<IdBadge id={attributes.userId} />
) : (
<span className="text-slate-300">{t("environments.contacts.not_provided")}</span>
)}
</dd>
</div>
<AttributeRow label="email" value={attributes.email} notProvidedText={notProvidedText} />
<AttributeRow label="language" value={attributes.language} notProvidedText={notProvidedText} />
<AttributeRow label="userId" value={attributes.userId} notProvidedText={notProvidedText} isIdBadge />
<AttributeRow label="firstName" value={attributes.firstName} notProvidedText={notProvidedText} />
<AttributeRow label="lastName" value={attributes.lastName} notProvidedText={notProvidedText} />
<div>
<dt className="text-sm font-medium text-slate-500">contactId</dt>
<dd className="ph-no-capture mt-1 text-sm text-slate-900">{contact.id}</dd>
</div>
{Object.entries(attributes)
.filter(([key, _]) => key !== "email" && key !== "userId" && key !== "language")
.map(([key, attributeData]) => {
return (
<div key={key}>
<dt className="text-sm font-medium text-slate-500">{key}</dt>
<dd className="mt-1 text-sm text-slate-900">{attributeData}</dd>
</div>
);
})}
{customAttributes.map(([key, value]) => (
<div key={key}>
<dt className="text-sm font-medium text-slate-500">{key}</dt>
<dd className="mt-1 text-sm text-slate-900">{value}</dd>
</div>
))}
<hr />
<div>
@@ -175,6 +175,23 @@ export const EditContactAttributesModal = ({
setOpen(newOpen);
};
// Memoize email/userId existence checks for delete button logic
// A contact MUST have either email or userId, so we prevent deletion if it would violate this
const { hasEmail, hasUserId } = useMemo(() => {
const emailAttr = watchedAttributes.find((attr) => attr.key === "email");
const userIdAttr = watchedAttributes.find((attr) => attr.key === "userId");
return {
hasEmail: !!emailAttr?.value?.trim(),
hasUserId: !!userIdAttr?.value?.trim(),
};
}, [watchedAttributes]);
const isDeleteDisabled = (key: string): boolean => {
if (key === "email") return !hasUserId;
if (key === "userId") return !hasEmail;
return false;
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent width="default" className="max-h-[90vh]">
@@ -235,7 +252,7 @@ export const EditContactAttributesModal = ({
<Button
type="button"
variant="outline"
disabled={["email", "userId", "firstName", "lastName"].includes(field.key)}
disabled={isDeleteDisabled(field.key)}
size="sm"
onClick={() => handleRemoveAttribute(index)}
className="h-10 w-10 p-0">
+12 -17
View File
@@ -11,30 +11,27 @@ import {
hasUserIdAttribute,
} from "@/modules/ee/contacts/lib/contact-attributes";
// Default/system attributes that should not be deleted even if missing from payload
const DEFAULT_ATTRIBUTES = new Set(["email", "userId", "firstName", "lastName"]);
const deleteAttributes = async (
contactId: string,
currentAttributes: TContactAttributes,
submittedAttributes: TContactAttributes,
contactAttributeKeys: TContactAttributeKey[]
): Promise<{ success: boolean }> => {
): Promise<void> => {
const contactAttributeKeyMap = new Map(contactAttributeKeys.map((ack) => [ack.key, ack]));
// Determine which attributes should be deleted (exist in DB but not in payload, and not default attributes)
// Determine which attributes should be deleted (exist in DB but not in payload)
const submittedKeys = new Set(Object.keys(submittedAttributes));
const currentKeys = new Set(Object.keys(currentAttributes));
const keysToDelete = Array.from(currentKeys).filter(
(key) => !submittedKeys.has(key) && !DEFAULT_ATTRIBUTES.has(key)
);
const keysToDelete = Object.keys(currentAttributes).filter((key) => !submittedKeys.has(key));
// Get attribute key IDs for deletion
// Get attribute key IDs for deletion, but exclude default attributes
const attributeKeyIdsToDelete = keysToDelete
.map((key) => contactAttributeKeyMap.get(key)?.id)
.map((key) => {
const attributeKey = contactAttributeKeyMap.get(key);
// Only include non-default attributes for deletion
return attributeKey?.type === "custom" ? attributeKey.id : null;
})
.filter((id): id is string => !!id);
// Delete attributes that were removed from the form (but not default attributes)
if (attributeKeyIdsToDelete.length > 0) {
await prisma.contactAttribute.deleteMany({
where: {
@@ -45,10 +42,6 @@ const deleteAttributes = async (
},
});
}
return {
success: true,
};
};
/**
@@ -161,8 +154,10 @@ export const updateAttributes = async (
// Delete attributes that were removed (only when explicitly requested)
// This is used by UI forms where all attributes are submitted
// For API calls, we want merge behavior by default (only update passed attributes)
// We use contactAttributes (not contactAttributesParam) because it includes validation adjustments
// (e.g., preserving email/userId when both would be empty)
if (deleteRemovedAttributes) {
await deleteAttributes(contactId, currentAttributes, contactAttributesParam, contactAttributeKeys);
await deleteAttributes(contactId, currentAttributes, contactAttributes, contactAttributeKeys);
}
// Create lookup map for attribute keys
@@ -157,6 +157,7 @@ describe("License Core Logic", () => {
lastChecked: expect.any(Date),
isPendingDowngrade: false,
fallbackLevel: "live" as const,
status: "active" as const,
};
test("should return cached license from FETCH_LICENSE_CACHE_KEY if available and valid", async () => {
@@ -233,6 +234,7 @@ describe("License Core Logic", () => {
lastChecked: previousTime,
isPendingDowngrade: true,
fallbackLevel: "grace" as const,
status: "unreachable" as const,
});
});
@@ -309,6 +311,7 @@ describe("License Core Logic", () => {
lastChecked: expect.any(Date),
isPendingDowngrade: false,
fallbackLevel: "default" as const,
status: "unreachable" as const,
});
});
@@ -356,6 +359,7 @@ describe("License Core Logic", () => {
lastChecked: expect.any(Date),
isPendingDowngrade: false,
fallbackLevel: "default" as const,
status: "unreachable" as const,
});
});
@@ -389,6 +393,7 @@ describe("License Core Logic", () => {
lastChecked: expect.any(Date),
isPendingDowngrade: false,
fallbackLevel: "default" as const,
status: "no-license" as const,
});
expect(mockCache.get).not.toHaveBeenCalled();
expect(mockCache.set).not.toHaveBeenCalled();
@@ -414,6 +419,7 @@ describe("License Core Logic", () => {
lastChecked: expect.any(Date),
isPendingDowngrade: false,
fallbackLevel: "default" as const,
status: "no-license" as const,
});
});
});
+168 -52
View File
@@ -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;
@@ -37,6 +38,17 @@ const CONFIG = {
// Types
type FallbackLevel = "live" | "cached" | "grace" | "default";
type TEnterpriseLicenseStatusReturn = "active" | "expired" | "unreachable" | "no-license";
type TEnterpriseLicenseResult = {
active: boolean;
features: TEnterpriseLicenseFeatures | null;
lastChecked: Date;
isPendingDowngrade: boolean;
fallbackLevel: FallbackLevel;
status: TEnterpriseLicenseStatusReturn;
};
type TPreviousResult = {
active: boolean;
lastChecked: Date;
@@ -89,7 +101,7 @@ class LicenseApiError extends LicenseError {
// Cache keys using enterprise-grade hierarchical patterns
const getCacheIdentifier = () => {
if (typeof window !== "undefined") {
if (globalThis.window !== undefined) {
return "browser"; // Browser environment
}
if (!env.ENTERPRISE_LICENSE_KEY) {
@@ -141,36 +153,50 @@ const validateConfig = () => {
};
// Cache functions with async pattern
let getPreviousResultPromise: Promise<TPreviousResult> | null = null;
const getPreviousResult = async (): Promise<TPreviousResult> => {
if (typeof window !== "undefined") {
if (getPreviousResultPromise) return getPreviousResultPromise;
getPreviousResultPromise = (async () => {
if (globalThis.window !== undefined) {
return {
active: false,
lastChecked: new Date(0),
features: DEFAULT_FEATURES,
};
}
try {
const result = await cache.get<TPreviousResult>(getCacheKeys().PREVIOUS_RESULT_CACHE_KEY);
if (result.ok && result.data) {
return {
...result.data,
lastChecked: new Date(result.data.lastChecked),
};
}
} catch (error) {
logger.error({ error }, "Failed to get previous result from cache");
}
return {
active: false,
lastChecked: new Date(0),
features: DEFAULT_FEATURES,
};
}
})();
try {
const result = await cache.get<TPreviousResult>(getCacheKeys().PREVIOUS_RESULT_CACHE_KEY);
if (result.ok && result.data) {
return {
...result.data,
lastChecked: new Date(result.data.lastChecked),
};
}
} catch (error) {
logger.error({ error }, "Failed to get previous result from cache");
}
getPreviousResultPromise
.finally(() => {
getPreviousResultPromise = null;
})
.catch(() => {});
return {
active: false,
lastChecked: new Date(0),
features: DEFAULT_FEATURES,
};
return getPreviousResultPromise;
};
const setPreviousResult = async (previousResult: TPreviousResult) => {
if (typeof window !== "undefined") return;
if (globalThis.window !== undefined) return;
try {
const result = await cache.set(
@@ -220,12 +246,21 @@ const validateLicenseDetails = (data: unknown): TEnterpriseLicenseDetails => {
};
// Fallback functions
let memoryCache: {
data: TEnterpriseLicenseResult;
timestamp: number;
} | null = null;
const MEMORY_CACHE_TTL_MS = 60 * 1000; // 1 minute memory cache to avoid stampedes and reduce load when Redis is slow
let getEnterpriseLicensePromise: Promise<TEnterpriseLicenseResult> | null = null;
const getFallbackLevel = (
liveLicense: TEnterpriseLicenseDetails | null,
previousResult: TPreviousResult,
currentTime: Date
): FallbackLevel => {
if (liveLicense) return "live";
if (liveLicense?.status === "active") return "live";
if (previousResult.active) {
const elapsedTime = currentTime.getTime() - previousResult.lastChecked.getTime();
return elapsedTime < CONFIG.CACHE.GRACE_PERIOD_MS ? "grace" : "default";
@@ -233,7 +268,7 @@ const getFallbackLevel = (
return "default";
};
const handleInitialFailure = async (currentTime: Date) => {
const handleInitialFailure = async (currentTime: Date): Promise<TEnterpriseLicenseResult> => {
const initialFailResult: TPreviousResult = {
active: false,
features: DEFAULT_FEATURES,
@@ -246,10 +281,13 @@ const handleInitialFailure = async (currentTime: Date) => {
lastChecked: currentTime,
isPendingDowngrade: false,
fallbackLevel: "default" as const,
status: "unreachable" as const,
};
};
// API functions
let fetchLicensePromise: Promise<TEnterpriseLicenseDetails | null> | null = null;
const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpriseLicenseDetails | null> => {
if (!env.ENTERPRISE_LICENSE_KEY) return null;
@@ -265,6 +303,7 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
// first millisecond of next year => current year is fully included
const startOfNextYear = new Date(now.getFullYear() + 1, 0, 1);
const startTime = Date.now();
const [instanceId, responseCount] = await Promise.all([
// Skip instance ID during E2E tests to avoid license key conflicts
// as the instance ID changes with each test run
@@ -278,6 +317,11 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
},
}),
]);
const duration = Date.now() - startTime;
if (duration > 1000) {
logger.warn({ duration, responseCount }, "Slow license check prerequisite data fetching (DB count)");
}
// No organization exists, cannot perform license check
// (skip this check during E2E tests as we intentionally use null)
@@ -310,7 +354,19 @@ const fetchLicenseFromServerInternal = async (retryCount = 0): Promise<TEnterpri
if (res.ok) {
const responseJson = (await res.json()) as { data: unknown };
return validateLicenseDetails(responseJson.data);
const licenseDetails = validateLicenseDetails(responseJson.data);
logger.debug(
{
status: licenseDetails.status,
instanceId: instanceId ?? "not-set",
responseCount,
timestamp: new Date().toISOString(),
},
"License check API response received"
);
return licenseDetails;
}
const error = new LicenseApiError(`License check API responded with status: ${res.status}`, res.status);
@@ -341,23 +397,41 @@ export const fetchLicense = async (): Promise<TEnterpriseLicenseDetails | null>
return null;
}
return await cache.withCache(
async () => {
return await fetchLicenseFromServerInternal();
},
getCacheKeys().FETCH_LICENSE_CACHE_KEY,
CONFIG.CACHE.FETCH_LICENSE_TTL_MS
);
if (fetchLicensePromise) {
return fetchLicensePromise;
}
fetchLicensePromise = (async () => {
return await cache.withCache(
async () => {
return await fetchLicenseFromServerInternal();
},
getCacheKeys().FETCH_LICENSE_CACHE_KEY,
CONFIG.CACHE.FETCH_LICENSE_TTL_MS
);
})();
fetchLicensePromise
.finally(() => {
fetchLicensePromise = null;
})
.catch(() => {});
return fetchLicensePromise;
};
export const getEnterpriseLicense = reactCache(
async (): Promise<{
active: boolean;
features: TEnterpriseLicenseFeatures | null;
lastChecked: Date;
isPendingDowngrade: boolean;
fallbackLevel: FallbackLevel;
}> => {
export const getEnterpriseLicense = reactCache(async (): Promise<TEnterpriseLicenseResult> => {
if (
process.env.NODE_ENV !== "test" &&
memoryCache &&
Date.now() - memoryCache.timestamp < MEMORY_CACHE_TTL_MS
) {
return memoryCache.data;
}
if (getEnterpriseLicensePromise) return getEnterpriseLicensePromise;
getEnterpriseLicensePromise = (async () => {
validateConfig();
if (!env.ENTERPRISE_LICENSE_KEY || env.ENTERPRISE_LICENSE_KEY.length === 0) {
@@ -367,12 +441,11 @@ export const getEnterpriseLicense = reactCache(
lastChecked: new Date(),
isPendingDowngrade: false,
fallbackLevel: "default" as const,
status: "no-license" as const,
};
}
const currentTime = new Date();
const liveLicenseDetails = await fetchLicense();
const previousResult = await getPreviousResult();
const [liveLicenseDetails, previousResult] = await Promise.all([fetchLicense(), getPreviousResult()]);
const fallbackLevel = getFallbackLevel(liveLicenseDetails, previousResult, currentTime);
trackFallbackUsage(fallbackLevel);
@@ -380,41 +453,84 @@ export const getEnterpriseLicense = reactCache(
let currentLicenseState: TPreviousResult | undefined;
switch (fallbackLevel) {
case "live":
case "live": {
if (!liveLicenseDetails) throw new Error("Invalid state: live license expected");
currentLicenseState = {
active: liveLicenseDetails.status === "active",
features: liveLicenseDetails.features,
lastChecked: currentTime,
};
await setPreviousResult(currentLicenseState);
return {
// Only update previous result if it's actually different or if it's old (1 hour)
// This prevents hammering Redis on every request when the license is active
if (
!previousResult.active ||
previousResult.active !== currentLicenseState.active ||
currentTime.getTime() - previousResult.lastChecked.getTime() > 60 * 60 * 1000
) {
await setPreviousResult(currentLicenseState);
}
const liveResult: TEnterpriseLicenseResult = {
active: currentLicenseState.active,
features: currentLicenseState.features,
lastChecked: currentTime,
isPendingDowngrade: false,
fallbackLevel: "live" as const,
status: liveLicenseDetails.status,
};
memoryCache = { data: liveResult, timestamp: Date.now() };
return liveResult;
}
case "grace":
case "grace": {
if (!validateFallback(previousResult)) {
return handleInitialFailure(currentTime);
return await handleInitialFailure(currentTime);
}
return {
const graceResult: TEnterpriseLicenseResult = {
active: previousResult.active,
features: previousResult.features,
lastChecked: previousResult.lastChecked,
isPendingDowngrade: true,
fallbackLevel: "grace" as const,
status: (liveLicenseDetails?.status as TEnterpriseLicenseStatusReturn) ?? "unreachable",
};
memoryCache = { data: graceResult, timestamp: Date.now() };
return graceResult;
}
case "default":
return handleInitialFailure(currentTime);
case "default": {
if (liveLicenseDetails?.status === "expired") {
const expiredResult: TEnterpriseLicenseResult = {
active: false,
features: DEFAULT_FEATURES,
lastChecked: currentTime,
isPendingDowngrade: false,
fallbackLevel: "default" as const,
status: "expired" as const,
};
memoryCache = { data: expiredResult, timestamp: Date.now() };
return expiredResult;
}
const failResult = await handleInitialFailure(currentTime);
memoryCache = { data: failResult, timestamp: Date.now() };
return failResult;
}
}
return handleInitialFailure(currentTime);
}
);
const finalFailResult = await handleInitialFailure(currentTime);
memoryCache = { data: finalFailResult, timestamp: Date.now() };
return finalFailResult;
})();
getEnterpriseLicensePromise
.finally(() => {
getEnterpriseLicensePromise = null;
})
.catch(() => {});
return getEnterpriseLicensePromise;
});
export const getLicenseFeatures = async (): Promise<TEnterpriseLicenseFeatures | null> => {
try {
@@ -8,6 +8,7 @@ import {
getBiggerUploadFileSizePermission,
getIsContactsEnabled,
getIsMultiOrgEnabled,
getIsQuotasEnabled,
getIsSamlSsoEnabled,
getIsSpamProtectionEnabled,
getIsSsoEnabled,
@@ -48,6 +49,7 @@ const defaultFeatures: TEnterpriseLicenseFeatures = {
auditLogs: false,
multiLanguageSurveys: false,
accessControl: false,
quotas: false,
};
const defaultLicense = {
@@ -184,10 +186,10 @@ describe("License Utils", () => {
expect(result).toBe(true);
});
test("should return true if license active but accessControl feature disabled because of fallback", async () => {
test("should return false if license active but accessControl feature disabled (self-hosted)", async () => {
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getAccessControlPermission(mockOrganization.billing.plan);
expect(result).toBe(true);
expect(result).toBe(false);
});
test("should return false if license is inactive", async () => {
@@ -273,10 +275,10 @@ describe("License Utils", () => {
expect(result).toBe(true);
});
test("should return true if license active but multiLanguageSurveys feature disabled because of fallback", async () => {
test("should return false if license active but multiLanguageSurveys feature disabled (self-hosted)", async () => {
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getMultiLanguagePermission(mockOrganization.billing.plan);
expect(result).toBe(true);
expect(result).toBe(false);
});
test("should return false if license is inactive", async () => {
@@ -289,6 +291,54 @@ describe("License Utils", () => {
});
});
describe("getIsQuotasEnabled", () => {
test("should return true if license active and quotas feature enabled (self-hosted)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, quotas: true },
});
const result = await getIsQuotasEnabled(mockOrganization.billing.plan);
expect(result).toBe(true);
});
test("should return true if license active, quotas enabled and plan is CUSTOM (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, quotas: true },
});
const result = await getIsQuotasEnabled(constants.PROJECT_FEATURE_KEYS.CUSTOM);
expect(result).toBe(true);
});
test("should return false if license active, quotas enabled but plan is not CUSTOM (cloud)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = true;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
features: { ...defaultFeatures, quotas: true },
});
const result = await getIsQuotasEnabled(constants.PROJECT_FEATURE_KEYS.STARTUP);
expect(result).toBe(false);
});
test("should return false if license active but quotas feature disabled (self-hosted)", async () => {
vi.mocked(constants).IS_FORMBRICKS_CLOUD = false;
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue(defaultLicense);
const result = await getIsQuotasEnabled(mockOrganization.billing.plan);
expect(result).toBe(false);
});
test("should return false if license is inactive", async () => {
vi.mocked(licenseModule.getEnterpriseLicense).mockResolvedValue({
...defaultLicense,
active: false,
});
const result = await getIsQuotasEnabled(mockOrganization.billing.plan);
expect(result).toBe(false);
});
});
describe("getIsMultiOrgEnabled", () => {
test("should return true if feature flag isMultiOrgEnabled is true", async () => {
vi.mocked(licenseModule.getLicenseFeatures).mockResolvedValue({
+40 -43
View File
@@ -10,6 +10,8 @@ import { TEnterpriseLicenseFeatures } from "@/modules/ee/license-check/types/ent
import { getEnterpriseLicense, getLicenseFeatures } from "./license";
// Helper function for feature permissions (e.g., removeBranding, whitelabel)
// On Cloud: requires active license and non-FREE plan
// On Self-hosted: requires active license and feature enabled
const getFeaturePermission = async (
billingPlan: Organization["billing"]["plan"],
featureKey: keyof Pick<TEnterpriseLicenseFeatures, "removeBranding" | "whitelabel">
@@ -23,6 +25,41 @@ const getFeaturePermission = async (
}
};
// Helper function for enterprise features that require CUSTOM plan on Cloud
// On Cloud: requires active license AND feature enabled in license AND CUSTOM billing plan
// On Self-hosted: requires active license AND feature enabled in license
const getCustomPlanFeaturePermission = async (
billingPlan: Organization["billing"]["plan"],
featureKey: keyof Pick<TEnterpriseLicenseFeatures, "accessControl" | "multiLanguageSurveys" | "quotas">
): Promise<boolean> => {
const license = await getEnterpriseLicense();
if (!license.active) return false;
const isFeatureEnabled = license.features?.[featureKey] ?? false;
if (!isFeatureEnabled) return false;
if (IS_FORMBRICKS_CLOUD) {
return billingPlan === PROJECT_FEATURE_KEYS.CUSTOM;
}
return true;
};
// Helper function for license-only feature flags (no billing plan check)
// Returns true only if the license is active AND the specific feature is enabled in the license
// Used for features that are controlled purely by the license key, not billing plans
const getSpecificFeatureFlag = async (
featureKey: keyof Pick<
TEnterpriseLicenseFeatures,
"isMultiOrgEnabled" | "contacts" | "twoFactorAuth" | "sso" | "auditLogs"
>
): Promise<boolean> => {
const licenseFeatures = await getLicenseFeatures();
if (!licenseFeatures) return false;
return typeof licenseFeatures[featureKey] === "boolean" ? licenseFeatures[featureKey] : false;
};
export const getRemoveBrandingPermission = async (
billingPlan: Organization["billing"]["plan"]
): Promise<boolean> => {
@@ -45,24 +82,6 @@ export const getBiggerUploadFileSizePermission = async (
return false;
};
const getSpecificFeatureFlag = async (
featureKey: keyof Pick<
TEnterpriseLicenseFeatures,
| "isMultiOrgEnabled"
| "contacts"
| "twoFactorAuth"
| "sso"
| "auditLogs"
| "multiLanguageSurveys"
| "accessControl"
| "quotas"
>
): Promise<boolean> => {
const licenseFeatures = await getLicenseFeatures();
if (!licenseFeatures) return false;
return typeof licenseFeatures[featureKey] === "boolean" ? licenseFeatures[featureKey] : false;
};
export const getIsMultiOrgEnabled = async (): Promise<boolean> => {
return getSpecificFeatureFlag("isMultiOrgEnabled");
};
@@ -80,12 +99,7 @@ export const getIsSsoEnabled = async (): Promise<boolean> => {
};
export const getIsQuotasEnabled = async (billingPlan: Organization["billing"]["plan"]): Promise<boolean> => {
const isEnabled = await getSpecificFeatureFlag("quotas");
// If the feature is enabled in the license, return true
if (isEnabled) return true;
// If the feature is not enabled in the license, check the fallback(Backwards compatibility)
return featureFlagFallback(billingPlan);
return getCustomPlanFeaturePermission(billingPlan, "quotas");
};
export const getIsAuditLogsEnabled = async (): Promise<boolean> => {
@@ -118,33 +132,16 @@ export const getIsSpamProtectionEnabled = async (
return license.active && !!license.features?.spamProtection;
};
const featureFlagFallback = async (billingPlan: Organization["billing"]["plan"]): Promise<boolean> => {
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;
};
export const getMultiLanguagePermission = async (
billingPlan: Organization["billing"]["plan"]
): Promise<boolean> => {
const isEnabled = await getSpecificFeatureFlag("multiLanguageSurveys");
// If the feature is enabled in the license, return true
if (isEnabled) return true;
// If the feature is not enabled in the license, check the fallback(Backwards compatibility)
return featureFlagFallback(billingPlan);
return getCustomPlanFeaturePermission(billingPlan, "multiLanguageSurveys");
};
export const getAccessControlPermission = async (
billingPlan: Organization["billing"]["plan"]
): Promise<boolean> => {
const isEnabled = await getSpecificFeatureFlag("accessControl");
// If the feature is enabled in the license, return true
if (isEnabled) return true;
// If the feature is not enabled in the license, check the fallback(Backwards compatibility)
return featureFlagFallback(billingPlan);
return getCustomPlanFeaturePermission(billingPlan, "accessControl");
};
export const getOrganizationProjectsLimit = async (
@@ -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);
}}>
@@ -62,7 +62,7 @@ export const QuotaConditionBuilder = ({
);
return (
<div className="space-y-4">
<div className="max-h-[150px] space-y-4 overflow-y-auto">
<ConditionsEditor
conditions={genericConditions}
config={config}
@@ -438,5 +438,45 @@ describe("Quota Evaluation Service", () => {
"Error evaluating quotas for response"
);
});
test("should use 'default' language when provided language matches default language", async () => {
const surveyWithLanguages = {
...mockSurvey,
languages: [
{ default: true, language: { code: "en", flag: "🇺🇸" } },
{ default: false, language: { code: "fr", flag: "🇫🇷" } },
],
};
const input: QuotaEvaluationInput = {
surveyId: mockSurveyId,
responseId: mockResponseId,
data: mockResponseData,
variables: mockVariablesData,
language: "en",
responseFinished: true,
tx: mockTx,
};
const evaluateResult = {
passedQuotas: [mockQuota],
failedQuotas: [],
};
vi.mocked(getQuotas).mockResolvedValue([mockQuota]);
vi.mocked(getSurvey).mockResolvedValue(surveyWithLanguages as unknown as TSurvey);
vi.mocked(evaluateQuotas).mockReturnValue(evaluateResult);
vi.mocked(handleQuotas).mockResolvedValue(null);
await evaluateResponseQuotas(input);
expect(evaluateQuotas).toHaveBeenCalledWith(
surveyWithLanguages,
mockResponseData,
mockVariablesData,
[mockQuota],
"default"
);
});
});
});

Some files were not shown because too many files have changed in this diff Show More