Compare commits

..

4 Commits

Author SHA1 Message Date
pandeymangg
cdddf034f2 chore: merge with main 2026-01-19 10:04:22 +05:30
Johannes
5555112e56 feat: add typed attributes system with date filtering and comprehensive UI improvements
## Summary

Introduces a complete typed attribute system for contacts, enabling date/number/text types with auto-detection, time-based segment filtering, full CRUD operations, and unified table styling across Contacts/Segments/Attributes.

## Core Features

### 1. Typed Attribute System
- Add `ContactAttributeDataType` enum (text, number, date) to database schema
- Auto-detection logic recognizes ISO 8601 dates and numeric values
- Backwards compatible: existing attributes default to "text" type
- SDK now accepts Date objects and auto-converts to ISO strings

### 2. Date-Based Segment Filtering
- Six new operators: isOlderThan, isNewerThan, isBefore, isAfter, isBetween, isSameDay
- Relative time filtering with days/weeks/months/years units
- Custom DateFilterValue component with type-aware inputs (date pickers, time unit selectors)
- Server-side evaluation and Prisma query generation using ISO string comparison

### 3. Attribute Management UI
- New "Attributes" tab in Contacts section for schema management
- TanStack data table with search, bulk selection, and deletion
- Create modal with type selection (text/number/date)
- Icon system: Calendar (date), Hash (number), Tag (text)

### 4. Contact Attribute Editing
- Edit Attributes modal with React Hook Form and Framer Motion animations
- Type-aware inputs: text/number/date pickers based on dataType
- Add new attributes with validation (email format, date validity)
- Delete attributes with confirmation for critical fields
- Dynamic form that updates on delete/add without modal close

### 5. Table Visual Consistency
- Unified styling across Contacts, Segments, and Attributes tables
- Removed vertical borders, added rounded corners (top-left, top-right, bottom-left, bottom-right)
- Consistent header styling: h-10, font-semibold, border-b
- Hover effects: subtle bg-slate-50
- Search bar aligned with action buttons in all tables

### 6. Segments Table Performance
- Refactored from custom grid to TanStack Table
- Removed expensive N+1 survey queries (5-10x faster page load)
- Survey details lazy-loaded only when modal opens
- Columns: Title, Updated (relative), Created (formatted)

## Technical Implementation

### Database Changes
- Add ContactAttributeDataType enum to schema.prisma
- Add dataType field to ContactAttributeKey with @default(text)
- No migration of existing data required

### Backend
- Auto-detection on attribute creation (detect-attribute-type.ts)
- Date utility functions for relative time calculations (date-utils.ts)
- Extended segment evaluation logic to handle date operators
- Prisma query builders for date comparisons using string operators
- Server actions for attribute CRUD and key management

### Frontend
- 6 new components for date filtering and attribute management
- Type-conditional operator selection in segment filters
- Smart defaults: date attributes default to "isOlderThan 1 days"
- Operator/value reset when switching between attribute types
- Badge components showing data types throughout UI

### SDK
- Accept Record<string, string | Date> in setAttributes()
- Auto-convert Date objects to ISO strings
- Fully backwards compatible

### API
- V1 and V2 endpoints updated to support optional dataType parameter
- Additive changes only, no breaking changes

## UX Improvements

### Icons & Visual Indicators
- Calendar icon for date attributes everywhere (filters, modals, tables)
- Hash icon for number attributes
- Tag icon for text attributes
- Type badges on attribute rows and form fields

### Table Consistency
- All three tables (Contacts, Segments, Attributes) now have identical:
  - Border radius (rounded-lg)
  - Header height and font weight
  - Hover effects (bg-slate-50)
  - Empty states (text-slate-400)
  - Search bar positioning
- Removed unnecessary vertical borders for cleaner look
- Proper rounded corners on first/last rows

### Form Patterns
- Consistent modal patterns using React Hook Form
- Type-specific validation (email format, date validity, positive numbers)
- Clear error messages with field-level feedback
- Framer Motion animations for smooth add/delete transitions

## Files Changed

**New Files (16):**
- Attribute type detection and tests
- Date utilities and tests
- Date filter value component
- Edit attributes modal
- Attribute keys management (page, actions, components)
- Segment table refactor (columns, updated table)

**Modified Files (20):**
- Schema, types, and Zod validators
- Segment filter components and evaluation logic
- Contact table styling and column definitions
- API endpoints (V1 and V2)
- Data table header and toolbar components
- Translation files

**Deleted Files (1):**
- Old segment table component (replaced with TanStack version)

## Translation Keys

Added 30+ new keys for date operators, attribute management, and validation messages with proper plural support.

## Testing

- Unit tests for date detection edge cases
- Unit tests for date math operations (leap years, month boundaries)
- Type validation ensures filter values match operator requirements

## Backwards Compatibility

- All changes are additive and non-breaking
- Existing attributes get text type automatically
- Old segment filters continue working
- SDK signature is backwards compatible (string | Date union)
- API changes are optional parameters only

## Next Steps

- Run database migration: pnpm db:migrate:dev --name add_contact_attribute_data_type
- Sync translations to other languages via Lingo.dev
- Add Playwright E2E tests for date filtering workflows
2025-12-14 08:54:05 +01:00
Johannes
7c92e2b5bb Merge branch 'main' of https://github.com/formbricks/formbricks into feat/attribute-type-date 2025-12-12 22:54:02 +01:00
Johannes
e90bb93dfb vibe draft 2025-12-10 20:37:42 +01:00
301 changed files with 8763 additions and 12795 deletions

View File

@@ -0,0 +1,404 @@
---
name: Date Attribute Type Feature
overview: Add DATE type support to the Formbricks attribute system, enabling time-based segment filters like "Sign Up Date is older than 3 months". This involves schema changes, new operators, UI components, SDK updates, and evaluation logic.
todos:
- id: schema
content: Add ContactAttributeDataType enum and dataType field to ContactAttributeKey in Prisma schema
status: completed
- id: types
content: "Update type definitions: add data type to contact-attribute-key.ts, add date operators to segment.ts"
status: completed
- id: zod
content: Update Zod schemas in packages/database/zod/ to include dataType
status: completed
- id: detect
content: Create auto-detection logic for attribute data types based on value format
status: completed
- id: attributes
content: Update attribute creation/update logic to auto-detect and persist dataType
status: completed
- id: date-utils
content: Create date utility functions for relative time calculations
status: completed
- id: eval-logic
content: Add date filter evaluation logic to segments.ts evaluateSegment function
status: completed
- id: prisma-query
content: Update prisma-query.ts to handle date comparisons in segment filters
status: completed
- id: ui-operators
content: Update segment-filter.tsx to show date-specific operators when attribute is DATE type
status: completed
- id: ui-value
content: Create date-filter-value.tsx component for date filter value input
status: completed
- id: utils
content: Add date operator text/title conversions in utils.ts
status: completed
- id: sdk
content: Update JS SDK to accept Date objects and convert to ISO strings
status: completed
- id: api
content: Update API endpoints to expose dataType in contact attribute key responses
status: completed
- id: i18n
content: Add translation keys for new operators and UI elements
status: completed
- id: tests
content: Add unit tests for date detection, evaluation, and UI components
status: completed
---
# Date Attribute Type Feature
## Current State Analysis
The attribute system currently stores all values as strings:
- `ContactAttribute.value` is `String` in Prisma schema (line 73)
- `ContactAttributeKey` has no `dataType` field - only `type` (default/custom)
- Segment filter operators are string/number-focused with no date awareness
- SDK accepts `Record<string, string>` only
## Architecture Changes
### 1. Database Schema Updates
Add `dataType` enum and field to `ContactAttributeKey`:
```prisma
enum ContactAttributeDataType {
text
number
date
}
model ContactAttributeKey {
// ... existing fields
dataType ContactAttributeDataType @default(text)
}
```
Store dates as ISO 8601 strings in `ContactAttribute.value` (no schema change needed for value column).
### 2. Type Definitions (`packages/types/`)
**`packages/types/contact-attribute-key.ts`** - Add data type:
```typescript
export const ZContactAttributeDataType = z.enum(["text", "number", "date"]);
export type TContactAttributeDataType = z.infer<typeof ZContactAttributeDataType>;
```
**`packages/types/segment.ts`** - Add date operators:
```typescript
export const DATE_OPERATORS = [
"isOlderThan", // relative: X days/weeks/months/years ago
"isNewerThan", // relative: within last X days/weeks/months/years
"isBefore", // absolute: before specific date
"isAfter", // absolute: after specific date
"isBetween", // absolute: between two dates
"isSameDay", // absolute: matches specific date
] as const;
export const TIME_UNITS = ["days", "weeks", "months", "years"] as const;
export const ZSegmentDateFilter = z.object({
id: z.string().cuid2(),
root: z.object({
type: z.literal("attribute"),
contactAttributeKey: z.string(),
}),
value: z.union([
// Relative: { amount: 3, unit: "months" }
z.object({ amount: z.number(), unit: z.enum(TIME_UNITS) }),
// Absolute: ISO date string or [start, end] for between
z.string(),
z.tuple([z.string(), z.string()]),
]),
qualifier: z.object({
operator: z.enum(DATE_OPERATORS),
}),
});
```
### 3. Auto-Detection Logic (`apps/web/modules/ee/contacts/lib/`)
Create `detect-attribute-type.ts`:
```typescript
export const detectAttributeDataType = (value: string): TContactAttributeDataType => {
// Check if valid ISO 8601 date
const date = new Date(value);
if (!isNaN(date.getTime()) && /^\d{4}-\d{2}-\d{2}/.test(value)) {
return "date";
}
// Check if numeric
if (!isNaN(Number(value)) && value.trim() !== "") {
return "number";
}
return "text";
};
```
Update `apps/web/modules/ee/contacts/lib/attributes.ts` to auto-detect and set `dataType` when creating new attribute keys.
### 4. Segment Filter UI Components
**New files in `apps/web/modules/ee/contacts/segments/components/`:**
- `date-filter-value.tsx` - Combined component for date filter value input:
- Relative time: number input + unit dropdown (days/weeks/months/years)
- Absolute date: date picker component
- Between: two date pickers for range
- Update `segment-filter.tsx`:
- Check `contactAttributeKey.dataType` to determine which operators to show
- Render appropriate value input based on operator type
- Handle date-specific validation
### 5. Filter Evaluation Logic
Update `apps/web/modules/ee/contacts/segments/lib/segments.ts`:
```typescript
const evaluateDateFilter = (
attributeValue: string,
filterValue: TDateFilterValue,
operator: TDateOperator
): boolean => {
const attrDate = new Date(attributeValue);
const now = new Date();
switch (operator) {
case "isOlderThan": {
const threshold = subtractTimeUnit(now, filterValue.amount, filterValue.unit);
return attrDate < threshold;
}
case "isNewerThan": {
const threshold = subtractTimeUnit(now, filterValue.amount, filterValue.unit);
return attrDate >= threshold;
}
case "isBefore":
return attrDate < new Date(filterValue);
case "isAfter":
return attrDate > new Date(filterValue);
case "isBetween":
return attrDate >= new Date(filterValue[0]) && attrDate <= new Date(filterValue[1]);
case "isSameDay":
return isSameDay(attrDate, new Date(filterValue));
}
};
```
### 6. Prisma Query Generation (No Raw SQL)
Update `apps/web/modules/ee/contacts/segments/lib/filter/prisma-query.ts`:
Since dates are stored as ISO 8601 strings, lexicographic string comparison works correctly (e.g., `"2024-01-15" < "2024-02-01"`). Calculate threshold dates in JavaScript and pass as ISO strings:
```typescript
const buildDateAttributeFilterWhereClause = (filter: TSegmentDateFilter): Prisma.ContactWhereInput => {
const { root, qualifier, value } = filter;
const { operator } = qualifier;
const now = new Date();
let dateCondition: Prisma.StringFilter = {};
switch (operator) {
case "isOlderThan": {
const threshold = subtractTimeUnit(now, value.amount, value.unit);
dateCondition = { lt: threshold.toISOString() };
break;
}
case "isNewerThan": {
const threshold = subtractTimeUnit(now, value.amount, value.unit);
dateCondition = { gte: threshold.toISOString() };
break;
}
case "isBefore":
dateCondition = { lt: value };
break;
case "isAfter":
dateCondition = { gt: value };
break;
case "isBetween":
dateCondition = { gte: value[0], lte: value[1] };
break;
case "isSameDay": {
const dayStart = startOfDay(new Date(value)).toISOString();
const dayEnd = endOfDay(new Date(value)).toISOString();
dateCondition = { gte: dayStart, lte: dayEnd };
break;
}
}
return {
attributes: {
some: {
attributeKey: { key: root.contactAttributeKey },
value: dateCondition,
},
},
};
};
```
## Backwards Compatibility Concerns
### 1. API Response Changes (Non-Breaking)
- **Concern**: Adding `dataType` to `ContactAttributeKey` responses
- **Solution**: This is an additive change - existing clients ignore unknown fields
- **Action**: No breaking change, just document the new field
### 2. API Request Changes (Non-Breaking)
- **Concern**: Existing integrations create attributes without specifying `dataType`
- **Solution**: Make `dataType` optional in create/update requests; auto-detect from value if not provided
- **Action**: Default to auto-detection, allow explicit override
### 3. SDK Signature Change (Backwards Compatible)
- **Concern**: Current signature `Record<string, string>` changing to `Record<string, string | Date>`
- **Solution**: TypeScript union types are backwards compatible - existing string values work
- **Action**: Existing code continues to work; Date objects are a new optional capability
### 4. Existing Segment Filters (Critical)
- **Concern**: Existing filters in database use current operator format
- **Solution**:
- Keep all existing operators functional
- Date operators only appear in UI when attribute has `dataType: "date"`
- Filter evaluation checks operator type and routes to appropriate handler
- **Action**: Add `isDateOperator()` check in evaluation logic
### 5. Filter Value Schema Change (Requires Careful Handling)
- **Concern**: Current `TSegmentFilterValue = string | number`, dates need `{ amount, unit }` for relative
- **Solution**: Extend the union type, not replace:
```typescript
export const ZSegmentFilterValue = z.union([
z.string(),
z.number(),
z.object({ amount: z.number(), unit: z.enum(TIME_UNITS) }), // NEW
z.tuple([z.string(), z.string()]), // NEW: for "between" operator
]);
```
- **Action**: Existing filters parse correctly; new format only used for date operators
### 6. Database Migration (Safe)
- **Concern**: Adding `dataType` column to existing `ContactAttributeKey` rows
- **Solution**:
- Add column with `@default(text)`
- All existing attributes become `text` type automatically
- No data transformation needed
- **Action**: Simple additive migration, no downtime
### 7. Segment Evaluation at Runtime
- **Concern**: Old segments with text operators should not break
- **Solution**:
- `evaluateAttributeFilter()` checks if operator is date-specific
- If yes, calls `evaluateDateFilter()`
- If no, uses existing `compareValues()` logic
- **Action**: Add operator type routing in evaluation
### 8. Client-Side Segment Evaluation (JS SDK)
- **Concern**: SDK may evaluate segments client-side for performance
- **Solution**: Ensure SDK's segment evaluation logic also handles date operators
- **Action**: Update `packages/js-core` if client-side evaluation exists
### Version Matrix
| Component | Breaking Change | Migration Required |
|-----------|-----------------|-------------------|
| Database Schema | No | Yes (additive) |
| REST API | No | No |
| JS SDK | No | No (optional upgrade) |
| Existing Segments | No | No |
| UI | No | No |
### 7. SDK Updates (`packages/js-core/`)
Update `packages/js-core/src/lib/user/attribute.ts`:
```typescript
export const setAttributes = async (
attributes: Record<string, string | Date>
): Promise<Result<void, NetworkError>> => {
// Convert Date objects to ISO strings
const normalizedAttributes = Object.fromEntries(
Object.entries(attributes).map(([key, value]) => [
key,
value instanceof Date ? value.toISOString() : value,
])
);
// ... rest of implementation
};
```
### 8. API Updates
Update attribute endpoints to include `dataType` in responses:
- `apps/web/modules/api/v2/management/contact-attribute-keys/`
- `apps/web/modules/ee/contacts/api/v1/management/contact-attribute-keys/`
## Files to Modify
| File | Change |
|------|--------|
| `packages/database/schema.prisma` | Add `ContactAttributeDataType` enum, add `dataType` field |
| `packages/types/contact-attribute-key.ts` | Add data type definitions |
| `packages/types/segment.ts` | Add date operators, time units, date filter schema |
| `packages/database/zod/contact-attribute-keys.ts` | Add dataType to zod schema |
| `apps/web/modules/ee/contacts/lib/attributes.ts` | Auto-detect dataType on attribute creation |
| `apps/web/modules/ee/contacts/segments/lib/segments.ts` | Add date filter evaluation |
| `apps/web/modules/ee/contacts/segments/lib/filter/prisma-query.ts` | Add date query building |
| `apps/web/modules/ee/contacts/segments/lib/utils.ts` | Add date operator text/title conversions |
| `apps/web/modules/ee/contacts/segments/components/segment-filter.tsx` | Conditionally render date operators/inputs |
| `packages/js-core/src/lib/user/attribute.ts` | Accept Date objects |
## New Files to Create
| File | Purpose |
|------|---------|
| `apps/web/modules/ee/contacts/lib/detect-attribute-type.ts` | Auto-detection logic |
| `apps/web/modules/ee/contacts/segments/components/date-filter-value.tsx` | Date filter value UI |
| `apps/web/modules/ee/contacts/segments/lib/date-utils.ts` | Date comparison utilities |
## Migration
Create Prisma migration:
```bash
pnpm db:migrate:dev --name add_contact_attribute_data_type
```
Default existing attributes to `text` dataType (no data migration needed).

View File

@@ -0,0 +1,61 @@
---
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

View File

@@ -0,0 +1,415 @@
---
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)

View File

@@ -0,0 +1,41 @@
---
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
.cursor/rules/database.mdc Normal file
View File

@@ -0,0 +1,105 @@
---
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.

View File

@@ -0,0 +1,28 @@
---
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>

View File

@@ -0,0 +1,332 @@
---
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

View File

@@ -0,0 +1,232 @@
---
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)

View File

@@ -0,0 +1,457 @@
---
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

View File

@@ -0,0 +1,52 @@
---
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

View File

@@ -0,0 +1,179 @@
---
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.**

View File

@@ -0,0 +1,216 @@
---
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)

View File

@@ -0,0 +1,177 @@
---
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.

View File

@@ -111,21 +111,27 @@ jobs:
const additions = ${{ steps.check-size.outputs.total_additions }};
const deletions = ${{ steps.check-size.outputs.total_deletions }};
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.';
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.`;
// Check if we already commented
const { data: comments } = await github.rest.issues.listComments({

View File

@@ -1,3 +1,6 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
# Load environment variables from .env files
if [ -f .env ]; then
set -a

View File

@@ -18,65 +18,11 @@ 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.

View File

@@ -11,24 +11,24 @@
"clean": "rimraf .turbo node_modules dist storybook-static"
},
"dependencies": {
"@formbricks/survey-ui": "workspace:*"
"@formbricks/survey-ui": "workspace:*",
"eslint-plugin-react-refresh": "0.4.24"
},
"devDependencies": {
"@chromatic-com/storybook": "^5.0.0",
"@storybook/addon-a11y": "10.1.11",
"@storybook/addon-links": "10.1.11",
"@storybook/addon-onboarding": "10.1.11",
"@storybook/react-vite": "10.1.11",
"@typescript-eslint/eslint-plugin": "8.53.0",
"@tailwindcss/vite": "4.1.18",
"@typescript-eslint/parser": "8.53.0",
"@vitejs/plugin-react": "5.1.2",
"esbuild": "0.27.2",
"eslint-plugin-react-refresh": "0.4.26",
"eslint-plugin-storybook": "10.1.11",
"@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",
"prop-types": "15.8.1",
"storybook": "10.1.11",
"vite": "7.3.1",
"@storybook/addon-docs": "10.1.11"
"storybook": "10.0.8",
"vite": "7.2.4",
"@storybook/addon-docs": "10.0.8"
}
}

View File

@@ -1,7 +0,0 @@
node_modules/
.next/
public/
playwright/
dist/
coverage/
vendor/

View File

@@ -104,9 +104,6 @@ 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
@@ -125,9 +122,6 @@ 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

View File

@@ -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, status } = license;
const { features, lastChecked, isPendingDowngrade, active } = license;
const isMultiOrgEnabled = features?.isMultiOrgEnabled ?? false;
const organizationProjectsLimit = await getOrganizationProjectsLimit(organization.billing.limits);
const isOwnerOrManager = isOwner || isManager;
@@ -63,7 +63,6 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
active={active}
environmentId={environment.id}
locale={user.locale}
status={status}
/>
<div className="flex h-full">

View File

@@ -209,7 +209,7 @@ export const OrganizationBreadcrumb = ({
)}
{!isLoadingOrganizations && !loadError && (
<>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
<DropdownMenuGroup>
{organizations.map((org) => (
<DropdownMenuCheckboxItem
key={org.id}

View File

@@ -234,7 +234,7 @@ export const ProjectBreadcrumb = ({
)}
{!isLoadingProjects && !loadError && (
<>
<DropdownMenuGroup className="max-h-[300px] overflow-y-auto">
<DropdownMenuGroup>
{projects.map((proj) => (
<DropdownMenuCheckboxItem
key={proj.id}

View File

@@ -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, ctx.user.locale);
await sendVerificationNewEmail(ctx.user.id, inputEmail);
}
return payload;
}

View File

@@ -1,26 +0,0 @@
"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>
);
};

View File

@@ -12,7 +12,6 @@ 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;
@@ -49,7 +48,6 @@ 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")}>

View File

@@ -58,7 +58,6 @@ export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
ctx.user.email,
emailHtml,
survey.environmentId,
ctx.user.locale,
organizationLogoUrl || ""
);
});

View File

@@ -215,14 +215,7 @@ export const POST = async (request: Request) => {
}
const emailPromises = usersWithNotifications.map((user) =>
sendResponseFinishedEmail(
user.email,
user.locale,
environmentId,
survey,
response,
responseCount
).catch((error) => {
sendResponseFinishedEmail(user.email, environmentId, survey, response, responseCount).catch((error) => {
logger.error(
{ error, url: request.url, userEmail: user.email },
`Failed to send email to ${user.email}`

View File

@@ -8,10 +8,6 @@ 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";
@@ -144,24 +140,6 @@ 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 {

View File

@@ -7,10 +7,6 @@ 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 {
@@ -153,24 +149,6 @@ 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;
}

View File

@@ -1,80 +0,0 @@
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: "",
});
});
});
});

View File

@@ -302,9 +302,7 @@ export const buildBlock = ({
elements,
logic,
logicFallback,
buttonLabel: buttonLabel ? getDefaultButtonLabel(buttonLabel, t) : createI18nString(t(""), []),
backButtonLabel: backButtonLabel
? getDefaultBackButtonLabel(backButtonLabel, t)
: createI18nString(t(""), []),
buttonLabel: buttonLabel ? getDefaultButtonLabel(buttonLabel, t) : undefined,
backButtonLabel: backButtonLabel ? getDefaultBackButtonLabel(backButtonLabel, t) : undefined,
};
};

View File

@@ -61,10 +61,6 @@ 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
@@ -216,6 +212,7 @@ checksums:
common/imprint: c4e5f2a1994d3cc5896b200709cc499c
common/in_progress: 3de9afebcb9d4ce8ac42e14995f79ffd
common/inactive_surveys: 324b8e1844739cdc2a3bc71aef143a76
common/input_type: df4865b5d0a598a8d7f563dcec104df5
common/integration: 40d02f65c4356003e0e90ffb944907d2
common/integrations: 0ccce343287704cd90150c32e2fcad36
common/invalid_date: 4c18c82f7317d4a02f8d5fef611e82b7
@@ -239,11 +236,13 @@ checksums:
common/look_and_feel: 9125503712626d495cedec7a79f1418c
common/manage: a3d40c0267b81ae53c9598eaeb05087d
common/marketing: fcf0f06f8b64b458c7ca6d95541a3cc8
common/maximum: 4c07541dd1f093775bdc61b559cca6c8
common/member: 1606dc30b369856b9dba1fe9aec425d2
common/members: 0932e80cba1e3e0a7f52bb67ff31da32
common/members_and_teams: bf5c3fadcb9fc23533ec1532b805ac08
common/membership_not_found: 7ac63584af23396aace9992ad919ffd4
common/metadata: 695d4f7da261ba76e3be4de495491028
common/minimum: d9759235086d0169928b3c1401115e22
common/mobile_overlay_app_works_best_on_desktop: 4509f7bfbb4edbd42e534042d6cb7e72
common/mobile_overlay_surveys_look_good: 6d73b635018b4a5a89cce58e1d2497f5
common/mobile_overlay_title: 42f52259b7527989fb3a3240f5352a8b
@@ -296,7 +295,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: 03d54a21ecd27723c72a13644837e5ed
common/please_upgrade_your_plan: bfe98d41cd7383ad42169785d8c818fc
common/preview: 3173ee1f0f1d4e50665ca4a84c38e15d
common/preview_survey: 7409e9c118e3e5d5f2a86201c2b354f2
common/privacy: 7459744a63ef8af4e517a09024bd7c08
@@ -955,8 +954,6 @@ 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
@@ -1098,6 +1095,7 @@ 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
@@ -1107,9 +1105,6 @@ 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
@@ -1163,6 +1158,8 @@ checksums:
environments/surveys/edit/change_the_question_color_of_the_survey: ab6942138a8c5fc6c8c3b9f8dd95e980
environments/surveys/edit/changes_saved: 90aab363c9e96eaa1295a997c48f97f6
environments/surveys/edit/changing_survey_type_will_remove_existing_distribution_channels: 9ce817be04f13f2f0db981145ec48df4
environments/surveys/edit/character_limit_toggle_description: d15a6895eaaf4d4c7212d9240c6bf45d
environments/surveys/edit/character_limit_toggle_title: fdc45bcc6335e5116aec895fecda0d87
environments/surveys/edit/checkbox_label: 12a07d6bdf38e283a2e95892ec49b7f8
environments/surveys/edit/choose_the_actions_which_trigger_the_survey: 773b311a148a112243f3b139506b9987
environments/surveys/edit/choose_the_first_question_on_your_block: bdece06ca04f89d0c445ba1554dd5b80
@@ -1182,6 +1179,7 @@ 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
@@ -1194,7 +1192,6 @@ 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
@@ -1336,7 +1333,8 @@ 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_the_maximum_file_size: 6ae5944fe490b9acdaaee92b30381ec0
environments/surveys/edit/limit_file_types: 2ee563bc98c65f565014945d6fef389c
environments/surveys/edit/limit_the_maximum_file_size: f3f8682de34eaae30351d570805ba172
environments/surveys/edit/limit_upload_file_size_to: 949c48d25ae45259cc19464e95752d29
environments/surveys/edit/link_survey_description: f45569b5e6b78be6bc02bc6a46da948b
environments/surveys/edit/load_segment: 5341d3de37ff10f7526152e38e25e3c5
@@ -1382,12 +1380,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
@@ -1396,8 +1394,7 @@ 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_warning_text: ec78767a7cf335222d41b98cb5baa6be
environments/surveys/edit/question_used_in_logic_warning_title: 4bb8528cdc3b8649c194487067737f6d
environments/surveys/edit/question_used_in_logic: cd1fab1a4ccdea83c6d630a59cdc9931
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
@@ -1459,7 +1456,6 @@ 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
@@ -1507,6 +1503,8 @@ 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
@@ -1527,49 +1525,8 @@ checksums:
environments/surveys/edit/upper_label: 1fa48bce3fade6ffc1a52d9fdddf9e17
environments/surveys/edit/url_filters: e524879d2eb74463d7fd06a7e0f53421
environments/surveys/edit/url_not_supported: af8a753467c617b596aadef1aaaed664
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/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: 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: 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/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

View File

@@ -444,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", () => {
@@ -472,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", () => {
@@ -496,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");
});
});
});

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.code);
export const iso639Identifiers = iso639Languages.map((language) => language.alpha2);
// 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

View File

@@ -308,10 +308,6 @@ 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", () => {

View File

@@ -329,7 +329,7 @@ export const updateSurveyInternal = async (
? currentSurvey.languages.map((l) => l.language.id)
: [];
const updatedLanguageIds =
languages.length > 0 ? updatedSurvey.languages.map((l) => l.language.id) : [];
languages.length > 1 ? updatedSurvey.languages.map((l) => l.language.id) : [];
const enabledLanguageIds = languages.map((language) => {
if (language.enabled) return language.language.id;
});

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { getLocale } from "@/lingodotdev/language";
import { getTranslate } from "./server";
@@ -11,10 +11,6 @@ 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();
@@ -26,16 +22,4 @@ 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();
});
});

View File

@@ -2,7 +2,6 @@ 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";
@@ -22,9 +21,9 @@ const initI18next = async (lng: string) => {
return i18nInstance;
};
export async function getTranslate(locale?: TUserLocale) {
const resolvedLocale = locale ?? (await getLocale());
export async function getTranslate() {
const locale = await getLocale();
const i18nextInstance = await initI18next(resolvedLocale);
return i18nextInstance.getFixedT(resolvedLocale);
const i18nextInstance = await initI18next(locale);
return i18nextInstance.getFixedT(locale);
}

View File

@@ -75,10 +75,6 @@
"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"
},
@@ -243,6 +239,7 @@
"imprint": "Impressum",
"in_progress": "Im Gange",
"inactive_surveys": "Inaktive Umfragen",
"input_type": "Eingabetyp",
"integration": "Integration",
"integrations": "Integrationen",
"invalid_date": "Ungültiges Datum",
@@ -254,7 +251,6 @@
"label": "Bezeichnung",
"language": "Sprache",
"learn_more": "Mehr erfahren",
"license_expired": "License Expired",
"light_overlay": "Helle Überlagerung",
"limits_reached": "Limits erreicht",
"link": "Link",
@@ -267,11 +263,13 @@
"look_and_feel": "Darstellung",
"manage": "Verwalten",
"marketing": "Marketing",
"maximum": "Maximal",
"member": "Mitglied",
"members": "Mitglieder",
"members_and_teams": "Mitglieder & Teams",
"membership_not_found": "Mitgliedschaft nicht gefunden",
"metadata": "Metadaten",
"minimum": "Minimum",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funktioniert am besten auf einem größeren Bildschirm. Um Umfragen zu verwalten oder zu erstellen, wechsle zu einem anderen Gerät.",
"mobile_overlay_surveys_look_good": "Keine Sorge deine Umfragen sehen auf jedem Gerät und jeder Bildschirmgröße großartig aus!",
"mobile_overlay_title": "Oops, Bildschirm zu klein erkannt!",
@@ -324,7 +322,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 aktualisieren Sie Ihren Plan",
"please_upgrade_your_plan": "Bitte upgrade deinen Plan.",
"preview": "Vorschau",
"preview_survey": "Umfragevorschau",
"privacy": "Datenschutz",
@@ -461,8 +459,7 @@
"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.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
"you_will_be_downgraded_to_the_community_edition_on_date": "Du wirst am {date} auf die Community Edition herabgestuft."
},
"emails": {
"accept": "Annehmen",
@@ -1018,8 +1015,6 @@
"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",
@@ -1171,6 +1166,7 @@
"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",
@@ -1180,9 +1176,6 @@
"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.",
@@ -1236,6 +1229,8 @@
"change_the_question_color_of_the_survey": "Fragefarbe der Umfrage ändern.",
"changes_saved": "Änderungen gespeichert.",
"changing_survey_type_will_remove_existing_distribution_channels": "\"Das Ändern des Umfragetypen beeinflusst, wie er geteilt werden kann. Wenn Teilnehmer bereits Zugriffslinks für den aktuellen Typ haben, könnten sie das Zugriffsrecht nach dem Wechsel verlieren.\"",
"character_limit_toggle_description": "Begrenzen Sie, wie kurz oder lang eine Antwort sein kann.",
"character_limit_toggle_title": "Fügen Sie Zeichenbeschränkungen hinzu",
"checkbox_label": "Checkbox-Beschriftung",
"choose_the_actions_which_trigger_the_survey": "Aktionen auswählen, die die Umfrage auslösen.",
"choose_the_first_question_on_your_block": "Wählen sie die erste frage in ihrem block",
@@ -1255,6 +1250,7 @@
"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",
@@ -1267,7 +1263,6 @@
"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.",
@@ -1372,7 +1367,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 Sie mehr benötigen, bitte",
"if_you_need_more_please": "Wenn Du mehr brauchst, 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.",
@@ -1409,8 +1404,9 @@
"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_the_maximum_file_size": "Begrenzen Sie die maximale Dateigröße für Uploads.",
"limit_upload_file_size_to": "Upload-Dateigröße begrenzen auf",
"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",
"link_survey_description": "Teile einen Link zu einer Umfrageseite oder bette ihn in eine Webseite oder E-Mail ein.",
"load_segment": "Segment laden",
"logic_error_warning": "Änderungen werden zu Logikfehlern führen",
@@ -1422,8 +1418,8 @@
"manage_languages": "Sprachen verwalten",
"matrix_all_fields": "Alle Felder",
"matrix_rows": "Zeilen",
"max_file_size": "Maximale Dateigröße",
"max_file_size_limit_is": "Die maximale Dateigrößenbeschränkung beträgt",
"max_file_size": "Max. Dateigröße",
"max_file_size_limit_is": "Max. Dateigröße ist",
"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",
@@ -1455,12 +1451,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",
@@ -1469,8 +1465,7 @@
"question_deleted": "Frage gelöscht.",
"question_duplicated": "Frage dupliziert.",
"question_id_updated": "Frage-ID aktualisiert",
"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_logic": "Diese Frage wird in der Logik der Frage {questionIndex} verwendet.",
"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.",
@@ -1534,7 +1529,6 @@
"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",
@@ -1582,6 +1576,8 @@
"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",
@@ -1602,51 +1598,8 @@
"upper_label": "Oberes Label",
"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",
"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": "Höchstens",
"mb": "MB",
"min_length": "Mindestens",
"min_selections": "Mindestens",
"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",
"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.",

View File

@@ -75,10 +75,6 @@
"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"
},
@@ -188,8 +184,10 @@
"customer_success": "Customer Success",
"dark_overlay": "Dark overlay",
"date": "Date",
"days": "days",
"default": "Default",
"delete": "Delete",
"delete_selected": "{count, plural, one {Delete # item} other {Delete # items}}",
"description": "Description",
"dev_env": "Dev Environment",
"development": "Development",
@@ -210,6 +208,7 @@
"email": "Email",
"ending_card": "Ending card",
"enter_url": "Enter URL",
"enter_value": "Enter value",
"enterprise_license": "Enterprise License",
"environment": "Environment",
"environment_not_found": "Environment not found",
@@ -243,6 +242,7 @@
"imprint": "Imprint",
"in_progress": "In Progress",
"inactive_surveys": "Inactive surveys",
"input_type": "Input type",
"integration": "integration",
"integrations": "Integrations",
"invalid_date": "Invalid date",
@@ -254,7 +254,6 @@
"label": "Label",
"language": "Language",
"learn_more": "Learn more",
"license_expired": "License Expired",
"light_overlay": "Light overlay",
"limits_reached": "Limits Reached",
"link": "Link",
@@ -267,14 +266,17 @@
"look_and_feel": "Look & Feel",
"manage": "Manage",
"marketing": "Marketing",
"maximum": "Maximum",
"member": "Member",
"members": "Members",
"members_and_teams": "Members & Teams",
"membership_not_found": "Membership not found",
"metadata": "Metadata",
"minimum": "Minimum",
"mobile_overlay_app_works_best_on_desktop": "Formbricks works best on a bigger screen. To manage or build surveys, switch to another device.",
"mobile_overlay_surveys_look_good": "Don't worry your surveys look great on every device and screen size!",
"mobile_overlay_title": "Oops, tiny screen detected!",
"months": "months",
"move_down": "Move down",
"move_up": "Move up",
"multiple_languages": "Multiple languages",
@@ -324,7 +326,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",
@@ -390,6 +392,7 @@
"status": "Status",
"step_by_step_manual": "Step by step manual",
"storage_not_configured": "File storage not set up, uploads will likely fail",
"string": "Text",
"styling": "Styling",
"submit": "Submit",
"summary": "Summary",
@@ -433,6 +436,7 @@
"user": "User",
"user_id": "User ID",
"user_not_found": "User not found",
"value": "Value",
"variable": "Variable",
"variable_ids": "Variable IDs",
"variables": "Variables",
@@ -445,6 +449,7 @@
"website_and_app_connection": "Website & App Connection",
"website_app_survey": "Website & App Survey",
"website_survey": "Website Survey",
"weeks": "weeks",
"welcome_card": "Welcome card",
"workspace_configuration": "Workspace Configuration",
"workspace_created_successfully": "Workspace created successfully",
@@ -455,14 +460,14 @@
"workspace_not_found": "Workspace not found",
"workspace_permission_not_found": "Workspace permission not found",
"workspaces": "Workspaces",
"years": "years",
"you": "You",
"you_are_downgraded_to_the_community_edition": "You are downgraded to the Community Edition.",
"you_are_not_authorized_to_perform_this_action": "You are not authorized to perform this action.",
"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}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
"you_will_be_downgraded_to_the_community_edition_on_date": "You will be downgraded to the Community Edition on {date}."
},
"emails": {
"accept": "Accept",
@@ -612,38 +617,55 @@
},
"contacts": {
"add_attribute": "Add Attribute",
"attribute_added_successfully": "Attribute added successfully",
"attribute_created_successfully": "Attribute created successfully",
"attribute_deleted_successfully": "Attribute deleted successfully",
"attribute_description": "Description",
"attribute_description_placeholder": "Short description",
"attribute_key": "Key",
"attribute_key_cannot_be_changed": "Key cannot be changed after creation",
"attribute_key_created_successfully": "Attribute key created successfully",
"attribute_key_description": "Unique identifier (e.g., signUpDate, planType)",
"attribute_key_hint": "Only lowercase letters, numbers, and underscores. Must start with a letter.",
"attribute_key_placeholder": "e.g. date_of_birth",
"attribute_key_required": "Key is required",
"attribute_key_safe_identifier_required": "Key must be a safe identifier: only lowercase letters, numbers, and underscores, and must start with a letter",
"attribute_keys_deleted_successfully": "{count, plural, one {Attribute key deleted successfully} other {# attribute keys deleted successfully}}",
"attribute_label": "Label",
"attribute_label_placeholder": "e.g. Date of Birth",
"attribute_name_description": "Human-readable display name",
"attribute_updated_successfully": "Attribute updated successfully",
"attribute_value": "Value",
"attribute_value_placeholder": "Attribute Value",
"attributes_updated_successfully": "Attributes updated successfully",
"confirm_delete_attribute": "Are you sure you want to delete the {attributeName} attribute? This cannot be undone.",
"contact_deleted_successfully": "Contact deleted successfully",
"contact_not_found": "No such contact found",
"contacts_table_refresh": "Refresh contacts",
"contacts_table_refresh_success": "Contacts refreshed successfully",
"create_attribute": "Create attribute",
"create_attribute_key": "Create Attribute Key",
"create_key": "Create Key",
"create_new_attribute": "Create new attribute",
"create_new_attribute_description": "Create a new attribute for segmentation purposes.",
"data_type": "Data Type",
"data_type_cannot_be_changed": "Data type cannot be changed after creation",
"data_type_description": "Choose how this attribute should be stored and filtered",
"delete_attribute_confirmation": "{value, plural, one {This will delete the selected attribute. Any contact data associated with this attribute will be lost.} other {This will delete the selected attributes. Any contact data associated with these attributes will be lost.}}",
"delete_attribute_keys_warning_detailed": "{count, plural, one {Deleting this attribute key will permanently remove all attribute values across all contacts in this environment. Any segments or filters using this attribute will stop working. This action cannot be undone.} other {Deleting these # attribute keys will permanently remove all attribute values across all contacts in this environment. Any segments or filters using these attributes will stop working. This action cannot be undone.}}",
"delete_contact_confirmation": "This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contact's data will be lost.",
"delete_contact_confirmation_with_quotas": "{value, plural, one {This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contact's data will be lost. If this contact has responses that count towards survey quotas, the quota counts will be reduced but the quota limits will remain unchanged.} other {This will delete all survey responses and contact attributes associated with these contacts. Any targeting and personalization based on these contacts' data will be lost. If these contacts have responses that count towards survey quotas, the quota counts will be reduced but the quota limits will remain unchanged.}}",
"edit_attribute": "Edit attribute",
"edit_attribute_description": "Update the label and description for this attribute.",
"edit_attribute_values": "Edit attributes",
"edit_attribute_values_description": "Change the values for specific attributes for this contact.",
"edit_attributes": "Edit Attributes",
"edit_attributes_success": "Contact attributes updated successfully",
"generate_personal_link": "Generate Personal Link",
"generate_personal_link_description": "Select a published survey to generate a personalized link for this contact.",
"invalid_date_value": "Invalid date value",
"invalid_email_value": "Invalid email address",
"no_custom_attributes_yet": "No custom attribute keys yet. Create one to get started.",
"no_published_link_surveys_available": "No published link surveys available. Please publish a link survey first.",
"no_published_surveys": "No published surveys",
"no_responses_found": "No responses found",
@@ -652,10 +674,12 @@
"personal_link_generated_but_clipboard_failed": "Personal link generated but failed to copy to clipboard: {url}",
"personal_survey_link": "Personal Survey Link",
"please_select_a_survey": "Please select a survey",
"please_select_attribute_and_value": "Please select an attribute and enter a value",
"search_attribute_keys": "Search attribute keys...",
"search_contact": "Search contact",
"select_a_survey": "Select a survey",
"select_attribute": "Select Attribute",
"selected_attribute_keys": "{count, plural, one {# attribute key} other {# attribute keys}}",
"unlock_contacts_description": "Manage contacts and send out targeted surveys",
"unlock_contacts_title": "Unlock contacts with a higher plan",
"upload_contacts_modal_attributes_description": "Map the columns in your CSV to the attributes in Formbricks.",
@@ -867,6 +891,7 @@
"user_targeting_is_currently_only_available_when": "User targeting is currently only available when",
"value_cannot_be_empty": "Value cannot be empty.",
"value_must_be_a_number": "Value must be a number.",
"value_must_be_positive": "Value must be a positive number.",
"view_filters": "View filters",
"where": "Where",
"with_the_formbricks_sdk": "with the Formbricks SDK"
@@ -1018,8 +1043,6 @@
"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",
@@ -1171,6 +1194,7 @@
"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",
@@ -1180,9 +1204,6 @@
"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.",
@@ -1236,6 +1257,8 @@
"change_the_question_color_of_the_survey": "Change the question color of the survey.",
"changes_saved": "Changes saved.",
"changing_survey_type_will_remove_existing_distribution_channels": "Changing the survey type will affect how it can be shared. If respondents already have access links for the current type, they may lose access after the switch.",
"character_limit_toggle_description": "Limit how short or long an answer can be.",
"character_limit_toggle_title": "Add character limits",
"checkbox_label": "Checkbox Label",
"choose_the_actions_which_trigger_the_survey": "Choose the actions which trigger the survey.",
"choose_the_first_question_on_your_block": "Choose the first question on your Block",
@@ -1255,6 +1278,7 @@
"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",
@@ -1267,7 +1291,6 @@
"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.",
@@ -1409,7 +1432,8 @@
"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_the_maximum_file_size": "Limit the maximum file size for uploads.",
"limit_file_types": "Limit file types",
"limit_the_maximum_file_size": "Limit the maximum file size",
"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.",
"load_segment": "Load segment",
@@ -1455,12 +1479,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",
@@ -1469,8 +1493,7 @@
"question_deleted": "Question deleted.",
"question_duplicated": "Question duplicated.",
"question_id_updated": "Question ID updated",
"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_logic": "This question is used in logic of question {questionIndex}.",
"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",
@@ -1534,7 +1557,6 @@
"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",
@@ -1582,6 +1604,8 @@
"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",
@@ -1602,51 +1626,8 @@
"upper_label": "Upper Label",
"url_filters": "URL Filters",
"url_not_supported": "URL not supported",
"validation": {
"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",
"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": "At most",
"mb": "MB",
"min_length": "At least",
"min_selections": "At least",
"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",
"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}.",

View File

@@ -75,10 +75,6 @@
"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"
},
@@ -243,6 +239,7 @@
"imprint": "Aviso legal",
"in_progress": "En progreso",
"inactive_surveys": "Encuestas inactivas",
"input_type": "Tipo de entrada",
"integration": "integración",
"integrations": "Integraciones",
"invalid_date": "Fecha no válida",
@@ -254,7 +251,6 @@
"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",
@@ -267,11 +263,13 @@
"look_and_feel": "Apariencia",
"manage": "Gestionar",
"marketing": "Marketing",
"maximum": "Máximo",
"member": "Miembro",
"members": "Miembros",
"members_and_teams": "Miembros y equipos",
"membership_not_found": "Membresía no encontrada",
"metadata": "Metadatos",
"minimum": "Mínimo",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona mejor en una pantalla más grande. Para gestionar o crear encuestas, cambia a otro dispositivo.",
"mobile_overlay_surveys_look_good": "No te preocupes ¡tus encuestas se ven geniales en todos los dispositivos y tamaños de pantalla!",
"mobile_overlay_title": "¡Ups, pantalla pequeña detectada!",
@@ -324,7 +322,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",
@@ -461,8 +459,7 @@
"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}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
"you_will_be_downgraded_to_the_community_edition_on_date": "Serás degradado a la edición Community el {date}."
},
"emails": {
"accept": "Aceptar",
@@ -1018,8 +1015,6 @@
"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",
@@ -1171,6 +1166,7 @@
"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",
@@ -1180,9 +1176,6 @@
"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.",
@@ -1236,6 +1229,8 @@
"change_the_question_color_of_the_survey": "Cambiar el color de las preguntas de la encuesta.",
"changes_saved": "Cambios guardados.",
"changing_survey_type_will_remove_existing_distribution_channels": "Cambiar el tipo de encuesta afectará a cómo se puede compartir. Si los encuestados ya tienen enlaces de acceso para el tipo actual, podrían perder el acceso después del cambio.",
"character_limit_toggle_description": "Limitar lo corta o larga que puede ser una respuesta.",
"character_limit_toggle_title": "Añadir límites de caracteres",
"checkbox_label": "Etiqueta de casilla de verificación",
"choose_the_actions_which_trigger_the_survey": "Elige las acciones que activan la encuesta.",
"choose_the_first_question_on_your_block": "Elige la primera pregunta en tu bloque",
@@ -1255,6 +1250,7 @@
"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",
@@ -1267,7 +1263,6 @@
"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.",
@@ -1409,8 +1404,9 @@
"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_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",
"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",
"link_survey_description": "Comparte un enlace a una página de encuesta o incrústala en una página web o correo electrónico.",
"load_segment": "Cargar segmento",
"logic_error_warning": "El cambio causará errores lógicos",
@@ -1455,12 +1451,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",
@@ -1469,8 +1465,7 @@
"question_deleted": "Pregunta eliminada.",
"question_duplicated": "Pregunta duplicada.",
"question_id_updated": "ID de pregunta actualizado",
"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_logic": "Esta pregunta se utiliza en la lógica de la pregunta {questionIndex}.",
"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",
@@ -1534,7 +1529,6 @@
"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",
@@ -1582,6 +1576,8 @@
"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",
@@ -1602,51 +1598,8 @@
"upper_label": "Etiqueta superior",
"url_filters": "Filtros de URL",
"url_not_supported": "URL no compatible",
"validation": {
"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",
"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": "Como máximo",
"mb": "MB",
"min_length": "Al menos",
"min_selections": "Al menos",
"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",
"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}.",

View File

@@ -75,10 +75,6 @@
"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"
},
@@ -243,6 +239,7 @@
"imprint": "Empreinte",
"in_progress": "En cours",
"inactive_surveys": "Sondages inactifs",
"input_type": "Type d'entrée",
"integration": "intégration",
"integrations": "Intégrations",
"invalid_date": "Date invalide",
@@ -254,7 +251,6 @@
"label": "Étiquette",
"language": "Langue",
"learn_more": "En savoir plus",
"license_expired": "License Expired",
"light_overlay": "Claire",
"limits_reached": "Limites atteints",
"link": "Lien",
@@ -267,11 +263,13 @@
"look_and_feel": "Apparence",
"manage": "Gérer",
"marketing": "Marketing",
"maximum": "Max",
"member": "Membre",
"members": "Membres",
"members_and_teams": "Membres & Équipes",
"membership_not_found": "Abonnement non trouvé",
"metadata": "Métadonnées",
"minimum": "Min",
"mobile_overlay_app_works_best_on_desktop": "Formbricks fonctionne mieux sur un écran plus grand. Pour gérer ou créer des sondages, passez à un autre appareil.",
"mobile_overlay_surveys_look_good": "Ne t'inquiète pas tes enquêtes sont superbes sur tous les appareils et tailles d'écran!",
"mobile_overlay_title": "Oups, écran minuscule détecté!",
@@ -324,7 +322,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é",
@@ -461,8 +459,7 @@
"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}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
"you_will_be_downgraded_to_the_community_edition_on_date": "Vous serez rétrogradé à l'édition communautaire le {date}."
},
"emails": {
"accept": "Accepter",
@@ -1018,8 +1015,6 @@
"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",
@@ -1171,6 +1166,7 @@
"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",
@@ -1180,9 +1176,6 @@
"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.",
@@ -1236,6 +1229,8 @@
"change_the_question_color_of_the_survey": "Vous pouvez modifier la couleur des questions d'une enquête.",
"changes_saved": "Modifications enregistrées.",
"changing_survey_type_will_remove_existing_distribution_channels": "Le changement du type de sondage affectera la façon dont il peut être partagé. Si les répondants ont déjà des liens d'accès pour le type actuel, ils peuvent perdre l'accès après le changement.",
"character_limit_toggle_description": "Limitez la longueur des réponses.",
"character_limit_toggle_title": "Ajouter des limites de caractères",
"checkbox_label": "Étiquette de case à cocher",
"choose_the_actions_which_trigger_the_survey": "Choisissez les actions qui déclenchent l'enquête.",
"choose_the_first_question_on_your_block": "Choisissez la première question de votre bloc",
@@ -1255,6 +1250,7 @@
"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",
@@ -1267,7 +1263,6 @@
"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.",
@@ -1372,7 +1367,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 avez besoin de plus, veuillez",
"if_you_need_more_please": "Si vous en avez besoin de plus, s'il vous plaît",
"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.",
@@ -1409,8 +1404,9 @@
"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_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 à",
"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 à",
"link_survey_description": "Partagez un lien vers une page d'enquête ou intégrez-le dans une page web ou un e-mail.",
"load_segment": "Segment de chargement",
"logic_error_warning": "Changer causera des erreurs logiques",
@@ -1423,7 +1419,7 @@
"matrix_all_fields": "Tous les champs",
"matrix_rows": "Lignes",
"max_file_size": "Taille maximale du fichier",
"max_file_size_limit_is": "La limite de taille maximale du fichier est",
"max_file_size_limit_is": "La 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",
@@ -1455,12 +1451,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",
@@ -1469,8 +1465,7 @@
"question_deleted": "Question supprimée.",
"question_duplicated": "Question dupliquée.",
"question_id_updated": "ID de la question mis à jour",
"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_logic": "Cette question est utilisée dans la logique de la question '{'questionIndex'}'.",
"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.",
@@ -1534,7 +1529,6 @@
"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",
@@ -1582,6 +1576,8 @@
"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",
@@ -1602,51 +1598,8 @@
"upper_label": "Étiquette supérieure",
"url_filters": "Filtres d'URL",
"url_not_supported": "URL non supportée",
"validation": {
"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",
"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": "Au maximum",
"mb": "Mo",
"min_length": "Au moins",
"min_selections": "Au moins",
"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",
"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}.",

View File

@@ -75,10 +75,6 @@
"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アカウントを作成"
},
@@ -243,6 +239,7 @@
"imprint": "企業情報",
"in_progress": "進行中",
"inactive_surveys": "非アクティブなフォーム",
"input_type": "入力タイプ",
"integration": "連携",
"integrations": "連携",
"invalid_date": "無効な日付です",
@@ -254,7 +251,6 @@
"label": "ラベル",
"language": "言語",
"learn_more": "詳細を見る",
"license_expired": "License Expired",
"light_overlay": "明るいオーバーレイ",
"limits_reached": "上限に達しました",
"link": "リンク",
@@ -267,11 +263,13 @@
"look_and_feel": "デザイン",
"manage": "管理",
"marketing": "マーケティング",
"maximum": "最大",
"member": "メンバー",
"members": "メンバー",
"members_and_teams": "メンバー&チーム",
"membership_not_found": "メンバーシップが見つかりません",
"metadata": "メタデータ",
"minimum": "最小",
"mobile_overlay_app_works_best_on_desktop": "Formbricks は より 大きな 画面 で最適に 作動します。 フォーム を 管理または 構築する には、 別の デバイス に 切り替える 必要が あります。",
"mobile_overlay_surveys_look_good": "ご安心ください - お使い の デバイス や 画面 サイズ に 関係なく、 フォーム は 素晴らしく 見えます!",
"mobile_overlay_title": "おっと、 小さな 画面 が 検出されました!",
@@ -324,7 +322,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": "プライバシーポリシー",
@@ -461,8 +459,7 @@
"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} に行われます。",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
"you_will_be_downgraded_to_the_community_edition_on_date": "コミュニティ版へのダウングレードは {date} に行われます。"
},
"emails": {
"accept": "承認",
@@ -1018,8 +1015,6 @@
"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": "テストメールを正常に送信しました",
@@ -1171,6 +1166,7 @@
"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": "ユーザーが複数の画像を選択できるようにする",
@@ -1180,9 +1176,6 @@
"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": "ユーザーが一定秒数応答しない場合、フォームを自動的に閉じます。",
@@ -1236,6 +1229,8 @@
"change_the_question_color_of_the_survey": "フォームの質問の色を変更します。",
"changes_saved": "変更を保存しました。",
"changing_survey_type_will_remove_existing_distribution_channels": "フォームの種類を変更すると、共有方法に影響します。回答者が現在のタイプのアクセスリンクをすでに持っている場合、切り替え後にアクセスを失う可能性があります。",
"character_limit_toggle_description": "回答の長さの上限・下限を設定します。",
"character_limit_toggle_title": "文字数制限を追加",
"checkbox_label": "チェックボックスのラベル",
"choose_the_actions_which_trigger_the_survey": "フォームをトリガーするアクションを選択してください。",
"choose_the_first_question_on_your_block": "ブロックの最初の質問を選択してください",
@@ -1255,6 +1250,7 @@
"contact_fields": "連絡先フィールド",
"contains": "を含む",
"continue_to_settings": "設定に進む",
"control_which_file_types_can_be_uploaded": "アップロードできるファイルの種類を制御します。",
"convert_to_multiple_choice": "複数選択に変換",
"convert_to_single_choice": "単一選択に変換",
"country": "国",
@@ -1267,7 +1263,6 @@
"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": "フォームの進捗状況の表示を無効にする。",
@@ -1409,8 +1404,9 @@
"key": "キー",
"last_name": "姓",
"let_people_upload_up_to_25_files_at_the_same_time": "一度に最大25個のファイルをアップロードできるようにする。",
"limit_the_maximum_file_size": "アップロードの最大ファイルサイズを制限します。",
"limit_upload_file_size_to": "アップロードファイルサイズの上限",
"limit_file_types": "ファイルタイプを制限",
"limit_the_maximum_file_size": "最大ファイルサイズを制限",
"limit_upload_file_size_to": "アップロードファイルサイズを以下に制限",
"link_survey_description": "フォームページへのリンクを共有するか、ウェブページやメールに埋め込みます。",
"load_segment": "セグメントを読み込み",
"logic_error_warning": "変更するとロジックエラーが発生します",
@@ -1455,12 +1451,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": "公開",
@@ -1469,8 +1465,7 @@
"question_deleted": "質問を削除しました。",
"question_duplicated": "質問を複製しました。",
"question_id_updated": "質問IDを更新しました",
"question_used_in_logic_warning_text": "このブロックの要素はロジックルールで使用されていますが、本当に削除しますか?",
"question_used_in_logic_warning_title": "ロジックの不整合",
"question_used_in_logic": "この質問は質問 {questionIndex} のロジックで使用されています",
"question_used_in_quota": "この 質問 は \"{quotaName}\" の クオータ に使用されています",
"question_used_in_recall": "この 質問 は 質問 {questionIndex} で 呼び出され て います 。",
"question_used_in_recall_ending_card": "この 質問 は エンディング カード で 呼び出され て います。",
@@ -1534,7 +1529,6 @@
"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": "保存済みのアクションを選択",
@@ -1582,6 +1576,8 @@
"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": "すべてのフォームの配置を一貫させるために、",
@@ -1602,51 +1598,8 @@
"upper_label": "上限ラベル",
"url_filters": "URLフィルター",
"url_not_supported": "URLはサポートされていません",
"validation": {
"add_validation_rule": "検証ルールを追加",
"answer_all_rows": "すべての行に回答してください",
"characters": "文字数",
"contains": "を含む",
"delete_validation_rule": "検証ルールを削除",
"does_not_contain": "を含まない",
"email": "有効なメールアドレスである",
"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": "最大",
"mb": "MB",
"min_length": "最小",
"min_selections": "最小",
"min_value": "最小",
"minimum_options_ranked": "ランク付けされた最小オプション数",
"minimum_rows_answered": "回答された最小行数",
"options_selected": "選択されたオプション",
"pattern": "正規表現パターンに一致する",
"phone": "有効な電話番号である",
"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} で 呼び出され て います 。",

View File

@@ -75,10 +75,6 @@
"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"
},
@@ -243,6 +239,7 @@
"imprint": "Afdruk",
"in_progress": "In uitvoering",
"inactive_surveys": "Inactieve enquêtes",
"input_type": "Invoertype",
"integration": "integratie",
"integrations": "Integraties",
"invalid_date": "Ongeldige datum",
@@ -254,7 +251,6 @@
"label": "Label",
"language": "Taal",
"learn_more": "Meer informatie",
"license_expired": "License Expired",
"light_overlay": "Lichte overlay",
"limits_reached": "Grenzen bereikt",
"link": "Link",
@@ -267,11 +263,13 @@
"look_and_feel": "Kijk & voel",
"manage": "Beheren",
"marketing": "Marketing",
"maximum": "Maximaal",
"member": "Lid",
"members": "Leden",
"members_and_teams": "Leden & teams",
"membership_not_found": "Lidmaatschap niet gevonden",
"metadata": "Metagegevens",
"minimum": "Minimum",
"mobile_overlay_app_works_best_on_desktop": "Formbricks werkt het beste op een groter scherm. Schakel over naar een ander apparaat om enquêtes te beheren of samen te stellen.",
"mobile_overlay_surveys_look_good": "Maakt u zich geen zorgen: uw enquêtes zien er geweldig uit op elk apparaat en schermformaat!",
"mobile_overlay_title": "Oeps, klein scherm gedetecteerd!",
@@ -324,7 +322,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 je abonnement",
"please_upgrade_your_plan": "Upgrade uw abonnement.",
"preview": "Voorbeeld",
"preview_survey": "Voorbeeld van enquête",
"privacy": "Privacybeleid",
@@ -461,8 +459,7 @@
"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}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
"you_will_be_downgraded_to_the_community_edition_on_date": "Je wordt gedowngraded naar de Community-editie op {date}."
},
"emails": {
"accept": "Accepteren",
@@ -1018,8 +1015,6 @@
"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",
@@ -1171,6 +1166,7 @@
"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",
@@ -1180,9 +1176,6 @@
"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.",
@@ -1236,6 +1229,8 @@
"change_the_question_color_of_the_survey": "Verander de vraagkleur van de enquête.",
"changes_saved": "Wijzigingen opgeslagen.",
"changing_survey_type_will_remove_existing_distribution_channels": "Het wijzigen van het enquêtetype heeft invloed op de manier waarop deze kan worden gedeeld. Als respondenten al toegangslinks hebben voor het huidige type, verliezen ze mogelijk de toegang na de overstap.",
"character_limit_toggle_description": "Beperk hoe kort of lang een antwoord mag zijn.",
"character_limit_toggle_title": "Tekenlimieten toevoegen",
"checkbox_label": "Selectievakje-label",
"choose_the_actions_which_trigger_the_survey": "Kies de acties die de enquête activeren.",
"choose_the_first_question_on_your_block": "Kies de eerste vraag in je blok",
@@ -1255,6 +1250,7 @@
"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",
@@ -1267,7 +1263,6 @@
"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.",
@@ -1372,7 +1367,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 je meer nodig hebt,",
"if_you_need_more_please": "Als u meer nodig heeft, alstublieft",
"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.",
@@ -1409,8 +1404,9 @@
"key": "Sleutel",
"last_name": "Achternaam",
"let_people_upload_up_to_25_files_at_the_same_time": "Laat mensen maximaal 25 bestanden tegelijk uploaden.",
"limit_the_maximum_file_size": "Beperk de maximale bestandsgrootte voor uploads.",
"limit_upload_file_size_to": "Beperk uploadbestandsgrootte tot",
"limit_file_types": "Beperk bestandstypen",
"limit_the_maximum_file_size": "Beperk de maximale bestandsgrootte",
"limit_upload_file_size_to": "Beperk de uploadbestandsgrootte tot",
"link_survey_description": "Deel een link naar een enquêtepagina of sluit deze in op een webpagina of e-mail.",
"load_segment": "Laadsegment",
"logic_error_warning": "Wijzigen zal logische fouten veroorzaken",
@@ -1423,7 +1419,7 @@
"matrix_all_fields": "Alle velden",
"matrix_rows": "Rijen",
"max_file_size": "Maximale bestandsgrootte",
"max_file_size_limit_is": "Maximale bestandsgroottelimiet is",
"max_file_size_limit_is": "De maximale bestandsgrootte 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",
@@ -1455,12 +1451,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",
@@ -1469,8 +1465,7 @@
"question_deleted": "Vraag verwijderd.",
"question_duplicated": "Vraag dubbel gesteld.",
"question_id_updated": "Vraag-ID bijgewerkt",
"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_logic": "Deze vraag wordt gebruikt in de logica van vraag {questionIndex}.",
"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",
@@ -1534,7 +1529,6 @@
"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",
@@ -1582,6 +1576,8 @@
"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",
@@ -1602,51 +1598,8 @@
"upper_label": "Bovenste etiket",
"url_filters": "URL-filters",
"url_not_supported": "URL niet ondersteund",
"validation": {
"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",
"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": "Maximaal",
"mb": "MB",
"min_length": "Minimaal",
"min_selections": "Minimaal",
"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",
"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}.",

View File

@@ -75,10 +75,6 @@
"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"
},
@@ -243,6 +239,7 @@
"imprint": "impressão",
"in_progress": "Em andamento",
"inactive_surveys": "Pesquisas inativas",
"input_type": "Tipo de entrada",
"integration": "integração",
"integrations": "Integrações",
"invalid_date": "Data inválida",
@@ -254,7 +251,6 @@
"label": "Etiqueta",
"language": "Língua",
"learn_more": "Saiba mais",
"license_expired": "License Expired",
"light_overlay": "sobreposição leve",
"limits_reached": "Limites Atingidos",
"link": "link",
@@ -267,11 +263,13 @@
"look_and_feel": "Aparência e Experiência",
"manage": "gerenciar",
"marketing": "marketing",
"maximum": "Máximo",
"member": "Membros",
"members": "Membros",
"members_and_teams": "Membros e equipes",
"membership_not_found": "Assinatura não encontrada",
"metadata": "metadados",
"minimum": "Mínimo",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor em uma tela maior. Para gerenciar ou criar pesquisas, mude para outro dispositivo.",
"mobile_overlay_surveys_look_good": "Não se preocupe suas pesquisas ficam ótimas em qualquer dispositivo e tamanho de tela!",
"mobile_overlay_title": "Eita, tela pequena detectada!",
@@ -324,7 +322,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",
@@ -461,8 +459,7 @@
"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}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
"you_will_be_downgraded_to_the_community_edition_on_date": "Você será rebaixado para a Edição Comunitária em {date}."
},
"emails": {
"accept": "Aceitar",
@@ -1018,8 +1015,6 @@
"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",
@@ -1171,6 +1166,7 @@
"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",
@@ -1180,9 +1176,6 @@
"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.",
@@ -1236,6 +1229,8 @@
"change_the_question_color_of_the_survey": "Muda a cor da pergunta da pesquisa.",
"changes_saved": "Mudanças salvas.",
"changing_survey_type_will_remove_existing_distribution_channels": "Alterar o tipo de pesquisa afetará a forma como ela pode ser compartilhada. Se os respondentes já tiverem links de acesso para o tipo atual, podem perder o acesso após a mudança.",
"character_limit_toggle_description": "Limite o quão curta ou longa uma resposta pode ser.",
"character_limit_toggle_title": "Adicionar limites de caracteres",
"checkbox_label": "Rótulo da Caixa de Seleção",
"choose_the_actions_which_trigger_the_survey": "Escolha as ações que disparam a pesquisa.",
"choose_the_first_question_on_your_block": "Escolha a primeira pergunta do seu bloco",
@@ -1255,6 +1250,7 @@
"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",
@@ -1267,7 +1263,6 @@
"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.",
@@ -1409,8 +1404,9 @@
"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_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",
"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",
"link_survey_description": "Compartilhe um link para a página da pesquisa ou incorpore-a em uma página da web ou e-mail.",
"load_segment": "segmento de carga",
"logic_error_warning": "Mudar vai causar erros de lógica",
@@ -1423,7 +1419,7 @@
"matrix_all_fields": "Todos os campos",
"matrix_rows": "Linhas",
"max_file_size": "Tamanho máximo do arquivo",
"max_file_size_limit_is": "O limite de tamanho máximo do arquivo é",
"max_file_size_limit_is": "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",
@@ -1455,12 +1451,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",
@@ -1469,8 +1465,7 @@
"question_deleted": "Pergunta deletada.",
"question_duplicated": "Pergunta duplicada.",
"question_id_updated": "ID da pergunta atualizado",
"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_logic": "Essa pergunta é usada na lógica da pergunta {questionIndex}.",
"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",
@@ -1534,7 +1529,6 @@
"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",
@@ -1582,6 +1576,8 @@
"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",
@@ -1602,51 +1598,8 @@
"upper_label": "Etiqueta Superior",
"url_filters": "Filtros de URL",
"url_not_supported": "URL não suportada",
"validation": {
"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",
"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": "No máximo",
"mb": "MB",
"min_length": "No mínimo",
"min_selections": "No mínimo",
"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",
"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}.",

View File

@@ -75,10 +75,6 @@
"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"
},
@@ -243,6 +239,7 @@
"imprint": "Impressão",
"in_progress": "Em Progresso",
"inactive_surveys": "Inquéritos inativos",
"input_type": "Tipo de entrada",
"integration": "integração",
"integrations": "Integrações",
"invalid_date": "Data inválida",
@@ -254,7 +251,6 @@
"label": "Etiqueta",
"language": "Idioma",
"learn_more": "Saiba mais",
"license_expired": "License Expired",
"light_overlay": "Sobreposição leve",
"limits_reached": "Limites Atingidos",
"link": "Link",
@@ -267,11 +263,13 @@
"look_and_feel": "Aparência e Sensação",
"manage": "Gerir",
"marketing": "Marketing",
"maximum": "Máximo",
"member": "Membro",
"members": "Membros",
"members_and_teams": "Membros e equipas",
"membership_not_found": "Associação não encontrada",
"metadata": "Metadados",
"minimum": "Mínimo",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funciona melhor num ecrã maior. Para gerir ou criar inquéritos, mude de dispositivo.",
"mobile_overlay_surveys_look_good": "Não se preocupe os seus inquéritos têm uma ótima aparência em todos os dispositivos e tamanhos de ecrã!",
"mobile_overlay_title": "Oops, ecrã pequeno detectado!",
@@ -324,7 +322,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",
@@ -461,8 +459,7 @@
"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}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
"you_will_be_downgraded_to_the_community_edition_on_date": "Será rebaixado para a Edição Comunitária em {date}."
},
"emails": {
"accept": "Aceitar",
@@ -1018,8 +1015,6 @@
"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",
@@ -1171,6 +1166,7 @@
"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",
@@ -1180,9 +1176,6 @@
"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.",
@@ -1236,6 +1229,8 @@
"change_the_question_color_of_the_survey": "Alterar a cor da pergunta do inquérito",
"changes_saved": "Alterações guardadas.",
"changing_survey_type_will_remove_existing_distribution_channels": "Alterar o tipo de inquérito afetará como ele pode ser partilhado. Se os respondentes já tiverem links de acesso para o tipo atual, podem perder o acesso após a mudança.",
"character_limit_toggle_description": "Limitar o quão curta ou longa uma resposta pode ser.",
"character_limit_toggle_title": "Adicionar limites de caracteres",
"checkbox_label": "Rótulo da Caixa de Seleção",
"choose_the_actions_which_trigger_the_survey": "Escolha as ações que desencadeiam o inquérito.",
"choose_the_first_question_on_your_block": "Escolha a primeira pergunta no seu bloco",
@@ -1255,6 +1250,7 @@
"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",
@@ -1267,7 +1263,6 @@
"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.",
@@ -1409,8 +1404,9 @@
"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_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",
"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",
"link_survey_description": "Partilhe um link para uma página de inquérito ou incorpore-o numa página web ou email.",
"load_segment": "Carregar segmento",
"logic_error_warning": "A alteração causará erros de lógica",
@@ -1422,8 +1418,8 @@
"manage_languages": "Gerir Idiomas",
"matrix_all_fields": "Todos os campos",
"matrix_rows": "Linhas",
"max_file_size": "Tamanho máximo de ficheiro",
"max_file_size_limit_is": "O limite de tamanho máximo de ficheiro é",
"max_file_size": "Tamanho máximo do ficheiro",
"max_file_size_limit_is": "O limite do tamanho máximo do 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",
@@ -1455,12 +1451,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",
@@ -1469,8 +1465,7 @@
"question_deleted": "Pergunta eliminada.",
"question_duplicated": "Pergunta duplicada.",
"question_id_updated": "ID da pergunta atualizado",
"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_logic": "Esta pergunta é usada na lógica da pergunta {questionIndex}.",
"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",
@@ -1534,7 +1529,6 @@
"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",
@@ -1582,6 +1576,8 @@
"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",
@@ -1602,51 +1598,8 @@
"upper_label": "Etiqueta Superior",
"url_filters": "Filtros de URL",
"url_not_supported": "URL não suportado",
"validation": {
"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",
"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": "No máximo",
"mb": "MB",
"min_length": "Pelo menos",
"min_selections": "Pelo menos",
"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",
"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}.",

View File

@@ -75,10 +75,6 @@
"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"
},
@@ -243,6 +239,7 @@
"imprint": "Amprentă",
"in_progress": "În progres",
"inactive_surveys": "Sondaje inactive",
"input_type": "Tipul de intrare",
"integration": "integrare",
"integrations": "Integrări",
"invalid_date": "Dată invalidă",
@@ -254,7 +251,6 @@
"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",
@@ -267,11 +263,13 @@
"look_and_feel": "Aspect și Comportament",
"manage": "Gestionați",
"marketing": "Marketing",
"maximum": "Maximum",
"member": "Membru",
"members": "Membri",
"members_and_teams": "Membri și echipe",
"membership_not_found": "Apartenența nu a fost găsită",
"metadata": "Metadate",
"minimum": "Minim",
"mobile_overlay_app_works_best_on_desktop": "Formbricks funcționează cel mai bine pe un ecran mai mare. Pentru a gestiona sau crea chestionare, treceți la un alt dispozitiv.",
"mobile_overlay_surveys_look_good": "Nu vă faceți griji chestionarele dumneavoastră arată grozav pe orice dispozitiv și dimensiune a ecranului!",
"mobile_overlay_title": "Ups, ecran mic detectat!",
@@ -324,7 +322,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ă faceți upgrade la planul dumneavoastră",
"please_upgrade_your_plan": "Vă rugăm să vă actualizați planul.",
"preview": "Previzualizare",
"preview_survey": "Previzualizare Chestionar",
"privacy": "Politica de Confidențialitate",
@@ -461,8 +459,7 @@
"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}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
"you_will_be_downgraded_to_the_community_edition_on_date": "Vei fi retrogradat la ediția Community pe {date}."
},
"emails": {
"accept": "Acceptă",
@@ -1018,8 +1015,6 @@
"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",
@@ -1171,6 +1166,7 @@
"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",
@@ -1180,9 +1176,6 @@
"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.",
@@ -1236,6 +1229,8 @@
"change_the_question_color_of_the_survey": "Schimbați culoarea întrebării chestionarului.",
"changes_saved": "Modificările au fost salvate",
"changing_survey_type_will_remove_existing_distribution_channels": "Schimbarea tipului chestionarului va afecta modul în care acesta poate fi distribuit. Dacă respondenții au deja linkuri de acces pentru tipul curent, aceștia ar putea pierde accesul după schimbare.",
"character_limit_toggle_description": "Limitați cât de scurt sau lung poate fi un răspuns.",
"character_limit_toggle_title": "Adăugați limite de caractere",
"checkbox_label": "Etichetă casetă de selectare",
"choose_the_actions_which_trigger_the_survey": "Alegeți acțiunile care declanșează sondajul.",
"choose_the_first_question_on_your_block": "Alege prima întrebare din blocul tău",
@@ -1255,6 +1250,7 @@
"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ă",
@@ -1267,7 +1263,6 @@
"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",
@@ -1372,7 +1367,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 mult, vă rugăm",
"if_you_need_more_please": "Dacă aveți nevoie de mai multe, 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.",
@@ -1409,8 +1404,9 @@
"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_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",
"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",
"link_survey_description": "Partajați un link către o pagină de chestionar sau încorporați-l într-o pagină web sau email.",
"load_segment": "Încarcă segment",
"logic_error_warning": "Schimbarea va provoca erori de logică",
@@ -1423,7 +1419,7 @@
"matrix_all_fields": "Toate câmpurile",
"matrix_rows": "Rânduri",
"max_file_size": "Dimensiune maximă fișier",
"max_file_size_limit_is": "Limita maximă pentru dimensiunea fișierului este",
"max_file_size_limit_is": "Limita dimensiunii maxime a 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",
@@ -1455,12 +1451,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ă",
@@ -1469,8 +1465,7 @@
"question_deleted": "Întrebare ștearsă.",
"question_duplicated": "Întrebare duplicată.",
"question_id_updated": "ID întrebare actualizat",
"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_logic": "Această întrebare este folosită în logica întrebării {questionIndex}.",
"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.",
@@ -1534,7 +1529,6 @@
"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ă",
@@ -1582,6 +1576,8 @@
"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",
@@ -1602,51 +1598,8 @@
"upper_label": "Etichetă superioară",
"url_filters": "Filtre URL",
"url_not_supported": "URL nesuportat",
"validation": {
"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",
"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": "Cel mult",
"mb": "MB",
"min_length": "Cel puțin",
"min_selections": "Cel puțin",
"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",
"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}.",

View File

@@ -75,10 +75,6 @@
"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"
},
@@ -243,6 +239,7 @@
"imprint": "Выходные данные",
"in_progress": "В процессе",
"inactive_surveys": "Неактивные опросы",
"input_type": "Тип ввода",
"integration": "интеграция",
"integrations": "Интеграции",
"invalid_date": "Неверная дата",
@@ -254,7 +251,6 @@
"label": "Метка",
"language": "Язык",
"learn_more": "Подробнее",
"license_expired": "License Expired",
"light_overlay": "Светлый оверлей",
"limits_reached": "Достигнуты лимиты",
"link": "Ссылка",
@@ -267,11 +263,13 @@
"look_and_feel": "Внешний вид",
"manage": "Управление",
"marketing": "Маркетинг",
"maximum": "Максимум",
"member": "Участник",
"members": "Участники",
"members_and_teams": "Участники и команды",
"membership_not_found": "Участие не найдено",
"metadata": "Метаданные",
"minimum": "Минимум",
"mobile_overlay_app_works_best_on_desktop": "Formbricks лучше всего работает на большом экране. Для управления или создания опросов перейдите на другое устройство.",
"mobile_overlay_surveys_look_good": "Не волнуйтесь — ваши опросы отлично выглядят на любом устройстве и экране!",
"mobile_overlay_title": "Ой, обнаружен маленький экран!",
@@ -324,7 +322,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": "Политика конфиденциальности",
@@ -461,8 +459,7 @@
"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}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
"you_will_be_downgraded_to_the_community_edition_on_date": "Ваша версия будет понижена до Community Edition {date}."
},
"emails": {
"accept": "Принять",
@@ -1018,8 +1015,6 @@
"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": "Тестовое письмо успешно отправлено",
@@ -1171,6 +1166,7 @@
"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": "Разрешить пользователям выбирать более одного изображения",
@@ -1180,9 +1176,6 @@
"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": "Автоматически закрывать опрос, если пользователь не ответил за определённое количество секунд.",
@@ -1236,6 +1229,8 @@
"change_the_question_color_of_the_survey": "Изменить цвет вопросов в опросе.",
"changes_saved": "Изменения сохранены.",
"changing_survey_type_will_remove_existing_distribution_channels": "Изменение типа опроса повлияет на способы его распространения. Если у респондентов уже есть ссылки для доступа к текущему типу, после смены они могут потерять доступ.",
"character_limit_toggle_description": "Ограничьте минимальную и максимальную длину ответа.",
"character_limit_toggle_title": "Добавить ограничения на количество символов",
"checkbox_label": "Метка флажка",
"choose_the_actions_which_trigger_the_survey": "Выберите действия, которые запускают опрос.",
"choose_the_first_question_on_your_block": "Выберите первый вопрос в вашем блоке",
@@ -1255,6 +1250,7 @@
"contact_fields": "Поля контакта",
"contains": "Содержит",
"continue_to_settings": "Перейти к настройкам",
"control_which_file_types_can_be_uploaded": "Управляйте типами файлов, которые можно загружать.",
"convert_to_multiple_choice": "Преобразовать в мультивыбор",
"convert_to_single_choice": "Преобразовать в одиночный выбор",
"country": "Страна",
@@ -1267,7 +1263,6 @@
"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": "Отключить отображение прогресса опроса.",
@@ -1372,7 +1367,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": "Этот опрос может отображаться при выполнении условий, даже если недавно уже был показан другой опрос.",
@@ -1409,7 +1404,8 @@
"key": "Ключ",
"last_name": "Фамилия",
"let_people_upload_up_to_25_files_at_the_same_time": "Разрешить загружать до 25 файлов одновременно.",
"limit_the_maximum_file_size": "Ограничьте максимальный размер загружаемых файлов.",
"limit_file_types": "Ограничить типы файлов",
"limit_the_maximum_file_size": "Ограничить максимальный размер файла",
"limit_upload_file_size_to": "Ограничить размер загружаемого файла до",
"link_survey_description": "Поделитесь ссылкой на страницу опроса или вставьте её на веб-страницу или в электронное письмо.",
"load_segment": "Загрузить сегмент",
@@ -1455,12 +1451,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": "Опубликовать",
@@ -1469,8 +1465,7 @@
"question_deleted": "Вопрос удалён.",
"question_duplicated": "Вопрос дублирован.",
"question_id_updated": "ID вопроса обновлён",
"question_used_in_logic_warning_text": "Элементы из этого блока используются в правиле логики. Вы уверены, что хотите удалить его?",
"question_used_in_logic_warning_title": "Несогласованность логики",
"question_used_in_logic": "Этот вопрос используется в логике вопроса {questionIndex}.",
"question_used_in_quota": "Этот вопрос используется в квоте \"{quotaName}\"",
"question_used_in_recall": "Этот вопрос используется в отзыве в вопросе {questionIndex}.",
"question_used_in_recall_ending_card": "Этот вопрос используется в отзыве на финальной карточке",
@@ -1534,7 +1529,6 @@
"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": "Выберите сохранённое действие",
@@ -1582,6 +1576,8 @@
"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": "Чтобы сохранить единое расположение во всех опросах, вы можете",
@@ -1602,51 +1598,8 @@
"upper_label": "Верхняя метка",
"url_filters": "Фильтры URL",
"url_not_supported": "URL не поддерживается",
"validation": {
"add_validation_rule": "Добавить правило проверки",
"answer_all_rows": "Ответьте на все строки",
"characters": "Символы",
"contains": "Содержит",
"delete_validation_rule": "Удалить правило проверки",
"does_not_contain": "Не содержит",
"email": "Корректный email",
"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": "Не более",
"mb": "МБ",
"min_length": "Не менее",
"min_selections": "Не менее",
"min_value": "Не менее",
"minimum_options_ranked": "Минимальное количество ранжированных вариантов",
"minimum_rows_answered": "Минимальное количество заполненных строк",
"options_selected": "Выбранные опции",
"pattern": "Соответствует шаблону regex",
"phone": "Корректный телефон",
"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}.",

View File

@@ -75,10 +75,6 @@
"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"
},
@@ -243,6 +239,7 @@
"imprint": "Impressum",
"in_progress": "Pågående",
"inactive_surveys": "Inaktiva enkäter",
"input_type": "Inmatningstyp",
"integration": "integration",
"integrations": "Integrationer",
"invalid_date": "Ogiltigt datum",
@@ -254,7 +251,6 @@
"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",
@@ -267,11 +263,13 @@
"look_and_feel": "Utseende",
"manage": "Hantera",
"marketing": "Marknadsföring",
"maximum": "Maximum",
"member": "Medlem",
"members": "Medlemmar",
"members_and_teams": "Medlemmar och team",
"membership_not_found": "Medlemskap hittades inte",
"metadata": "Metadata",
"minimum": "Minimum",
"mobile_overlay_app_works_best_on_desktop": "Formbricks fungerar bäst på en större skärm. Byt till en annan enhet för att hantera eller bygga enkäter.",
"mobile_overlay_surveys_look_good": "Oroa dig inte dina enkäter ser bra ut på alla enheter och skärmstorlekar!",
"mobile_overlay_title": "Hoppsan, liten skärm upptäckt!",
@@ -324,7 +322,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",
@@ -461,8 +459,7 @@
"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}.",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
"you_will_be_downgraded_to_the_community_edition_on_date": "Du kommer att nedgraderas till Community Edition den {date}."
},
"emails": {
"accept": "Acceptera",
@@ -1018,8 +1015,6 @@
"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",
@@ -1171,6 +1166,7 @@
"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",
@@ -1180,9 +1176,6 @@
"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.",
@@ -1236,6 +1229,8 @@
"change_the_question_color_of_the_survey": "Ändra enkätens frågefärg.",
"changes_saved": "Ändringar sparade.",
"changing_survey_type_will_remove_existing_distribution_channels": "Att ändra enkättypen påverkar hur den kan delas. Om respondenter redan har åtkomstlänkar för den nuvarande typen kan de förlora åtkomst efter bytet.",
"character_limit_toggle_description": "Begränsa hur kort eller långt ett svar kan vara.",
"character_limit_toggle_title": "Lägg till teckengränser",
"checkbox_label": "Kryssruteetikett",
"choose_the_actions_which_trigger_the_survey": "Välj de åtgärder som utlöser enkäten.",
"choose_the_first_question_on_your_block": "Välj den första frågan i ditt block",
@@ -1255,6 +1250,7 @@
"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",
@@ -1267,7 +1263,6 @@
"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.",
@@ -1372,7 +1367,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 mer, vänligen",
"if_you_need_more_please": "Om du behöver fler, 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.",
@@ -1409,8 +1404,9 @@
"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_the_maximum_file_size": "Begränsa den maximala filstorleken för uppladdningar.",
"limit_upload_file_size_to": "Begränsa uppladdad filstorlek till",
"limit_file_types": "Begränsa filtyper",
"limit_the_maximum_file_size": "Begränsa maximal filstorlek",
"limit_upload_file_size_to": "Begränsa uppladdningsfilstorlek till",
"link_survey_description": "Dela en länk till en enkätsida eller bädda in den på en webbsida eller i e-post.",
"load_segment": "Ladda segment",
"logic_error_warning": "Ändring kommer att orsaka logikfel",
@@ -1423,7 +1419,7 @@
"matrix_all_fields": "Alla fält",
"matrix_rows": "Rader",
"max_file_size": "Max filstorlek",
"max_file_size_limit_is": "Maximal filstorleksgräns är",
"max_file_size_limit_is": "Maxgräns för filstorlek ä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",
@@ -1455,12 +1451,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",
@@ -1469,8 +1465,7 @@
"question_deleted": "Fråga borttagen.",
"question_duplicated": "Fråga duplicerad.",
"question_id_updated": "Fråge-ID uppdaterat",
"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_logic": "Denna fråga används i logiken för fråga {questionIndex}.",
"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",
@@ -1534,7 +1529,6 @@
"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",
@@ -1582,6 +1576,8 @@
"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",
@@ -1602,51 +1598,8 @@
"upper_label": "Övre etikett",
"url_filters": "URL-filter",
"url_not_supported": "URL stöds inte",
"validation": {
"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",
"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": "Högst",
"mb": "MB",
"min_length": "Minst",
"min_selections": "Minst",
"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",
"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}.",

View File

@@ -75,10 +75,6 @@
"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 账户"
},
@@ -243,6 +239,7 @@
"imprint": "印记",
"in_progress": "进行中",
"inactive_surveys": "不 活跃 调查",
"input_type": "输入类型",
"integration": "集成",
"integrations": "集成",
"invalid_date": "无效 日期",
@@ -254,7 +251,6 @@
"label": "标签",
"language": "语言",
"learn_more": "了解 更多",
"license_expired": "License Expired",
"light_overlay": "浅色遮罩层",
"limits_reached": "限制 达到",
"link": "链接",
@@ -267,11 +263,13 @@
"look_and_feel": "外观 & 感觉",
"manage": "管理",
"marketing": "市场营销",
"maximum": "最大值",
"member": "成员",
"members": "成员",
"members_and_teams": "成员和团队",
"membership_not_found": "未找到会员资格",
"metadata": "元数据",
"minimum": "最低",
"mobile_overlay_app_works_best_on_desktop": "Formbricks 在 更大 的 屏幕 上 效果 最佳。 若 需要 管理 或 构建 调查, 请 切换 到 其他 设备。",
"mobile_overlay_surveys_look_good": "别 担心 您 的 调查 在 每 一 种 设备 和 屏幕 尺寸 上 看起来 都 很 棒!",
"mobile_overlay_title": "噢, 检测 到 小 屏幕!",
@@ -324,7 +322,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": "隐私政策",
@@ -461,8 +459,7 @@
"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} 降级到社区版。",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
"you_will_be_downgraded_to_the_community_edition_on_date": "您将在 {date} 降级到社区版。"
},
"emails": {
"accept": "接受",
@@ -1018,8 +1015,6 @@
"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": "测试 邮件 发送 成功",
@@ -1171,6 +1166,7 @@
"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": "允许 用户 选择 多于 一个 图片",
@@ -1180,9 +1176,6 @@
"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": "用户未在一定秒数内应答时 自动关闭 问卷",
@@ -1236,6 +1229,8 @@
"change_the_question_color_of_the_survey": "更改调查的 问题颜色",
"changes_saved": "更改 已 保存",
"changing_survey_type_will_remove_existing_distribution_channels": "更改 调查 类型 会影 响 分享 方式 。 如果 受访者 已经 拥有 当前 类型 的 访问 链接 在 更改 之后 ,他们 可能 会 失去 访问 权限 。",
"character_limit_toggle_description": "限制 答案的短或长程度。",
"character_limit_toggle_title": "添加 字符限制",
"checkbox_label": "复选框 标签",
"choose_the_actions_which_trigger_the_survey": "选择 触发 调查 的 动作 。",
"choose_the_first_question_on_your_block": "选择区块中的第一个问题",
@@ -1255,6 +1250,7 @@
"contact_fields": "联络字段",
"contains": "包含",
"continue_to_settings": "继续 到 设置",
"control_which_file_types_can_be_uploaded": "控制 可以 上传的 文件 类型",
"convert_to_multiple_choice": "转换为 多选",
"convert_to_single_choice": "转换为 单选",
"country": "国家",
@@ -1267,7 +1263,6 @@
"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": "禁用问卷 进度 的可见性。",
@@ -1372,7 +1367,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": "只要满足条件,此调查即可显示,即使最近刚显示过其他调查。",
@@ -1409,8 +1404,9 @@
"key": "键",
"last_name": "姓",
"let_people_upload_up_to_25_files_at_the_same_time": "允许 人们 同时 上传 最多 25 个 文件",
"limit_the_maximum_file_size": "限制上传文件的最大大小。",
"limit_upload_file_size_to": "将上传文件大小限制为",
"limit_file_types": "限制 文件 类型",
"limit_the_maximum_file_size": "限制 最大 文件 大小",
"limit_upload_file_size_to": "将 上传 文件 大小 限制 为",
"link_survey_description": "分享 问卷 页面 链接 或 将其 嵌入 网页 或 电子邮件 中。",
"load_segment": "载入 段落",
"logic_error_warning": "更改 将 导致 逻辑 错误",
@@ -1422,8 +1418,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 实例",
@@ -1455,12 +1451,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": "发布",
@@ -1469,8 +1465,7 @@
"question_deleted": "问题 已删除",
"question_duplicated": "问题重复。",
"question_id_updated": "问题 ID 更新",
"question_used_in_logic_warning_text": "此区块中的元素已被用于逻辑规则,您确定要删除吗?",
"question_used_in_logic_warning_title": "逻辑不一致",
"question_used_in_logic": "\"这个 问题 在 问题 {questionIndex} 的 逻辑 中 使用。\"",
"question_used_in_quota": "此 问题 正在 被 \"{quotaName}\" 配额 使用",
"question_used_in_recall": "此问题正在召回于问题 {questionIndex}。",
"question_used_in_recall_ending_card": "此 问题 正在召回于结束 卡片。",
@@ -1534,7 +1529,6 @@
"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": "选择 保存的 操作",
@@ -1582,6 +1576,8 @@
"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": "为了 保持 所有 调查 的 放置 一致,您 可以",
@@ -1602,51 +1598,8 @@
"upper_label": "上限标签",
"url_filters": "URL 过滤器",
"url_not_supported": "URL 不支持",
"validation": {
"add_validation_rule": "添加验证规则",
"answer_all_rows": "请填写所有行",
"characters": "字符",
"contains": "包含",
"delete_validation_rule": "删除验证规则",
"does_not_contain": "不包含",
"email": "是有效的邮箱地址",
"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": "最多",
"mb": "MB",
"min_length": "至少",
"min_selections": "至少",
"min_value": "至少",
"minimum_options_ranked": "最少排序选项数",
"minimum_rows_answered": "最少回答行数",
"options_selected": "已选择的选项",
"pattern": "匹配正则表达式模式",
"phone": "是有效的手机号",
"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}。",

View File

@@ -75,10 +75,6 @@
"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 帳戶"
},
@@ -243,6 +239,7 @@
"imprint": "版本訊息",
"in_progress": "進行中",
"inactive_surveys": "停用中的問卷",
"input_type": "輸入類型",
"integration": "整合",
"integrations": "整合",
"invalid_date": "無效日期",
@@ -254,7 +251,6 @@
"label": "標籤",
"language": "語言",
"learn_more": "瞭解更多",
"license_expired": "License Expired",
"light_overlay": "淺色覆蓋",
"limits_reached": "已達上限",
"link": "連結",
@@ -267,11 +263,13 @@
"look_and_feel": "外觀與風格",
"manage": "管理",
"marketing": "行銷",
"maximum": "最大值",
"member": "成員",
"members": "成員",
"members_and_teams": "成員與團隊",
"membership_not_found": "找不到成員資格",
"metadata": "元數據",
"minimum": "最小值",
"mobile_overlay_app_works_best_on_desktop": "Formbricks 適合在大螢幕上使用。若要管理或建立問卷,請切換到其他裝置。",
"mobile_overlay_surveys_look_good": "別擔心 -你的 問卷 在每個 裝置 和 螢幕尺寸 上 都 很出色!",
"mobile_overlay_title": "糟糕 ,偵測到小螢幕!",
@@ -324,7 +322,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": "隱私權政策",
@@ -461,8 +459,7 @@
"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'}' 降級至社群版。",
"your_license_has_expired_please_renew": "Your enterprise license has expired. Please renew it to continue using enterprise features."
"you_will_be_downgraded_to_the_community_edition_on_date": "您將於 '{'date'}' 降級至社群版。"
},
"emails": {
"accept": "接受",
@@ -1018,8 +1015,6 @@
"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": "測試電子郵件已成功發送",
@@ -1171,6 +1166,7 @@
"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": "允許使用者選取多張圖片",
@@ -1180,9 +1176,6 @@
"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": "如果用戶在特定秒數後未回應,則自動關閉問卷。",
@@ -1236,6 +1229,8 @@
"change_the_question_color_of_the_survey": "變更問卷的問題顏色。",
"changes_saved": "已儲存變更。",
"changing_survey_type_will_remove_existing_distribution_channels": "更改問卷類型會影響其共享方式。如果受訪者已擁有當前類型的存取連結,則在切換後可能會失去存取權限。",
"character_limit_toggle_description": "限制答案的長度或短度。",
"character_limit_toggle_title": "新增字元限制",
"checkbox_label": "核取方塊標籤",
"choose_the_actions_which_trigger_the_survey": "選擇觸發問卷的操作。",
"choose_the_first_question_on_your_block": "選擇此區塊的第一個問題",
@@ -1255,6 +1250,7 @@
"contact_fields": "聯絡人欄位",
"contains": "包含",
"continue_to_settings": "繼續設定",
"control_which_file_types_can_be_uploaded": "控制可以上傳哪些檔案類型。",
"convert_to_multiple_choice": "轉換為多選",
"convert_to_single_choice": "轉換為單選",
"country": "國家/地區",
@@ -1267,7 +1263,6 @@
"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": "停用問卷進度的可見性。",
@@ -1409,8 +1404,9 @@
"key": "金鑰",
"last_name": "姓氏",
"let_people_upload_up_to_25_files_at_the_same_time": "允許使用者同時上傳最多 25 個檔案。",
"limit_the_maximum_file_size": "限制上傳檔案的最大大小。",
"limit_upload_file_size_to": "將上傳檔案大小限制為",
"limit_file_types": "限制檔案類型",
"limit_the_maximum_file_size": "限制最大檔案大小",
"limit_upload_file_size_to": "限制上傳檔案大小為",
"link_survey_description": "分享問卷頁面的連結或將其嵌入網頁或電子郵件中。",
"load_segment": "載入區隔",
"logic_error_warning": "變更將導致邏輯錯誤",
@@ -1455,12 +1451,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": "發布",
@@ -1469,8 +1465,7 @@
"question_deleted": "問題已刪除。",
"question_duplicated": "問題已複製。",
"question_id_updated": "問題 ID 已更新",
"question_used_in_logic_warning_text": "此區塊中的元素已用於邏輯規則,確定要刪除嗎?",
"question_used_in_logic_warning_title": "邏輯不一致",
"question_used_in_logic": "此問題用於問題 '{'questionIndex'}' 的邏輯中。",
"question_used_in_quota": "此問題 正被使用於 \"{quotaName}\" 配額中",
"question_used_in_recall": "此問題於問題 {questionIndex} 中被召回。",
"question_used_in_recall_ending_card": "此問題於結尾卡中被召回。",
@@ -1534,7 +1529,6 @@
"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": "選取已儲存的操作",
@@ -1582,6 +1576,8 @@
"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": "若要保持所有問卷的位置一致,您可以",
@@ -1602,51 +1598,8 @@
"upper_label": "上標籤",
"url_filters": "網址篩選器",
"url_not_supported": "不支援網址",
"validation": {
"add_validation_rule": "新增驗證規則",
"answer_all_rows": "請填答所有列",
"characters": "字元",
"contains": "包含",
"delete_validation_rule": "刪除驗證規則",
"does_not_contain": "不包含",
"email": "是有效的電子郵件",
"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": "最多",
"mb": "MB",
"min_length": "至少",
"min_selections": "至少",
"min_value": "至少",
"minimum_options_ranked": "最少排序選項數",
"minimum_rows_answered": "最少作答列數",
"options_selected": "已選擇的選項",
"pattern": "符合正則表達式樣式",
"phone": "是有效的電話號碼",
"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} 中被召回。",

View File

@@ -56,7 +56,7 @@ const handleDomainAwareRouting = (request: NextRequest): Response | null => {
}
};
export const proxy = async (originalRequest: NextRequest) => {
export const middleware = async (originalRequest: NextRequest) => {
// Handle domain-aware routing first
const domainResponse = handleDomainAwareRouting(originalRequest);
if (domainResponse) return domainResponse;

View File

@@ -1,15 +1,11 @@
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;
@@ -18,31 +14,38 @@ interface LanguageDropdownProps {
}
export const LanguageDropdown = ({ survey, setLanguage, locale }: LanguageDropdownProps) => {
const [showLanguageSelect, setShowLanguageSelect] = useState(false);
const containerRef = useRef(null);
const enabledLanguages = getEnabledLanguages(survey.languages ?? []);
if (enabledLanguages.length <= 1) {
return null;
}
useClickOutside(containerRef, () => setShowLanguageSelect(false));
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" title="Select Language" aria-label="Select Language">
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)}>
<Languages className="h-5 w-5" />
</Button>
</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>
</div>
)
);
};

View File

@@ -3,7 +3,6 @@
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";
@@ -68,16 +67,6 @@ 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)
);

View File

@@ -37,7 +37,7 @@ export const getContactAttributeKeys = reactCache(
export const createContactAttributeKey = async (
contactAttributeKey: TContactAttributeKeyInput
): Promise<Result<ContactAttributeKey, ApiErrorResponseV2>> => {
const { environmentId, name, description, key } = contactAttributeKey;
const { environmentId, name, description, key, dataType } = contactAttributeKey;
try {
const prismaData: Prisma.ContactAttributeKeyCreateInput = {
@@ -49,6 +49,8 @@ export const createContactAttributeKey = async (
name,
description,
key,
// If dataType is provided, use it; otherwise Prisma will use the default (text)
...(dataType && { dataType }),
};
const createdContactAttributeKey = await prisma.contactAttributeKey.create({

View File

@@ -28,8 +28,12 @@ export const ZContactAttributeKeyInput = ZContactAttributeKey.pick({
key: true,
name: true,
description: true,
dataType: true,
environmentId: true,
})
.extend({
dataType: ZContactAttributeKey.shape.dataType.optional(),
})
.superRefine((data, ctx) => {
// Enforce safe identifier format for key
if (!isSafeIdentifier(data.key)) {

View File

@@ -1,10 +1,10 @@
import { Prisma } from "@prisma/client";
import { describe, expect, test } from "vitest";
import { describe, expect, it } from "vitest";
import { buildCommonFilterQuery } from "./utils";
describe("buildCommonFilterQuery", () => {
// Test for line 32: spread existing date filter when adding startDate
test("should preserve existing date filter when adding startDate", () => {
it("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
test("should preserve existing date filter when adding endDate", () => {
it("should preserve existing date filter when adding endDate", () => {
const query: Prisma.ResponseFindManyArgs = {
where: {
createdAt: {

View File

@@ -15,7 +15,6 @@ 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 }> }) =>
@@ -193,25 +192,6 @@ 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) {

View File

@@ -1,210 +0,0 @@
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",
});
});
});

View File

@@ -1,92 +0,0 @@
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;
};

View File

@@ -13,7 +13,6 @@ 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({
@@ -129,25 +128,6 @@ 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);

View File

@@ -33,7 +33,7 @@ export const resetPasswordAction = actionClient.schema(ZResetPasswordAction).act
ctx.auditLoggingCtx.oldObject = oldObject;
ctx.auditLoggingCtx.newObject = updatedUser;
await sendPasswordResetNotifyEmail({ email: updatedUser.email, locale: updatedUser.locale });
await sendPasswordResetNotifyEmail(updatedUser);
return { success: true };
}
)

View File

@@ -69,7 +69,6 @@ describe("invite", () => {
creator: {
name: "Test User",
email: "test@example.com",
locale: "en-US",
},
};
@@ -90,7 +89,6 @@ describe("invite", () => {
select: {
name: true,
email: true,
locale: true,
},
},
},

View File

@@ -46,7 +46,6 @@ export const getInvite = reactCache(async (inviteId: string): Promise<InviteWith
select: {
name: true,
email: true,
locale: true,
},
},
},

View File

@@ -102,12 +102,7 @@ export const InvitePage = async (props: InvitePageProps) => {
);
}
await deleteInvite(inviteId);
await sendInviteAcceptedEmail(
invite.creator.name ?? "",
user?.name ?? "",
invite.creator.email,
invite.creator.locale
);
await sendInviteAcceptedEmail(invite.creator.name ?? "", user?.name ?? "", invite.creator.email);
await updateUser(session.user.id, {
notificationSettings: {
...user.notificationSettings,

View File

@@ -1,12 +1,10 @@
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;
};
}

View File

@@ -18,7 +18,6 @@ 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({
@@ -45,9 +44,6 @@ 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> {
@@ -127,12 +123,7 @@ async function handleInviteAcceptance(
},
});
await sendInviteAcceptedEmail(
invite.creator.name ?? "",
user.name,
invite.creator.email,
invite.creator.locale
);
await sendInviteAcceptedEmail(invite.creator.name ?? "", user.name, invite.creator.email);
await deleteInvite(invite.id);
}
@@ -173,7 +164,7 @@ async function handlePostUserCreation(
}
if (!emailVerificationDisabled) {
await sendVerificationEmail({ id: user.id, email: user.email, locale: user.locale });
await sendVerificationEmail(user);
}
}
@@ -200,13 +191,6 @@ 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) {

View File

@@ -15,7 +15,6 @@ 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";
@@ -49,7 +48,6 @@ interface SignupFormProps {
samlTenant: string;
samlProduct: string;
turnstileSiteKey?: string;
isFormbricksCloud: boolean;
}
export const SignupForm = ({
@@ -71,7 +69,6 @@ export const SignupForm = ({
samlTenant,
samlProduct,
turnstileSiteKey,
isFormbricksCloud,
}: SignupFormProps) => {
const [showLogin, setShowLogin] = useState(false);
const searchParams = useSearchParams();
@@ -79,8 +76,6 @@ 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();
@@ -115,9 +110,6 @@ export const SignupForm = ({
inviteToken: inviteToken ?? "",
emailVerificationDisabled,
turnstileToken,
isFormbricksCloud,
subscribeToSecurityUpdates,
subscribeToProductUpdates,
});
const emailTokenActionResponse = await createEmailTokenAction({ email: data.email });
@@ -247,43 +239,6 @@ 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"

View File

@@ -5,7 +5,6 @@ import {
EMAIL_VERIFICATION_DISABLED,
GITHUB_OAUTH_ENABLED,
GOOGLE_OAUTH_ENABLED,
IS_FORMBRICKS_CLOUD,
IS_TURNSTILE_CONFIGURED,
OIDC_DISPLAY_NAME,
OIDC_OAUTH_ENABLED,
@@ -77,7 +76,6 @@ export const SignupPage = async ({ searchParams: searchParamsProps }) => {
samlTenant={SAML_TENANT}
samlProduct={SAML_PRODUCT}
turnstileSiteKey={TURNSTILE_SITE_KEY}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
/>
</FormWrapper>
</div>

View File

@@ -48,7 +48,8 @@ describe("resendVerificationEmailAction", () => {
const mockUser = {
id: "user123",
email: "test@example.com",
locale: "en-US",
emailVerified: null, // Not verified
name: "Test User",
};
const mockVerifiedUser = {

View File

@@ -32,7 +32,7 @@ export const resendVerificationEmailAction = actionClient.schema(ZResendVerifica
};
}
ctx.auditLoggingCtx.userId = user.id;
await sendVerificationEmail({ id: user.id, email: user.email, locale: user.locale });
await sendVerificationEmail(user);
return {
success: true,
};

View File

@@ -1,12 +1,17 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { ZId } from "@formbricks/types/common";
import { ZContactAttributes } from "@formbricks/types/contact-attribute";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromContactId, getProjectIdFromContactId } from "@/lib/utils/helper";
import { updateAttributes } from "@/modules/ee/contacts/lib/attributes";
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
import { getContact } from "@/modules/ee/contacts/lib/contacts";
const ZGeneratePersonalSurveyLinkAction = z.object({
contactId: ZId,
@@ -58,3 +63,105 @@ export const generatePersonalSurveyLinkAction = authenticatedActionClient
surveyUrl: result.data,
};
});
const ZUpdateContactAttributesAction = z.object({
contactId: ZId,
attributes: ZContactAttributes,
});
export const updateContactAttributesAction = authenticatedActionClient
.schema(ZUpdateContactAttributesAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromContactId(parsedInput.contactId);
const projectId = await getProjectIdFromContactId(parsedInput.contactId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId,
},
],
});
const contact = await getContact(parsedInput.contactId);
if (!contact) {
throw new ResourceNotFoundError("Contact", parsedInput.contactId);
}
// Get userId from contact attributes
const userIdAttribute = await prisma.contactAttribute.findFirst({
where: {
contactId: parsedInput.contactId,
attributeKey: { key: "userId" },
},
select: { value: true },
});
if (!userIdAttribute) {
throw new InvalidInputError("Contact does not have a userId attribute");
}
const result = await updateAttributes(
parsedInput.contactId,
userIdAttribute.value,
contact.environmentId,
parsedInput.attributes
);
revalidatePath(`/environments/${contact.environmentId}/contacts/${parsedInput.contactId}`);
return result;
});
const ZDeleteContactAttributeAction = z.object({
contactId: ZId,
attributeKey: z.string(),
});
export const deleteContactAttributeAction = authenticatedActionClient
.schema(ZDeleteContactAttributeAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromContactId(parsedInput.contactId);
const projectId = await getProjectIdFromContactId(parsedInput.contactId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "readWrite",
projectId,
},
],
});
const contact = await getContact(parsedInput.contactId);
if (!contact) {
throw new ResourceNotFoundError("Contact", parsedInput.contactId);
}
// Delete the attribute
await prisma.contactAttribute.deleteMany({
where: {
contactId: parsedInput.contactId,
attributeKey: { key: parsedInput.attributeKey },
},
});
revalidatePath(`/environments/${contact.environmentId}/contacts/${parsedInput.contactId}`);
return { success: true };
});

View File

@@ -1,12 +1,21 @@
import { getResponsesByContactId } from "@/lib/response/service";
import { getTranslate } from "@/lingodotdev/server";
import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attributes";
import {
getContactAttributes,
getContactAttributesWithMetadata,
} from "@/modules/ee/contacts/lib/contact-attributes";
import { getContact } from "@/modules/ee/contacts/lib/contacts";
import { formatAttributeValue } from "@/modules/ee/contacts/lib/format-attribute-value";
import { Badge } from "@/modules/ui/components/badge";
import { IdBadge } from "@/modules/ui/components/id-badge";
export const AttributesSection = async ({ contactId }: { contactId: string }) => {
const t = await getTranslate();
const [contact, attributes] = await Promise.all([getContact(contactId), getContactAttributes(contactId)]);
const [contact, attributes, attributesWithMetadata] = await Promise.all([
getContact(contactId),
getContactAttributes(contactId),
getContactAttributesWithMetadata(contactId),
]);
if (!contact) {
throw new Error(t("environments.contacts.contact_not_found"));
@@ -53,13 +62,18 @@ export const AttributesSection = async ({ contactId }: { contactId: string }) =>
<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]) => {
{attributesWithMetadata
.filter((attr) => attr.key !== "email" && attr.key !== "userId" && attr.key !== "language")
.map((attr) => {
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 key={attr.key}>
<dt className="flex items-center gap-2 text-sm font-medium text-slate-500">
<span>{attr.name || attr.key}</span>
<Badge text={attr.dataType} type="gray" size="tiny" />
</dt>
<dd className="mt-1 text-sm text-slate-900">
{formatAttributeValue(attr.value, attr.dataType)}
</dd>
</div>
);
})}

View File

@@ -5,23 +5,31 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { deleteContactAction } from "@/modules/ee/contacts/actions";
import { EditContactAttributesModal } from "@/modules/ee/contacts/components/edit-contact-attributes-modal";
import { PublishedLinkSurvey } from "@/modules/ee/contacts/lib/surveys";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { IconBar } from "@/modules/ui/components/iconbar";
import { EditAttributesModal } from "./edit-attributes-modal";
import { GeneratePersonalLinkModal } from "./generate-personal-link-modal";
interface AttributeWithMetadata {
key: string;
name: string | null;
value: string;
dataType: TContactAttributeDataType;
}
interface ContactControlBarProps {
environmentId: string;
contactId: string;
isReadOnly: boolean;
isQuotasAllowed: boolean;
publishedLinkSurveys: PublishedLinkSurvey[];
currentAttributes: TContactAttributes;
allAttributeKeys: TContactAttributeKey[];
currentAttributes: AttributeWithMetadata[];
attributeKeys: TContactAttributeKey[];
}
@@ -31,6 +39,7 @@ export const ContactControlBar = ({
isReadOnly,
isQuotasAllowed,
publishedLinkSurveys,
allAttributeKeys,
currentAttributes,
attributeKeys,
}: ContactControlBarProps) => {
@@ -63,7 +72,7 @@ export const ContactControlBar = ({
const iconActions = [
{
icon: PencilIcon,
tooltip: t("environments.contacts.edit_attribute_values"),
tooltip: t("environments.contacts.edit_attributes"),
onClick: () => {
setIsEditAttributesModalOpen(true);
},
@@ -104,6 +113,13 @@ export const ContactControlBar = ({
: t("environments.contacts.delete_contact_confirmation")
}
/>
<EditAttributesModal
open={isEditAttributesModalOpen}
setOpen={setIsEditAttributesModalOpen}
contactId={contactId}
attributes={currentAttributes}
allAttributeKeys={allAttributeKeys}
/>
<GeneratePersonalLinkModal
open={isGenerateLinkModalOpen}
setOpen={setIsGenerateLinkModalOpen}

View File

@@ -0,0 +1,426 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { AnimatePresence, motion } from "framer-motion";
import { PlusIcon, Trash2Icon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import {
deleteContactAttributeAction,
updateContactAttributesAction,
} from "@/modules/ee/contacts/[contactId]/actions";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
DialogBody,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/modules/ui/components/dialog";
import {
FormControl,
FormError,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@/modules/ui/components/form";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
interface AttributeWithMetadata {
key: string;
name: string | null;
value: string;
dataType: TContactAttributeDataType;
}
interface EditAttributesModalProps {
contactId: string;
attributes: AttributeWithMetadata[];
allAttributeKeys: TContactAttributeKey[];
open: boolean;
setOpen: (open: boolean) => void;
}
export function EditAttributesModal({
contactId,
attributes,
allAttributeKeys,
open,
setOpen,
}: EditAttributesModalProps) {
const { t } = useTranslation();
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const [deletedKeys, setDeletedKeys] = useState<Set<string>>(new Set());
const [deletingKeys, setDeletingKeys] = useState<Set<string>>(new Set());
const [selectedNewAttributeKey, setSelectedNewAttributeKey] = useState<string>("");
const [newAttributeValue, setNewAttributeValue] = useState<string>("");
// Reset deleted keys when modal opens
useEffect(() => {
if (open) {
setDeletedKeys(new Set());
}
}, [open]);
// Filter out protected attributes and locally deleted ones
const editableAttributes = useMemo(() => {
return attributes.filter(
(attr) => attr.key !== "contactId" && attr.key !== "userId" && !deletedKeys.has(attr.key)
);
}, [attributes, deletedKeys]);
// Get available attribute keys that are not yet assigned to this contact (including deleted ones)
const availableAttributeKeys = useMemo(() => {
const currentKeys = new Set(editableAttributes.map((attr) => attr.key));
return allAttributeKeys.filter((key) => !currentKeys.has(key.key) && key.key !== "userId");
}, [editableAttributes, allAttributeKeys]);
const selectedAttributeKey = useMemo(() => {
return allAttributeKeys.find((key) => key.key === selectedNewAttributeKey);
}, [selectedNewAttributeKey, allAttributeKeys]);
// Create schema dynamically based on current editable attributes
const attributeSchema = useMemo(() => {
return z.object(
editableAttributes.reduce(
(acc, attr) => {
// Add specific validation for known attributes
if (attr.key === "email") {
acc[attr.key] = z.string().email({ message: "Invalid email address" });
} else if (attr.key === "language") {
acc[attr.key] = z.string().min(2, { message: "Language code must be at least 2 characters" });
} else {
// Generic string validation for other attributes
acc[attr.key] = z.string();
}
return acc;
},
{} as Record<string, z.ZodString | z.ZodEffects<z.ZodString>>
)
);
}, [editableAttributes]);
type TAttributeForm = z.infer<typeof attributeSchema>;
const form = useForm<TAttributeForm>({
resolver: zodResolver(attributeSchema),
defaultValues: editableAttributes.reduce(
(acc, attr) => {
acc[attr.key] = attr.value;
return acc;
},
{} as Record<string, string>
),
});
// Update form when editable attributes change
useEffect(() => {
const newDefaults = editableAttributes.reduce(
(acc, attr) => {
acc[attr.key] = attr.value;
return acc;
},
{} as Record<string, string>
);
form.reset(newDefaults);
}, [editableAttributes, form]);
const onSubmit = async (data: TAttributeForm) => {
setIsSubmitting(true);
try {
const result = await updateContactAttributesAction({
contactId,
attributes: data,
});
if (result?.data) {
toast.success(t("environments.contacts.attributes_updated_successfully"));
router.refresh();
setOpen(false);
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
}
} catch (error) {
toast.error(t("common.something_went_wrong_please_try_again"));
} finally {
setIsSubmitting(false);
}
};
const handleDeleteAttribute = async (attributeKey: string) => {
// Confirm deletion for important attributes
if (attributeKey === "email" || attributeKey === "language") {
const confirmed = globalThis.confirm(
t("environments.contacts.confirm_delete_attribute", {
attributeName: attributeKey,
})
);
if (!confirmed) return;
}
setDeletingKeys((prev) => new Set(prev).add(attributeKey));
try {
const result = await deleteContactAttributeAction({
contactId,
attributeKey,
});
if (result?.data) {
toast.success(t("environments.contacts.attribute_deleted_successfully"));
// Mark as deleted locally and remove from form
setDeletedKeys((prev) => new Set(prev).add(attributeKey));
form.unregister(attributeKey);
router.refresh();
// Keep modal open so user can see the attribute is now available to add
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
}
} catch (error) {
toast.error(t("common.something_went_wrong_please_try_again"));
} finally {
setDeletingKeys((prev) => {
const newSet = new Set(prev);
newSet.delete(attributeKey);
return newSet;
});
}
};
const handleAddAttribute = async () => {
if (!selectedNewAttributeKey || !newAttributeValue) {
toast.error(t("environments.contacts.please_select_attribute_and_value"));
return;
}
// Validate based on data type
const selectedKey = selectedAttributeKey;
if (selectedKey?.dataType === "date") {
const date = new Date(newAttributeValue);
if (Number.isNaN(date.getTime())) {
toast.error(t("environments.contacts.invalid_date_value"));
return;
}
} else if (selectedKey?.key === "email") {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(newAttributeValue)) {
toast.error(t("environments.contacts.invalid_email_value"));
return;
}
}
try {
const result = await updateContactAttributesAction({
contactId,
attributes: {
[selectedNewAttributeKey]: newAttributeValue,
},
});
if (result?.data) {
toast.success(t("environments.contacts.attribute_added_successfully"));
// Add to form dynamically
form.setValue(selectedNewAttributeKey, newAttributeValue);
// Remove from deleted keys if it was previously deleted
setDeletedKeys((prev) => {
const newSet = new Set(prev);
newSet.delete(selectedNewAttributeKey);
return newSet;
});
setSelectedNewAttributeKey("");
setNewAttributeValue("");
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
}
} catch (error) {
toast.error(t("common.something_went_wrong_please_try_again"));
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t("environments.contacts.edit_attributes")}</DialogTitle>
</DialogHeader>
<DialogBody className="max-h-[60vh] overflow-y-auto pb-4 pr-6">
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<AnimatePresence mode="popLayout">
{editableAttributes.map((attr) => (
<motion.div
key={attr.key}
layout
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}>
<FormField
control={form.control}
name={attr.key}
render={({ field }) => (
<FormItem>
<FormLabel>
<div className="flex items-center gap-2">
<span>{attr.name || attr.key}</span>
<Badge text={attr.dataType} type="gray" size="tiny" />
</div>
</FormLabel>
<FormControl>
<div className="flex items-center gap-2">
{attr.dataType === "date" ? (
<Input
type="date"
{...field}
value={field.value ? field.value.split("T")[0] : ""}
onChange={(e) => {
const dateValue = e.target.value
? new Date(e.target.value).toISOString()
: "";
field.onChange(dateValue);
}}
/>
) : attr.dataType === "number" ? (
<Input type="number" {...field} />
) : (
<Input type="text" {...field} />
)}
<Button
type="button"
variant="outline"
size="icon"
onClick={() => handleDeleteAttribute(attr.key)}
disabled={deletingKeys.has(attr.key)}
loading={deletingKeys.has(attr.key)}
title={t("common.delete")}>
<Trash2Icon className="h-4 w-4" />
</Button>
</div>
</FormControl>
<FormError />
</FormItem>
)}
/>
</motion.div>
))}
</AnimatePresence>
{/* Add New Attribute Section */}
{availableAttributeKeys.length > 0 && (
<>
<hr className="my-6" />
<div className="space-y-3">
<h3 className="text-sm font-medium text-slate-900">
{t("environments.contacts.add_attribute")}
</h3>
<div className="flex items-end gap-2">
<div className="flex-1">
<label className="mb-1 block text-xs text-slate-600">
{t("environments.contacts.select_attribute")}
</label>
<Select
value={selectedNewAttributeKey}
onValueChange={(value) => {
setSelectedNewAttributeKey(value);
setNewAttributeValue("");
}}>
<SelectTrigger>
<SelectValue placeholder={t("environments.contacts.select_attribute")} />
</SelectTrigger>
<SelectContent>
{availableAttributeKeys.map((key) => (
<SelectItem key={key.id} value={key.key}>
<div className="flex items-center gap-2">
<Badge text={key.dataType} type="gray" size="tiny" />
<span>{key.name || key.key}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedNewAttributeKey && (
<div className="flex-1">
<label className="mb-1 block text-xs text-slate-600">{t("common.value")}</label>
{selectedAttributeKey?.dataType === "date" ? (
<Input
type="date"
value={newAttributeValue ? newAttributeValue.split("T")[0] : ""}
onChange={(e) => {
const dateValue = e.target.value
? new Date(e.target.value).toISOString()
: "";
setNewAttributeValue(dateValue);
}}
/>
) : selectedAttributeKey?.dataType === "number" ? (
<Input
type="number"
value={newAttributeValue}
onChange={(e) => setNewAttributeValue(e.target.value)}
placeholder={t("common.enter_value")}
/>
) : (
<Input
type="text"
value={newAttributeValue}
onChange={(e) => setNewAttributeValue(e.target.value)}
placeholder={t("common.enter_value")}
/>
)}
</div>
)}
<Button
type="button"
onClick={handleAddAttribute}
disabled={!selectedNewAttributeKey || !newAttributeValue}>
<PlusIcon className="h-4 w-4" />
{t("common.add")}
</Button>
</div>
</div>
</>
)}
</form>
</FormProvider>
</DialogBody>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setOpen(false)} disabled={isSubmitting}>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={form.handleSubmit(onSubmit)}
disabled={isSubmitting || !form.formState.isDirty}
loading={isSubmitting}>
{t("common.save_changes")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -3,7 +3,10 @@ import { getTranslate } from "@/lingodotdev/server";
import { AttributesSection } from "@/modules/ee/contacts/[contactId]/components/attributes-section";
import { ContactControlBar } from "@/modules/ee/contacts/[contactId]/components/contact-control-bar";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import { getContactAttributes } from "@/modules/ee/contacts/lib/contact-attributes";
import {
getContactAttributes,
getContactAttributesWithMetadata,
} from "@/modules/ee/contacts/lib/contact-attributes";
import { getContact } from "@/modules/ee/contacts/lib/contacts";
import { getPublishedLinkSurveys } from "@/modules/ee/contacts/lib/surveys";
import { getContactIdentifier } from "@/modules/ee/contacts/lib/utils";
@@ -22,14 +25,21 @@ export const SingleContactPage = async (props: {
const { environment, isReadOnly, organization } = await getEnvironmentAuth(params.environmentId);
const [environmentTags, contact, contactAttributes, publishedLinkSurveys, contactAttributeKeys] =
await Promise.all([
getTagsByEnvironmentId(params.environmentId),
getContact(params.contactId),
getContactAttributes(params.contactId),
getPublishedLinkSurveys(params.environmentId),
getContactAttributeKeys(params.environmentId),
]);
const [
environmentTags,
contact,
contactAttributes,
publishedLinkSurveys,
attributesWithMetadata,
allAttributeKeys,
] = await Promise.all([
getTagsByEnvironmentId(params.environmentId),
getContact(params.contactId),
getContactAttributes(params.contactId),
getPublishedLinkSurveys(params.environmentId),
getContactAttributesWithMetadata(params.contactId),
getContactAttributeKeys(params.environmentId),
]);
if (!contact) {
throw new Error(t("environments.contacts.contact_not_found"));
@@ -45,8 +55,9 @@ export const SingleContactPage = async (props: {
isReadOnly={isReadOnly}
isQuotasAllowed={isQuotasAllowed}
publishedLinkSurveys={publishedLinkSurveys}
currentAttributes={contactAttributes}
attributeKeys={contactAttributeKeys}
currentAttributes={attributesWithMetadata}
allAttributeKeys={allAttributeKeys}
attributeKeys={allAttributeKeys}
/>
);
};

View File

@@ -162,6 +162,8 @@ export const updateUser = async (
// Single comprehensive query - gets contact + user state data
let contactData = await getContactWithFullData(environmentId, userId);
console.log("contactData", contactData);
// Create contact if doesn't exist
if (!contactData) {
contactData = await createContact(environmentId, userId);

View File

@@ -65,6 +65,7 @@ export const updateContactAttributeKey = async (
description: data.description,
name: data.name,
key: data.key,
...(data.dataType && { dataType: data.dataType }),
},
});

View File

@@ -1,9 +1,11 @@
import { z } from "zod";
import { ZContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
export const ZContactAttributeKeyCreateInput = z.object({
key: z.string(),
description: z.string().optional(),
type: z.enum(["custom"]),
dataType: ZContactAttributeDataType.optional(),
environmentId: z.string(),
name: z.string().optional(),
});
@@ -13,6 +15,7 @@ export const ZContactAttributeKeyUpdateInput = z.object({
description: z.string().optional(),
name: z.string().optional(),
key: z.string().optional(),
dataType: ZContactAttributeDataType.optional(),
});
export type TContactAttributeKeyUpdateInput = z.infer<typeof ZContactAttributeKeyUpdateInput>;

View File

@@ -47,6 +47,7 @@ export const createContactAttributeKey = async (
name: data.name ?? data.key,
type: data.type,
description: data.description ?? "",
...(data.dataType && { dataType: data.dataType }),
environment: {
connect: {
id: environmentId,

View File

@@ -2,8 +2,11 @@ import { createId } from "@paralleldrive/cuid2";
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { prepareAttributeColumnsForStorage } from "@/modules/ee/contacts/lib/attribute-storage";
import { detectAttributeDataType } from "@/modules/ee/contacts/lib/detect-attribute-type";
import { TContactBulkUploadContact } from "@/modules/ee/contacts/types/contact";
export const upsertBulkContacts = async (
@@ -88,6 +91,72 @@ export const upsertBulkContacts = async (
}),
]);
// Type Detection Phase: Analyze attribute values to detect data types
// For each attribute key, collect all non-empty values and detect type from first value
const attributeValuesByKey = new Map<string, string[]>();
contacts.forEach((contact) => {
contact.attributes.forEach((attr) => {
if (!attributeValuesByKey.has(attr.attributeKey.key)) {
attributeValuesByKey.set(attr.attributeKey.key, []);
}
if (attr.value.trim() !== "") {
attributeValuesByKey.get(attr.attributeKey.key)!.push(attr.value);
}
});
});
// Build a map of attribute keys to their detected/existing data types
const attributeTypeMap = new Map<string, TContactAttributeDataType>();
for (const [key, values] of attributeValuesByKey) {
const existingKey = existingAttributeKeys.find((ak) => ak.key === key);
if (existingKey) {
// Use existing dataType for existing keys
attributeTypeMap.set(key, existingKey.dataType);
} else {
// Detect type from first non-empty value for new keys
const firstValue = values.find((v) => v !== "");
if (firstValue) {
const detectedType = detectAttributeDataType(firstValue);
attributeTypeMap.set(key, detectedType);
} else {
attributeTypeMap.set(key, "string"); // default for empty
}
}
}
// Validate that all values can be converted to their detected/expected type
// If validation fails for any value, we fallback to treating that attribute as string type
const typeValidationErrors: string[] = [];
for (const [key, dataType] of attributeTypeMap) {
const values = attributeValuesByKey.get(key) || [];
// Skip validation for string type (always valid)
if (dataType === "string") continue;
for (const value of values) {
try {
// Test if we can convert the value to the expected type
prepareAttributeColumnsForStorage(value, dataType);
} catch {
// If any value fails conversion, downgrade this attribute to string type for compatibility
attributeTypeMap.set(key, "string");
typeValidationErrors.push(
`Attribute "${key}" has mixed or invalid values for type "${dataType}", treating as string type`
);
break; // No need to check remaining values for this key
}
}
}
// Log validation warnings if any
if (typeValidationErrors.length > 0) {
logger.warn({ errors: typeValidationErrors }, "Type validation warnings during bulk upload");
}
// Build a map from email to contact id (if the email attribute exists)
const contactMap = new Map<
string,
@@ -239,28 +308,35 @@ export const upsertBulkContacts = async (
for (const contact of filteredContacts) {
for (const attr of contact.attributes) {
if (!attributeKeyMap[attr.attributeKey.key]) {
missingKeysMap.set(attr.attributeKey.key, attr.attributeKey);
} else {
if (attributeKeyMap[attr.attributeKey.key]) {
// Check if the name has changed for existing attribute keys
const existingKey = existingAttributeKeys.find((ak) => ak.key === attr.attributeKey.key);
if (existingKey && existingKey.name !== attr.attributeKey.name) {
attributeKeyNameUpdates.set(attr.attributeKey.key, attr.attributeKey);
}
} else {
missingKeysMap.set(attr.attributeKey.key, attr.attributeKey);
}
}
}
// Handle both missing keys and name updates in a single batch operation
const keysToUpsert = new Map<string, { key: string; name: string }>();
const keysToUpsert = new Map<
string,
{ key: string; name: string; dataType: TContactAttributeDataType }
>();
// Collect all keys that need to be created or updated
for (const [key, value] of missingKeysMap) {
keysToUpsert.set(key, value);
const dataType = attributeTypeMap.get(key) ?? "string";
keysToUpsert.set(key, { ...value, dataType });
}
for (const [key, value] of attributeKeyNameUpdates) {
keysToUpsert.set(key, value);
// For name updates, preserve existing dataType
const existingKey = existingAttributeKeys.find((ak) => ak.key === key);
const dataType = existingKey?.dataType ?? "string";
keysToUpsert.set(key, { ...value, dataType });
}
if (keysToUpsert.size > 0) {
@@ -272,12 +348,13 @@ export const upsertBulkContacts = async (
// Use raw query to perform upsert
const upsertedKeys = await tx.$queryRaw<{ id: string; key: string }[]>`
INSERT INTO "ContactAttributeKey" ("id", "key", "name", "environmentId", "created_at", "updated_at")
INSERT INTO "ContactAttributeKey" ("id", "key", "name", "environmentId", "dataType", "created_at", "updated_at")
SELECT
unnest(${Prisma.sql`ARRAY[${batch.map(() => createId())}]`}),
unnest(${Prisma.sql`ARRAY[${batch.map((k) => k.key)}]`}),
unnest(${Prisma.sql`ARRAY[${batch.map((k) => k.name)}]`}),
${environmentId},
unnest(${Prisma.sql`ARRAY[${batch.map((k) => k.dataType)}]`}::text[]::"ContactAttributeDataType"[]),
NOW(),
NOW()
ON CONFLICT ("key", "environmentId")
@@ -308,25 +385,39 @@ export const upsertBulkContacts = async (
// Prepare attributes for both new and existing contacts
const attributesUpsertForCreatedUsers = contactsToCreate.flatMap((contact, idx) =>
contact.attributes.map((attr) => ({
id: createId(),
contactId: newContacts[idx].id,
attributeKeyId: attributeKeyMap[attr.attributeKey.key],
value: attr.value,
createdAt: new Date(),
updatedAt: new Date(),
}))
contact.attributes.map((attr) => {
const dataType = attributeTypeMap.get(attr.attributeKey.key) ?? "string";
const columns = prepareAttributeColumnsForStorage(attr.value, dataType);
return {
id: createId(),
contactId: newContacts[idx].id,
attributeKeyId: attributeKeyMap[attr.attributeKey.key],
value: columns.value,
valueNumber: columns.valueNumber,
valueDate: columns.valueDate,
createdAt: new Date(),
updatedAt: new Date(),
};
})
);
const attributesUpsertForExistingUsers = contactsToUpdate.flatMap((contact) =>
contact.attributes.map((attr) => ({
id: attr.id,
contactId: contact.contactId,
attributeKeyId: attributeKeyMap[attr.attributeKey.key],
value: attr.value,
createdAt: attr.createdAt,
updatedAt: new Date(),
}))
contact.attributes.map((attr) => {
const dataType = attributeTypeMap.get(attr.attributeKey.key) ?? "string";
const columns = prepareAttributeColumnsForStorage(attr.value, dataType);
return {
id: attr.id,
contactId: contact.contactId,
attributeKeyId: attributeKeyMap[attr.attributeKey.key],
value: columns.value,
valueNumber: columns.valueNumber,
valueDate: columns.valueDate,
createdAt: attr.createdAt,
updatedAt: new Date(),
};
})
);
const attributesToUpsert = [...attributesUpsertForCreatedUsers, ...attributesUpsertForExistingUsers];
@@ -341,7 +432,7 @@ export const upsertBulkContacts = async (
// Use a raw query to perform a bulk insert with an ON CONFLICT clause
await tx.$executeRaw`
INSERT INTO "ContactAttribute" (
"id", "created_at", "updated_at", "contactId", "value", "attributeKeyId"
"id", "created_at", "updated_at", "contactId", "value", "valueNumber", "valueDate", "attributeKeyId"
)
SELECT
unnest(${Prisma.sql`ARRAY[${batch.map((a) => a.id)}]`}),
@@ -349,9 +440,13 @@ export const upsertBulkContacts = async (
unnest(${Prisma.sql`ARRAY[${batch.map((a) => a.updatedAt)}]`}),
unnest(${Prisma.sql`ARRAY[${batch.map((a) => a.contactId)}]`}),
unnest(${Prisma.sql`ARRAY[${batch.map((a) => a.value)}]`}),
unnest(${Prisma.sql`ARRAY[${batch.map((a) => a.valueNumber)}]`}),
unnest(${Prisma.sql`ARRAY[${batch.map((a) => a.valueDate)}]`}),
unnest(${Prisma.sql`ARRAY[${batch.map((a) => a.attributeKeyId)}]`})
ON CONFLICT ("contactId", "attributeKeyId") DO UPDATE SET
"value" = EXCLUDED."value",
"valueNumber" = EXCLUDED."valueNumber",
"valueDate" = EXCLUDED."valueDate",
"updated_at" = EXCLUDED."updated_at"
`;
}

View File

@@ -2,6 +2,7 @@
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
@@ -24,6 +25,7 @@ const ZCreateContactAttributeKeyAction = z.object({
}),
name: z.string().optional(),
description: z.string().optional(),
dataType: ZContactAttributeDataType.optional(),
});
type TCreateContactAttributeKeyActionInput = z.infer<typeof ZCreateContactAttributeKeyAction>;
@@ -66,6 +68,7 @@ export const createContactAttributeKeyAction = authenticatedActionClient
key: parsedInput.key,
name: parsedInput.name,
description: parsedInput.description,
dataType: parsedInput.dataType,
});
ctx.auditLoggingCtx.newObject = contactAttributeKey;

View File

@@ -0,0 +1,214 @@
"use client";
import { VisibilityState, flexRender, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { SearchBar } from "@/modules/ui/components/search-bar";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
import { deleteAttributeKeyAction } from "../actions";
import { generateAttributeKeysTableColumns } from "./attribute-keys-table-columns";
interface AttributeKeysManagerProps {
environmentId: string;
attributeKeys: TContactAttributeKey[];
isReadOnly: boolean;
}
export function AttributeKeysManager({
environmentId,
attributeKeys,
isReadOnly,
}: AttributeKeysManagerProps) {
const { t } = useTranslation();
const router = useRouter();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [isDeletingKeys, setIsDeletingKeys] = useState(false);
const [rowSelection, setRowSelection] = useState({});
const [searchValue, setSearchValue] = useState("");
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
// Filter to only show custom attribute keys
const customAttributeKeys = useMemo(() => {
return attributeKeys.filter((key) => key.type === "custom");
}, [attributeKeys]);
// Filter by search
const filteredAttributeKeys = useMemo(() => {
if (!searchValue) return customAttributeKeys;
return customAttributeKeys.filter((key) => {
const searchLower = searchValue.toLowerCase();
return (
key.key.toLowerCase().includes(searchLower) ||
key.name?.toLowerCase().includes(searchLower) ||
key.description?.toLowerCase().includes(searchLower)
);
});
}, [customAttributeKeys, searchValue]);
const columns = useMemo(() => {
return generateAttributeKeysTableColumns(isReadOnly);
}, [isReadOnly]);
const table = useReactTable({
data: filteredAttributeKeys,
columns,
state: {
rowSelection,
columnVisibility,
},
enableRowSelection: !isReadOnly,
onRowSelectionChange: setRowSelection,
onColumnVisibilityChange: setColumnVisibility,
getCoreRowModel: getCoreRowModel(),
});
const selectedRows = table.getFilteredSelectedRowModel().rows;
const selectedAttributeKeyIds = selectedRows.map((row) => row.original.id);
const handleBulkDelete = async () => {
if (selectedAttributeKeyIds.length === 0) return;
setIsDeletingKeys(true);
try {
const deletePromises = selectedAttributeKeyIds.map((id) =>
deleteAttributeKeyAction({ environmentId, attributeKeyId: id })
);
await Promise.all(deletePromises);
toast.success(
t("environments.contacts.attribute_keys_deleted_successfully", {
count: selectedAttributeKeyIds.length,
})
);
setRowSelection({});
router.refresh();
} catch (error) {
toast.error(t("common.something_went_wrong_please_try_again"));
} finally {
setIsDeletingKeys(false);
setDeleteDialogOpen(false);
}
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between gap-4">
<SearchBar
value={searchValue}
onChange={setSearchValue}
placeholder={t("environments.contacts.search_attribute_keys")}
/>
</div>
{/* Toolbar with bulk actions */}
{!isReadOnly && selectedRows.length > 0 && (
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-white px-4 py-3">
<p className="text-sm text-slate-700">
{t("environments.contacts.selected_attribute_keys", { count: selectedRows.length })}
</p>
<Button variant="destructive" size="sm" onClick={() => setDeleteDialogOpen(true)}>
{t("common.delete_selected", { count: selectedRows.length })}
</Button>
</div>
)}
{/* Data Table */}
<div className="rounded-lg border border-slate-200">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="rounded-t-lg">
{headerGroup.headers.map((header, index) => {
const isFirstHeader = index === 0;
const isLastHeader = index === headerGroup.headers.length - 1;
// Skip rendering checkbox in the header for selection column
if (header.id === "select") {
return (
<TableHead
key={header.id}
className="h-10 w-12 rounded-tl-lg border-b border-slate-200 bg-white px-4 font-semibold"
/>
);
}
return (
<TableHead
key={header.id}
className={`h-10 border-b border-slate-200 bg-white px-4 font-semibold ${
isFirstHeader ? "rounded-tl-lg" : isLastHeader ? "rounded-tr-lg" : ""
}`}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row, index) => {
const isLastRow = index === table.getRowModel().rows.length - 1;
return (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className={`hover:bg-white ${isLastRow ? "rounded-b-lg" : ""}`}>
{row.getVisibleCells().map((cell, cellIndex) => {
const isFirstCell = cellIndex === 0;
const isLastCell = cellIndex === row.getVisibleCells().length - 1;
return (
<TableCell
key={cell.id}
className={`py-2 ${
isLastRow
? isFirstCell
? "rounded-bl-lg"
: isLastCell
? "rounded-br-lg"
: ""
: ""
}`}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
);
})}
</TableRow>
);
})
) : (
<TableRow className="hover:bg-white">
<TableCell colSpan={columns.length} className="h-24 text-center">
<p className="text-slate-400">{t("environments.contacts.no_custom_attributes_yet")}</p>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Delete Confirmation Dialog */}
<DeleteDialog
open={deleteDialogOpen}
setOpen={setDeleteDialogOpen}
deleteWhat={
selectedRows.length === 1
? selectedRows[0].original.name || selectedRows[0].original.key
: t("environments.contacts.selected_attribute_keys", { count: selectedRows.length })
}
onDelete={handleBulkDelete}
isDeleting={isDeletingKeys}
text={t("environments.contacts.delete_attribute_keys_warning_detailed", {
count: selectedRows.length,
})}
/>
</div>
);
}

View File

@@ -0,0 +1,100 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { format, formatDistanceToNow } from "date-fns";
import { Calendar1Icon, HashIcon, TagIcon } from "lucide-react";
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { Badge } from "@/modules/ui/components/badge";
import { getSelectionColumn } from "@/modules/ui/components/data-table";
import { IdBadge } from "@/modules/ui/components/id-badge";
const getIconForDataType = (dataType: TContactAttributeDataType) => {
switch (dataType) {
case "date":
return <Calendar1Icon className="h-4 w-4 text-slate-600" />;
case "number":
return <HashIcon className="h-4 w-4 text-slate-600" />;
case "string":
default:
return <TagIcon className="h-4 w-4 text-slate-600" />;
}
};
export const generateAttributeKeysTableColumns = (isReadOnly: boolean): ColumnDef<TContactAttributeKey>[] => {
const nameColumn: ColumnDef<TContactAttributeKey> = {
id: "name",
accessorKey: "name",
header: "Name",
cell: ({ row }) => {
const name = row.original.name || row.original.key;
return <span className="font-medium text-slate-900">{name}</span>;
},
};
const keyColumn: ColumnDef<TContactAttributeKey> = {
id: "key",
accessorKey: "key",
header: "Key",
cell: ({ row }) => {
return <IdBadge id={row.original.key} />;
},
};
const dataTypeColumn: ColumnDef<TContactAttributeKey> = {
id: "dataType",
accessorKey: "dataType",
header: "Data Type",
cell: ({ row }) => {
return (
<div className="flex items-center gap-2">
{getIconForDataType(row.original.dataType)}
<Badge text={row.original.dataType} type="gray" size="tiny" />
</div>
);
},
};
const descriptionColumn: ColumnDef<TContactAttributeKey> = {
id: "description",
accessorKey: "description",
header: "Description",
cell: ({ row }) => {
return <span className="text-sm text-slate-600">{row.original.description || "—"}</span>;
},
};
const createdAtColumn: ColumnDef<TContactAttributeKey> = {
id: "createdAt",
accessorKey: "createdAt",
header: "Created",
cell: ({ row }) => {
return (
<span className="text-sm text-slate-900">{format(row.original.createdAt, "do 'of' MMMM, yyyy")}</span>
);
},
};
const updatedAtColumn: ColumnDef<TContactAttributeKey> = {
id: "updatedAt",
accessorKey: "updatedAt",
header: "Updated",
cell: ({ row }) => {
return (
<span className="text-sm text-slate-900">
{formatDistanceToNow(row.original.updatedAt, { addSuffix: true }).replace("about ", "")}
</span>
);
},
};
const baseColumns = [
nameColumn,
keyColumn,
dataTypeColumn,
descriptionColumn,
createdAtColumn,
updatedAtColumn,
];
return isReadOnly ? baseColumns : [getSelectionColumn(), ...baseColumns];
};

View File

@@ -1,10 +1,11 @@
"use client";
import { PlusIcon } from "lucide-react";
import { Calendar1Icon, HashIcon, PlusIcon, TagIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { isSafeIdentifier } from "@/lib/utils/safe-identifier";
import { Button } from "@/modules/ui/components/button";
@@ -18,6 +19,13 @@ import {
DialogTitle,
} from "@/modules/ui/components/dialog";
import { Input } from "@/modules/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/modules/ui/components/select";
import { createContactAttributeKeyAction } from "../actions";
interface CreateAttributeModalProps {
@@ -33,6 +41,7 @@ export function CreateAttributeModal({ environmentId }: Readonly<CreateAttribute
key: "",
name: "",
description: "",
dataType: "string" as TContactAttributeDataType,
});
const [keyError, setKeyError] = useState<string>("");
@@ -41,6 +50,7 @@ export function CreateAttributeModal({ environmentId }: Readonly<CreateAttribute
key: "",
name: "",
description: "",
dataType: "string",
});
setKeyError("");
setOpen(false);
@@ -92,6 +102,7 @@ export function CreateAttributeModal({ environmentId }: Readonly<CreateAttribute
key: formData.key,
name: formData.name || formData.key,
description: formData.description || undefined,
dataType: formData.dataType,
});
if (!createContactAttributeKeyResponse?.data) {
@@ -166,6 +177,42 @@ export function CreateAttributeModal({ environmentId }: Readonly<CreateAttribute
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-slate-900">
{t("environments.contacts.data_type")}
</label>
<Select
value={formData.dataType}
onValueChange={(value: TContactAttributeDataType) =>
setFormData((prev) => ({ ...prev, dataType: value }))
}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="string">
<div className="flex items-center gap-2">
<TagIcon className="h-4 w-4" />
<span>{t("common.string")}</span>
</div>
</SelectItem>
<SelectItem value="number">
<div className="flex items-center gap-2">
<HashIcon className="h-4 w-4" />
<span>{t("common.number")}</span>
</div>
</SelectItem>
<SelectItem value="date">
<div className="flex items-center gap-2">
<Calendar1Icon className="h-4 w-4" />
<span>{t("common.date")}</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-slate-500">{t("environments.contacts.data_type_description")}</p>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-slate-900">
{t("environments.contacts.attribute_description")} ({t("common.optional")})

View File

@@ -1,11 +1,13 @@
"use client";
import { Calendar1Icon, HashIcon, TagIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
import {
Dialog,
@@ -19,6 +21,18 @@ import {
import { Input } from "@/modules/ui/components/input";
import { updateContactAttributeKeyAction } from "../actions";
const getDataTypeIcon = (dataType: string) => {
switch (dataType) {
case "date":
return <Calendar1Icon className="h-4 w-4" />;
case "number":
return <HashIcon className="h-4 w-4" />;
case "string":
default:
return <TagIcon className="h-4 w-4" />;
}
};
interface EditAttributeModalProps {
attribute: TContactAttributeKey;
open: boolean;
@@ -86,6 +100,19 @@ export function EditAttributeModal({ attribute, open, setOpen }: Readonly<EditAt
</p>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-slate-900">
{t("environments.contacts.data_type")}
</label>
<div className="flex h-10 items-center gap-2 rounded-md border border-slate-200 bg-slate-50 px-3">
{getDataTypeIcon(attribute.dataType)}
<Badge text={t(`common.${attribute.dataType}`)} type="gray" size="tiny" />
</div>
<p className="text-xs text-slate-500">
{t("environments.contacts.data_type_cannot_be_changed")}
</p>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-slate-900">
{t("environments.contacts.attribute_label")}

View File

@@ -131,6 +131,7 @@ export const ContactDataView = ({
key: attr.key,
name: attr.name,
value: contact.attributes[attr.key] ?? "",
dataType: attr.dataType,
})),
}));
}, [contacts, environmentAttributes]);

View File

@@ -1,6 +1,7 @@
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { formatAttributeValue } from "@/modules/ee/contacts/lib/format-attribute-value";
import { getSelectionColumn } from "@/modules/ui/components/data-table";
import { HighlightedText } from "@/modules/ui/components/highlighted-text";
import { IdBadge } from "@/modules/ui/components/id-badge";
@@ -27,7 +28,7 @@ export const generateContactTableColumns = (
header: "User ID",
cell: ({ row }) => {
const userId = row.original.userId;
return <IdBadge id={userId} showCopyIconOnHover={true} />;
return <IdBadge id={userId} />;
},
};
@@ -71,7 +72,9 @@ export const generateContactTableColumns = (
header: attr.name ?? attr.key,
cell: ({ row }) => {
const attribute = row.original.attributes.find((a) => a.key === attr.key);
return <HighlightedText value={attribute?.value} searchValue={searchValue} />;
if (!attribute) return null;
const formattedValue = formatAttributeValue(attribute.value, attribute.dataType);
return <HighlightedText value={formattedValue} searchValue={searchValue} />;
},
};
})

View File

@@ -294,9 +294,9 @@ export const ContactsTable = ({
</TableRow>
))}
{table.getRowModel().rows.length === 0 && (
<TableRow>
<TableRow className="hover:bg-white">
<TableCell colSpan={columns.length} className="h-24 text-center">
{t("common.no_results")}
<p className="text-slate-400">{t("common.no_results")}</p>
</TableCell>
</TableRow>
)}

View File

@@ -7,8 +7,7 @@ import { useEffect, useMemo, useRef } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TContactAttributes } from "@formbricks/types/contact-attribute";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Button } from "@/modules/ui/components/button";
import {
@@ -33,11 +32,18 @@ import { InputCombobox, TComboboxOption } from "@/modules/ui/components/input-co
import { updateContactAttributesAction } from "../actions";
import { TEditContactAttributesForm, ZEditContactAttributesForm } from "../types/contact";
interface AttributeWithMetadata {
key: string;
name: string | null;
value: string;
dataType: TContactAttributeDataType;
}
interface EditContactAttributesModalProps {
open: boolean;
setOpen: (open: boolean) => void;
contactId: string;
currentAttributes: TContactAttributes;
currentAttributes: AttributeWithMetadata[];
attributeKeys: TContactAttributeKey[];
}
@@ -53,9 +59,9 @@ export const EditContactAttributesModal = ({
// Convert current attributes to form format
const defaultValues: TEditContactAttributesForm = useMemo(
() => ({
attributes: Object.entries(currentAttributes).map(([key, value]) => ({
key,
value: value ?? "",
attributes: currentAttributes.map((attr) => ({
key: attr.key,
value: attr.value ?? "",
})),
}),
[currentAttributes]

View File

@@ -0,0 +1,151 @@
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
import { detectAttributeDataType } from "./detect-attribute-type";
/**
* Storage columns for a contact attribute value
*/
export type TAttributeStorageColumns = {
value: string;
valueNumber: number | null;
valueDate: Date | null;
};
/**
* Prepares an attribute value for storage by routing to the appropriate column(s).
* Used when creating a new attribute - detects type and prepares all columns.
*
* @param value - The raw value to store (string, number, or Date)
* @returns Object with dataType and column values for storage
*/
export const prepareNewAttributeForStorage = (
value: string | number | Date
): {
dataType: TContactAttributeDataType;
columns: TAttributeStorageColumns;
} => {
const dataType = detectAttributeDataType(value);
const columns = prepareAttributeColumnsForStorage(value, dataType);
return { dataType, columns };
};
/**
* Prepares attribute column values based on the data type.
* Used when updating an existing attribute with a known data type.
*
* @param value - The raw value to store (string, number, or Date)
* @param dataType - The data type of the attribute key
* @returns Object with column values for storage
*/
export const prepareAttributeColumnsForStorage = (
value: string | number | Date,
dataType: TContactAttributeDataType
): TAttributeStorageColumns => {
switch (dataType) {
case "string": {
// String type - only use value column
let stringValue: string;
if (value instanceof Date) {
stringValue = value.toISOString();
} else if (typeof value === "number") {
stringValue = String(value);
} else {
stringValue = value;
}
return {
value: stringValue,
valueNumber: null,
valueDate: null,
};
}
case "number": {
// Number type - use both value (for backwards compat) and valueNumber columns
let numericValue: number;
if (typeof value === "number") {
numericValue = value;
} else if (typeof value === "string") {
numericValue = Number(value.trim());
} else {
// Date - shouldn't happen if validation passed, but handle gracefully
numericValue = value.getTime();
}
return {
value: String(numericValue),
valueNumber: numericValue,
valueDate: null,
};
}
case "date": {
// Date type - use both value (for backwards compat) and valueDate columns
let dateValue: Date;
if (value instanceof Date) {
dateValue = value;
} else if (typeof value === "string") {
dateValue = new Date(value);
} else {
// Number - treat as timestamp
dateValue = new Date(value);
}
return {
value: dateValue.toISOString(),
valueNumber: null,
valueDate: dateValue,
};
}
default: {
// Unknown type - treat as string
return {
value: String(value),
valueNumber: null,
valueDate: null,
};
}
}
};
/**
* Reads an attribute value from the appropriate column based on data type.
*
* @param attribute - The attribute with all column values
* @param dataType - The data type of the attribute key
* @returns The value from the appropriate column
*/
export const readAttributeValue = (
attribute: {
value: string;
valueNumber: number | null;
valueDate: Date | null;
},
dataType: TContactAttributeDataType
): string => {
// For now, always return from value column for backwards compatibility
// The typed columns are primarily for query performance
switch (dataType) {
case "number":
// Return from valueNumber if available, otherwise fallback to value
if (attribute.valueNumber === null) {
return attribute.value;
}
return String(attribute.valueNumber);
case "date":
// Return from valueDate if available, otherwise fallback to value
if (attribute.valueDate === null) {
return attribute.value;
}
return attribute.valueDate.toISOString();
case "string":
default:
return attribute.value;
}
};

View File

@@ -4,12 +4,14 @@ import { TContactAttributes, ZContactAttributes } from "@formbricks/types/contac
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@/lib/constants";
import { validateInputs } from "@/lib/utils/validate";
import { prepareNewAttributeForStorage } from "@/modules/ee/contacts/lib/attribute-storage";
import { getContactAttributeKeys } from "@/modules/ee/contacts/lib/contact-attribute-keys";
import {
getContactAttributes,
hasEmailAttribute,
hasUserIdAttribute,
} from "@/modules/ee/contacts/lib/contact-attributes";
import { validateAndParseAttributeValue } from "@/modules/ee/contacts/lib/validate-attribute-type";
// Default/system attributes that should not be deleted even if missing from payload
const DEFAULT_ATTRIBUTES = new Set(["email", "userId", "firstName", "lastName"]);
@@ -168,22 +170,42 @@ export const updateAttributes = async (
// Create lookup map for attribute keys
const contactAttributeKeyMap = new Map(contactAttributeKeys.map((ack) => [ack.key, ack]));
// Separate existing and new attributes in a single pass
const { existingAttributes, newAttributes } = Object.entries(contactAttributes).reduce(
(acc, [key, value]) => {
const attributeKey = contactAttributeKeyMap.get(key);
if (attributeKey) {
acc.existingAttributes.push({ key, value, attributeKeyId: attributeKey.id });
// Separate existing and new attributes, validating types for existing attributes
const existingAttributes: {
key: string;
attributeKeyId: string;
columns: { value: string; valueNumber: number | null; valueDate: Date | null };
}[] = [];
const newAttributes: { key: string; value: string }[] = [];
const typeValidationErrors: string[] = [];
for (const [key, value] of Object.entries(contactAttributes)) {
const attributeKey = contactAttributeKeyMap.get(key);
if (attributeKey) {
// Existing attribute - validate type and prepare columns
const validationResult = validateAndParseAttributeValue(value, attributeKey.dataType, key);
if (validationResult.valid) {
existingAttributes.push({
key,
attributeKeyId: attributeKey.id,
columns: validationResult.parsedValue,
});
} else {
acc.newAttributes.push({ key, value });
// Type mismatch - add to errors
typeValidationErrors.push(validationResult.error);
}
return acc;
},
{ existingAttributes: [], newAttributes: [] } as {
existingAttributes: { key: string; value: string; attributeKeyId: string }[];
newAttributes: { key: string; value: string }[];
} else {
// New attribute - will detect type on creation
newAttributes.push({ key, value });
}
);
}
// Add type validation errors to messages
if (typeValidationErrors.length > 0) {
messages.push(...typeValidationErrors);
}
if (emailExists) {
messages.push("The email already exists for this environment and was not updated.");
@@ -193,10 +215,10 @@ export const updateAttributes = async (
messages.push("The userId already exists for this environment and was not updated.");
}
// Update all existing attributes
// Update all existing attributes with typed column values
if (existingAttributes.length > 0) {
await prisma.$transaction(
existingAttributes.map(({ attributeKeyId, value }) =>
existingAttributes.map(({ attributeKeyId, columns }) =>
prisma.contactAttribute.upsert({
where: {
contactId_attributeKeyId: {
@@ -204,11 +226,17 @@ export const updateAttributes = async (
attributeKeyId,
},
},
update: { value },
update: {
value: columns.value,
valueNumber: columns.valueNumber,
valueDate: columns.valueDate,
},
create: {
contactId,
attributeKeyId,
value,
value: columns.value,
valueNumber: columns.valueNumber,
valueDate: columns.valueDate,
},
})
)
@@ -227,18 +255,25 @@ export const updateAttributes = async (
} else {
// Create new attributes since we're under the limit
await prisma.$transaction(
newAttributes.map(({ key, value }) =>
prisma.contactAttributeKey.create({
newAttributes.map(({ key, value }) => {
const { dataType, columns } = prepareNewAttributeForStorage(value);
return prisma.contactAttributeKey.create({
data: {
key,
type: "custom",
dataType,
environment: { connect: { id: environmentId } },
attributes: {
create: { contactId, value },
create: {
contactId,
value: columns.value,
valueNumber: columns.valueNumber,
valueDate: columns.valueDate,
},
},
},
})
)
});
})
);
}
}

View File

@@ -1,7 +1,7 @@
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { TContactAttributeDataType, TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { InvalidInputError, OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
export const getContactAttributeKeys = reactCache(
@@ -28,6 +28,7 @@ export const createContactAttributeKey = async (data: {
key: string;
name?: string;
description?: string;
dataType?: TContactAttributeDataType;
}): Promise<TContactAttributeKey> => {
try {
const contactAttributeKey = await prisma.contactAttributeKey.create({
@@ -37,6 +38,7 @@ export const createContactAttributeKey = async (data: {
description: data.description ?? null,
environmentId: data.environmentId,
type: "custom",
...(data.dataType && { dataType: data.dataType }),
},
});

View File

@@ -9,10 +9,13 @@ import { validateInputs } from "@/lib/utils/validate";
const selectContactAttribute = {
value: true,
valueNumber: true,
valueDate: true,
attributeKey: {
select: {
key: true,
name: true,
dataType: true,
},
},
} satisfies Prisma.ContactAttributeSelect;
@@ -41,6 +44,34 @@ export const getContactAttributes = reactCache(async (contactId: string) => {
}
});
export const getContactAttributesWithMetadata = reactCache(async (contactId: string) => {
validateInputs([contactId, ZId]);
try {
const prismaAttributes = await prisma.contactAttribute.findMany({
where: {
contactId,
},
select: selectContactAttribute,
});
return prismaAttributes.map((attr) => ({
key: attr.attributeKey.key,
name: attr.attributeKey.name,
value: attr.value,
valueNumber: attr.valueNumber,
valueDate: attr.valueDate,
dataType: attr.attributeKey.dataType,
}));
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError(error.message);
}
throw error;
}
});
export const hasEmailAttribute = reactCache(
async (email: string, environmentId: string, contactId: string): Promise<boolean> => {
validateInputs([email, ZUserEmail], [environmentId, ZId], [contactId, ZId]);

View File

@@ -4,10 +4,13 @@ import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ZId, ZOptionalNumber, ZOptionalString } from "@formbricks/types/common";
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import { ITEMS_PER_PAGE } from "@/lib/constants";
import { validateInputs } from "@/lib/utils/validate";
import { prepareAttributeColumnsForStorage } from "@/modules/ee/contacts/lib/attribute-storage";
import { getContactSurveyLink } from "@/modules/ee/contacts/lib/contact-survey-link";
import { detectAttributeDataType } from "@/modules/ee/contacts/lib/detect-attribute-type";
import { segmentFilterToPrismaQuery } from "@/modules/ee/contacts/segments/lib/filter/prisma-query";
import { getSegment } from "@/modules/ee/contacts/segments/lib/segments";
import {
@@ -210,14 +213,25 @@ const contactAttributesInclude = {
},
} satisfies Prisma.ContactInclude;
// Helper to create attribute objects for Prisma create operations
const createAttributeConnections = (record: Record<string, string>, environmentId: string) =>
Object.entries(record).map(([key, value]) => ({
attributeKey: {
connect: { key_environmentId: { key, environmentId } },
},
value,
}));
// Helper to create attribute objects for Prisma create operations with typed columns
const createAttributeConnections = (
record: Record<string, string>,
environmentId: string,
attributeTypeMap: Map<string, TContactAttributeDataType>
) =>
Object.entries(record).map(([key, value]) => {
const dataType = attributeTypeMap.get(key) ?? "string";
const columns = prepareAttributeColumnsForStorage(value, dataType);
return {
attributeKey: {
connect: { key_environmentId: { key, environmentId } },
},
value: columns.value,
valueNumber: columns.valueNumber,
valueDate: columns.valueDate,
};
});
// Helper to handle userId conflicts when updating/overwriting contacts
const resolveUserIdConflict = (
@@ -327,7 +341,7 @@ export const createContactsFromCSV = async (
// Fetch existing attribute keys and cache them
const existingAttributeKeys = await prisma.contactAttributeKey.findMany({
where: { environmentId },
select: { key: true, id: true },
select: { key: true, id: true, dataType: true },
});
const attributeKeyMap = new Map<string, string>();
@@ -345,6 +359,71 @@ export const createContactsFromCSV = async (
Object.keys(record).forEach((key) => csvKeys.add(key));
});
// Type Detection Phase: Detect data types for new attribute keys
const attributeValuesByKey = new Map<string, string[]>();
csvData.forEach((record) => {
Object.entries(record).forEach(([key, value]) => {
if (!attributeValuesByKey.has(key)) {
attributeValuesByKey.set(key, []);
}
if (value && value.trim() !== "") {
attributeValuesByKey.get(key)!.push(value);
}
});
});
// Build a map of attribute keys to their detected/existing data types
const attributeTypeMap = new Map<string, TContactAttributeDataType>();
for (const [key, values] of attributeValuesByKey) {
// Use case-insensitive lookup for existing keys
const actualKey = lowercaseToActualKeyMap.get(key.toLowerCase());
const existingKey = actualKey ? existingAttributeKeys.find((ak) => ak.key === actualKey) : null;
if (existingKey) {
// Use existing dataType for existing keys
attributeTypeMap.set(key, existingKey.dataType);
} else {
// Detect type from first non-empty value for new keys
const firstValue = values.find((v) => v !== "");
if (firstValue) {
const detectedType = detectAttributeDataType(firstValue);
attributeTypeMap.set(key, detectedType);
} else {
attributeTypeMap.set(key, "string"); // default for empty
}
}
}
// Validate that all values can be converted to their detected type
// If validation fails, fallback to string type for compatibility
const typeValidationErrors: string[] = [];
for (const [key, dataType] of attributeTypeMap) {
const values = attributeValuesByKey.get(key) || [];
// Skip validation for string type (always valid)
if (dataType === "string") continue;
for (const value of values) {
try {
prepareAttributeColumnsForStorage(value, dataType);
} catch {
// If any value fails conversion, downgrade to string type
attributeTypeMap.set(key, "string");
typeValidationErrors.push(
`Attribute "${key}" has mixed or invalid values for type "${dataType}", treating as string type`
);
break;
}
}
}
if (typeValidationErrors.length > 0) {
logger.warn({ errors: typeValidationErrors }, "Type validation warnings during CSV upload");
}
// Identify missing attribute keys (case-insensitive check)
const missingKeys = Array.from(csvKeys).filter((key) => !lowercaseToActualKeyMap.has(key.toLowerCase()));
@@ -363,6 +442,7 @@ export const createContactsFromCSV = async (
data: Array.from(uniqueMissingKeys.values()).map((key) => ({
key,
name: key,
dataType: attributeTypeMap.get(key) ?? "string",
environmentId,
})),
skipDuplicates: true,
@@ -374,7 +454,7 @@ export const createContactsFromCSV = async (
key: { in: Array.from(uniqueMissingKeys.values()) },
environmentId,
},
select: { key: true, id: true },
select: { key: true, id: true, dataType: true },
});
newAttributeKeys.forEach((attrKey) => {
@@ -414,19 +494,30 @@ export const createContactsFromCSV = async (
case "update": {
const recordToProcess = resolveUserIdConflict(mappedRecord, existingContact, existingUserIds);
const attributesToUpsert = Object.entries(recordToProcess).map(([key, value]) => ({
where: {
contactId_attributeKeyId: {
contactId: existingContact.id,
attributeKeyId: attributeKeyMap.get(key),
const attributesToUpsert = Object.entries(recordToProcess).map(([key, value]) => {
const dataType = attributeTypeMap.get(key) ?? "string";
const columns = prepareAttributeColumnsForStorage(value, dataType);
return {
where: {
contactId_attributeKeyId: {
contactId: existingContact.id,
attributeKeyId: attributeKeyMap.get(key),
},
},
},
update: { value },
create: {
attributeKeyId: attributeKeyMap.get(key),
value,
},
}));
update: {
value: columns.value,
valueNumber: columns.valueNumber,
valueDate: columns.valueDate,
},
create: {
attributeKeyId: attributeKeyMap.get(key),
value: columns.value,
valueNumber: columns.valueNumber,
valueDate: columns.valueDate,
},
};
});
// Update contact with upserted attributes
return prisma.contact.update({
@@ -453,7 +544,7 @@ export const createContactsFromCSV = async (
where: { id: existingContact.id },
data: {
attributes: {
create: createAttributeConnections(recordToProcess, environmentId),
create: createAttributeConnections(recordToProcess, environmentId, attributeTypeMap),
},
},
include: contactAttributesInclude,
@@ -466,7 +557,7 @@ export const createContactsFromCSV = async (
data: {
environmentId,
attributes: {
create: createAttributeConnections(mappedRecord, environmentId),
create: createAttributeConnections(mappedRecord, environmentId, attributeTypeMap),
},
},
include: contactAttributesInclude,

View File

@@ -0,0 +1,57 @@
import { describe, expect, test } from "vitest";
import { detectAttributeDataType } from "./detect-attribute-type";
describe("detectAttributeDataType", () => {
describe("Date object input", () => {
test("detects Date objects as date type", () => {
expect(detectAttributeDataType(new Date())).toBe("date");
expect(detectAttributeDataType(new Date("2024-01-15"))).toBe("date");
expect(detectAttributeDataType(new Date("2024-01-15T10:30:00Z"))).toBe("date");
});
});
describe("number input", () => {
test("detects numbers as number type", () => {
expect(detectAttributeDataType(42)).toBe("number");
expect(detectAttributeDataType(3.14)).toBe("number");
expect(detectAttributeDataType(-10)).toBe("number");
expect(detectAttributeDataType(0)).toBe("number");
});
});
describe("string input", () => {
test("detects ISO 8601 date strings", () => {
expect(detectAttributeDataType("2024-01-15")).toBe("date");
expect(detectAttributeDataType("2024-01-15T10:30:00Z")).toBe("date");
expect(detectAttributeDataType("2024-01-15T10:30:00.000Z")).toBe("date");
expect(detectAttributeDataType("2023-12-31")).toBe("date");
});
test("detects numeric string values", () => {
expect(detectAttributeDataType("42")).toBe("number");
expect(detectAttributeDataType("3.14")).toBe("number");
expect(detectAttributeDataType("-10")).toBe("number");
expect(detectAttributeDataType("0")).toBe("number");
expect(detectAttributeDataType(" 123 ")).toBe("number");
});
test("detects string values", () => {
expect(detectAttributeDataType("hello")).toBe("string");
expect(detectAttributeDataType("john@example.com")).toBe("string");
expect(detectAttributeDataType("123abc")).toBe("string");
expect(detectAttributeDataType("")).toBe("string");
});
test("handles invalid date strings as string", () => {
expect(detectAttributeDataType("2024-13-01")).toBe("string"); // Invalid month
expect(detectAttributeDataType("not-a-date")).toBe("string");
expect(detectAttributeDataType("2024/01/15")).toBe("string"); // Wrong format
});
test("handles edge cases", () => {
expect(detectAttributeDataType(" ")).toBe("string"); // Whitespace only
expect(detectAttributeDataType("NaN")).toBe("string");
expect(detectAttributeDataType("Infinity")).toBe("number"); // Technically a number
});
});
});

View File

@@ -0,0 +1,91 @@
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
/**
* Parses a date string in DD-MM-YYYY or MM-DD-YYYY format.
* Uses heuristics to disambiguate between formats.
*/
const parseDateFromParts = (part1: number, part2: number, part3: number): Date | null => {
// Heuristic: If first part > 12, it's likely DD-MM-YYYY
if (part1 > 12) {
return new Date(part3, part2 - 1, part1);
}
// If second part > 12, it's definitely MM-DD-YYYY
if (part2 > 12) {
return new Date(part3, part1 - 1, part2);
}
// Ambiguous - use additional heuristics
if (part1 > 31 || part3 < 100) {
// Likely YYYY-MM-DD format
return new Date(part1, part2 - 1, part3);
}
// Default to American format MM-DD-YYYY
return new Date(part3, part1 - 1, part2);
};
/**
* Attempts to parse a string as a date in various formats.
*/
const tryParseDate = (stringValue: string): Date | null => {
// Try ISO format first (YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss)
if (/^\d{4}[-/]\d{2}[-/]\d{2}/.test(stringValue)) {
return new Date(stringValue);
}
// For DD-MM-YYYY or MM-DD-YYYY formats, parse manually
const parts = stringValue.split(/[-/]/);
if (parts.length < 3) {
return null;
}
const [part1, part2, part3] = parts.map((p) => Number.parseInt(p, 10));
return parseDateFromParts(part1, part2, part3);
};
/**
* Detects the data type of an attribute value based on its format.
* Used for first-time attribute creation to infer the dataType.
*
* Supported date formats:
* - ISO 8601: YYYY-MM-DD or YYYY-MM-DDTHH:mm:ss.sssZ
* - European: DD-MM-YYYY or DD/MM/YYYY
* - American: MM-DD-YYYY or MM/DD/YYYY
*
* @param value - The attribute value to detect the type of (string, number, or Date)
* @returns The detected data type (string, number, or date)
*/
export const detectAttributeDataType = (value: string | number | Date): TContactAttributeDataType => {
// Handle Date objects directly
if (value instanceof Date) {
return "date";
}
// Handle numbers directly
if (typeof value === "number") {
return "number";
}
// For string values, try to detect the actual type
const stringValue = value.trim();
// Check if it matches common date formats
const datePattern = /^(\d{4}[-/]\d{2}[-/]\d{2}|\d{2}[-/]\d{2}[-/]\d{4})/;
if (datePattern.test(stringValue)) {
const parsedDate = tryParseDate(stringValue);
// Verify it's a valid date
if (parsedDate && !Number.isNaN(parsedDate.getTime())) {
return "date";
}
}
// Check if numeric (integer or decimal)
if (stringValue !== "" && !Number.isNaN(Number(stringValue))) {
return "number";
}
// Default to string for everything else
return "string";
};

View File

@@ -0,0 +1,46 @@
import { format } from "date-fns";
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
/**
* Formats an attribute value for display based on its data type.
*
* @param value - The raw attribute value (string representation from DB)
* @param dataType - The data type of the attribute
* @returns Formatted string for display
*/
export const formatAttributeValue = (
value: string | number | Date | null | undefined,
dataType: TContactAttributeDataType
): string => {
// Handle null/undefined
if (value === null || value === undefined || value === "") {
return "-";
}
switch (dataType) {
case "date": {
try {
const date = value instanceof Date ? value : new Date(value);
// Format as "Jan 15, 2024" for better readability
return format(date, "MMM d, yyyy");
} catch {
// If date parsing fails, return the raw value
return String(value);
}
}
case "number": {
// Format numbers with proper localization
const num = typeof value === "number" ? value : Number.parseFloat(String(value));
if (Number.isNaN(num)) {
return String(value);
}
// Use toLocaleString for proper formatting with commas
return num.toLocaleString();
}
case "string":
default:
return String(value);
}
};

View File

@@ -0,0 +1,154 @@
import { describe, expect, test } from "vitest";
import { validateAndParseAttributeValue } from "./validate-attribute-type";
describe("validateAndParseAttributeValue", () => {
describe("string type", () => {
test("accepts any string value", () => {
const result = validateAndParseAttributeValue("hello", "string", "testKey");
expect(result.valid).toBe(true);
if (result.valid) {
expect(result.parsedValue.value).toBe("hello");
expect(result.parsedValue.valueNumber).toBeNull();
expect(result.parsedValue.valueDate).toBeNull();
}
});
test("converts numbers to string", () => {
const result = validateAndParseAttributeValue(42, "string", "testKey");
expect(result.valid).toBe(true);
if (result.valid) {
expect(result.parsedValue.value).toBe("42");
expect(result.parsedValue.valueNumber).toBeNull();
}
});
test("converts Date to ISO string", () => {
const date = new Date("2024-01-15T10:30:00.000Z");
const result = validateAndParseAttributeValue(date, "string", "testKey");
expect(result.valid).toBe(true);
if (result.valid) {
expect(result.parsedValue.value).toBe("2024-01-15T10:30:00.000Z");
expect(result.parsedValue.valueDate).toBeNull();
}
});
});
describe("number type", () => {
test("accepts number values", () => {
const result = validateAndParseAttributeValue(42, "number", "testKey");
expect(result.valid).toBe(true);
if (result.valid) {
expect(result.parsedValue.value).toBe("42");
expect(result.parsedValue.valueNumber).toBe(42);
expect(result.parsedValue.valueDate).toBeNull();
}
});
test("accepts numeric string values", () => {
const result = validateAndParseAttributeValue("3.14", "number", "testKey");
expect(result.valid).toBe(true);
if (result.valid) {
expect(result.parsedValue.valueNumber).toBe(3.14);
}
});
test("accepts numeric strings with whitespace", () => {
const result = validateAndParseAttributeValue(" 123 ", "number", "testKey");
expect(result.valid).toBe(true);
if (result.valid) {
expect(result.parsedValue.valueNumber).toBe(123);
}
});
test("rejects non-numeric strings", () => {
const result = validateAndParseAttributeValue("hello", "number", "testKey");
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.error).toContain("testKey");
expect(result.error).toContain("expects a number");
}
});
test("rejects Date values", () => {
const date = new Date();
const result = validateAndParseAttributeValue(date, "number", "testKey");
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.error).toContain("expects a number");
}
});
});
describe("date type", () => {
test("accepts Date objects", () => {
const date = new Date("2024-01-15T10:30:00.000Z");
const result = validateAndParseAttributeValue(date, "date", "testKey");
expect(result.valid).toBe(true);
if (result.valid) {
expect(result.parsedValue.value).toBe("2024-01-15T10:30:00.000Z");
expect(result.parsedValue.valueNumber).toBeNull();
expect(result.parsedValue.valueDate).toEqual(date);
}
});
test("accepts ISO date strings", () => {
const result = validateAndParseAttributeValue("2024-01-15T10:30:00.000Z", "date", "testKey");
expect(result.valid).toBe(true);
if (result.valid) {
expect(result.parsedValue.valueDate).toEqual(new Date("2024-01-15T10:30:00.000Z"));
}
});
test("accepts date-only strings", () => {
const result = validateAndParseAttributeValue("2024-01-15", "date", "testKey");
expect(result.valid).toBe(true);
if (result.valid) {
expect(result.parsedValue.valueDate).not.toBeNull();
}
});
test("rejects invalid date strings", () => {
const result = validateAndParseAttributeValue("not-a-date", "date", "purchaseDate");
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.error).toContain("purchaseDate");
expect(result.error).toContain("expects a date");
}
});
test("rejects number values", () => {
const result = validateAndParseAttributeValue(42, "date", "testKey");
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.error).toContain("expects a date");
}
});
test("rejects invalid Date objects", () => {
const invalidDate = new Date("invalid");
const result = validateAndParseAttributeValue(invalidDate, "date", "testKey");
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.error).toContain("Invalid Date");
}
});
});
describe("error messages", () => {
test("includes attribute key in error message", () => {
const result = validateAndParseAttributeValue("hello", "number", "purchaseAmount");
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.error).toContain("purchaseAmount");
}
});
test("includes received value type in error message", () => {
const result = validateAndParseAttributeValue("hello", "number", "testKey");
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.error).toContain("hello");
}
});
});
});

View File

@@ -0,0 +1,146 @@
import { TContactAttributeDataType } from "@formbricks/types/contact-attribute-key";
/**
* Result of attribute value validation
*/
export type TAttributeValidationResult =
| {
valid: true;
parsedValue: {
value: string;
valueNumber: number | null;
valueDate: Date | null;
};
}
| {
valid: false;
error: string;
};
/**
* Checks if a string value is a valid ISO 8601 date
*/
const isValidISODate = (value: string): boolean => {
if (!/^\d{4}-\d{2}-\d{2}/.test(value)) {
return false;
}
const date = new Date(value);
return !Number.isNaN(date.getTime());
};
/**
* Checks if a string value is a valid number
*/
const isValidNumber = (value: string): boolean => {
const trimmed = value.trim();
return trimmed !== "" && !Number.isNaN(Number(trimmed));
};
/**
* Validates that a value matches the expected data type and parses it for storage.
* Used for subsequent writes to an existing attribute key.
*
* @param value - The value to validate (string, number, or Date)
* @param expectedDataType - The expected data type of the attribute key
* @param attributeKey - The attribute key name (for error messages)
* @returns Validation result with parsed values for storage or error message
*/
export const validateAndParseAttributeValue = (
value: string | number | Date,
expectedDataType: TContactAttributeDataType,
attributeKey: string
): TAttributeValidationResult => {
switch (expectedDataType) {
case "string": {
// String type accepts any value - convert to string
let stringValue: string;
if (value instanceof Date) {
stringValue = value.toISOString();
} else if (typeof value === "number") {
stringValue = String(value);
} else {
stringValue = value;
}
return {
valid: true,
parsedValue: {
value: stringValue,
valueNumber: null,
valueDate: null,
},
};
}
case "number": {
// Number type expects a numeric value
let numericValue: number;
if (typeof value === "number") {
numericValue = value;
} else if (typeof value === "string" && isValidNumber(value)) {
numericValue = Number(value.trim());
} else {
const receivedType = value instanceof Date ? "Date" : typeof value;
return {
valid: false,
error: `Attribute '${attributeKey}' expects a number. Received: ${receivedType} value '${String(value)}'`,
};
}
return {
valid: true,
parsedValue: {
value: String(numericValue), // Keep string column for backwards compatibility
valueNumber: numericValue,
valueDate: null,
},
};
}
case "date": {
// Date type expects a Date object or valid ISO date string
let dateValue: Date;
if (value instanceof Date) {
if (Number.isNaN(value.getTime())) {
return {
valid: false,
error: `Attribute '${attributeKey}' expects a valid date. Received: Invalid Date`,
};
}
dateValue = value;
} else if (typeof value === "string" && isValidISODate(value)) {
dateValue = new Date(value);
} else {
const receivedType = typeof value;
return {
valid: false,
error: `Attribute '${attributeKey}' expects a date (ISO 8601 string or Date object). Received: ${receivedType} value '${String(value)}'`,
};
}
return {
valid: true,
parsedValue: {
value: dateValue.toISOString(), // Keep string column for backwards compatibility
valueNumber: null,
valueDate: dateValue,
},
};
}
default: {
// Unknown type - treat as string
return {
valid: true,
parsedValue: {
value: String(value),
valueNumber: null,
valueDate: null,
},
};
}
}
};

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