Compare commits

..

17 Commits

Author SHA1 Message Date
Dhruwang Jariwala
882ad99ed7 fix: templates page back button (#6088)
Co-authored-by: Piyush Gupta <56182734+gupta-piyush19@users.noreply.github.com>
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-06-26 10:38:45 +00:00
Piyush Gupta
ce47b4c2d8 fix: improper zod validation in action classes management API (#6084) 2025-06-26 10:21:01 +00:00
Matti Nannt
ce8f9de8ec fix: confetti animation display issue (#6085)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-06-26 06:35:19 +00:00
Anshuman Pandey
ed3c2d2b58 fix: fixes shrinking checkbox (#6092) 2025-06-26 05:14:54 +00:00
Anshuman Pandey
9ae226329b fix: decreases environment ttl to 5 minutes (#6087) 2025-06-25 10:30:36 +00:00
Piyush Gupta
12c3899b85 fix: input validation in management v2 webhooks API (#6078) 2025-06-25 09:49:56 +00:00
Piyush Gupta
ccb1353eb5 fix: split domain docs (#6086) 2025-06-25 00:50:23 -07:00
Johannes
22eb0b79ee chore: update issue templates (#6081) 2025-06-24 13:42:10 -07:00
Abhishek Sharma
5eb7a496da fix: "Add ending" button ui distortion in safari browser (#6048) 2025-06-24 11:50:17 -07:00
Matti Nannt
7ea55e199f chore(infra): always pull new images on staging (#6079) 2025-06-24 19:45:00 +02:00
Varun Singh
83eb472acd fix: Empty survey list state after deleting the last survey. (#6044)
Co-authored-by: Victor Santos <victor@formbricks.com>
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-06-24 07:52:18 -07:00
Jakob Schott
d9fe6ee4f4 fix: styling update and loading animation for survey media (#6020) 2025-06-24 09:53:27 +00:00
Anshuman Pandey
51b58be079 docs: fixes the bulk contact upload api docs and adds the email property (#6066)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-06-24 01:44:34 -07:00
Harsh Bhat
397643330a docs: Update docs for Private file upload and general client API (#6045) 2025-06-23 08:26:10 -07:00
Piyush Gupta
e5fa4328e1 fix: tls handshake failure in self-hosting license generation (#6050) 2025-06-23 08:42:08 +00:00
Jakob Schott
4b777f1907 feat: unify modal component in storybook (#5901) 2025-06-22 13:54:04 +00:00
Piyush Gupta
c3547ccb36 fix: default environment redirect (#6033) 2025-06-20 16:46:43 +00:00
56 changed files with 1891 additions and 698 deletions

View File

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

@@ -15,10 +15,9 @@ 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
- In all headlines, only capitalize the current feature and nothing else, NO Camel Case
- Update the mint.json file and add the nav item at the correct position corresponding to where the MDX file is located
- In all Headlines, only capitalize the current feature and nothing else, to Camel Case
- 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).
FEATURE NAME is part of the @Enterprise Edition.
</Note>

View File

@@ -1,6 +1,7 @@
name: Feature request
description: "Suggest an idea for this project \U0001F680"
type: feature
projects: "formbricks/21"
body:
- type: textarea
id: problem-description

View File

@@ -1,11 +0,0 @@
name: Task (internal)
description: "Template for creating a task. Used by the Formbricks Team only \U0001f4e5"
type: task
body:
- type: textarea
id: task-summary
attributes:
label: Task description
description: A clear detailed-rich description of the task.
validations:
required: true

View File

@@ -106,8 +106,7 @@ export const ShareEmbedSurvey = ({
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTitle className="sr-only" />
<DialogContent className="w-full max-w-xl bg-white p-0 md:max-w-3xl lg:h-[700px] lg:max-w-5xl">
<DialogContent className="w-full bg-white p-0 lg:h-[700px]" width="wide">
{showView === "start" ? (
<div className="h-full max-w-full overflow-hidden">
<div className="flex h-[200px] w-full flex-col items-center justify-center space-y-6 p-8 text-center lg:h-2/5">

View File

@@ -274,7 +274,7 @@ describe("getEnvironmentState", () => {
expect(withCache).toHaveBeenCalledWith(expect.any(Function), {
key: `fb:env:${environmentId}:state`,
ttl: 60 * 30 * 1000, // 30 minutes in milliseconds
ttl: 5 * 60 * 1000, // 5 minutes in milliseconds
});
});
});

View File

@@ -83,9 +83,8 @@ export const getEnvironmentState = async (
{
// Use enterprise-grade cache key pattern
key: createCacheKey.environment.state(environmentId),
// 30 minutes TTL ensures fresh data for hourly SDK checks
// Balances performance with freshness requirements
ttl: 60 * 30 * 1000, // 30 minutes in milliseconds
// This is a temporary fix for the invalidation issues, will be changed later with a proper solution
ttl: 5 * 60 * 1000, // 5 minutes in milliseconds
}
);

View File

@@ -52,14 +52,6 @@ export const POST = withApiLogging(
}
const inputValidation = ZActionClassInput.safeParse(actionClassInput);
const environmentId = actionClassInput.environmentId;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return {
response: responses.unauthorizedResponse(),
};
}
if (!inputValidation.success) {
return {
response: responses.badRequestResponse(
@@ -70,6 +62,14 @@ export const POST = withApiLogging(
};
}
const environmentId = inputValidation.data.environmentId;
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
return {
response: responses.unauthorizedResponse(),
};
}
const actionClass: TActionClass = await createActionClass(environmentId, inputValidation.data);
auditLog.targetId = actionClass.id;
auditLog.newObject = actionClass;

View File

@@ -8,7 +8,7 @@ import { TUser } from "@formbricks/types/user";
import Page from "./page";
vi.mock("@/lib/project/service", () => ({
getProjectEnvironmentsByOrganizationIds: vi.fn(),
getUserProjectEnvironmentsByOrganizationIds: vi.fn(),
}));
vi.mock("@/lib/instance/service", () => ({
@@ -152,7 +152,7 @@ describe("Page", () => {
const { getIsFreshInstance } = await import("@/lib/instance/service");
const { getUser } = await import("@/lib/user/service");
const { getOrganizationsByUserId } = await import("@/lib/organization/service");
const { getProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service");
const { getUserProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service");
const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service");
const { getAccessFlags } = await import("@/lib/membership/utils");
const { redirect } = await import("next/navigation");
@@ -220,7 +220,7 @@ describe("Page", () => {
} as any);
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getProjectEnvironmentsByOrganizationIds).mockResolvedValue(
vi.mocked(getUserProjectEnvironmentsByOrganizationIds).mockResolvedValue(
mockUserProjects as unknown as TProject[]
);
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
@@ -241,7 +241,7 @@ describe("Page", () => {
const { getServerSession } = await import("next-auth");
const { getIsFreshInstance } = await import("@/lib/instance/service");
const { getUser } = await import("@/lib/user/service");
const { getProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service");
const { getUserProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service");
const { getOrganizationsByUserId } = await import("@/lib/organization/service");
const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service");
const { getAccessFlags } = await import("@/lib/membership/utils");
@@ -310,7 +310,7 @@ describe("Page", () => {
} as any);
vi.mocked(getIsFreshInstance).mockResolvedValue(false);
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getProjectEnvironmentsByOrganizationIds).mockResolvedValue(
vi.mocked(getUserProjectEnvironmentsByOrganizationIds).mockResolvedValue(
mockUserProjects as unknown as TProject[]
);
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
@@ -334,7 +334,7 @@ describe("Page", () => {
const { getOrganizationsByUserId } = await import("@/lib/organization/service");
const { getMembershipByUserIdOrganizationId } = await import("@/lib/membership/service");
const { getAccessFlags } = await import("@/lib/membership/utils");
const { getProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service");
const { getUserProjectEnvironmentsByOrganizationIds } = await import("@/lib/project/service");
const { render } = await import("@testing-library/react");
const mockUser: TUser = {
@@ -432,7 +432,7 @@ describe("Page", () => {
vi.mocked(getUser).mockResolvedValue(mockUser);
vi.mocked(getOrganizationsByUserId).mockResolvedValue([mockOrganization]);
vi.mocked(getMembershipByUserIdOrganizationId).mockResolvedValue(mockMembership);
vi.mocked(getProjectEnvironmentsByOrganizationIds).mockResolvedValue(mockUserProjects);
vi.mocked(getUserProjectEnvironmentsByOrganizationIds).mockResolvedValue(mockUserProjects);
vi.mocked(getAccessFlags).mockReturnValue({
isManager: false,
isOwner: false,

View File

@@ -3,7 +3,7 @@ import { getIsFreshInstance } from "@/lib/instance/service";
import { getMembershipByUserIdOrganizationId } from "@/lib/membership/service";
import { getAccessFlags } from "@/lib/membership/utils";
import { getOrganizationsByUserId } from "@/lib/organization/service";
import { getProjectEnvironmentsByOrganizationIds } from "@/lib/project/service";
import { getUserProjectEnvironmentsByOrganizationIds } from "@/lib/project/service";
import { getUser } from "@/lib/user/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { ClientLogout } from "@/modules/ui/components/client-logout";
@@ -34,7 +34,10 @@ const Page = async () => {
return redirect("/setup/organization/create");
}
const projectsByOrg = await getProjectEnvironmentsByOrganizationIds(userOrganizations.map((org) => org.id));
const projectsByOrg = await getUserProjectEnvironmentsByOrganizationIds(
userOrganizations.map((org) => org.id),
user.id
);
// Flatten all environments from all projects across all organizations
const allEnvironments = projectsByOrg.flatMap((project) => project.environments);

View File

@@ -7,8 +7,8 @@ import { ITEMS_PER_PAGE } from "../constants";
import {
getProject,
getProjectByEnvironmentId,
getProjectEnvironmentsByOrganizationIds,
getProjects,
getUserProjectEnvironmentsByOrganizationIds,
getUserProjects,
} from "./service";
@@ -21,6 +21,7 @@ vi.mock("@formbricks/database", () => ({
},
membership: {
findFirst: vi.fn(),
findMany: vi.fn(),
},
},
}));
@@ -488,6 +489,7 @@ describe("Project Service", () => {
test("getProjectsByOrganizationIds should return projects for given organization IDs", async () => {
const organizationId1 = createId();
const organizationId2 = createId();
const userId = createId();
const mockProjects = [
{
environments: [],
@@ -497,16 +499,34 @@ describe("Project Service", () => {
},
];
vi.mocked(prisma.membership.findMany).mockResolvedValue([
{
userId,
organizationId: organizationId1,
role: "owner" as any,
accepted: true,
deprecatedRole: null,
},
{
userId,
organizationId: organizationId2,
role: "owner" as any,
accepted: true,
deprecatedRole: null,
},
]);
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
const result = await getProjectEnvironmentsByOrganizationIds([organizationId1, organizationId2]);
const result = await getUserProjectEnvironmentsByOrganizationIds(
[organizationId1, organizationId2],
userId
);
expect(result).toEqual(mockProjects);
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: {
organizationId: {
in: [organizationId1, organizationId2],
},
OR: [{ organizationId: organizationId1 }, { organizationId: organizationId2 }],
},
select: { environments: true },
});
@@ -515,17 +535,36 @@ describe("Project Service", () => {
test("getProjectsByOrganizationIds should return empty array when no projects are found", async () => {
const organizationId1 = createId();
const organizationId2 = createId();
const userId = createId();
vi.mocked(prisma.membership.findMany).mockResolvedValue([
{
userId,
organizationId: organizationId1,
role: "owner" as any,
accepted: true,
deprecatedRole: null,
},
{
userId,
organizationId: organizationId2,
role: "owner" as any,
accepted: true,
deprecatedRole: null,
},
]);
vi.mocked(prisma.project.findMany).mockResolvedValue([]);
const result = await getProjectEnvironmentsByOrganizationIds([organizationId1, organizationId2]);
const result = await getUserProjectEnvironmentsByOrganizationIds(
[organizationId1, organizationId2],
userId
);
expect(result).toEqual([]);
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: {
organizationId: {
in: [organizationId1, organizationId2],
},
OR: [{ organizationId: organizationId1 }, { organizationId: organizationId2 }],
},
select: { environments: true },
});
@@ -534,18 +573,111 @@ describe("Project Service", () => {
test("getProjectsByOrganizationIds should throw DatabaseError when prisma throws", async () => {
const organizationId1 = createId();
const organizationId2 = createId();
const userId = createId();
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
clientVersion: "5.0.0",
});
vi.mocked(prisma.membership.findMany).mockResolvedValue([
{
userId,
organizationId: organizationId1,
role: "owner" as any,
accepted: true,
deprecatedRole: null,
},
]);
vi.mocked(prisma.project.findMany).mockRejectedValue(prismaError);
await expect(getProjectEnvironmentsByOrganizationIds([organizationId1, organizationId2])).rejects.toThrow(
DatabaseError
);
await expect(
getUserProjectEnvironmentsByOrganizationIds([organizationId1, organizationId2], userId)
).rejects.toThrow(DatabaseError);
});
test("getProjectsByOrganizationIds should throw ValidationError with wrong input", async () => {
await expect(getProjectEnvironmentsByOrganizationIds(["wrong-id"])).rejects.toThrow(ValidationError);
const userId = createId();
await expect(getUserProjectEnvironmentsByOrganizationIds(["wrong-id"], userId)).rejects.toThrow(
ValidationError
);
});
test("getProjectsByOrganizationIds should return empty array when user has no memberships", async () => {
const organizationId1 = createId();
const organizationId2 = createId();
const userId = createId();
// Mock no memberships found
vi.mocked(prisma.membership.findMany).mockResolvedValue([]);
const result = await getUserProjectEnvironmentsByOrganizationIds(
[organizationId1, organizationId2],
userId
);
expect(result).toEqual([]);
expect(prisma.membership.findMany).toHaveBeenCalledWith({
where: {
userId,
organizationId: {
in: [organizationId1, organizationId2],
},
},
});
// Should not call project.findMany when no memberships
expect(prisma.project.findMany).not.toHaveBeenCalled();
});
test("getProjectsByOrganizationIds should handle member role with team access", async () => {
const organizationId1 = createId();
const organizationId2 = createId();
const userId = createId();
const mockProjects = [
{
environments: [],
},
];
// Mock membership where user is a member
vi.mocked(prisma.membership.findMany).mockResolvedValue([
{
userId,
organizationId: organizationId1,
role: "member" as any,
accepted: true,
deprecatedRole: null,
},
]);
vi.mocked(prisma.project.findMany).mockResolvedValue(mockProjects as any);
const result = await getUserProjectEnvironmentsByOrganizationIds(
[organizationId1, organizationId2],
userId
);
expect(result).toEqual(mockProjects);
expect(prisma.project.findMany).toHaveBeenCalledWith({
where: {
OR: [
{
organizationId: organizationId1,
projectTeams: {
some: {
team: {
teamUsers: {
some: {
userId,
},
},
},
},
},
},
],
},
select: { environments: true },
});
});
});

View File

@@ -171,20 +171,56 @@ export const getOrganizationProjectsCount = reactCache(async (organizationId: st
}
});
export const getProjectEnvironmentsByOrganizationIds = reactCache(
async (organizationIds: string[]): Promise<Pick<TProject, "environments">[]> => {
validateInputs([organizationIds, ZId.array()]);
export const getUserProjectEnvironmentsByOrganizationIds = reactCache(
async (organizationIds: string[], userId: string): Promise<Pick<TProject, "environments">[]> => {
validateInputs([organizationIds, ZId.array()], [userId, ZId]);
try {
if (organizationIds.length === 0) {
return [];
}
const projects = await prisma.project.findMany({
const memberships = await prisma.membership.findMany({
where: {
userId,
organizationId: {
in: organizationIds,
},
},
});
if (memberships.length === 0) {
return [];
}
const whereConditions: Prisma.ProjectWhereInput[] = memberships.map((membership) => {
let projectWhereClause: Prisma.ProjectWhereInput = {
organizationId: membership.organizationId,
};
if (membership.role === "member") {
projectWhereClause = {
...projectWhereClause,
projectTeams: {
some: {
team: {
teamUsers: {
some: {
userId,
},
},
},
},
},
};
}
return projectWhereClause;
});
const projects = await prisma.project.findMany({
where: {
OR: whereConditions,
},
select: { environments: true },
});

View File

@@ -34,8 +34,7 @@
"text": "Du kannst Dich jetzt mit deinem neuen Passwort einloggen"
}
},
"reset_password": "Passwort zurücksetzen",
"reset_password_description": "Du wirst abgemeldet, um dein Passwort zurückzusetzen."
"reset_password": "Passwort zurücksetzen"
},
"invite": {
"create_account": "Konto erstellen",
@@ -135,6 +134,7 @@
"app_survey": "App-Umfrage",
"apply_filters": "Filter anwenden",
"are_you_sure": "Bist Du sicher?",
"are_you_sure_this_action_cannot_be_undone": "Bist Du sicher? Diese Aktion kann nicht rückgängig gemacht werden.",
"attributes": "Attribute",
"avatar": "Avatar",
"back": "Zurück",
@@ -191,6 +191,7 @@
"e_commerce": "E-Commerce",
"edit": "Bearbeiten",
"email": "E-Mail",
"embed": "Einbetten",
"enterprise_license": "Enterprise Lizenz",
"environment_not_found": "Umgebung nicht gefunden",
"environment_notice": "Du befindest dich derzeit in der {environment}-Umgebung.",
@@ -315,7 +316,6 @@
"remove": "Entfernen",
"reorder_and_hide_columns": "Spalten neu anordnen und ausblenden",
"report_survey": "Umfrage melden",
"request_pricing": "Preise anfragen",
"request_trial_license": "Testlizenz anfordern",
"reset_to_default": "Auf Standard zurücksetzen",
"response": "Antwort",
@@ -411,6 +411,7 @@
"website_survey": "Website-Umfrage",
"weekly_summary": "Wöchentliche Zusammenfassung",
"welcome_card": "Willkommenskarte",
"yes": "Ja",
"you": "Du",
"you_are_downgraded_to_the_community_edition": "Du wurdest auf die Community Edition herabgestuft.",
"you_are_not_authorised_to_perform_this_action": "Du bist nicht berechtigt, diese Aktion auszuführen.",
@@ -595,7 +596,6 @@
"contact_not_found": "Kein solcher Kontakt gefunden",
"contacts_table_refresh": "Kontakte aktualisieren",
"contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert",
"delete_contact_confirmation": "Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesem Kontakt verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren.",
"first_name": "Vorname",
"last_name": "Nachname",
"no_responses_found": "Keine Antworten gefunden",
@@ -632,7 +632,6 @@
"airtable_integration": "Airtable Integration",
"airtable_integration_description": "Synchronisiere Antworten direkt mit Airtable.",
"airtable_integration_is_not_configured": "Airtable Integration ist nicht konfiguriert",
"airtable_logo": "Airtable-Logo",
"connect_with_airtable": "Mit Airtable verbinden",
"link_airtable_table": "Airtable Tabelle verknüpfen",
"link_new_table": "Neue Tabelle verknüpfen",
@@ -700,6 +699,7 @@
"select_a_database": "Datenbank auswählen",
"select_a_field_to_map": "Wähle ein Feld zum Zuordnen aus",
"select_a_survey_question": "Wähle eine Umfragefrage aus",
"sync_responses_with_a_notion_database": "Antworten mit einer Datenbank in Notion synchronisieren",
"update_connection": "Notion erneut verbinden",
"update_connection_tooltip": "Verbinde die Integration erneut, um neu hinzugefügte Datenbanken einzuschließen. Deine bestehenden Integrationen bleiben erhalten."
},
@@ -721,7 +721,6 @@
"slack_integration": "Slack Integration",
"slack_integration_description": "Sende Antworten direkt an Slack.",
"slack_integration_is_not_configured": "Slack Integration ist in deiner Instanz von Formbricks nicht konfiguriert.",
"slack_logo": "Slack-Logo",
"slack_reconnect_button": "Erneut verbinden",
"slack_reconnect_button_description": "<b>Hinweis:</b> Wir haben kürzlich unsere Slack-Integration geändert, um auch private Kanäle zu unterstützen. Bitte verbinden Sie Ihren Slack-Workspace erneut."
},
@@ -906,7 +905,8 @@
"tag_already_exists": "Tag existiert bereits",
"tag_deleted": "Tag gelöscht",
"tag_updated": "Tag aktualisiert",
"tags_merged": "Tags zusammengeführt"
"tags_merged": "Tags zusammengeführt",
"unique_constraint_failed_on_the_fields": "Eindeutige Einschränkung für die Felder fehlgeschlagen"
},
"teams": {
"manage_teams": "Teams verwalten",
@@ -979,53 +979,63 @@
"api_keys_description": "Verwalte API-Schlüssel, um auf die Formbricks-Management-APIs zuzugreifen"
},
"billing": {
"1000_monthly_responses": "1,000 monatliche Antworten",
"1_project": "1 Projekt",
"2000_contacts": "2,000 Kontakte",
"10000_monthly_responses": "10,000 monatliche Antworten",
"1500_monthly_responses": "1,500 monatliche Antworten",
"2000_monthly_identified_users": "2,000 monatlich identifizierte Nutzer",
"30000_monthly_identified_users": "30,000 monatlich identifizierte Nutzer",
"3_projects": "3 Projekte",
"5000_monthly_responses": "5,000 monatliche Antworten",
"7500_contacts": "7,500 Kontakte",
"5_projects": "5 Projekte",
"7500_monthly_identified_users": "7,500 monatlich identifizierte Nutzer",
"advanced_targeting": "Erweitertes Targeting",
"all_integrations": "Alle Integrationen",
"all_surveying_features": "Alle Umfragefunktionen",
"annually": "Jährlich",
"api_webhooks": "API & Webhooks",
"app_surveys": "In-app Umfragen",
"attribute_based_targeting": "Attributbasiertes Targeting",
"contact_us": "Kontaktiere uns",
"current": "aktuell",
"current_plan": "Aktueller Plan",
"current_tier_limit": "Aktuelles Limit",
"custom": "Benutzerdefiniert & Skalierung",
"custom_contacts_limit": "Benutzerdefiniertes Kontaktlimit",
"custom_miu_limit": "Benutzerdefiniertes MIU-Limit",
"custom_project_limit": "Benutzerdefiniertes Projektlimit",
"custom_response_limit": "Benutzerdefiniertes Antwortlimit",
"customer_success_manager": "Customer Success Manager",
"email_embedded_surveys": "Eingebettete Umfragen in E-Mails",
"email_follow_ups": "E-Mail Follow-ups",
"email_support": "E-Mail-Support",
"enterprise": "Enterprise",
"enterprise_description": "Premium-Support und benutzerdefinierte Limits.",
"everybody_has_the_free_plan_by_default": "Jeder hat standardmäßig den kostenlosen Plan!",
"everything_in_free": "Alles in 'Free''",
"everything_in_scale": "Alles in 'Scale''",
"everything_in_startup": "Alles in 'Startup''",
"free": "Kostenlos",
"free_description": "Unbegrenzte Umfragen, Teammitglieder und mehr.",
"get_2_months_free": "2 Monate gratis",
"get_in_touch": "Kontaktiere uns",
"hosted_in_frankfurt": "Gehostet in Frankfurt",
"ios_android_sdks": "iOS & Android SDK für mobile Umfragen",
"link_surveys": "Umfragen verlinken (teilbar)",
"logic_jumps_hidden_fields_recurring_surveys": "Logik, versteckte Felder, wiederkehrende Umfragen, usw.",
"manage_card_details": "Karteninformationen verwalten",
"manage_subscription": "Abonnement verwalten",
"monthly": "Monatlich",
"monthly_identified_users": "Monatlich identifizierte Nutzer",
"multi_language_surveys": "Mehrsprachige Umfragen",
"per_month": "pro Monat",
"per_year": "pro Jahr",
"plan_upgraded_successfully": "Plan erfolgreich aktualisiert",
"premium_support_with_slas": "Premium-Support mit SLAs",
"priority_support": "Priorisierter Support",
"remove_branding": "Branding entfernen",
"say_hi": "Sag Hi!",
"scale": "Scale",
"scale_description": "Erweiterte Funktionen für größere Unternehmen.",
"startup": "Start-up",
"startup_description": "Alles in 'Free' mit zusätzlichen Funktionen.",
"switch_plan": "Plan wechseln",
"switch_plan_confirmation_text": "Bist du sicher, dass du zum {plan}-Plan wechseln möchtest? Dir werden {price} {period} berechnet.",
"team_access_roles": "Rollen für Teammitglieder",
"technical_onboarding": "Technische Einführung",
"unable_to_upgrade_plan": "Plan kann nicht aktualisiert werden",
"unlimited_apps_websites": "Unbegrenzte Apps & Websites",
"unlimited_miu": "Unbegrenzte MIU",
"unlimited_projects": "Unbegrenzte Projekte",
"unlimited_responses": "Unbegrenzte Antworten",
@@ -1064,7 +1074,6 @@
"create_new_organization": "Neue Organisation erstellen",
"create_new_organization_description": "Erstelle eine neue Organisation, um weitere Projekte zu verwalten.",
"customize_email_with_a_higher_plan": "E-Mail-Anpassung mit einem höheren Plan",
"delete_member_confirmation": "Gelöschte Mitglieder verlieren den Zugriff auf alle Projekte und Umfragen deiner Organisation.",
"delete_organization": "Organisation löschen",
"delete_organization_description": "Organisation mit allen Projekten einschließlich aller Umfragen, Antworten, Personen, Aktionen und Attribute löschen",
"delete_organization_warning": "Bevor Du mit dem Löschen dieser Organisation fortfährst, sei dir bitte der folgenden Konsequenzen bewusst:",
@@ -1221,9 +1230,8 @@
"copy_survey_description": "Kopiere diese Umfrage in eine andere Umgebung",
"copy_survey_error": "Kopieren der Umfrage fehlgeschlagen",
"copy_survey_link_to_clipboard": "Umfragelink in die Zwischenablage kopieren",
"copy_survey_partially_success": "{success} Umfragen erfolgreich kopiert, {error} fehlgeschlagen.",
"copy_survey_success": "Umfrage erfolgreich kopiert!",
"delete_survey_and_responses_warning": "Bist Du sicher, dass Du diese Umfrage und alle ihre Antworten löschen möchtest?",
"delete_survey_and_responses_warning": "Bist Du sicher, dass Du diese Umfrage und alle ihre Antworten löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. Wähle die Standardsprache für diese Umfrage:",
"2_activate_translation_for_specific_languages": "2. Übersetzung für bestimmte Sprachen aktivieren:",
@@ -1296,6 +1304,7 @@
"card_arrangement_for_survey_type_derived": "Kartenanordnung für {surveyTypeDerived} Umfragen",
"card_background_color": "Hintergrundfarbe der Karte",
"card_border_color": "Farbe des Kartenrandes",
"card_shadow_color": "Farbton des Kartenschattens",
"card_styling": "Kartenstil",
"casual": "Lässig",
"caution_edit_duplicate": "Duplizieren & bearbeiten",
@@ -1320,6 +1329,7 @@
"change_the_brand_color_of_the_survey": "Markenfarbe der Umfrage ändern.",
"change_the_placement_of_this_survey": "Platzierung dieser Umfrage ändern.",
"change_the_question_color_of_the_survey": "Fragefarbe der Umfrage ändern.",
"change_the_shadow_color_of_the_card": "Schattenfarbe der Karte ändern.",
"changes_saved": "Änderungen gespeichert.",
"character_limit_toggle_description": "Begrenzen Sie, wie kurz oder lang eine Antwort sein kann.",
"character_limit_toggle_title": "Fügen Sie Zeichenbeschränkungen hinzu",
@@ -1776,7 +1786,6 @@
"setup_instructions": "Einrichtung",
"setup_integrations": "Integrationen einrichten",
"share_results": "Ergebnisse teilen",
"share_survey": "Umfrage teilen",
"share_the_link": "Teile den Link",
"share_the_link_to_get_responses": "Teile den Link, um Antworten einzusammeln",
"show_all_responses_that_match": "Zeige alle Antworten, die übereinstimmen",

View File

@@ -34,8 +34,7 @@
"text": "You can now log in with your new password"
}
},
"reset_password": "Reset password",
"reset_password_description": "You will be logged out to reset your password."
"reset_password": "Reset password"
},
"invite": {
"create_account": "Create an account",
@@ -135,6 +134,7 @@
"app_survey": "App Survey",
"apply_filters": "Apply filters",
"are_you_sure": "Are you sure?",
"are_you_sure_this_action_cannot_be_undone": "Are you sure? This action cannot be undone.",
"attributes": "Attributes",
"avatar": "Avatar",
"back": "Back",
@@ -191,6 +191,7 @@
"e_commerce": "E-Commerce",
"edit": "Edit",
"email": "Email",
"embed": "Embed",
"enterprise_license": "Enterprise License",
"environment_not_found": "Environment not found",
"environment_notice": "You're currently in the {environment} environment.",
@@ -315,7 +316,6 @@
"remove": "Remove",
"reorder_and_hide_columns": "Reorder and hide columns",
"report_survey": "Report Survey",
"request_pricing": "Request Pricing",
"request_trial_license": "Request trial license",
"reset_to_default": "Reset to default",
"response": "Response",
@@ -411,6 +411,7 @@
"website_survey": "Website Survey",
"weekly_summary": "Weekly summary",
"welcome_card": "Welcome card",
"yes": "Yes",
"you": "You",
"you_are_downgraded_to_the_community_edition": "You are downgraded to the Community Edition.",
"you_are_not_authorised_to_perform_this_action": "You are not authorised to perform this action.",
@@ -595,7 +596,6 @@
"contact_not_found": "No such contact found",
"contacts_table_refresh": "Refresh contacts",
"contacts_table_refresh_success": "Contacts refreshed successfully",
"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.",
"first_name": "First Name",
"last_name": "Last Name",
"no_responses_found": "No responses found",
@@ -632,7 +632,6 @@
"airtable_integration": "Airtable Integration",
"airtable_integration_description": "Sync responses directly with Airtable.",
"airtable_integration_is_not_configured": "Airtable Integration is not configured",
"airtable_logo": "Airtable logo",
"connect_with_airtable": "Connect with Airtable",
"link_airtable_table": "Link Airtable Table",
"link_new_table": "Link new table",
@@ -700,6 +699,7 @@
"select_a_database": "Select Database",
"select_a_field_to_map": "Select a field to map",
"select_a_survey_question": "Select a survey question",
"sync_responses_with_a_notion_database": "Sync responses with a Notion Database",
"update_connection": "Reconnect Notion",
"update_connection_tooltip": "Reconnect the integration to include newly added databases. Your existing integrations will remain intact."
},
@@ -721,7 +721,6 @@
"slack_integration": "Slack Integration",
"slack_integration_description": "Send responses directly to Slack.",
"slack_integration_is_not_configured": "Slack Integration is not configured in your instance of Formbricks.",
"slack_logo": "Slack logo",
"slack_reconnect_button": "Reconnect",
"slack_reconnect_button_description": "<b>Note:</b> We recently changed our Slack integration to also support private channels. Please reconnect your Slack workspace."
},
@@ -906,7 +905,8 @@
"tag_already_exists": "Tag already exists",
"tag_deleted": "Tag deleted",
"tag_updated": "Tag updated",
"tags_merged": "Tags merged"
"tags_merged": "Tags merged",
"unique_constraint_failed_on_the_fields": "Unique constraint failed on the fields"
},
"teams": {
"manage_teams": "Manage teams",
@@ -979,53 +979,63 @@
"api_keys_description": "Manage API keys to access Formbricks management APIs"
},
"billing": {
"1000_monthly_responses": "Monthly 1,000 Responses",
"1_project": "1 Project",
"2000_contacts": "2,000 Contacts",
"10000_monthly_responses": "10000 Monthly Responses",
"1500_monthly_responses": "1500 Monthly Responses",
"2000_monthly_identified_users": "2000 Monthly Identified Users",
"30000_monthly_identified_users": "30000 Monthly Identified Users",
"3_projects": "3 Projects",
"5000_monthly_responses": "5,000 Monthly Responses",
"7500_contacts": "7,500 Contacts",
"5_projects": "5 Projects",
"7500_monthly_identified_users": "7500 Monthly Identified Users",
"advanced_targeting": "Advanced Targeting",
"all_integrations": "All Integrations",
"all_surveying_features": "All surveying features",
"annually": "Annually",
"api_webhooks": "API & Webhooks",
"app_surveys": "App Surveys",
"attribute_based_targeting": "Attribute-based Targeting",
"contact_us": "Contact Us",
"current": "Current",
"current_plan": "Current Plan",
"current_tier_limit": "Current Tier Limit",
"custom": "Custom & Scale",
"custom_contacts_limit": "Custom Contacts Limit",
"custom_miu_limit": "Custom MIU limit",
"custom_project_limit": "Custom Project Limit",
"custom_response_limit": "Custom Response Limit",
"customer_success_manager": "Customer Success Manager",
"email_embedded_surveys": "Email Embedded Surveys",
"email_follow_ups": "Email Follow-ups",
"email_support": "Email Support",
"enterprise": "Enterprise",
"enterprise_description": "Premium support and custom limits.",
"everybody_has_the_free_plan_by_default": "Everybody has the free plan by default!",
"everything_in_free": "Everything in Free",
"everything_in_scale": "Everything in Scale",
"everything_in_startup": "Everything in Startup",
"free": "Free",
"free_description": "Unlimited Surveys, Team Members, and more.",
"get_2_months_free": "Get 2 months free",
"get_in_touch": "Get in touch",
"hosted_in_frankfurt": "Hosted in Frankfurt",
"ios_android_sdks": "iOS & Android SDK for mobile surveys",
"link_surveys": "Link Surveys (Shareable)",
"logic_jumps_hidden_fields_recurring_surveys": "Logic Jumps, Hidden Fields, Recurring Surveys, etc.",
"manage_card_details": "Manage Card Details",
"manage_subscription": "Manage Subscription",
"monthly": "Monthly",
"monthly_identified_users": "Monthly Identified Users",
"multi_language_surveys": "Multi-Language Surveys",
"per_month": "per month",
"per_year": "per year",
"plan_upgraded_successfully": "Plan upgraded successfully",
"premium_support_with_slas": "Premium support with SLAs",
"priority_support": "Priority Support",
"remove_branding": "Remove Branding",
"say_hi": "Say Hi!",
"scale": "Scale",
"scale_description": "Advanced features for scaling your business.",
"startup": "Startup",
"startup_description": "Everything in Free with additional features.",
"switch_plan": "Switch Plan",
"switch_plan_confirmation_text": "Are you sure you want to switch to the {plan} plan? You will be charged {price} {period}.",
"team_access_roles": "Team Access Roles",
"technical_onboarding": "Technical Onboarding",
"unable_to_upgrade_plan": "Unable to upgrade plan",
"unlimited_apps_websites": "Unlimited Apps & Websites",
"unlimited_miu": "Unlimited MIU",
"unlimited_projects": "Unlimited Projects",
"unlimited_responses": "Unlimited Responses",
@@ -1064,7 +1074,6 @@
"create_new_organization": "Create new organization",
"create_new_organization_description": "Create a new organization to handle a different set of projects.",
"customize_email_with_a_higher_plan": "Customize email with a higher plan",
"delete_member_confirmation": "Deleted members will lose access to all projects and surveys of your organization.",
"delete_organization": "Delete Organization",
"delete_organization_description": "Delete organization with all its projects including all surveys, responses, people, actions and attributes",
"delete_organization_warning": "Before you proceed with deleting this organization, please be aware of the following consequences:",
@@ -1221,9 +1230,8 @@
"copy_survey_description": "Copy this survey to another environment",
"copy_survey_error": "Failed to copy survey",
"copy_survey_link_to_clipboard": "Copy survey link to clipboard",
"copy_survey_partially_success": "{success} surveys copied successfully, {error} failed.",
"copy_survey_success": "Survey copied successfully!",
"delete_survey_and_responses_warning": "Are you sure you want to delete this survey and all of its responses?",
"delete_survey_and_responses_warning": "Are you sure you want to delete this survey and all of its responses? This action cannot be undone.",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. Choose the default language for this survey:",
"2_activate_translation_for_specific_languages": "2. Activate translation for specific languages:",
@@ -1296,6 +1304,7 @@
"card_arrangement_for_survey_type_derived": "Card Arrangement for {surveyTypeDerived} Surveys",
"card_background_color": "Card background color",
"card_border_color": "Card border color",
"card_shadow_color": "Card shadow color",
"card_styling": "Card Styling",
"casual": "Casual",
"caution_edit_duplicate": "Duplicate & edit",
@@ -1320,6 +1329,7 @@
"change_the_brand_color_of_the_survey": "Change the brand color of the survey.",
"change_the_placement_of_this_survey": "Change the placement of this survey.",
"change_the_question_color_of_the_survey": "Change the question color of the survey.",
"change_the_shadow_color_of_the_card": "Change the shadow color of the card.",
"changes_saved": "Changes saved.",
"character_limit_toggle_description": "Limit how short or long an answer can be.",
"character_limit_toggle_title": "Add character limits",
@@ -1776,7 +1786,6 @@
"setup_instructions": "Setup instructions",
"setup_integrations": "Setup integrations",
"share_results": "Share results",
"share_survey": "Share survey",
"share_the_link": "Share the link",
"share_the_link_to_get_responses": "Share the link to get responses",
"show_all_responses_that_match": "Show all responses that match",

View File

@@ -34,8 +34,7 @@
"text": "Vous pouvez maintenant vous connecter avec votre nouveau mot de passe."
}
},
"reset_password": "Réinitialiser le mot de passe",
"reset_password_description": "Vous serez déconnecté pour réinitialiser votre mot de passe."
"reset_password": "Réinitialiser le mot de passe"
},
"invite": {
"create_account": "Créer un compte",
@@ -135,6 +134,7 @@
"app_survey": "Sondage d'application",
"apply_filters": "Appliquer des filtres",
"are_you_sure": "Es-tu sûr ?",
"are_you_sure_this_action_cannot_be_undone": "Êtes-vous sûr ? Cette action ne peut pas être annulée.",
"attributes": "Attributs",
"avatar": "Avatar",
"back": "Retour",
@@ -191,6 +191,7 @@
"e_commerce": "E-commerce",
"edit": "Modifier",
"email": "Email",
"embed": "Intégrer",
"enterprise_license": "Licence d'entreprise",
"environment_not_found": "Environnement non trouvé",
"environment_notice": "Vous êtes actuellement dans l'environnement {environment}.",
@@ -315,7 +316,6 @@
"remove": "Retirer",
"reorder_and_hide_columns": "Réorganiser et masquer des colonnes",
"report_survey": "Rapport d'enquête",
"request_pricing": "Demander la tarification",
"request_trial_license": "Demander une licence d'essai",
"reset_to_default": "Réinitialiser par défaut",
"response": "Réponse",
@@ -411,6 +411,7 @@
"website_survey": "Sondage de site web",
"weekly_summary": "Résumé hebdomadaire",
"welcome_card": "Carte de bienvenue",
"yes": "Oui",
"you": "Vous",
"you_are_downgraded_to_the_community_edition": "Vous êtes rétrogradé à l'édition communautaire.",
"you_are_not_authorised_to_perform_this_action": "Vous n'êtes pas autorisé à effectuer cette action.",
@@ -595,7 +596,6 @@
"contact_not_found": "Aucun contact trouvé",
"contacts_table_refresh": "Rafraîchir les contacts",
"contacts_table_refresh_success": "Contacts rafraîchis avec succès",
"delete_contact_confirmation": "Cela supprimera toutes les réponses aux enquêtes et les attributs de contact associés à ce contact. Toute la personnalisation et le ciblage basés sur les données de ce contact seront perdus.",
"first_name": "Prénom",
"last_name": "Nom de famille",
"no_responses_found": "Aucune réponse trouvée",
@@ -632,7 +632,6 @@
"airtable_integration": "Intégration Airtable",
"airtable_integration_description": "Synchronisez les réponses directement avec Airtable.",
"airtable_integration_is_not_configured": "L'intégration Airtable n'est pas configurée",
"airtable_logo": "Logo Airtable",
"connect_with_airtable": "Se connecter à Airtable",
"link_airtable_table": "Lier la table Airtable",
"link_new_table": "Lier nouvelle table",
@@ -700,6 +699,7 @@
"select_a_database": "Sélectionner la base de données",
"select_a_field_to_map": "Sélectionnez un champ à mapper",
"select_a_survey_question": "Sélectionnez une question d'enquête",
"sync_responses_with_a_notion_database": "Synchroniser les réponses avec une base de données Notion",
"update_connection": "Reconnecter Notion",
"update_connection_tooltip": "Reconnectez l'intégration pour inclure les nouvelles bases de données ajoutées. Vos intégrations existantes resteront intactes."
},
@@ -721,7 +721,6 @@
"slack_integration": "Intégration Slack",
"slack_integration_description": "Envoyez les réponses directement sur Slack.",
"slack_integration_is_not_configured": "L'intégration Slack n'est pas configurée dans votre instance de Formbricks.",
"slack_logo": "logo Slack",
"slack_reconnect_button": "Reconnecter",
"slack_reconnect_button_description": "<b>Remarque :</b> Nous avons récemment modifié notre intégration Slack pour prendre en charge les canaux privés. Veuillez reconnecter votre espace de travail Slack."
},
@@ -906,7 +905,8 @@
"tag_already_exists": "Le tag existe déjà",
"tag_deleted": "Tag supprimé",
"tag_updated": "Étiquette mise à jour",
"tags_merged": "Étiquettes fusionnées"
"tags_merged": "Étiquettes fusionnées",
"unique_constraint_failed_on_the_fields": "Échec de la contrainte unique sur les champs"
},
"teams": {
"manage_teams": "Gérer les équipes",
@@ -979,53 +979,63 @@
"api_keys_description": "Gérer les clés API pour accéder aux API de gestion de Formbricks"
},
"billing": {
"1000_monthly_responses": "1000 Réponses Mensuelles",
"1_project": "1 Projet",
"2000_contacts": "2 000 Contacts",
"10000_monthly_responses": "10000 Réponses Mensuelles",
"1500_monthly_responses": "1500 Réponses Mensuelles",
"2000_monthly_identified_users": "2000 Utilisateurs Identifiés Mensuels",
"30000_monthly_identified_users": "30000 Utilisateurs Identifiés Mensuels",
"3_projects": "3 Projets",
"5000_monthly_responses": "5,000 Réponses Mensuelles",
"7500_contacts": "7 500 Contacts",
"5_projects": "5 Projets",
"7500_monthly_identified_users": "7500 Utilisateurs Identifiés Mensuels",
"advanced_targeting": "Ciblage Avancé",
"all_integrations": "Toutes les intégrations",
"all_surveying_features": "Tous les outils d'arpentage",
"annually": "Annuellement",
"api_webhooks": "API et Webhooks",
"app_surveys": "Sondages d'application",
"attribute_based_targeting": "Ciblage basé sur les attributs",
"contact_us": "Contactez-nous",
"current": "Actuel",
"current_plan": "Plan actuel",
"current_tier_limit": "Limite de niveau actuel",
"custom": "Personnalisé et Échelle",
"custom_contacts_limit": "Limite de contacts personnalisé",
"custom_miu_limit": "Limite MIU personnalisé",
"custom_project_limit": "Limite de projet personnalisé",
"custom_response_limit": "Limite de réponse personnalisé",
"customer_success_manager": "Responsable de la réussite client",
"email_embedded_surveys": "Sondages intégrés par e-mail",
"email_follow_ups": "Relances par e-mail",
"email_support": "Support par e-mail",
"enterprise": "Entreprise",
"enterprise_description": "Soutien premium et limites personnalisées.",
"everybody_has_the_free_plan_by_default": "Tout le monde a le plan gratuit par défaut !",
"everything_in_free": "Tout est gratuit",
"everything_in_scale": "Tout à l'échelle",
"everything_in_startup": "Tout dans le Startup",
"free": "Gratuit",
"free_description": "Sondages illimités, membres d'équipe, et plus encore.",
"get_2_months_free": "Obtenez 2 mois gratuits",
"get_in_touch": "Prenez contact",
"hosted_in_frankfurt": "Hébergé à Francfort",
"ios_android_sdks": "SDK iOS et Android pour les sondages mobiles",
"link_surveys": "Sondages par lien (partageables)",
"logic_jumps_hidden_fields_recurring_surveys": "Sauts logiques, champs cachés, enquêtes récurrentes, etc.",
"manage_card_details": "Gérer les détails de la carte",
"manage_subscription": "Gérer l'abonnement",
"monthly": "Mensuel",
"monthly_identified_users": "Utilisateurs Identifiés Mensuels",
"multi_language_surveys": "Sondages multilingues",
"per_month": "par mois",
"per_year": "par an",
"plan_upgraded_successfully": "Plan mis à jour avec succès",
"premium_support_with_slas": "Soutien premium avec SLA",
"priority_support": "Soutien Prioritaire",
"remove_branding": "Supprimer la marque",
"say_hi": "Dis bonjour !",
"scale": "Échelle",
"scale_description": "Fonctionnalités avancées pour développer votre entreprise.",
"startup": "Startup",
"startup_description": "Tout est gratuit avec des fonctionnalités supplémentaires.",
"switch_plan": "Changer de plan",
"switch_plan_confirmation_text": "Êtes-vous sûr de vouloir passer au plan {plan} ? Vous serez facturé {price} {period}.",
"team_access_roles": "Rôles d'accès d'équipe",
"technical_onboarding": "Intégration technique",
"unable_to_upgrade_plan": "Impossible de mettre à niveau le plan",
"unlimited_apps_websites": "Applications et sites Web illimités",
"unlimited_miu": "MIU Illimité",
"unlimited_projects": "Projets illimités",
"unlimited_responses": "Réponses illimitées",
@@ -1064,7 +1074,6 @@
"create_new_organization": "Créer une nouvelle organisation",
"create_new_organization_description": "Créer une nouvelle organisation pour gérer un ensemble différent de projets.",
"customize_email_with_a_higher_plan": "Personnalisez l'e-mail avec un plan supérieur",
"delete_member_confirmation": "Les membres supprimés perdront l'accès à tous les projets et enquêtes de votre organisation.",
"delete_organization": "Supprimer l'organisation",
"delete_organization_description": "Supprimer l'organisation avec tous ses projets, y compris toutes les enquêtes, réponses, personnes, actions et attributs.",
"delete_organization_warning": "Avant de procéder à la suppression de cette organisation, veuillez prendre connaissance des conséquences suivantes :",
@@ -1221,9 +1230,8 @@
"copy_survey_description": "Copier cette enquête dans un autre environnement",
"copy_survey_error": "Échec de la copie du sondage",
"copy_survey_link_to_clipboard": "Copier le lien du sondage dans le presse-papiers",
"copy_survey_partially_success": "{success} enquêtes copiées avec succès, {error} échouées.",
"copy_survey_success": "Enquête copiée avec succès !",
"delete_survey_and_responses_warning": "Êtes-vous sûr de vouloir supprimer cette enquête et toutes ses réponses?",
"delete_survey_and_responses_warning": "Êtes-vous sûr de vouloir supprimer cette enquête et toutes ses réponses ? Cette action ne peut pas être annulée.",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. Choisissez la langue par défaut pour ce sondage :",
"2_activate_translation_for_specific_languages": "2. Activer la traduction pour des langues spécifiques :",
@@ -1296,6 +1304,7 @@
"card_arrangement_for_survey_type_derived": "Disposition des cartes pour les enquêtes {surveyTypeDerived}",
"card_background_color": "Couleur de fond de la carte",
"card_border_color": "Couleur de la bordure de la carte",
"card_shadow_color": "Couleur de l'ombre de la carte",
"card_styling": "Style de carte",
"casual": "Décontracté",
"caution_edit_duplicate": "Dupliquer et modifier",
@@ -1320,6 +1329,7 @@
"change_the_brand_color_of_the_survey": "Changez la couleur de la marque du sondage.",
"change_the_placement_of_this_survey": "Changez le placement de cette enquête.",
"change_the_question_color_of_the_survey": "Changez la couleur des questions du sondage.",
"change_the_shadow_color_of_the_card": "Changez la couleur de l'ombre de la carte.",
"changes_saved": "Modifications enregistrées.",
"character_limit_toggle_description": "Limitez la longueur des réponses.",
"character_limit_toggle_title": "Ajouter des limites de caractères",
@@ -1776,7 +1786,6 @@
"setup_instructions": "Instructions d'installation",
"setup_integrations": "Configurer les intégrations",
"share_results": "Partager les résultats",
"share_survey": "Partager l'enquête",
"share_the_link": "Partager le lien",
"share_the_link_to_get_responses": "Partagez le lien pour obtenir des réponses",
"show_all_responses_that_match": "Afficher toutes les réponses correspondantes",

View File

@@ -34,8 +34,7 @@
"text": "Agora você pode fazer login com sua nova senha"
}
},
"reset_password": "Redefinir senha",
"reset_password_description": "Você será desconectado para redefinir sua senha."
"reset_password": "Redefinir senha"
},
"invite": {
"create_account": "Cria uma conta",
@@ -135,6 +134,7 @@
"app_survey": "Pesquisa de App",
"apply_filters": "Aplicar filtros",
"are_you_sure": "Certeza?",
"are_you_sure_this_action_cannot_be_undone": "Tem certeza? Essa ação não pode ser desfeita.",
"attributes": "atributos",
"avatar": "Avatar",
"back": "Voltar",
@@ -191,6 +191,7 @@
"e_commerce": "comércio eletrônico",
"edit": "Editar",
"email": "Email",
"embed": "incorporar",
"enterprise_license": "Licença Empresarial",
"environment_not_found": "Ambiente não encontrado",
"environment_notice": "Você está atualmente no ambiente {environment}.",
@@ -315,7 +316,6 @@
"remove": "remover",
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
"report_survey": "Relatório de Pesquisa",
"request_pricing": "Solicitar Preços",
"request_trial_license": "Pedir licença de teste",
"reset_to_default": "Restaurar para o padrão",
"response": "Resposta",
@@ -411,6 +411,7 @@
"website_survey": "Pesquisa de Site",
"weekly_summary": "Resumo semanal",
"welcome_card": "Cartão de boas-vindas",
"yes": "Sim",
"you": "Você",
"you_are_downgraded_to_the_community_edition": "Você foi rebaixado para a Edição Comunitária.",
"you_are_not_authorised_to_perform_this_action": "Você não tem autorização para fazer isso.",
@@ -595,7 +596,6 @@
"contact_not_found": "Nenhum contato encontrado",
"contacts_table_refresh": "Atualizar contatos",
"contacts_table_refresh_success": "Contatos atualizados com sucesso",
"delete_contact_confirmation": "Isso irá apagar todas as respostas da pesquisa e atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.",
"first_name": "Primeiro Nome",
"last_name": "Sobrenome",
"no_responses_found": "Nenhuma resposta encontrada",
@@ -632,7 +632,6 @@
"airtable_integration": "Integração com Airtable",
"airtable_integration_description": "Sincronize respostas diretamente com o Airtable.",
"airtable_integration_is_not_configured": "A integração com o Airtable não está configurada",
"airtable_logo": "Logo do Airtable",
"connect_with_airtable": "Conectar com o Airtable",
"link_airtable_table": "Vincular Tabela do Airtable",
"link_new_table": "Vincular nova tabela",
@@ -700,6 +699,7 @@
"select_a_database": "Selecionar Banco de Dados",
"select_a_field_to_map": "Selecione um campo para mapear",
"select_a_survey_question": "Escolha uma pergunta da pesquisa",
"sync_responses_with_a_notion_database": "Sincronizar respostas com um banco de dados do Notion",
"update_connection": "Reconectar Notion",
"update_connection_tooltip": "Reconecte a integração para incluir os novos bancos de dados adicionados. Suas integrações existentes permanecerão intactas."
},
@@ -721,7 +721,6 @@
"slack_integration": "Integração com o Slack",
"slack_integration_description": "Manda as respostas direto pro Slack.",
"slack_integration_is_not_configured": "A integração do Slack não está configurada na sua instância do Formbricks.",
"slack_logo": "Logotipo do Slack",
"slack_reconnect_button": "Reconectar",
"slack_reconnect_button_description": "<b>Observação:</b> Recentemente, alteramos nossa integração com o Slack para também suportar canais privados. Por favor, reconecte seu workspace do Slack."
},
@@ -906,7 +905,8 @@
"tag_already_exists": "Tag já existe",
"tag_deleted": "Tag apagada",
"tag_updated": "Tag atualizada",
"tags_merged": "Tags mescladas"
"tags_merged": "Tags mescladas",
"unique_constraint_failed_on_the_fields": "Falha na restrição única nos campos"
},
"teams": {
"manage_teams": "Gerenciar Equipes",
@@ -979,53 +979,63 @@
"api_keys_description": "Gerencie chaves de API para acessar as APIs de gerenciamento do Formbricks"
},
"billing": {
"1000_monthly_responses": "1000 Respostas Mensais",
"1_project": "1 Projeto",
"2000_contacts": "2.000 Contatos",
"10000_monthly_responses": "10000 Respostas Mensais",
"1500_monthly_responses": "1500 Respostas Mensais",
"2000_monthly_identified_users": "2000 Usuários Identificados Mensalmente",
"30000_monthly_identified_users": "30000 Usuários Identificados Mensalmente",
"3_projects": "3 Projetos",
"5000_monthly_responses": "5,000 Respostas Mensais",
"7500_contacts": "7.500 Contatos",
"5_projects": "5 Projetos",
"7500_monthly_identified_users": "7500 Usuários Identificados Mensalmente",
"advanced_targeting": "Mira Avançada",
"all_integrations": "Todas as Integrações",
"all_surveying_features": "Todos os recursos de levantamento",
"annually": "anualmente",
"api_webhooks": "API e Webhooks",
"app_surveys": "Pesquisas de App",
"attribute_based_targeting": "Segmentação Baseada em Atributos",
"contact_us": "Fale Conosco",
"current": "atual",
"current_plan": "Plano Atual",
"current_tier_limit": "Limite Atual de Nível",
"custom": "Personalizado e Escala",
"custom_contacts_limit": "Limite de Contatos Personalizado",
"custom_miu_limit": "Limite MIU personalizado",
"custom_project_limit": "Limite de Projeto Personalizado",
"custom_response_limit": "Limite de Resposta Personalizado",
"customer_success_manager": "Gerente de Sucesso do Cliente",
"email_embedded_surveys": "Pesquisas Incorporadas no Email",
"email_follow_ups": "Acompanhamentos por Email",
"email_support": "Suporte por Email",
"enterprise": "Empresa",
"enterprise_description": "Suporte premium e limites personalizados.",
"everybody_has_the_free_plan_by_default": "Todo mundo tem o plano gratuito por padrão!",
"everything_in_free": "Tudo de graça",
"everything_in_scale": "Tudo em Escala",
"everything_in_startup": "Tudo em Startup",
"free": "grátis",
"free_description": "Pesquisas ilimitadas, membros da equipe e mais.",
"get_2_months_free": "Ganhe 2 meses grátis",
"get_in_touch": "Entre em contato",
"hosted_in_frankfurt": "Hospedado em Frankfurt",
"ios_android_sdks": "SDK para iOS e Android para pesquisas móveis",
"link_surveys": "Link de Pesquisas (Compartilhável)",
"logic_jumps_hidden_fields_recurring_surveys": "Pulos Lógicos, Campos Ocultos, Pesquisas Recorrentes, etc.",
"manage_card_details": "Gerenciar Detalhes do Cartão",
"manage_subscription": "Gerenciar Assinatura",
"monthly": "mensal",
"monthly_identified_users": "Usuários Identificados Mensalmente",
"multi_language_surveys": "Pesquisas Multilíngues",
"per_month": "por mês",
"per_year": "por ano",
"plan_upgraded_successfully": "Plano atualizado com sucesso",
"premium_support_with_slas": "Suporte premium com SLAs",
"priority_support": "Suporte Prioritário",
"remove_branding": "Remover Marca",
"say_hi": "Diz oi!",
"scale": "escala",
"scale_description": "Recursos avançados pra escalar seu negócio.",
"startup": "startup",
"startup_description": "Tudo no Grátis com recursos adicionais.",
"switch_plan": "Mudar Plano",
"switch_plan_confirmation_text": "Tem certeza de que deseja mudar para o plano {plan}? Você será cobrado {price} {period}.",
"team_access_roles": "Funções de Acesso da Equipe",
"technical_onboarding": "Integração Técnica",
"unable_to_upgrade_plan": "Não foi possível atualizar o plano",
"unlimited_apps_websites": "Apps e Sites Ilimitados",
"unlimited_miu": "MIU Ilimitado",
"unlimited_projects": "Projetos Ilimitados",
"unlimited_responses": "Respostas Ilimitadas",
@@ -1064,7 +1074,6 @@
"create_new_organization": "Criar nova organização",
"create_new_organization_description": "Criar uma nova organização para lidar com um conjunto diferente de projetos.",
"customize_email_with_a_higher_plan": "Personalize o email com um plano superior",
"delete_member_confirmation": "Membros apagados perderão acesso a todos os projetos e pesquisas da sua organização.",
"delete_organization": "Excluir Organização",
"delete_organization_description": "Excluir organização com todos os seus projetos, incluindo todas as pesquisas, respostas, pessoas, ações e atributos",
"delete_organization_warning": "Antes de continuar com a exclusão desta organização, esteja ciente das seguintes consequências:",
@@ -1221,9 +1230,8 @@
"copy_survey_description": "Copiar essa pesquisa para outro ambiente",
"copy_survey_error": "Falha ao copiar pesquisa",
"copy_survey_link_to_clipboard": "Copiar link da pesquisa para a área de transferência",
"copy_survey_partially_success": "{success} pesquisas copiadas com sucesso, {error} falharam.",
"copy_survey_success": "Pesquisa copiada com sucesso!",
"delete_survey_and_responses_warning": "Você tem certeza de que quer deletar essa pesquisa e todas as suas respostas?",
"delete_survey_and_responses_warning": "Você tem certeza de que quer deletar essa pesquisa e todas as suas respostas? Essa ação não pode ser desfeita.",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. Escolha o idioma padrão para essa pesquisa:",
"2_activate_translation_for_specific_languages": "2. Ativar tradução para idiomas específicos:",
@@ -1296,6 +1304,7 @@
"card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Pesquisas {surveyTypeDerived}",
"card_background_color": "Cor de fundo do cartão",
"card_border_color": "Cor da borda do cartão",
"card_shadow_color": "cor da sombra do cartão",
"card_styling": "Estilização de Cartão",
"casual": "Casual",
"caution_edit_duplicate": "Duplicar e editar",
@@ -1320,6 +1329,7 @@
"change_the_brand_color_of_the_survey": "Muda a cor da marca da pesquisa.",
"change_the_placement_of_this_survey": "Muda a posição dessa pesquisa.",
"change_the_question_color_of_the_survey": "Muda a cor da pergunta da pesquisa.",
"change_the_shadow_color_of_the_card": "Muda a cor da sombra do cartão.",
"changes_saved": "Mudanças salvas.",
"character_limit_toggle_description": "Limite o quão curta ou longa uma resposta pode ser.",
"character_limit_toggle_title": "Adicionar limites de caracteres",
@@ -1776,7 +1786,6 @@
"setup_instructions": "Instruções de configuração",
"setup_integrations": "Configurar integrações",
"share_results": "Compartilhar resultados",
"share_survey": "Compartilhar pesquisa",
"share_the_link": "Compartilha o link",
"share_the_link_to_get_responses": "Compartilha o link pra receber respostas",
"show_all_responses_that_match": "Mostrar todas as respostas que correspondem",

View File

@@ -34,8 +34,7 @@
"text": "Pode agora iniciar sessão com a sua nova palavra-passe"
}
},
"reset_password": "Redefinir palavra-passe",
"reset_password_description": "Será desconectado para redefinir a sua palavra-passe."
"reset_password": "Redefinir palavra-passe"
},
"invite": {
"create_account": "Criar uma conta",
@@ -135,6 +134,7 @@
"app_survey": "Inquérito da Aplicação",
"apply_filters": "Aplicar filtros",
"are_you_sure": "Tem a certeza?",
"are_you_sure_this_action_cannot_be_undone": "Tem a certeza? Esta ação não pode ser desfeita.",
"attributes": "Atributos",
"avatar": "Avatar",
"back": "Voltar",
@@ -191,6 +191,7 @@
"e_commerce": "Comércio Eletrónico",
"edit": "Editar",
"email": "Email",
"embed": "Incorporar",
"enterprise_license": "Licença Enterprise",
"environment_not_found": "Ambiente não encontrado",
"environment_notice": "Está atualmente no ambiente {environment}.",
@@ -315,7 +316,6 @@
"remove": "Remover",
"reorder_and_hide_columns": "Reordenar e ocultar colunas",
"report_survey": "Relatório de Inquérito",
"request_pricing": "Pedido de Preços",
"request_trial_license": "Solicitar licença de teste",
"reset_to_default": "Repor para o padrão",
"response": "Resposta",
@@ -411,6 +411,7 @@
"website_survey": "Inquérito do Website",
"weekly_summary": "Resumo semanal",
"welcome_card": "Cartão de boas-vindas",
"yes": "Sim",
"you": "Você",
"you_are_downgraded_to_the_community_edition": "Foi rebaixado para a Edição Comunitária.",
"you_are_not_authorised_to_perform_this_action": "Não está autorizado para realizar esta ação.",
@@ -595,7 +596,6 @@
"contact_not_found": "Nenhum contacto encontrado",
"contacts_table_refresh": "Atualizar contactos",
"contacts_table_refresh_success": "Contactos atualizados com sucesso",
"delete_contact_confirmation": "Isto irá eliminar todas as respostas das pesquisas e os atributos de contato associados a este contato. Qualquer direcionamento e personalização baseados nos dados deste contato serão perdidos.",
"first_name": "Primeiro Nome",
"last_name": "Apelido",
"no_responses_found": "Nenhuma resposta encontrada",
@@ -632,7 +632,6 @@
"airtable_integration": "Integração com o Airtable",
"airtable_integration_description": "Sincronize respostas diretamente com o Airtable.",
"airtable_integration_is_not_configured": "A integração com o Airtable não está configurada",
"airtable_logo": "logotipo Airtable",
"connect_with_airtable": "Ligar ao Airtable",
"link_airtable_table": "Ligar Tabela Airtable",
"link_new_table": "Ligar nova tabela",
@@ -700,6 +699,7 @@
"select_a_database": "Selecionar Base de Dados",
"select_a_field_to_map": "Selecione um campo para mapear",
"select_a_survey_question": "Selecione uma pergunta do inquérito",
"sync_responses_with_a_notion_database": "Sincronizar respostas com uma Base de Dados do Notion",
"update_connection": "Reconectar Notion",
"update_connection_tooltip": "Restabeleça a integração para incluir as bases de dados recentemente adicionadas. As suas integrações existentes permanecerão intactas."
},
@@ -721,7 +721,6 @@
"slack_integration": "Integração com Slack",
"slack_integration_description": "Enviar respostas diretamente para o Slack.",
"slack_integration_is_not_configured": "A integração com o Slack não está configurada na sua instância do Formbricks.",
"slack_logo": "Logótipo Slack",
"slack_reconnect_button": "Reconectar",
"slack_reconnect_button_description": "<b>Nota:</b> Recentemente alterámos a nossa integração com o Slack para também suportar canais privados. Por favor, reconecte o seu espaço de trabalho do Slack."
},
@@ -906,7 +905,8 @@
"tag_already_exists": "A etiqueta já existe",
"tag_deleted": "Etiqueta eliminada",
"tag_updated": "Etiqueta atualizada",
"tags_merged": "Etiquetas fundidas"
"tags_merged": "Etiquetas fundidas",
"unique_constraint_failed_on_the_fields": "A restrição de unicidade falhou nos campos"
},
"teams": {
"manage_teams": "Gerir equipas",
@@ -979,53 +979,63 @@
"api_keys_description": "Gerir chaves API para aceder às APIs de gestão do Formbricks"
},
"billing": {
"1000_monthly_responses": "1000 Respostas Mensais",
"1_project": "1 Projeto",
"2000_contacts": "2,000 Contactos",
"10000_monthly_responses": "10000 Respostas Mensais",
"1500_monthly_responses": "1500 Respostas Mensais",
"2000_monthly_identified_users": "2000 Utilizadores Identificados Mensalmente",
"30000_monthly_identified_users": "30000 Utilizadores Identificados Mensalmente",
"3_projects": "3 Projetos",
"5000_monthly_responses": "5,000 Respostas Mensais",
"7500_contacts": "7,500 Contactos",
"5_projects": "5 Projetos",
"7500_monthly_identified_users": "7500 Utilizadores Identificados Mensalmente",
"advanced_targeting": "Segmentação Avançada",
"all_integrations": "Todas as Integrações",
"all_surveying_features": "Todas as funcionalidades de inquérito",
"annually": "Anualmente",
"api_webhooks": "API e Webhooks",
"app_surveys": "Inquéritos da Aplicação",
"attribute_based_targeting": "Segmentação Baseada em Atributos",
"contact_us": "Contacte-nos",
"current": "Atual",
"current_plan": "Plano Atual",
"current_tier_limit": "Limite Atual do Nível",
"custom": "Personalizado e Escala",
"custom_contacts_limit": "Limite de Contactos Personalizado",
"custom_miu_limit": "Limite MIU Personalizado",
"custom_project_limit": "Limite de Projeto Personalizado",
"custom_response_limit": "Limite de Resposta Personalizado",
"customer_success_manager": "Gestor de Sucesso do Cliente",
"email_embedded_surveys": "Inquéritos Incorporados no Email",
"email_follow_ups": "Acompanhamentos por Email",
"email_support": "Suporte por Email",
"enterprise": "Empresa",
"enterprise_description": "Suporte premium e limites personalizados.",
"everybody_has_the_free_plan_by_default": "Todos têm o plano gratuito por defeito!",
"everything_in_free": "Tudo em Gratuito",
"everything_in_scale": "Tudo em Escala",
"everything_in_startup": "Tudo em Startup",
"free": "Grátis",
"free_description": "Inquéritos ilimitados, membros da equipa e mais.",
"get_2_months_free": "Obtenha 2 meses grátis",
"get_in_touch": "Entre em contacto",
"hosted_in_frankfurt": "Hospedado em Frankfurt",
"ios_android_sdks": "SDK iOS e Android para inquéritos móveis",
"link_surveys": "Ligar Inquéritos (Partilhável)",
"logic_jumps_hidden_fields_recurring_surveys": "Saltos Lógicos, Campos Ocultos, Inquéritos Recorrentes, etc.",
"manage_card_details": "Gerir Detalhes do Cartão",
"manage_subscription": "Gerir Subscrição",
"monthly": "Mensal",
"monthly_identified_users": "Utilizadores Identificados Mensalmente",
"multi_language_surveys": "Inquéritos Multilingues",
"per_month": "por mês",
"per_year": "por ano",
"plan_upgraded_successfully": "Plano atualizado com sucesso",
"premium_support_with_slas": "Suporte premium com SLAs",
"priority_support": "Suporte Prioritário",
"remove_branding": "Remover Marca",
"say_hi": "Diga Olá!",
"scale": "Escala",
"scale_description": "Funcionalidades avançadas para escalar o seu negócio.",
"startup": "Inicialização",
"startup_description": "Tudo no plano Gratuito com funcionalidades adicionais.",
"switch_plan": "Mudar Plano",
"switch_plan_confirmation_text": "Tem a certeza de que deseja mudar para o plano {plan}? Ser-lhe-á cobrado {price} {period}.",
"team_access_roles": "Funções de Acesso da Equipa",
"technical_onboarding": "Integração Técnica",
"unable_to_upgrade_plan": "Não é possível atualizar o plano",
"unlimited_apps_websites": "Aplicações e Websites Ilimitados",
"unlimited_miu": "MIU Ilimitado",
"unlimited_projects": "Projetos Ilimitados",
"unlimited_responses": "Respostas Ilimitadas",
@@ -1064,7 +1074,6 @@
"create_new_organization": "Criar nova organização",
"create_new_organization_description": "Crie uma nova organização para gerir um conjunto diferente de projetos.",
"customize_email_with_a_higher_plan": "Personalize o e-mail com um plano superior",
"delete_member_confirmation": "Membros eliminados perderão acesso a todos os projetos e inquéritos da sua organização.",
"delete_organization": "Eliminar Organização",
"delete_organization_description": "Eliminar organização com todos os seus projetos, incluindo todos os inquéritos, respostas, pessoas, ações e atributos",
"delete_organization_warning": "Antes de prosseguir com a eliminação desta organização, esteja ciente das seguintes consequências:",
@@ -1221,9 +1230,8 @@
"copy_survey_description": "Copiar este questionário para outro ambiente",
"copy_survey_error": "Falha ao copiar inquérito",
"copy_survey_link_to_clipboard": "Copiar link do inquérito para a área de transferência",
"copy_survey_partially_success": "{success} inquéritos copiados com sucesso, {error} falharam.",
"copy_survey_success": "Inquérito copiado com sucesso!",
"delete_survey_and_responses_warning": "Tem a certeza de que deseja eliminar este inquérito e todas as suas respostas?",
"delete_survey_and_responses_warning": "Tem a certeza de que deseja eliminar este inquérito e todas as suas respostas? Esta ação não pode ser desfeita.",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. Escolha o idioma padrão para este inquérito:",
"2_activate_translation_for_specific_languages": "2. Ativar tradução para idiomas específicos:",
@@ -1296,6 +1304,7 @@
"card_arrangement_for_survey_type_derived": "Arranjo de Cartões para Inquéritos {surveyTypeDerived}",
"card_background_color": "Cor de fundo do cartão",
"card_border_color": "Cor da borda do cartão",
"card_shadow_color": "Cor da sombra do cartão",
"card_styling": "Estilo do cartão",
"casual": "Casual",
"caution_edit_duplicate": "Duplicar e editar",
@@ -1320,6 +1329,7 @@
"change_the_brand_color_of_the_survey": "Alterar a cor da marca do inquérito",
"change_the_placement_of_this_survey": "Alterar a colocação deste inquérito.",
"change_the_question_color_of_the_survey": "Alterar a cor da pergunta do inquérito",
"change_the_shadow_color_of_the_card": "Alterar a cor da sombra do cartão.",
"changes_saved": "Alterações guardadas.",
"character_limit_toggle_description": "Limitar o quão curta ou longa uma resposta pode ser.",
"character_limit_toggle_title": "Adicionar limites de caracteres",
@@ -1776,7 +1786,6 @@
"setup_instructions": "Instruções de configuração",
"setup_integrations": "Configurar integrações",
"share_results": "Partilhar resultados",
"share_survey": "Partilhar inquérito",
"share_the_link": "Partilhar o link",
"share_the_link_to_get_responses": "Partilhe o link para obter respostas",
"show_all_responses_that_match": "Mostrar todas as respostas que correspondem",

View File

@@ -34,8 +34,7 @@
"text": "您現在可以使用新密碼登入"
}
},
"reset_password": "重設密碼",
"reset_password_description": "您將被登出以重設您的密碼。"
"reset_password": "重設密碼"
},
"invite": {
"create_account": "建立帳戶",
@@ -135,6 +134,7 @@
"app_survey": "應用程式問卷",
"apply_filters": "套用篩選器",
"are_you_sure": "您確定嗎?",
"are_you_sure_this_action_cannot_be_undone": "您確定嗎?此操作無法復原。",
"attributes": "屬性",
"avatar": "頭像",
"back": "返回",
@@ -191,6 +191,7 @@
"e_commerce": "電子商務",
"edit": "編輯",
"email": "電子郵件",
"embed": "嵌入",
"enterprise_license": "企業授權",
"environment_not_found": "找不到環境",
"environment_notice": "您目前在 '{'environment'}' 環境中。",
@@ -315,7 +316,6 @@
"remove": "移除",
"reorder_and_hide_columns": "重新排序和隱藏欄位",
"report_survey": "報告問卷",
"request_pricing": "請求定價",
"request_trial_license": "請求試用授權",
"reset_to_default": "重設為預設值",
"response": "回應",
@@ -411,6 +411,7 @@
"website_survey": "網站問卷",
"weekly_summary": "每週摘要",
"welcome_card": "歡迎卡片",
"yes": "是",
"you": "您",
"you_are_downgraded_to_the_community_edition": "您已降級至社群版。",
"you_are_not_authorised_to_perform_this_action": "您未獲授權執行此操作。",
@@ -595,7 +596,6 @@
"contact_not_found": "找不到此聯絡人",
"contacts_table_refresh": "重新整理聯絡人",
"contacts_table_refresh_success": "聯絡人已成功重新整理",
"delete_contact_confirmation": "這將刪除與此聯繫人相關的所有調查回應和聯繫屬性。任何基於此聯繫人數據的定位和個性化將會丟失。",
"first_name": "名字",
"last_name": "姓氏",
"no_responses_found": "找不到回應",
@@ -632,7 +632,6 @@
"airtable_integration": "Airtable 整合",
"airtable_integration_description": "直接與 Airtable 同步回應。",
"airtable_integration_is_not_configured": "尚未設定 Airtable 整合",
"airtable_logo": "Airtable 標誌",
"connect_with_airtable": "連線 Airtable",
"link_airtable_table": "連結 Airtable 表格",
"link_new_table": "連結新表格",
@@ -700,6 +699,7 @@
"select_a_database": "選取資料庫",
"select_a_field_to_map": "選取要對應的欄位",
"select_a_survey_question": "選取問卷問題",
"sync_responses_with_a_notion_database": "與 Notion 資料庫同步回應",
"update_connection": "重新連線 Notion",
"update_connection_tooltip": "重新連接整合以包含新添加的資料庫。您現有的整合將保持不變。"
},
@@ -721,7 +721,6 @@
"slack_integration": "Slack 整合",
"slack_integration_description": "直接將回應傳送至 Slack。",
"slack_integration_is_not_configured": "您的 Formbricks 執行個體中尚未設定 Slack 整合。",
"slack_logo": "Slack 標誌",
"slack_reconnect_button": "重新連線",
"slack_reconnect_button_description": "<b>注意:</b>我們最近變更了我們的 Slack 整合以支援私人頻道。請重新連線您的 Slack 工作區。"
},
@@ -906,7 +905,8 @@
"tag_already_exists": "標籤已存在",
"tag_deleted": "標籤已刪除",
"tag_updated": "標籤已更新",
"tags_merged": "標籤已合併"
"tags_merged": "標籤已合併",
"unique_constraint_failed_on_the_fields": "欄位上唯一性限制失敗"
},
"teams": {
"manage_teams": "管理團隊",
@@ -979,53 +979,63 @@
"api_keys_description": "管理 API 金鑰以存取 Formbricks 管理 API"
},
"billing": {
"1000_monthly_responses": "1000 個每月回應",
"1_project": "1 個專案",
"2000_contacts": "2000 個聯絡人",
"10000_monthly_responses": "10000 個每月回應",
"1500_monthly_responses": "1500 個每月回應",
"2000_monthly_identified_users": "2000 個每月識別使用者",
"30000_monthly_identified_users": "30000 個每月識別使用者",
"3_projects": "3 個專案",
"5000_monthly_responses": "5000 個每月回應",
"7500_contacts": "7500 個聯絡人",
"5_projects": "5 個專案",
"7500_monthly_identified_users": "7500 個每月識別使用者",
"advanced_targeting": "進階目標設定",
"all_integrations": "所有整合",
"all_surveying_features": "所有調查功能",
"annually": "每年",
"api_webhooks": "API 和 Webhook",
"app_surveys": "應用程式問卷",
"attribute_based_targeting": "基於屬性的定位",
"contact_us": "聯絡我們",
"current": "目前",
"current_plan": "目前方案",
"current_tier_limit": "目前層級限制",
"custom": "自訂 & 規模",
"custom_contacts_limit": "自訂聯絡人上限",
"custom_miu_limit": "自訂 MIU 上限",
"custom_project_limit": "自訂專案上限",
"custom_response_limit": "自訂回應上限",
"customer_success_manager": "客戶成功經理",
"email_embedded_surveys": "電子郵件嵌入式問卷",
"email_follow_ups": "電子郵件後續追蹤",
"email_support": "電子郵件支援",
"enterprise": "企業版",
"enterprise_description": "頂級支援和自訂限制。",
"everybody_has_the_free_plan_by_default": "每個人預設都有免費方案!",
"everything_in_free": "免費方案中的所有功能",
"everything_in_scale": "進階方案中的所有功能",
"everything_in_startup": "啟動方案中的所有功能",
"free": "免費",
"free_description": "無限問卷、團隊成員等。",
"get_2_months_free": "免費獲得 2 個月",
"get_in_touch": "取得聯繫",
"hosted_in_frankfurt": "託管在 Frankfurt",
"ios_android_sdks": "iOS 和 Android SDK 用於行動問卷",
"link_surveys": "連結問卷(可分享)",
"logic_jumps_hidden_fields_recurring_surveys": "邏輯跳躍、隱藏欄位、定期問卷等。",
"manage_card_details": "管理卡片詳細資料",
"manage_subscription": "管理訂閱",
"monthly": "每月",
"monthly_identified_users": "每月識別使用者",
"multi_language_surveys": "多語言問卷",
"per_month": "每月",
"per_year": "每年",
"plan_upgraded_successfully": "方案已成功升級",
"premium_support_with_slas": "具有 SLA 的頂級支援",
"priority_support": "優先支援",
"remove_branding": "移除品牌",
"say_hi": "打個招呼!",
"scale": "進階版",
"scale_description": "用於擴展業務的進階功能。",
"startup": "啟動版",
"startup_description": "免費方案中的所有功能以及其他功能。",
"switch_plan": "切換方案",
"switch_plan_confirmation_text": "您確定要切換到 {plan} 計劃嗎?您將被收取 {price} {period}。",
"team_access_roles": "團隊存取角色",
"technical_onboarding": "技術新手上路",
"unable_to_upgrade_plan": "無法升級方案",
"unlimited_apps_websites": "無限應用程式和網站",
"unlimited_miu": "無限 MIU",
"unlimited_projects": "無限專案",
"unlimited_responses": "無限回應",
@@ -1064,7 +1074,6 @@
"create_new_organization": "建立新組織",
"create_new_organization_description": "建立新組織以處理一組不同的專案。",
"customize_email_with_a_higher_plan": "使用更高等級的方案自訂電子郵件",
"delete_member_confirmation": "刪除的成員將失去存取您組織的所有專案和問卷的權限。",
"delete_organization": "刪除組織",
"delete_organization_description": "刪除包含所有專案的組織,包括所有問卷、回應、人員、操作和屬性",
"delete_organization_warning": "在您繼續刪除此組織之前,請注意以下後果:",
@@ -1221,9 +1230,8 @@
"copy_survey_description": "將此問卷複製到另一個環境",
"copy_survey_error": "無法複製問卷",
"copy_survey_link_to_clipboard": "將問卷連結複製到剪貼簿",
"copy_survey_partially_success": "{success} 個問卷已成功複製,{error} 個失敗。",
"copy_survey_success": "問卷已成功複製!",
"delete_survey_and_responses_warning": "您確定要刪除此問卷及其所有回應嗎?",
"delete_survey_and_responses_warning": "您確定要刪除此問卷及其所有回應嗎?此操作無法復原。",
"edit": {
"1_choose_the_default_language_for_this_survey": "1. 選擇此問卷的預設語言:",
"2_activate_translation_for_specific_languages": "2. 啟用特定語言的翻譯:",
@@ -1296,6 +1304,7 @@
"card_arrangement_for_survey_type_derived": "'{'surveyTypeDerived'}' 問卷的卡片排列",
"card_background_color": "卡片背景顏色",
"card_border_color": "卡片邊框顏色",
"card_shadow_color": "卡片陰影顏色",
"card_styling": "卡片樣式設定",
"casual": "隨意",
"caution_edit_duplicate": "複製 & 編輯",
@@ -1320,6 +1329,7 @@
"change_the_brand_color_of_the_survey": "變更問卷的品牌顏色。",
"change_the_placement_of_this_survey": "變更此問卷的位置。",
"change_the_question_color_of_the_survey": "變更問卷的問題顏色。",
"change_the_shadow_color_of_the_card": "變更卡片的陰影顏色。",
"changes_saved": "已儲存變更。",
"character_limit_toggle_description": "限制答案的長度或短度。",
"character_limit_toggle_title": "新增字元限制",
@@ -1776,7 +1786,6 @@
"setup_instructions": "設定說明",
"setup_integrations": "設定整合",
"share_results": "分享結果",
"share_survey": "分享問卷",
"share_the_link": "分享連結",
"share_the_link_to_get_responses": "分享連結以取得回應",
"show_all_responses_that_match": "顯示所有相符的回應",

View File

@@ -25,7 +25,9 @@ export const getEnvironmentId = async (
*/
export const getEnvironmentIdFromSurveyIds = async (
surveyIds: string[]
): Promise<Result<string, ApiErrorResponseV2>> => {
): Promise<Result<string | null, ApiErrorResponseV2>> => {
if (surveyIds.length === 0) return ok(null);
const result = await fetchEnvironmentIdFromSurveyIds(surveyIds);
if (!result.ok) {

View File

@@ -75,13 +75,14 @@ export const PUT = async (request: NextRequest, props: { params: Promise<{ webho
);
}
// get surveys environment
const surveysEnvironmentId = await getEnvironmentIdFromSurveyIds(body.surveyIds);
const surveysEnvironmentIdResult = await getEnvironmentIdFromSurveyIds(body.surveyIds);
if (!surveysEnvironmentId.ok) {
return handleApiError(request, surveysEnvironmentId.error, auditLog);
if (!surveysEnvironmentIdResult.ok) {
return handleApiError(request, surveysEnvironmentIdResult.error, auditLog);
}
const surveysEnvironmentId = surveysEnvironmentIdResult.data;
// get webhook environment
const webhook = await getWebhook(params.webhookId);
@@ -101,7 +102,7 @@ export const PUT = async (request: NextRequest, props: { params: Promise<{ webho
}
// check if webhook environment matches the surveys environment
if (webhook.data.environmentId !== surveysEnvironmentId.data) {
if (surveysEnvironmentId && webhook.data.environmentId !== surveysEnvironmentId) {
return handleApiError(
request,
{

View File

@@ -57,10 +57,12 @@ export const POST = async (request: NextRequest) =>
);
}
const environmentIdResult = await getEnvironmentIdFromSurveyIds(body.surveyIds);
if (body.surveyIds && body.surveyIds.length > 0) {
const environmentIdResult = await getEnvironmentIdFromSurveyIds(body.surveyIds);
if (!environmentIdResult.ok) {
return handleApiError(request, environmentIdResult.error, auditLog);
if (!environmentIdResult.ok) {
return handleApiError(request, environmentIdResult.error, auditLog);
}
}
if (!hasPermission(authentication.environmentPermissions, body.environmentId, "POST")) {

View File

@@ -34,8 +34,8 @@ export const getSegments = reactCache((environmentId: string) =>
},
{
key: createCacheKey.environment.segments(environmentId),
// 30 minutes TTL - segment definitions change infrequently
ttl: 60 * 30 * 1000, // 30 minutes in milliseconds
// This is a temporary fix for the invalidation issues, will be changed later with a proper solution
ttl: 5 * 60 * 1000, // 5 minutes in milliseconds
}
)()
);

View File

@@ -6,13 +6,70 @@ import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
const bulkContactEndpoint: ZodOpenApiOperationObject = {
operationId: "uploadBulkContacts",
summary: "Upload Bulk Contacts",
description: "Uploads contacts in bulk",
description:
"Uploads contacts in bulk. Each contact in the payload must have an 'email' attribute present in their attributes array. The email attribute is mandatory and must be a valid email format. Without a valid email, the contact will be skipped during processing.",
requestBody: {
required: true,
description: "The contacts to upload",
description:
"The contacts to upload. Each contact must include an 'email' attribute in their attributes array. The email is used as the unique identifier for the contact.",
content: {
"application/json": {
schema: ZContactBulkUploadRequest,
example: {
environmentId: "env_01h2xce9q8p3w4x5y6z7a8b9c0",
contacts: [
{
attributes: [
{
attributeKey: {
key: "email",
name: "Email Address",
},
value: "john.doe@example.com",
},
{
attributeKey: {
key: "firstName",
name: "First Name",
},
value: "John",
},
{
attributeKey: {
key: "lastName",
name: "Last Name",
},
value: "Doe",
},
],
},
{
attributes: [
{
attributeKey: {
key: "email",
name: "Email Address",
},
value: "jane.smith@example.com",
},
{
attributeKey: {
key: "firstName",
name: "First Name",
},
value: "Jane",
},
{
attributeKey: {
key: "lastName",
name: "Last Name",
},
value: "Smith",
},
],
},
],
},
},
},
},

View File

@@ -44,8 +44,6 @@ describe("ProjectLimitModal", () => {
test("renders dialog and upgrade prompt with correct props", () => {
render(<ProjectLimitModal open={true} setOpen={setOpen} projectLimit={3} buttons={buttons} />);
expect(screen.getByTestId("dialog")).toBeInTheDocument();
expect(screen.getByTestId("dialog-content")).toHaveClass("bg-white");
expect(screen.getByTestId("dialog-title")).toHaveTextContent("common.projects_limit_reached");
expect(screen.getByTestId("upgrade-prompt")).toBeInTheDocument();
expect(screen.getByText("common.unlock_more_projects_with_a_higher_plan")).toBeInTheDocument();
expect(screen.getByText("common.you_have_reached_your_limit_of_project_limit")).toBeInTheDocument();

View File

@@ -1,6 +1,6 @@
"use client";
import { Dialog, DialogContent, DialogTitle } from "@/modules/ui/components/dialog";
import { Dialog, DialogContent } from "@/modules/ui/components/dialog";
import { ModalButton, UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
import { useTranslate } from "@tolgee/react";
@@ -16,8 +16,7 @@ export const ProjectLimitModal = ({ open, setOpen, projectLimit, buttons }: Proj
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="w-full max-w-[564px] bg-white">
<DialogTitle>{t("common.projects_limit_reached")}</DialogTitle>
<DialogContent>
<UpgradePrompt
title={t("common.unlock_more_projects_with_a_higher_plan")}
description={t("common.you_have_reached_your_limit_of_project_limit", { projectLimit })}

View File

@@ -13,7 +13,7 @@ export const AddEndingCardButton = ({ localSurvey, addEndingCard }: AddEndingCar
const { t } = useTranslate();
return (
<button
className="group inline-flex rounded-lg border border-slate-300 bg-slate-50 hover:cursor-pointer hover:bg-white"
className="group inline-flex items-stretch rounded-lg border border-slate-300 bg-slate-50 hover:cursor-pointer hover:bg-white"
onClick={() => addEndingCard(localSurvey.endings.length)}>
<div className="flex w-10 items-center justify-center rounded-l-lg bg-slate-400 transition-all duration-300 ease-in-out group-hover:bg-slate-500 group-aria-expanded:rounded-bl-none group-aria-expanded:rounded-br">
<PlusIcon className="h-6 w-6 text-white" />

View File

@@ -1,9 +1,16 @@
// Import the actions to access mocked functions
import { deleteSurveyAction } from "@/modules/survey/list/actions";
import { TSurvey } from "@/modules/survey/list/types/surveys";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { SurveyDropDownMenu } from "./survey-dropdown-menu";
// Cast to mocked functions
const mockDeleteSurveyAction = vi.mocked(deleteSurveyAction);
const mockToast = vi.mocked(toast);
// Mock translation
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({ t: (key: string) => key }),
@@ -43,6 +50,24 @@ vi.mock("@/modules/survey/list/actions", () => ({
getSurveyAction: vi.fn(() =>
Promise.resolve({ data: { id: "duplicatedSurveyId", name: "Duplicated Survey" } })
),
deleteSurveyAction: vi.fn(),
}));
// Mock next/navigation
const mockRouterRefresh = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({
refresh: mockRouterRefresh,
push: vi.fn(),
}),
}));
// Mock react-hot-toast
vi.mock("react-hot-toast", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
describe("SurveyDropDownMenu", () => {
@@ -240,4 +265,245 @@ describe("SurveyDropDownMenu", () => {
expect(mockDuplicateSurvey).toHaveBeenCalled();
});
});
describe("handleDeleteSurvey", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("successfully deletes survey - calls all expected functions and shows success toast", async () => {
const mockDeleteSurvey = vi.fn();
mockDeleteSurveyAction.mockResolvedValueOnce({ data: true });
render(
<SurveyDropDownMenu
environmentId="env123"
survey={fakeSurvey}
publicDomain="http://survey.test"
refreshSingleUseId={vi.fn()}
duplicateSurvey={vi.fn()}
deleteSurvey={mockDeleteSurvey}
/>
);
// Open dropdown and click delete
const menuWrapper = screen.getByTestId("survey-dropdown-menu");
const triggerElement = menuWrapper.querySelector("[class*='p-2']") as HTMLElement;
await userEvent.click(triggerElement);
const deleteButton = screen.getByText("common.delete");
await userEvent.click(deleteButton);
// Confirm deletion in dialog
const confirmDeleteButton = screen.getByText("common.delete");
await userEvent.click(confirmDeleteButton);
await waitFor(() => {
expect(mockDeleteSurveyAction).toHaveBeenCalledWith({ surveyId: "testSurvey" });
expect(mockDeleteSurvey).toHaveBeenCalledWith("testSurvey");
expect(mockToast.success).toHaveBeenCalledWith("environments.surveys.survey_deleted_successfully");
expect(mockRouterRefresh).toHaveBeenCalled();
});
});
test("handles deletion error - shows error toast and resets loading state", async () => {
const mockDeleteSurvey = vi.fn();
const deletionError = new Error("Deletion failed");
mockDeleteSurveyAction.mockRejectedValueOnce(deletionError);
render(
<SurveyDropDownMenu
environmentId="env123"
survey={fakeSurvey}
publicDomain="http://survey.test"
refreshSingleUseId={vi.fn()}
duplicateSurvey={vi.fn()}
deleteSurvey={mockDeleteSurvey}
/>
);
// Open dropdown and click delete
const menuWrapper = screen.getByTestId("survey-dropdown-menu");
const triggerElement = menuWrapper.querySelector("[class*='p-2']") as HTMLElement;
await userEvent.click(triggerElement);
const deleteButton = screen.getByText("common.delete");
await userEvent.click(deleteButton);
// Confirm deletion in dialog
const confirmDeleteButton = screen.getByText("common.delete");
await userEvent.click(confirmDeleteButton);
await waitFor(() => {
expect(mockDeleteSurveyAction).toHaveBeenCalledWith({ surveyId: "testSurvey" });
expect(mockDeleteSurvey).not.toHaveBeenCalled();
expect(mockToast.error).toHaveBeenCalledWith("environments.surveys.error_deleting_survey");
expect(mockRouterRefresh).not.toHaveBeenCalled();
});
});
test("manages loading state correctly during successful deletion", async () => {
const mockDeleteSurvey = vi.fn();
mockDeleteSurveyAction.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve({ data: true }), 100))
);
render(
<SurveyDropDownMenu
environmentId="env123"
survey={fakeSurvey}
publicDomain="http://survey.test"
refreshSingleUseId={vi.fn()}
duplicateSurvey={vi.fn()}
deleteSurvey={mockDeleteSurvey}
/>
);
// Open dropdown and click delete
const menuWrapper = screen.getByTestId("survey-dropdown-menu");
const triggerElement = menuWrapper.querySelector("[class*='p-2']") as HTMLElement;
await userEvent.click(triggerElement);
const deleteButton = screen.getByText("common.delete");
await userEvent.click(deleteButton);
// Confirm deletion in dialog using a more reliable selector
const confirmDeleteButton = screen.getByText("common.delete");
await userEvent.click(confirmDeleteButton);
// Wait for the deletion process to complete
await waitFor(() => {
expect(mockDeleteSurveyAction).toHaveBeenCalled();
expect(mockDeleteSurvey).toHaveBeenCalled();
expect(mockToast.success).toHaveBeenCalled();
});
});
test("manages loading state correctly during failed deletion", async () => {
const mockDeleteSurvey = vi.fn();
mockDeleteSurveyAction.mockImplementation(
() => new Promise((_, reject) => setTimeout(() => reject(new Error("Network error")), 100))
);
render(
<SurveyDropDownMenu
environmentId="env123"
survey={fakeSurvey}
publicDomain="http://survey.test"
refreshSingleUseId={vi.fn()}
duplicateSurvey={vi.fn()}
deleteSurvey={mockDeleteSurvey}
/>
);
// Open dropdown and click delete
const menuWrapper = screen.getByTestId("survey-dropdown-menu");
const triggerElement = menuWrapper.querySelector("[class*='p-2']") as HTMLElement;
await userEvent.click(triggerElement);
const deleteButton = screen.getByText("common.delete");
await userEvent.click(deleteButton);
// Confirm deletion in dialog using a more reliable selector
const confirmDeleteButton = screen.getByText("common.delete");
await userEvent.click(confirmDeleteButton);
// Wait for the error to occur
await waitFor(() => {
expect(mockDeleteSurveyAction).toHaveBeenCalled();
expect(mockToast.error).toHaveBeenCalledWith("environments.surveys.error_deleting_survey");
});
// Verify that deleteSurvey callback was not called due to error
expect(mockDeleteSurvey).not.toHaveBeenCalled();
expect(mockRouterRefresh).not.toHaveBeenCalled();
});
test("does not call router.refresh or success toast when deleteSurveyAction throws", async () => {
const mockDeleteSurvey = vi.fn();
mockDeleteSurveyAction.mockRejectedValueOnce(new Error("API Error"));
render(
<SurveyDropDownMenu
environmentId="env123"
survey={fakeSurvey}
publicDomain="http://survey.test"
refreshSingleUseId={vi.fn()}
duplicateSurvey={vi.fn()}
deleteSurvey={mockDeleteSurvey}
/>
);
// Open dropdown and click delete
const menuWrapper = screen.getByTestId("survey-dropdown-menu");
const triggerElement = menuWrapper.querySelector("[class*='p-2']") as HTMLElement;
await userEvent.click(triggerElement);
const deleteButton = screen.getByText("common.delete");
await userEvent.click(deleteButton);
// Confirm deletion in dialog
const confirmDeleteButton = screen.getByText("common.delete");
await userEvent.click(confirmDeleteButton);
await waitFor(() => {
expect(mockDeleteSurveyAction).toHaveBeenCalled();
expect(mockToast.error).toHaveBeenCalled();
});
// Verify success-path functions are not called
expect(mockDeleteSurvey).not.toHaveBeenCalled();
expect(mockToast.success).not.toHaveBeenCalled();
expect(mockRouterRefresh).not.toHaveBeenCalled();
});
test("calls functions in correct order during successful deletion", async () => {
const mockDeleteSurvey = vi.fn();
const callOrder: string[] = [];
mockDeleteSurveyAction.mockImplementation(async () => {
callOrder.push("deleteSurveyAction");
return { data: true };
});
mockDeleteSurvey.mockImplementation(() => {
callOrder.push("deleteSurvey");
});
(mockToast.success as any).mockImplementation(() => {
callOrder.push("toast.success");
});
mockRouterRefresh.mockImplementation(() => {
callOrder.push("router.refresh");
});
render(
<SurveyDropDownMenu
environmentId="env123"
survey={fakeSurvey}
publicDomain="http://survey.test"
refreshSingleUseId={vi.fn()}
duplicateSurvey={vi.fn()}
deleteSurvey={mockDeleteSurvey}
/>
);
// Open dropdown and click delete
const menuWrapper = screen.getByTestId("survey-dropdown-menu");
const triggerElement = menuWrapper.querySelector("[class*='p-2']") as HTMLElement;
await userEvent.click(triggerElement);
const deleteButton = screen.getByText("common.delete");
await userEvent.click(deleteButton);
// Confirm deletion in dialog
const confirmDeleteButton = screen.getByText("common.delete");
await userEvent.click(confirmDeleteButton);
await waitFor(() => {
expect(callOrder).toEqual(["deleteSurveyAction", "deleteSurvey", "toast.success", "router.refresh"]);
});
});
});
});

View File

@@ -71,13 +71,13 @@ export const SurveyDropDownMenu = ({
try {
await deleteSurveyAction({ surveyId });
deleteSurvey(surveyId);
router.refresh();
setDeleteDialogOpen(false);
toast.success(t("environments.surveys.survey_deleted_successfully"));
router.refresh();
} catch (error) {
toast.error(t("environments.surveys.error_deleting_survey"));
} finally {
setLoading(false);
}
setLoading(false);
};
const handleCopyLink = async (e: React.MouseEvent<HTMLButtonElement>) => {
@@ -242,6 +242,7 @@ export const SurveyDropDownMenu = ({
setOpen={setDeleteDialogOpen}
onDelete={() => handleDeleteSurvey(survey.id)}
text={t("environments.surveys.delete_survey_and_responses_warning")}
isDeleting={loading}
/>
)}

View File

@@ -10,7 +10,6 @@ import { TProjectConfigChannel } from "@formbricks/types/project";
import { TSurveyFilters } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { SurveyCard } from "./survey-card";
import { SurveyFilters } from "./survey-filters";
import { SurveysList, initialFilters as surveyFiltersInitialFiltersFromModule } from "./survey-list";
import { SurveyLoading } from "./survey-loading";
@@ -324,6 +323,24 @@ describe("SurveysList", () => {
expect(screen.getByText("Survey Two")).toBeInTheDocument();
});
test("handleDeleteSurvey shows loading state when the last survey is deleted", async () => {
const surveysData = [{ ...surveyMock, id: "s1", name: "Last Survey" }];
vi.mocked(getSurveysAction).mockResolvedValueOnce({ data: surveysData });
const user = userEvent.setup();
render(<SurveysList {...defaultProps} />);
await waitFor(() => expect(screen.getByText("Last Survey")).toBeInTheDocument());
expect(screen.queryByTestId("survey-loading")).not.toBeInTheDocument();
const deleteButtonS1 = screen.getByTestId("delete-s1");
await user.click(deleteButtonS1);
await waitFor(() => {
expect(screen.queryByText("Last Survey")).not.toBeInTheDocument();
expect(screen.getByTestId("survey-loading")).toBeInTheDocument();
});
});
test("handleDuplicateSurvey adds the duplicated survey to the beginning of the list", async () => {
const initialSurvey = { ...surveyMock, id: "s1", name: "Original Survey" };
vi.mocked(getSurveysAction).mockResolvedValueOnce({ data: [initialSurvey] });

View File

@@ -123,6 +123,7 @@ export const SurveysList = ({
const handleDeleteSurvey = async (surveyId: string) => {
const newSurveys = surveys.filter((survey) => survey.id !== surveyId);
setSurveys(newSurveys);
if (newSurveys.length === 0) setIsFetching(true);
};
const handleDuplicateSurvey = async (survey: TSurvey) => {

View File

@@ -5,7 +5,11 @@ import { useTranslate } from "@tolgee/react";
import { ArrowLeftIcon } from "lucide-react";
import { useRouter } from "next/navigation";
export const BackButton = () => {
interface BackButtonProps {
path?: string;
}
export const BackButton = ({ path }: BackButtonProps) => {
const router = useRouter();
const { t } = useTranslate();
return (
@@ -13,7 +17,11 @@ export const BackButton = () => {
variant="secondary"
size="sm"
onClick={() => {
router.back();
if (path) {
router.push(path);
} else {
router.back();
}
}}>
<ArrowLeftIcon />
{t("common.back")}

View File

@@ -7,7 +7,7 @@ export const MenuBar = () => {
<>
<div className="border-b border-slate-200 bg-white px-5 py-2.5 sm:flex sm:items-center sm:justify-between">
<div className="flex items-center space-x-2 whitespace-nowrap">
<BackButton />
<BackButton path="/" />
</div>
</div>
</>

View File

@@ -12,6 +12,7 @@ vi.mock("react-confetti", () => ({
data-colors={JSON.stringify(props.colors)}
data-number-of-pieces={props.numberOfPieces}
data-recycle={props.recycle}
style={props.style}
/>
)),
}));
@@ -36,6 +37,10 @@ describe("Confetti", () => {
expect(confettiElement).toHaveAttribute("data-colors", JSON.stringify(["#00C4B8", "#eee"]));
expect(confettiElement).toHaveAttribute("data-number-of-pieces", "400");
expect(confettiElement).toHaveAttribute("data-recycle", "false");
expect(confettiElement).toHaveAttribute(
"style",
"position: fixed; top: 0px; left: 0px; z-index: 9999; pointer-events: none;"
);
});
test("renders with custom colors", () => {

View File

@@ -13,5 +13,20 @@ export const Confetti: React.FC<ConfettiProps> = ({
colors?: string[];
}) => {
const { width, height } = useWindowSize();
return <ReactConfetti width={width} height={height} colors={colors} numberOfPieces={400} recycle={false} />;
return (
<ReactConfetti
width={width}
height={height}
colors={colors}
numberOfPieces={400}
recycle={false}
style={{
position: "fixed",
top: 0,
left: 0,
zIndex: 9999,
pointerEvents: "none",
}}
/>
);
};

View File

@@ -9,7 +9,7 @@ import {
DialogHeader,
DialogTitle,
DialogTrigger,
} from "./index";
} from ".";
// Mock Radix UI Dialog components
vi.mock("@radix-ui/react-dialog", () => {
@@ -120,7 +120,7 @@ describe("Dialog Components", () => {
</DialogContent>
);
expect(screen.queryByTestId("dialog-close")).toBeInTheDocument();
expect(screen.queryByTestId("dialog-close")).not.toBeInTheDocument();
expect(screen.queryByTestId("x-icon")).not.toBeInTheDocument();
});

View File

@@ -11,19 +11,19 @@ const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = ({ children, ...props }: DialogPrimitive.DialogPortalProps) => (
<DialogPrimitive.Portal {...props}>
<div className="fixed inset-0 z-50 flex items-start justify-center sm:items-center">{children}</div>
<div className="fixed inset-0 z-50 flex items-end justify-center md:items-center">{children}</div>
</DialogPrimitive.Portal>
);
DialogPortal.displayName = DialogPrimitive.Portal.displayName;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"bg-background/80 data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in fixed inset-0 z-50 backdrop-blur-sm transition-all duration-100",
"data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=open]:fade-in fixed inset-0 z-50 bg-black/80 backdrop-blur-sm transition-all duration-100",
className
)}
{...props}
@@ -31,29 +31,43 @@ const DialogOverlay = React.forwardRef<
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
interface DialogContentProps {
hideCloseButton?: boolean;
disableCloseOnOutsideClick?: boolean;
width?: "default" | "wide";
}
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
hideCloseButton?: boolean;
}
>(({ className, children, hideCloseButton, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"bg-background animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 sm:zoom-in-90 data-[state=open]:sm:slide-in-from-bottom-0 fixed z-50 grid gap-4 rounded-b-lg border p-6 shadow-lg sm:rounded-lg",
className
)}
{...props}>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
{!hideCloseButton ? <X className="h-4 w-4" /> : null}
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
React.ComponentRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & DialogContentProps
>(
(
{ className, children, hideCloseButton, disableCloseOnOutsideClick, width = "default", ...props },
ref
) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"animate-in data-[state=open]:fade-in-90 data-[state=open]:slide-in-from-bottom-10 md:zoom-in-90 data-[state=open]:md:slide-in-from-bottom-0 fixed z-50 flex max-h-[90dvh] w-full flex-col space-y-4 rounded-t-lg border bg-white p-4 shadow-lg md:overflow-hidden md:rounded-lg",
width === "default" ? "md:w-[720px]" : "md:w-[720px] lg:w-[960px]",
className
)}
onPointerDownOutside={disableCloseOnOutsideClick ? (e) => e.preventDefault() : undefined}
onEscapeKeyDown={disableCloseOnOutsideClick ? (e) => e.preventDefault() : undefined}
{...props}>
{children}
{!hideCloseButton && (
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent absolute right-3 top-[-0.25rem] z-10 rounded-sm bg-white transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:text-slate-500">
<X className="size-4 text-slate-500" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
);
DialogContent.displayName = DialogPrimitive.Content.displayName;
type DialogHeaderProps = Omit<React.HTMLAttributes<HTMLDivElement>, "dangerouslySetInnerHTML"> & {
@@ -63,7 +77,14 @@ type DialogHeaderProps = Omit<React.HTMLAttributes<HTMLDivElement>, "dangerously
};
const DialogHeader = ({ className, ...props }: DialogHeaderProps) => (
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
<div
className={cn(
"sticky top-[-32px] z-10 flex flex-shrink-0 flex-col gap-y-1 bg-white text-left",
"[&>svg]:text-primary [&>svg]:absolute [&>svg]:size-4 [&>svg~*]:items-center [&>svg~*]:pl-6 md:[&>svg~*]:flex",
className
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
@@ -75,35 +96,56 @@ type DialogFooterProps = Omit<React.HTMLAttributes<HTMLDivElement>, "dangerously
const DialogFooter = ({ className, ...props }: DialogFooterProps) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
className={cn(
"bottom-0 z-10 flex flex-shrink-0 flex-col-reverse bg-white md:sticky md:flex-row md:justify-end",
className
)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogBody = ({ className, ...props }: React.HTMLAttributes<HTMLElement>) => (
<section
className={cn("flex-1 overflow-y-auto text-sm", className)}
aria-label="Dialog content"
{...props}
/>
);
DialogBody.displayName = "DialogBody";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
className={cn("text-primary min-h-4 text-sm font-medium leading-none tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-muted-foreground text-sm", className)}
className={cn("font-regular text-sm text-slate-500", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger };
export {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogBody,
};

View File

@@ -0,0 +1,460 @@
import type { Meta, StoryObj } from "@storybook/react";
import { AlertCircle } from "lucide-react";
import { Button } from "../button";
import {
Dialog,
DialogBody,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "./index";
// Story options separate from component props
interface StoryOptions {
triggerText: string;
showHeader: boolean;
showIcon: boolean;
title: string;
showDescription: boolean;
description: string;
bodyContent?: React.ReactNode;
showFooter: boolean;
footerButtonConfiguration: "1" | "2" | "3";
primaryButtonText: string;
secondaryButtonText: string;
tertiaryButtonText: string;
bodyElementCount: number;
}
type StoryProps = React.ComponentProps<typeof DialogContent> & StoryOptions;
const DefaultBodyContent = (elementCount: number): React.ReactNode => {
return (
<div>
{Array(elementCount)
.fill(0)
.map((_, i) => (
<p key={i}>Scrollable content line {i + 1}</p>
))}
</div>
);
};
const meta: Meta<StoryProps> = {
title: "UI/Modal",
component: DialogContent,
tags: ["autodocs"],
parameters: {
layout: "centered",
controls: {
sort: "requiredFirst",
exclude: [],
},
},
argTypes: {
// Component Props - Behavior Category
hideCloseButton: {
control: "boolean",
description: "Whether to hide the close button (X)",
table: {
category: "Behavior",
type: { summary: "boolean" },
defaultValue: { summary: "false" },
},
order: 2,
},
disableCloseOnOutsideClick: {
control: "boolean",
description: "Whether to disable closing when clicking outside",
table: {
category: "Behavior",
type: { summary: "boolean" },
defaultValue: { summary: "false" },
},
order: 1,
},
// Story Options - Appearance Category
width: {
control: "select",
options: ["default", "wide"],
description: "Width of the modal",
table: {
category: "Appearance",
type: { summary: "string" },
defaultValue: { summary: "default" },
},
order: 1,
},
showHeader: {
control: "boolean",
description: "Whether to show the header section",
table: {
category: "Appearance",
type: { summary: "boolean" },
},
order: 2,
},
showIcon: {
control: "boolean",
description: "Whether to show an icon in the header",
table: {
category: "Appearance",
type: { summary: "boolean" },
},
order: 3,
},
showDescription: {
control: "boolean",
description: "Whether to show a description in the header",
table: {
category: "Appearance",
type: { summary: "boolean" },
},
order: 4,
},
showFooter: {
control: "boolean",
description: "Whether to show the footer section",
table: {
category: "Appearance",
type: { summary: "boolean" },
},
order: 5,
},
footerButtonConfiguration: {
control: "select",
options: ["1", "2", "3"],
description: "Number of buttons to show in footer",
table: {
category: "Appearance",
type: { summary: "string" },
},
order: 6,
},
// Story Options - Content Category
triggerText: {
control: "text",
description: "Text for the trigger button",
table: {
category: "Content",
type: { summary: "string" },
},
order: 1,
},
title: {
control: "text",
description: "Modal title text",
table: {
category: "Content",
type: { summary: "string" },
},
order: 2,
},
description: {
control: "text",
description: "Modal description text",
table: {
category: "Content",
type: { summary: "string" },
},
order: 3,
},
primaryButtonText: {
control: "text",
description: "Text for the primary button",
table: {
category: "Content",
type: { summary: "string" },
},
order: 4,
},
secondaryButtonText: {
control: "text",
description: "Text for the secondary button",
table: {
category: "Content",
type: { summary: "string" },
},
order: 5,
},
tertiaryButtonText: {
control: "text",
description: "Text for the tertiary button",
table: {
category: "Content",
type: { summary: "string" },
},
order: 6,
},
bodyElementCount: {
control: { type: "number", min: 1, max: 100, step: 1 },
description: "Number of elements in the body content",
table: {
category: "Content",
type: { summary: "number" },
},
order: 7,
},
},
};
export default meta;
type Story = StoryObj<typeof DialogContent> & { args: StoryOptions };
// Create a common render function to reduce duplication
const renderModal = (args: StoryProps) => {
// Extract component props
const {
hideCloseButton = false,
disableCloseOnOutsideClick = false,
width = "default",
className = "",
} = args;
// Extract story content options
const {
triggerText = "Open Modal",
showHeader = true,
showIcon = false,
title = "Modal Title",
showDescription = true,
description = "Modal description",
showFooter = true,
footerButtonConfiguration = "3",
primaryButtonText = "Confirm",
secondaryButtonText = "Cancel",
tertiaryButtonText = "Learn more",
bodyElementCount = 5,
} = args as StoryOptions;
const bodyContent = DefaultBodyContent(bodyElementCount);
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline">{triggerText}</Button>
</DialogTrigger>
<DialogContent
hideCloseButton={hideCloseButton}
disableCloseOnOutsideClick={disableCloseOnOutsideClick}
width={width}
className={className}>
{showHeader && (
<DialogHeader>
{showIcon && <AlertCircle />}
<DialogTitle>{title}</DialogTitle>
{showDescription && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
)}
<DialogBody>{bodyContent}</DialogBody>
{showFooter && footerButtonConfiguration === "3" && (
<DialogFooter className="md:justify-between">
<div className="flex w-full flex-col space-y-2 md:hidden">
<Button className="w-full">{primaryButtonText}</Button>
<Button className="w-full" variant="secondary">
{secondaryButtonText}
</Button>
<Button className="w-full" variant="ghost">
{tertiaryButtonText}
</Button>
</div>
<div className="hidden md:block">
<Button className="justify-self-start" variant="ghost">
{tertiaryButtonText}
</Button>
</div>
<div className="hidden md:flex md:space-x-2">
<Button variant="secondary">{secondaryButtonText}</Button>
<Button>{primaryButtonText}</Button>
</div>
</DialogFooter>
)}
{showFooter && footerButtonConfiguration !== "3" && (
<DialogFooter>
<div className="flex w-full flex-col space-y-2 md:hidden">
<Button className="w-full">{primaryButtonText}</Button>
{footerButtonConfiguration !== "1" && (
<Button className="w-full" variant="secondary">
{secondaryButtonText}
</Button>
)}
</div>
<div className="hidden md:flex md:space-x-2">
{footerButtonConfiguration !== "1" && (
<Button variant="secondary">{secondaryButtonText}</Button>
)}
<Button>{primaryButtonText}</Button>
</div>
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
};
export const Default: Story = {
render: renderModal,
args: {
triggerText: "Open Modal",
showHeader: true,
showIcon: true,
title: "Modal Title",
showDescription: true,
description: "This is a description of what this modal is for.",
showFooter: true,
footerButtonConfiguration: "3",
primaryButtonText: "Confirm",
secondaryButtonText: "Cancel",
tertiaryButtonText: "Learn more",
bodyElementCount: 5,
hideCloseButton: false,
disableCloseOnOutsideClick: false,
width: "default",
},
};
export const OnlyBody: Story = {
render: renderModal,
args: {
triggerText: "Open Modal - Body Only",
showHeader: false,
showIcon: false,
title: "",
showDescription: false,
description: "",
showFooter: false,
footerButtonConfiguration: "1",
primaryButtonText: "",
secondaryButtonText: "",
tertiaryButtonText: "",
bodyElementCount: 50,
hideCloseButton: false,
disableCloseOnOutsideClick: false,
width: "default",
},
parameters: {
docs: {
description: {
story: "A minimal modal with only body content, useful for simple content display.",
},
},
},
};
export const NoFooter: Story = {
render: renderModal,
args: {
triggerText: "Open Modal - No Footer",
showHeader: true,
showIcon: true,
title: "Modal Without Footer",
showDescription: false,
description: "This modal has a header and body but no footer buttons.",
showFooter: false,
footerButtonConfiguration: "1",
primaryButtonText: "",
secondaryButtonText: "",
tertiaryButtonText: "",
bodyElementCount: 10,
hideCloseButton: false,
disableCloseOnOutsideClick: false,
width: "default",
},
parameters: {
docs: {
description: {
story: "Modal with header and body content but no footer actions.",
},
},
},
};
export const NoHeader: Story = {
render: renderModal,
args: {
triggerText: "Open Modal - No Header",
showHeader: false,
showIcon: false,
title: "",
showDescription: false,
description: "",
showFooter: true,
footerButtonConfiguration: "2",
primaryButtonText: "Confirm",
secondaryButtonText: "Cancel",
tertiaryButtonText: "",
bodyElementCount: 8,
hideCloseButton: false,
disableCloseOnOutsideClick: false,
width: "default",
},
parameters: {
docs: {
description: {
story: "Modal without header, useful when you want to focus on content and actions.",
},
},
},
};
export const RestrictClose: Story = {
render: renderModal,
args: {
triggerText: "Open Modal - Restrict Close",
showHeader: true,
showIcon: true,
title: "Modal with Restricted Close",
showDescription: false,
description: "This modal hides the close button and prevents closing on outside click.",
showFooter: true,
footerButtonConfiguration: "2",
primaryButtonText: "Save",
secondaryButtonText: "Cancel",
tertiaryButtonText: "",
bodyElementCount: 5,
hideCloseButton: true,
disableCloseOnOutsideClick: true,
width: "default",
},
parameters: {
docs: {
description: {
story: "Use when you need to force user interaction with the modal content before closing.",
},
},
},
};
export const WideModal: Story = {
render: renderModal,
args: {
triggerText: "Open Modal - Wide Modal",
showHeader: true,
showIcon: true,
title: "Modal with more width",
showDescription: false,
description: "This modal has more width than the default modal.",
showFooter: true,
footerButtonConfiguration: "2",
primaryButtonText: "Save",
secondaryButtonText: "Cancel",
tertiaryButtonText: "",
bodyElementCount: 5,
hideCloseButton: false,
disableCloseOnOutsideClick: false,
width: "wide",
},
parameters: {
docs: {
description: {
story: "Use when you need to force user interaction with the modal content before closing.",
},
},
},
};

View File

@@ -1,106 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react";
import { fn } from "@storybook/test";
import { Modal } from "./index";
const meta = {
title: "UI/Modal",
component: Modal,
tags: ["autodocs"],
parameters: {
layout: "centered",
docs: {
description: {
component: "Modal component for displaying content in an overlay.",
},
story: {
inline: true,
},
},
},
argTypes: {
open: { control: "boolean" },
setOpen: { action: "setOpen" },
title: { control: "text" },
noPadding: { control: "boolean" },
blur: { control: "boolean" },
closeOnOutsideClick: { control: "boolean" },
size: { control: { type: "select", options: ["md", "lg"] } },
hideCloseButton: { control: "boolean" },
restrictOverflow: { control: "boolean" },
},
args: { setOpen: fn() },
} satisfies Meta<typeof Modal>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
open: true,
children: <div>Default Modal Content</div>,
title: "Default Modal",
},
parameters: {
docs: {
primary: true,
},
},
};
export const LargeSize: Story = {
args: {
...Default.args,
size: "lg",
title: "Large Modal",
},
};
export const NoPadding: Story = {
args: {
...Default.args,
noPadding: true,
title: "Modal without Padding",
},
};
export const WithBlur: Story = {
args: {
...Default.args,
blur: true,
title: "Modal with Blur",
},
};
export const HideCloseButton: Story = {
args: {
...Default.args,
hideCloseButton: true,
title: "Modal without Close Button",
},
};
export const PreventCloseOnOutsideClick: Story = {
args: {
...Default.args,
closeOnOutsideClick: false,
title: "Modal that doesn't close on outside click",
},
};
export const RestrictOverflow: Story = {
args: {
...Default.args,
restrictOverflow: true,
title: "Modal with Restricted Overflow",
children: (
<div style={{ height: "500px", overflowY: "auto" }}>
{Array(50)
.fill(0)
.map((_, i) => (
<p key={i}>Scrollable content line {i + 1}</p>
))}
</div>
),
},
};

View File

@@ -23,7 +23,7 @@ export const UpgradePrompt = ({ title, description, buttons }: UpgradePromptProp
<KeyIcon className="h-6 w-6 text-slate-900" />
</div>
<div className="flex max-w-[80%] flex-col items-center gap-2 text-center">
<p className="text-xl font-semibold text-slate-900">{title}</p>
<h2 className="text-xl font-semibold text-slate-900">{title}</h2>
<p className="text-sm text-slate-500">{description}</p>
</div>
<div className="flex gap-3">

View File

@@ -4,11 +4,19 @@ import { logger } from "@formbricks/logger";
// Define the v1 (now v2) client endpoints to be merged
const v1ClientEndpoints = {
"/responses/{responseId}": {
"/client/{environmentId}/responses/{responseId}": {
put: {
security: [],
description:
"Update an existing response for example when you want to mark a response as finished or you want to change an existing response's value.",
parameters: [
{
in: "path",
name: "environmentId",
required: true,
schema: { type: "string" },
description: "The ID of the environment.",
},
{
in: "path",
name: "responseId",
@@ -57,14 +65,15 @@ const v1ClientEndpoints = {
tags: ["Client API > Response"],
servers: [
{
url: "https://app.formbricks.com/api/v2/client",
url: "https://app.formbricks.com/api/v2",
description: "Formbricks Client",
},
],
},
},
"/{environmentId}/responses": {
"/client/{environmentId}/responses": {
post: {
security: [],
description:
"Create a response for a survey and its fields with the user's responses. The userId & meta here is optional",
requestBody: {
@@ -89,14 +98,15 @@ const v1ClientEndpoints = {
tags: ["Client API > Response"],
servers: [
{
url: "https://app.formbricks.com/api/v2/client",
url: "https://app.formbricks.com/api/v2",
description: "Formbricks Client",
},
],
},
},
"/{environmentId}/contacts/{userId}/attributes": {
"/client/{environmentId}/contacts/{userId}/attributes": {
put: {
security: [],
description:
"Update a contact's attributes in Formbricks to keep them in sync with your app or when you want to set a custom attribute in Formbricks.",
parameters: [
@@ -138,14 +148,15 @@ const v1ClientEndpoints = {
tags: ["Client API > Contacts"],
servers: [
{
url: "https://app.formbricks.com/api/v2/client",
url: "https://app.formbricks.com/api/v2",
description: "Formbricks Client",
},
],
},
},
"/{environmentId}/identify/contacts/{userId}": {
"/client/{environmentId}/identify/contacts/{userId}": {
get: {
security: [],
description:
"Retrieves a contact's state including their segments, displays, responses and other tracking information. If the contact doesn't exist, it will be created.",
parameters: [
@@ -167,14 +178,15 @@ const v1ClientEndpoints = {
tags: ["Client API > Contacts"],
servers: [
{
url: "https://app.formbricks.com/api/v2/client",
url: "https://app.formbricks.com/api/v2",
description: "Formbricks Client",
},
],
},
},
"/{environmentId}/displays": {
"/client/{environmentId}/displays": {
post: {
security: [],
description:
"Create a new display for a valid survey ID. If a userId is passed, the display is linked to the user.",
requestBody: {
@@ -199,48 +211,26 @@ const v1ClientEndpoints = {
tags: ["Client API > Display"],
servers: [
{
url: "https://app.formbricks.com/api/v2/client",
url: "https://app.formbricks.com/api/v2",
description: "Formbricks Client",
},
],
},
},
"/{environmentId}/displays/{displayId}": {
put: {
description:
"Update a Display for a user. A use case can be when a user submits a response & you want to link it to an existing display.",
parameters: [{ in: "path", name: "displayId", required: true, schema: { type: "string" } }],
requestBody: {
content: {
"application/json": {
schema: { example: { responseId: "response123" }, type: "object" },
},
},
},
responses: {
"200": {
content: {
"application/json": {
example: { displayId: "display123" },
schema: { type: "object" },
},
},
description: "OK",
},
},
summary: "Update Display",
tags: ["Client API > Display"],
servers: [
{
url: "https://app.formbricks.com/api/v2/client",
description: "Formbricks Client",
},
],
},
},
"/{environmentId}/environment": {
"/client/{environmentId}/environment": {
get: {
description: "Retrieves the environment state to be used in Formbricks SDKs",
security: [],
description:
"Retrieves the environment state to be used in Formbricks SDKs. **Cache Behavior**: This endpoint uses server-side caching with a **5-minute TTL (Time To Live)**. Any changes to surveys, action classes, project settings, or other environment data will take up to 5 minutes to reflect in the API response. This caching is implemented to improve performance for high-frequency SDK requests.",
parameters: [
{
in: "path",
name: "environmentId",
required: true,
schema: { type: "string" },
description: "The ID of the environment.",
},
],
responses: {
"200": {
content: {
@@ -256,14 +246,15 @@ const v1ClientEndpoints = {
tags: ["Client API > Environment"],
servers: [
{
url: "https://app.formbricks.com/api/v2/client",
url: "https://app.formbricks.com/api/v2",
description: "Formbricks Client",
},
],
},
},
"/{environmentId}/user": {
"/client/{environmentId}/user": {
post: {
security: [],
description:
"Endpoint for creating or identifying a user within the specified environment. If the user already exists, this will identify them and potentially update user attributes. If they don't exist, it will create a new user.",
requestBody: {
@@ -288,14 +279,15 @@ const v1ClientEndpoints = {
tags: ["Client API > User"],
servers: [
{
url: "https://app.formbricks.com/api/v2/client",
url: "https://app.formbricks.com/api/v2",
description: "Formbricks Client",
},
],
},
},
"/{environmentId}/storage": {
"/client/{environmentId}/storage": {
post: {
security: [],
summary: "Upload Private File",
description:
"API endpoint for uploading private files. Uploaded files are kept private so that only users with access to the specified environment can retrieve them. The endpoint validates the survey ID, file name, and file type from the request body, and returns a signed URL for S3 uploads along with a local upload URL.",
@@ -442,14 +434,15 @@ const v1ClientEndpoints = {
},
servers: [
{
url: "https://app.formbricks.com/api/v2/client",
url: "https://app.formbricks.com/api/v2",
description: "Formbricks API Server",
},
],
},
},
"/{environmentId}/storage/local": {
"/client/{environmentId}/storage/local": {
post: {
security: [],
summary: "Upload Private File to Local Storage",
description:
'API endpoint for uploading private files to local storage. The request must include a valid signature, UUID, and timestamp to verify the upload. The file is provided as a Base64 encoded string in the request body. The "Content-Type" header must be set to a valid MIME type, and the file data must be a valid file object (buffer).',
@@ -478,7 +471,8 @@ const v1ClientEndpoints = {
},
fileName: {
type: "string",
description: "The URI encoded file name.",
description:
"This must be the `fileName` returned from the [Upload Private File](/api-v2-reference/client-api->-file-upload/upload-private-file) endpoint (Step 1).",
},
fileType: {
type: "string",

View File

@@ -197,6 +197,7 @@ tls:
alpnProtocols:
- h2
- http/1.1
- acme-tls/1
EOT
echo "💡 Created traefik.yaml and traefik-dynamic.yaml file."

View File

@@ -632,7 +632,7 @@
},
"/api/v1/client/{environmentId}/environment": {
"get": {
"description": "Retrieves the environment state to be used in Formbricks SDKs",
"description": "Retrieves the environment state to be used in Formbricks SDKs **Cache Behavior**: This endpoint uses server-side caching with a **5-minute TTL (Time To Live)**. Any changes to surveys, action classes, project settings, or other environment data will take up to 5 minutes to reflect in the API response. This caching is implemented to improve performance for high-frequency SDK requests.",
"parameters": [
{
"description": "The ID of the environment",
@@ -2336,13 +2336,8 @@
"example": {
"description": "From API Docs (optional)",
"environmentId": "{{environmentId}}",
"key": "my-action",
"name": "My Action from Postman",
"noCodeConfig": {
"innerHtml": {
"value": "sign-up"
},
"type": "innerHtml"
},
"type": "code"
},
"type": "object"

View File

@@ -34,11 +34,18 @@ tags:
security:
- apiKeyAuth: []
paths:
/responses/{responseId}:
/client/{environmentId}/responses/{responseId}:
put:
security: []
description: Update an existing response for example when you want to mark a
response as finished or you want to change an existing response's value.
parameters:
- in: path
name: environmentId
required: true
schema:
type: string
description: The ID of the environment.
- in: path
name: responseId
required: true
@@ -77,10 +84,11 @@ paths:
tags:
- Client API > Response
servers:
- url: https://app.formbricks.com/api/v2/client
- url: https://app.formbricks.com/api/v2
description: Formbricks Client
/{environmentId}/responses:
/client/{environmentId}/responses:
post:
security: []
description: Create a response for a survey and its fields with the user's
responses. The userId & meta here is optional
requestBody:
@@ -104,10 +112,11 @@ paths:
tags:
- Client API > Response
servers:
- url: https://app.formbricks.com/api/v2/client
- url: https://app.formbricks.com/api/v2
description: Formbricks Client
/{environmentId}/contacts/{userId}/attributes:
/client/{environmentId}/contacts/{userId}/attributes:
put:
security: []
description: Update a contact's attributes in Formbricks to keep them in sync
with your app or when you want to set a custom attribute in Formbricks.
parameters:
@@ -152,10 +161,11 @@ paths:
tags:
- Client API > Contacts
servers:
- url: https://app.formbricks.com/api/v2/client
- url: https://app.formbricks.com/api/v2
description: Formbricks Client
/{environmentId}/identify/contacts/{userId}:
/client/{environmentId}/identify/contacts/{userId}:
get:
security: []
description: Retrieves a contact's state including their segments, displays,
responses and other tracking information. If the contact doesn't exist,
it will be created.
@@ -184,10 +194,11 @@ paths:
tags:
- Client API > Contacts
servers:
- url: https://app.formbricks.com/api/v2/client
- url: https://app.formbricks.com/api/v2
description: Formbricks Client
/{environmentId}/displays:
/client/{environmentId}/displays:
post:
security: []
description: Create a new display for a valid survey ID. If a userId is passed,
the display is linked to the user.
requestBody:
@@ -211,43 +222,24 @@ paths:
tags:
- Client API > Display
servers:
- url: https://app.formbricks.com/api/v2/client
- url: https://app.formbricks.com/api/v2
description: Formbricks Client
/{environmentId}/displays/{displayId}:
put:
description: Update a Display for a user. A use case can be when a user submits
a response & you want to link it to an existing display.
/client/{environmentId}/environment:
get:
security: []
description: "Retrieves the environment state to be used in Formbricks SDKs.
**Cache Behavior**: This endpoint uses server-side caching with a
**5-minute TTL (Time To Live)**. Any changes to surveys, action classes,
project settings, or other environment data will take up to 5 minutes to
reflect in the API response. This caching is implemented to improve
performance for high-frequency SDK requests."
parameters:
- in: path
name: displayId
name: environmentId
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
example:
responseId: response123
type: object
responses:
"200":
content:
application/json:
example:
displayId: display123
schema:
type: object
description: OK
summary: Update Display
tags:
- Client API > Display
servers:
- url: https://app.formbricks.com/api/v2/client
description: Formbricks Client
/{environmentId}/environment:
get:
description: Retrieves the environment state to be used in Formbricks SDKs
description: The ID of the environment.
responses:
"200":
content:
@@ -262,10 +254,11 @@ paths:
tags:
- Client API > Environment
servers:
- url: https://app.formbricks.com/api/v2/client
- url: https://app.formbricks.com/api/v2
description: Formbricks Client
/{environmentId}/user:
/client/{environmentId}/user:
post:
security: []
description: Endpoint for creating or identifying a user within the specified
environment. If the user already exists, this will identify them and
potentially update user attributes. If they don't exist, it will create
@@ -292,10 +285,11 @@ paths:
tags:
- Client API > User
servers:
- url: https://app.formbricks.com/api/v2/client
- url: https://app.formbricks.com/api/v2
description: Formbricks Client
/{environmentId}/storage:
/client/{environmentId}/storage:
post:
security: []
summary: Upload Private File
description: API endpoint for uploading private files. Uploaded files are kept
private so that only users with access to the specified environment can
@@ -402,10 +396,11 @@ paths:
example:
error: Survey survey123 not found
servers:
- url: https://app.formbricks.com/api/v2/client
- url: https://app.formbricks.com/api/v2
description: Formbricks API Server
/{environmentId}/storage/local:
/client/{environmentId}/storage/local:
post:
security: []
summary: Upload Private File to Local Storage
description: API endpoint for uploading private files to local storage. The
request must include a valid signature, UUID, and timestamp to verify
@@ -433,7 +428,9 @@ paths:
description: The ID of the survey associated with the file.
fileName:
type: string
description: The URI encoded file name.
description: This must be the `fileName` returned from the [Upload Private
File](/api-v2-reference/client-api->-file-upload/upload-private-file)
endpoint (Step 1).
fileType:
type: string
description: The MIME type of the file.
@@ -1531,12 +1528,17 @@ paths:
put:
operationId: uploadBulkContacts
summary: Upload Bulk Contacts
description: Uploads contacts in bulk
description: Uploads contacts in bulk. Each contact in the payload must have an
'email' attribute present in their attributes array. The email attribute
is mandatory and must be a valid email format. Without a valid email,
the contact will be skipped during processing.
tags:
- Management API > Contacts
requestBody:
required: true
description: The contacts to upload
description: The contacts to upload. Each contact must include an 'email'
attribute in their attributes array. The email is used as the unique
identifier for the contact.
content:
application/json:
schema:
@@ -1571,10 +1573,39 @@ paths:
- value
required:
- attributes
maxItems: 1000
maxItems: 250
required:
- environmentId
- contacts
example:
environmentId: env_01h2xce9q8p3w4x5y6z7a8b9c0
contacts:
- attributes:
- attributeKey:
key: email
name: Email Address
value: john.doe@example.com
- attributeKey:
key: firstName
name: First Name
value: John
- attributeKey:
key: lastName
name: Last Name
value: Doe
- attributes:
- attributeKey:
key: email
name: Email Address
value: jane.smith@example.com
- attributeKey:
key: firstName
name: First Name
value: Jane
- attributeKey:
key: lastName
name: Last Name
value: Smith
responses:
"200":
description: Contacts uploaded successfully.
@@ -4383,6 +4414,22 @@ components:
- enabled
- message
description: Email verification configuration (deprecated)
recaptcha:
type:
- object
- "null"
properties:
enabled:
type: boolean
threshold:
type: number
multipleOf: 0.1
minimum: 0.1
maximum: 0.9
required:
- enabled
- threshold
description: Google reCAPTCHA configuration
displayPercentage:
type:
- number
@@ -4431,6 +4478,7 @@ components:
- inlineTriggers
- isBackButtonHidden
- verifyEmail
- recaptcha
- displayPercentage
- questions
webhook:

View File

@@ -64,7 +64,6 @@
"pages": [
"xm-and-surveys/surveys/link-surveys/data-prefilling",
"xm-and-surveys/surveys/link-surveys/embed-surveys",
"xm-and-surveys/surveys/link-surveys/quota-management",
"xm-and-surveys/surveys/link-surveys/market-research-panel",
"xm-and-surveys/surveys/link-surveys/pin-protected-surveys",
"xm-and-surveys/surveys/link-surveys/single-use-links",

View File

@@ -80,11 +80,24 @@ When PUBLIC_URL is configured, the following routes are automatically served fro
#### Survey Routes
- `/s/{surveyId}` - Individual survey access
- `/c/{jwt}` - Personalized link survey access (JWT-based access)
- Embedded survey endpoints
#### API Routes
- `/api/v1/client/{environmentId}/*` - Client API endpoints
- `/api/v1/client/{environmentId}/*` - Client API endpoints (v1)
- `/api/v2/client/{environmentId}/*` - Client API endpoints (v2)
#### Static Assets & Next.js Routes
- `/favicon.ico` - Favicon
- `/_next/*` - Next.js static assets and build files
- `/js/*` - JavaScript files
- `/css/*` - CSS stylesheets
- `/images/*` - Image assets
- `/fonts/*` - Font files
- `/icons/*` - Icon assets
- `/public/*` - Public static files
#### Storage Routes

View File

@@ -1,125 +0,0 @@
---
title: "Quota Management"
description: "Control response collection by setting limits on specific segments to ensure balanced and representative survey datasets."
icon: "chart-pie"
---
## Overview
Quota Management allows you to set limits on the number of responses collected for specific segments or criteria in your survey. This feature helps ensure you collect a balanced and representative dataset while preventing oversaturation of certain response types.
<Note>
Quota Management is part of the [Enterprise Edition](/self-hosting/advanced/license).
</Note>
### Key benefits
- **Balanced Data Collection**: Ensure your survey responses are evenly distributed across different segments
- **Cost Control**: Prevent collecting more responses than needed from specific groups
- **Quality Assurance**: Maintain data quality by avoiding homogeneous response patterns
- **Automated Management**: Automatically stop collecting responses when quotas are met
### How Quota Management works
When you set up quotas for your survey, Formbricks automatically tracks responses against your defined limits. Once a quota is reached, the system can:
- Prevent new responses from that segment
- Skip respondents to the end of the survey
- Redirect respondents to a custom end screen
## Setting up Quotas
In the first step, you need to define the criteria for the quota:
<Steps>
<Step title="Name the quota">
Create a Quota and label it e.g. "Mobile Phone Users in Europe"
</Step>
<Step title="Set quota limit">
Set numerical limits for each hidden field value combination e.g. 500
</Step>
<Step title="Define inclusion criteria">
Choose a distinct set of answers to survey questions, variable values or hidden fields. Responses who match this set will be included in the quota.
</Step>
<Step title="Configure actions">
Choose what happens when this Quota is met (e.g. skip to specific end screen)
</Step>
</Steps>
## Quota actions
Configure what happens when a quota reaches its limit:
<Tabs>
<Tab title="Skip to End">
Jump respondents directly to the survey completion page
</Tab>
<Tab title="Custom Redirect (soon)">
Redirect respondents to a custom thank you page or alternative survey
</Tab>
</Tabs>
## Counting against Quotas
### 1. Count by Hidden Field value
Determine if a response falls in or out of a Quota based on hidden field values passed through URL parameters:
```
https://your-survey-url.com/s/abc123?product=credit-card&region=europe
```
### 2. Quota by survey responses
Create quotas based on specific answers to survey questions:
<Tabs>
<Tab title="Single Question Quota">
Set quotas for individual answer options:
- Question: "What is your gender?"
- Quota: 500 responses for "Male", 500 responses for "Female"
</Tab>
<Tab title="Multi-Question Quota">
Combine multiple question responses:
- Criteria: Age group "25-34" AND Location "Urban"
- Quota: 200 responses matching both criteria
</Tab>
</Tabs>
### 3. Multi-criteria quotas
Create complex quotas using multiple conditions:
<CodeGroup>
```example "Hidden Field + Response Combination"
Hidden Field: product = "mobile"
AND
Question Response: satisfaction = "very satisfied"
```
```example "Multiple Response Criteria"
Question 1: age_group = "18-25"
AND
Question 2: location = "urban"
AND
Question 3: income = "high"
Quota Limit: 50 responses
```
</CodeGroup>
### Partial vs. complete responses
<Info>
By default, Quota Management includes partial responses in quota counts. You can change this behavior by configuring the quota to only count complete responses.
</Info>
This means if a respondent starts but doesn't complete the survey, they may still count toward your quota if they've answered the qualifying questions.
## Quota monitoring
<Card title="Live Quota Status" icon="chart-line">
Monitor your quotas in real-time through the dashboard in the survey summary:
- **Current Count**: See how many responses each quota has collected
- **Progress Bars**: Visual representation of quota completion
- **Status Indicators**: Active, completed, or paused quota status
</Card>

View File

@@ -11,18 +11,21 @@ icon: "code"
The user performs an action in your application.
</Step>
<Step title="Formbricks widget detects action">
The embedded widget (SDK) detects the action, if you set up a [No Code Action](/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-no-code-actions) or a [Code Action](/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-code-actions) to do so.
</Step>
<Step title="Formbricks widget detects action">
The embedded widget (SDK) detects the action, if you set up a [No Code
Action](/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-no-code-actions) or a [Code
Action](/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-code-actions) to do so.
</Step>
<Step title="Check for survey trigger">
If a survey is set to trigger on that action, Formbricks checks if the user or visitor qualifies.
</Step>
<Step title="Check for survey trigger">
If a survey is set to trigger on that action, Formbricks checks if the user or visitor qualifies.
</Step>
<Step title="Check for user or visitor qualification">
If the user or visitor already filled out a survey in the past couple of days (see [Global Waiting Time](/xm-and-surveys/surveys/website-app-surveys/recontact#project-wide-global-waiting-time)), the survey will not be shown to prevent survey fatigue.
Also, if this user or visitor has seen this specific survey before, Formbricks might not show it as this is dependent on the [Recontact Options](/xm-and-surveys/surveys/website-app-surveys/recontact).
</Step>
<Step title="Show survey">
@@ -31,15 +34,29 @@ icon: "code"
</Steps>
<Tip>
Tying surveys to specific user actions enables **context-aware surveying**: You make sure that your user research is relevant to the current user context which leads to sign**ificantly higher response and completions rates** as well as lower survey fatigue.
Tying surveys to specific user actions enables **context-aware surveying**: You make sure that your user
research is relevant to the current user context which leads to sign**ificantly higher response and
completions rates** as well as lower survey fatigue.
</Tip>
<Note>
**Important**: Any changes to actions, surveys, or environment configuration will take up to 5 minutes to
reflect in your app/website running the formbricks sdk with debug mode enabled due to server-side caching.
This includes new actions, modified action configurations, and survey trigger updates. For quick updates
during development and testing, you can enable [Debug
Mode](/xm-and-surveys/surveys/website-app-surveys/framework-guides#activate-debug-mode) in your SDK
configuration.
</Note>
## **Setting Up No-Code Actions**
Formbricks offers an intuitive No-Code interface that allows you to configure actions without needing to write any code.
<Note>
No Code Actions are **not available for surveys in mobile apps**. No Code Action tracking are heavily dependent on JavaScript and most mobile apps are compiled into a different programming language. To track user actions in mobile apps use [Code Actions.](/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-code-actions)
No Code Actions are **not available for surveys in mobile apps**. No Code Action tracking are heavily
dependent on JavaScript and most mobile apps are compiled into a different programming language. To track
user actions in mobile apps use [Code
Actions.](/xm-and-surveys/surveys/website-app-surveys/actions#setting-up-code-actions)
</Note>
<Steps>
@@ -55,27 +72,31 @@ Formbricks offers an intuitive No-Code interface that allows you to configure ac
There are four types of No-Code actions:
### **1. Click Action**
![Add click action to open source in app survey](/images/xm-and-surveys/surveys/website-app-surveys/actions/click-action.webp "Add click action to open source in app survey")
A Click Action is triggered when a user clicks on a specific element within your application. You can define the element's inner text, CSS selector or both to trigger the survey.
* **Inner Text**: Checks if the innerText of a clicked HTML element, like a button label, matches a specific text. This action allows you to display a survey based on text interactions within your application.
- **Inner Text**: Checks if the innerText of a clicked HTML element, like a button label, matches a specific text. This action allows you to display a survey based on text interactions within your application.
* **CSS Selector**: Verifies if a clicked HTML element matches a provided CSS selector, such as a class, ID, or any other CSS selector used in your website. It enables survey triggers based on element interactions.
- **CSS Selector**: Verifies if a clicked HTML element matches a provided CSS selector, such as a class, ID, or any other CSS selector used in your website. It enables survey triggers based on element interactions.
* **Both**: Only if both is true, the action is triggered
- **Both**: Only if both is true, the action is triggered
### **2. Page View Action**
![Add page view action to open source in app survey](/images/xm-and-surveys/surveys/website-app-surveys/actions/page-view.webp "Add page view action to open source in app survey")
This action is triggered when a user visits a page within your application.
### **3. Exit Intent Action**
![Add exit intent action to open source in app survey](/images/xm-and-surveys/surveys/website-app-surveys/actions/exit-intent.webp "Add exit intent action to open source in app survey")
This action is triggered when a user is about to leave your application. It helps capture user feedback before they exit, providing valuable insights into user experiences and potential improvements.
### **4. 50% Scroll Action**
![Add 50% scroll action to open source in app survey](/images/xm-and-surveys/surveys/website-app-surveys/actions/scroll.webp "Add 50% scroll action to open source in app survey")
This action is triggered when a user scrolls through 50% of a page within your application. It helps capture user feedback at a specific point in their journey, enabling you to gather insights based on user interactions.
@@ -88,17 +109,17 @@ You can combine the url filters with any of the no-code actions to trigger the s
You can limit action tracking to specific subpages of your website or web app by using the Page Filter. Here you can use a variety of URL filter settings:
* **exactMatch**: Triggers the action when the URL exactly matches the specified string.
- **exactMatch**: Triggers the action when the URL exactly matches the specified string.
* **contains**: Activates when the URL contains the specified substring.
- **contains**: Activates when the URL contains the specified substring.
* **startsWith**: Fires when the URL starts with the specified string.
- **startsWith**: Fires when the URL starts with the specified string.
* **endsWith**: Executes when the URL ends with the specified string.
- **endsWith**: Executes when the URL ends with the specified string.
* **notMatch**: Triggers when the URL does not match the specified condition.
- **notMatch**: Triggers when the URL does not match the specified condition.
* **notContains**: Activates when the URL does not contain the specified substring.
- **notContains**: Activates when the URL does not contain the specified substring.
## **Setting Up Code Actions**
@@ -108,7 +129,7 @@ For more granular control, you can implement actions directly in your code:
<Step title="Configure action in Formbricks">
First, add the action via the Formbricks web interface to make it available for survey configuration:
![Add a code action to open source in app survey](/images/xm-and-surveys/surveys/website-app-surveys/actions/code-action.webp "Add a code action to open source in app survey")
![Add a code action to open source in app survey](/images/xm-and-surveys/surveys/website-app-surveys/actions/code-action.webp "Add a code action to open source in app survey")
</Step>
@@ -128,5 +149,6 @@ For more granular control, you can implement actions directly in your code:
return <button onClick={handleClick}>Click Me</button>;
```
</Step>
</Steps>
</Steps>

View File

@@ -16,33 +16,37 @@ sidebarTitle: "Quickstart"
You can create In-product and Link Surveys with both options, but the onboarding will prompt you to connect your app/website to Formbricks.
![three options](/images/xm-and-surveys/surveys/website-app-surveys/quickstart/survey-type.webp)
</Step>
<Step title="Connect your App/Website">
Follow the instructions to connect your app or website:
![connect product to formbricks](https://res.cloudinary.com/dwdb9tvii/image/upload/v1738114328/image_tv0jmj.png)
</Step>
<Step title="Connect your App/Website">
Follow the instructions to connect your app or website: ![connect product to
formbricks](https://res.cloudinary.com/dwdb9tvii/image/upload/v1738114328/image_tv0jmj.png)
</Step>
<Step title="Confirm setup">
As soon as Formbricks receives the first data point, you will see a message in the onboarding:
![congrats](https://res.cloudinary.com/dwdb9tvii/image/upload/v1738114793/image_lij7kw.png)
Onboarding is complete! Now let's create our first survey as you should see templates to choose from after clicking on **Next**:
![first survey](https://res.cloudinary.com/dwdb9tvii/image/upload/v1738115025/image_yyuuzn.jpg)
</Step>
<Step title="Create your first survey">
To be able to see a survey in your app, you need to create one. We'll choose one of the templates and head over to the survey settings:
Pick the Survey Type as **Website & App Survey**.
</Step>
<Step title="Create trigger to show survey">
Scroll to **Survey Trigger**, click **+ Add Action**, and select **Page View**. This ensures the survey appears when the Formbricks Widget detects any page load.
![page view action](/images/xm-and-surveys/surveys/website-app-surveys/quickstart/page-view-action.webp)
</Step>
<Step title="Set Recontact Options right">
@@ -52,17 +56,22 @@ sidebarTitle: "Quickstart"
<Note>
Please change this setting after testing your survey to avoid user fatigue.
</Note>
</Step>
</Step>
<Step title="Publish the survey">
Publish the survey to make it available for the SDK to pull into the website or app where you want to show it.
</Step>
<Step title="Enable debug mode in website / app">
For better scalability, we cache the request the SDK makes to the server. This allows you to use Formbricks on websites with millions of visitors without high hosting cost. On the downside, there can be **up to a 10 minute delay** until the SDK pulls the newest surveys from the server.
<Step title="Enable debug mode in website / app">
For better scalability, we cache the request the SDK makes to the server. This allows you to use Formbricks on websites with millions of visitors without high hosting cost. On the downside, there can be **up to a 5 minute delay** until the SDK pulls the newest surveys from the server.
To avoid the delay, please switch on the [Debug Mode.](/xm-and-surveys/surveys/website-app-surveys/framework-guides#activate-debug-mode)
<Note>
**Important**: Any changes to surveys, action classes, project settings, or environment configuration will take up to 5 minutes to reflect in debug mode in your app/website due to server-side caching. This includes survey modifications, new triggers, styling changes, and other updates.
</Note>
To avoid the delay during development and testing, please switch on the [Debug Mode.](/xm-and-surveys/surveys/website-app-surveys/framework-guides#activate-debug-mode)
</Step>
</Steps>
Need help? Join us in [**GitHub Discussions**](https://github.com/formbricks/formbricks/discussions), and well be happy to assist!
Need help? Join us in [**GitHub Discussions**](https://github.com/formbricks/formbricks/discussions), and we'll be happy to assist!

View File

@@ -74,6 +74,8 @@ cronJob:
## Deployment & Autoscaling
deployment:
image:
pullPolicy: Always
resources:
limits:
cpu: 2

View File

@@ -28,9 +28,14 @@ export const ZWebhook = z.object({
environmentId: z.string().cuid2().openapi({
description: "The ID of the environment",
}),
triggers: z.array(z.enum(["responseFinished", "responseCreated", "responseUpdated"])).openapi({
description: "The triggers of the webhook",
}),
triggers: z
.array(z.enum(["responseFinished", "responseCreated", "responseUpdated"]))
.openapi({
description: "The triggers of the webhook",
})
.min(1, {
message: "At least one trigger is required",
}),
surveyIds: z.array(z.string().cuid2()).openapi({
description: "The IDs of the surveys ",
}),

View File

@@ -1,3 +1,4 @@
import { cn } from "@/lib/utils";
import { checkForLoomUrl, checkForVimeoUrl, checkForYoutubeUrl, convertToEmbedUrl } from "@/lib/video-upload";
import { useState } from "preact/hooks";
@@ -26,7 +27,7 @@ export function QuestionMedia({ imgUrl, videoUrl, altText = "Image" }: QuestionM
const [isLoading, setIsLoading] = useState(true);
return (
<div className="fb-group/image fb-relative fb-mb-4 fb-block fb-min-h-40 fb-rounded-md">
<div className="fb-group/image fb-relative fb-mb-6 fb-block fb-min-h-40 fb-rounded-md">
{isLoading ? (
<div className="fb-absolute fb-inset-auto fb-flex fb-h-full fb-w-full fb-animate-pulse fb-items-center fb-justify-center fb-rounded-md fb-bg-slate-200" />
) : null}
@@ -35,10 +36,16 @@ export function QuestionMedia({ imgUrl, videoUrl, altText = "Image" }: QuestionM
key={imgUrl}
src={imgUrl}
alt={altText}
className="fb-rounded-custom"
className={cn(
"fb-rounded-custom fb-max-h-[40dvh] fb-mx-auto fb-object-contain",
isLoading ? "fb-opacity-0" : ""
)}
onLoad={() => {
setIsLoading(false);
}}
onError={() => {
setIsLoading(false);
}}
/>
) : null}
{videoUrlWithParams ? (
@@ -48,10 +55,13 @@ export function QuestionMedia({ imgUrl, videoUrl, altText = "Image" }: QuestionM
src={videoUrlWithParams}
title="Question Video"
frameBorder="0"
className="fb-rounded-custom fb-aspect-video fb-w-full"
className={cn("fb-rounded-custom fb-aspect-video fb-w-full", isLoading ? "fb-opacity-0" : "")}
onLoad={() => {
setIsLoading(false);
}}
onError={() => {
setIsLoading(false);
}}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
/>

View File

@@ -207,7 +207,7 @@ export function MultipleChoiceMultiQuestion({
name={question.id}
tabIndex={-1}
value={getLocalizedValue(choice.label, languageCode)}
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-flex-shrink-0 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${choice.id}-label`}
onChange={(e) => {
if ((e.target as HTMLInputElement).checked) {
@@ -251,7 +251,7 @@ export function MultipleChoiceMultiQuestion({
id={otherOption.id}
name={question.id}
value={getLocalizedValue(otherOption.label, languageCode)}
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-flex-shrink-0 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${otherOption.id}-label`}
onChange={() => {
if (otherSelected) {

View File

@@ -168,7 +168,7 @@ export function MultipleChoiceSingleQuestion({
name={question.id}
value={getLocalizedValue(choice.label, languageCode)}
dir="auto"
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-flex-shrink-0 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${choice.id}-label`}
onChange={() => {
setOtherSelected(false);
@@ -210,7 +210,7 @@ export function MultipleChoiceSingleQuestion({
id={otherOption.id}
name={question.id}
value={getLocalizedValue(otherOption.label, languageCode)}
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
className="fb-border-brand fb-text-brand fb-h-4 fb-w-4 fb-flex-shrink-0 fb-border focus:fb-ring-0 focus:fb-ring-offset-0"
aria-labelledby={`${otherOption.id}-label`}
onChange={() => {
setOtherSelected(!otherSelected);

View File

@@ -171,7 +171,7 @@ describe("PictureSelectionQuestion", () => {
render(<PictureSelectionQuestion {...mockProps} />);
const images = screen.getAllByRole("img");
const label = images[0].closest("label");
const label = images[0].closest("button");
fireEvent.keyDown(label!, { key: " " });

View File

@@ -43,6 +43,13 @@ export function PictureSelectionQuestion({
isBackButtonHidden,
}: Readonly<PictureSelectionProps>) {
const [startTime, setStartTime] = useState(performance.now());
const [loadingImages, setLoadingImages] = useState<Record<string, boolean>>(() => {
const initialLoadingState: Record<string, boolean> = {};
question.choices.forEach((choice) => {
initialLoadingState[choice.id] = true;
});
return initialLoadingState;
});
const isMediaAvailable = question.imageUrl || question.videoUrl;
const isCurrent = question.id === currentQuestionId;
useTtc(question.id, ttc, setTtc, startTime, setStartTime, isCurrent);
@@ -115,35 +122,75 @@ export function PictureSelectionQuestion({
<div className="fb-mt-4">
<fieldset>
<legend className="fb-sr-only">Options</legend>
<div className="fb-bg-survey-bg fb-relative fb-grid fb-grid-cols-2 fb-gap-4">
<div className="fb-bg-survey-bg fb-relative fb-grid fb-grid-cols-1 sm:fb-grid-cols-2 fb-gap-4">
{questionChoices.map((choice) => (
<label
key={choice.id}
tabIndex={isCurrent ? 0 : -1}
htmlFor={choice.id}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") {
e.preventDefault();
document.getElementById(choice.id)?.click();
document.getElementById(choice.id)?.focus();
}
}}
onClick={() => {
handleChange(choice.id);
}}
className={cn(
"fb-relative fb-w-full fb-cursor-pointer fb-overflow-hidden fb-border fb-rounded-custom focus:fb-outline-none fb-aspect-[4/3] fb-min-h-[7rem] fb-max-h-[50vh] focus:fb-border-brand focus:fb-border-4 group/image",
Array.isArray(value) && value.includes(choice.id)
? "fb-border-brand fb-text-brand fb-z-10 fb-border-4 fb-shadow-sm"
: ""
)}>
<img
src={choice.imageUrl}
id={choice.id}
alt={getOriginalFileNameFromUrl(choice.imageUrl)}
className="fb-h-full fb-w-full fb-object-cover"
/>
<div className="fb-relative" key={choice.id}>
<button
type="button"
tabIndex={isCurrent ? 0 : -1}
onKeyDown={(e) => {
// Accessibility: if spacebar was pressed pass this down to the input
if (e.key === " ") {
e.preventDefault();
e.currentTarget.click();
e.currentTarget.focus();
}
}}
onClick={() => {
handleChange(choice.id);
}}
className={cn(
"fb-relative fb-w-full fb-cursor-pointer fb-overflow-hidden fb-border fb-rounded-custom focus-visible:fb-outline-none focus-visible:fb-ring-2 focus-visible:fb-ring-brand focus-visible:fb-ring-offset-2 fb-aspect-[4/3] fb-min-h-[7rem] fb-max-h-[50vh] group/image",
Array.isArray(value) && value.includes(choice.id)
? "fb-border-brand fb-text-brand fb-z-10 fb-border-4 fb-shadow-sm"
: ""
)}>
{loadingImages[choice.id] && (
<div className="fb-absolute fb-inset-0 fb-flex fb-h-full fb-w-full fb-animate-pulse fb-items-center fb-justify-center fb-rounded-md fb-bg-slate-200" />
)}
<img
src={choice.imageUrl}
id={choice.id}
alt={getOriginalFileNameFromUrl(choice.imageUrl)}
className={cn(
"fb-h-full fb-w-full fb-object-cover",
loadingImages[choice.id] ? "fb-opacity-0" : ""
)}
onLoad={() => {
setLoadingImages((prev) => ({ ...prev, [choice.id]: false }));
}}
onError={() => {
setLoadingImages((prev) => ({ ...prev, [choice.id]: false }));
}}
/>
{question.allowMulti ? (
<input
id={`${choice.id}-checked`}
name={`${choice.id}-checkbox`}
type="checkbox"
tabIndex={-1}
checked={value.includes(choice.id)}
className={cn(
"fb-border-border fb-rounded-custom fb-pointer-events-none fb-absolute fb-right-2 fb-top-2 fb-z-20 fb-h-5 fb-w-5 fb-border",
value.includes(choice.id) ? "fb-border-brand fb-text-brand" : ""
)}
required={question.required && value.length ? false : question.required}
/>
) : (
<input
id={`${choice.id}-radio`}
name={`${question.id}`}
type="radio"
tabIndex={-1}
checked={value.includes(choice.id)}
className={cn(
"fb-border-border fb-pointer-events-none fb-absolute fb-right-2 fb-top-2 fb-z-20 fb-h-5 fb-w-5 fb-rounded-full fb-border",
value.includes(choice.id) ? "fb-border-brand fb-text-brand" : ""
)}
required={question.required && value.length ? false : question.required}
/>
)}
</button>
<a
tabIndex={-1}
href={choice.imageUrl}
@@ -153,52 +200,25 @@ export function PictureSelectionQuestion({
onClick={(e) => {
e.stopPropagation();
}}
className="fb-absolute fb-bottom-2 fb-right-2 fb-flex fb-items-center fb-gap-2 fb-whitespace-nowrap fb-rounded-md fb-bg-slate-800 fb-bg-opacity-40 fb-p-1.5 fb-text-white fb-opacity-0 fb-backdrop-blur-lg fb-transition fb-duration-300 fb-ease-in-out hover:fb-bg-opacity-65 group-hover/image:fb-opacity-100">
className="fb-absolute fb-bottom-4 fb-right-2 fb-flex fb-items-center fb-gap-2 fb-whitespace-nowrap fb-rounded-md fb-bg-slate-800 fb-bg-opacity-40 fb-p-1.5 fb-text-white fb-backdrop-blur-lg fb-transition fb-duration-300 fb-ease-in-out hover:fb-bg-opacity-65 group-hover/image:fb-opacity-100 fb-z-20">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
className="lucide lucide-expand">
<path d="m21 21-6-6m6 6v-4.8m0 4.8h-4.8" />
<path d="M3 16.2V21m0 0h4.8M3 21l6-6" />
<path d="M21 7.8V3m0 0h-4.8M21 3l-6 6" />
<path d="M3 7.8V3m0 0h4.8M3 3l6 6" />
className="lucide lucide-image-down-icon lucide-image-down">
<path d="M10.3 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10l-3.1-3.1a2 2 0 0 0-2.814.014L6 21" />
<path d="m14 19 3 3v-5.5" />
<path d="m17 22 3-3" />
<circle cx="9" cy="9" r="2" />
</svg>
</a>
{question.allowMulti ? (
<input
id={`${choice.id}-checked`}
name={`${choice.id}-checkbox`}
type="checkbox"
tabIndex={-1}
checked={value.includes(choice.id)}
className={cn(
"fb-border-border fb-rounded-custom fb-pointer-events-none fb-absolute fb-right-2 fb-top-2 fb-z-20 fb-h-5 fb-w-5 fb-border",
value.includes(choice.id) ? "fb-border-brand fb-text-brand" : ""
)}
required={question.required && value.length ? false : question.required}
/>
) : (
<input
id={`${choice.id}-radio`}
name={`${choice.id}-radio`}
type="radio"
tabIndex={-1}
checked={value.includes(choice.id)}
className={cn(
"fb-border-border fb-pointer-events-none fb-absolute fb-right-2 fb-top-2 fb-z-20 fb-h-5 fb-w-5 fb-rounded-full fb-border",
value.includes(choice.id) ? "fb-border-brand fb-text-brand" : ""
)}
required={question.required && value.length ? false : question.required}
/>
)}
</label>
</div>
))}
</div>
</fieldset>

View File

@@ -1,4 +1,5 @@
import { z } from "zod";
import { ZId } from "./common";
export const ZActionClassMatchType = z.union([
z.literal("exactMatch"),
@@ -91,8 +92,8 @@ const ZActionClassInputBase = z.object({
.string({ message: "Name is required" })
.trim()
.min(1, { message: "Name must be at least 1 character long" }),
description: z.string().nullable(),
environmentId: z.string(),
description: z.string().nullish(),
environmentId: ZId.min(1, { message: "Environment ID cannot be empty" }),
type: ZActionClassType,
});
@@ -108,6 +109,9 @@ const ZActionClassInputNoCode = ZActionClassInputBase.extend({
noCodeConfig: ZActionClassNoCodeConfig.nullable(),
});
export const ZActionClassInput = z.union([ZActionClassInputCode, ZActionClassInputNoCode]);
export const ZActionClassInput = z.discriminatedUnion("type", [
ZActionClassInputCode,
ZActionClassInputNoCode,
]);
export type TActionClassInput = z.infer<typeof ZActionClassInput>;