mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 02:10:12 -06:00
Compare commits
68 Commits
4.3.1
...
feat/proje
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c29e20a196 | ||
|
|
c4f11cf9a3 | ||
|
|
058d5217ec | ||
|
|
49c104738f | ||
|
|
f00d0b7e20 | ||
|
|
65abd4ee07 | ||
|
|
939f135bf4 | ||
|
|
729a16854a | ||
|
|
a2d3e37d69 | ||
|
|
adf12f551d | ||
|
|
3f2bddc358 | ||
|
|
ae6d1ac133 | ||
|
|
7c4569cd50 | ||
|
|
7354122447 | ||
|
|
d54dca2b27 | ||
|
|
acd5cff534 | ||
|
|
834929e766 | ||
|
|
09f40ad816 | ||
|
|
689b6491b3 | ||
|
|
b70b2eef95 | ||
|
|
392a95834b | ||
|
|
66d9cc8eac | ||
|
|
befdc078f1 | ||
|
|
13b983b3b2 | ||
|
|
1e285ebe4e | ||
|
|
a7c4971952 | ||
|
|
c8689d91d5 | ||
|
|
73a2ff7421 | ||
|
|
0c28e89b41 | ||
|
|
a736436e29 | ||
|
|
7dbb0300d3 | ||
|
|
e71f3f412c | ||
|
|
07ed926225 | ||
|
|
15dc83a4eb | ||
|
|
3ce07edf43 | ||
|
|
0f34d9cc5f | ||
|
|
e9f800f017 | ||
|
|
ba2070b638 | ||
|
|
75cdb25d27 | ||
|
|
6bc7db852c | ||
|
|
ffb4eac1a4 | ||
|
|
56da3b5725 | ||
|
|
c189af5482 | ||
|
|
5dbf42fd6a | ||
|
|
42525a86a8 | ||
|
|
b96f0e67c5 | ||
|
|
2d7b99ba26 | ||
|
|
666a79044f | ||
|
|
c3d97c2932 | ||
|
|
cc5d630a05 | ||
|
|
be38d76ccf | ||
|
|
a8eea306e5 | ||
|
|
4fd53ac115 | ||
|
|
eb92392ed1 | ||
|
|
7412b32526 | ||
|
|
193346a70d | ||
|
|
a1d4754b04 | ||
|
|
f4b918a4b6 | ||
|
|
fb9a0b197a | ||
|
|
95b6c16dd1 | ||
|
|
cfdf09650f | ||
|
|
4c94fc25ae | ||
|
|
ccf501d925 | ||
|
|
04dfbe0777 | ||
|
|
cbf255ab0d | ||
|
|
942366956c | ||
|
|
a6ee796cef | ||
|
|
a535529bd3 |
352
.cursor/commands/create-question.md
Normal file
352
.cursor/commands/create-question.md
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
# Create New Question Element
|
||||||
|
|
||||||
|
Use this command to scaffold a new question element component in `packages/survey-ui/src/elements/`.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
When creating a new question type (e.g., `single-select`, `rating`, `nps`), follow these steps:
|
||||||
|
|
||||||
|
1. **Create the component file** `{question-type}.tsx` with this structure:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import * as React from "react";
|
||||||
|
import { ElementHeader } from "../components/element-header";
|
||||||
|
import { useTextDirection } from "../hooks/use-text-direction";
|
||||||
|
import { cn } from "../lib/utils";
|
||||||
|
|
||||||
|
interface {QuestionType}Props {
|
||||||
|
/** Unique identifier for the element container */
|
||||||
|
elementId: string;
|
||||||
|
/** The main question or prompt text displayed as the headline */
|
||||||
|
headline: string;
|
||||||
|
/** Optional descriptive text displayed below the headline */
|
||||||
|
description?: string;
|
||||||
|
/** Unique identifier for the input/control group */
|
||||||
|
inputId: string;
|
||||||
|
/** Current value */
|
||||||
|
value?: {ValueType};
|
||||||
|
/** Callback function called when the value changes */
|
||||||
|
onChange: (value: {ValueType}) => void;
|
||||||
|
/** Whether the field is required (shows asterisk indicator) */
|
||||||
|
required?: boolean;
|
||||||
|
/** Error message to display */
|
||||||
|
errorMessage?: string;
|
||||||
|
/** Text direction: 'ltr' (left-to-right), 'rtl' (right-to-left), or 'auto' (auto-detect from content) */
|
||||||
|
dir?: "ltr" | "rtl" | "auto";
|
||||||
|
/** Whether the controls are disabled */
|
||||||
|
disabled?: boolean;
|
||||||
|
// Add question-specific props here
|
||||||
|
}
|
||||||
|
|
||||||
|
function {QuestionType}({
|
||||||
|
elementId,
|
||||||
|
headline,
|
||||||
|
description,
|
||||||
|
inputId,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
required = false,
|
||||||
|
errorMessage,
|
||||||
|
dir = "auto",
|
||||||
|
disabled = false,
|
||||||
|
// ... question-specific props
|
||||||
|
}: {QuestionType}Props): React.JSX.Element {
|
||||||
|
// Ensure value is always the correct type (handle undefined/null)
|
||||||
|
const currentValue = value ?? {defaultValue};
|
||||||
|
|
||||||
|
// Detect text direction from content
|
||||||
|
const detectedDir = useTextDirection({
|
||||||
|
dir,
|
||||||
|
textContent: [headline, description ?? "", /* add other text content from question */],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
|
||||||
|
{/* Headline */}
|
||||||
|
<ElementHeader
|
||||||
|
headline={headline}
|
||||||
|
description={description}
|
||||||
|
required={required}
|
||||||
|
htmlFor={inputId}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Question-specific controls */}
|
||||||
|
{/* TODO: Add your question-specific UI here */}
|
||||||
|
|
||||||
|
{/* Error message */}
|
||||||
|
{errorMessage && (
|
||||||
|
<div className="text-destructive flex items-center gap-1 text-sm" dir={detectedDir}>
|
||||||
|
<span>{errorMessage}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { {QuestionType} };
|
||||||
|
export type { {QuestionType}Props };
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Create the Storybook file** `{question-type}.stories.tsx`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { Decorator, Meta, StoryObj } from "@storybook/react";
|
||||||
|
import React from "react";
|
||||||
|
import { {QuestionType}, type {QuestionType}Props } from "./{question-type}";
|
||||||
|
|
||||||
|
// Styling options for the StylingPlayground story
|
||||||
|
interface StylingOptions {
|
||||||
|
// Question styling
|
||||||
|
questionHeadlineFontFamily: string;
|
||||||
|
questionHeadlineFontSize: string;
|
||||||
|
questionHeadlineFontWeight: string;
|
||||||
|
questionHeadlineColor: string;
|
||||||
|
questionDescriptionFontFamily: string;
|
||||||
|
questionDescriptionFontWeight: string;
|
||||||
|
questionDescriptionFontSize: string;
|
||||||
|
questionDescriptionColor: string;
|
||||||
|
// Add component-specific styling options here
|
||||||
|
}
|
||||||
|
|
||||||
|
type StoryProps = {QuestionType}Props & Partial<StylingOptions>;
|
||||||
|
|
||||||
|
const meta: Meta<StoryProps> = {
|
||||||
|
title: "UI-package/Elements/{QuestionType}",
|
||||||
|
component: {QuestionType},
|
||||||
|
parameters: {
|
||||||
|
layout: "centered",
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
component: "A complete {question type} question element...",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tags: ["autodocs"],
|
||||||
|
argTypes: {
|
||||||
|
headline: {
|
||||||
|
control: "text",
|
||||||
|
description: "The main question text",
|
||||||
|
table: { category: "Content" },
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
control: "text",
|
||||||
|
description: "Optional description or subheader text",
|
||||||
|
table: { category: "Content" },
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
control: "object",
|
||||||
|
description: "Current value",
|
||||||
|
table: { category: "State" },
|
||||||
|
},
|
||||||
|
required: {
|
||||||
|
control: "boolean",
|
||||||
|
description: "Whether the field is required",
|
||||||
|
table: { category: "Validation" },
|
||||||
|
},
|
||||||
|
errorMessage: {
|
||||||
|
control: "text",
|
||||||
|
description: "Error message to display",
|
||||||
|
table: { category: "Validation" },
|
||||||
|
},
|
||||||
|
dir: {
|
||||||
|
control: { type: "select" },
|
||||||
|
options: ["ltr", "rtl", "auto"],
|
||||||
|
description: "Text direction for RTL support",
|
||||||
|
table: { category: "Layout" },
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
control: "boolean",
|
||||||
|
description: "Whether the controls are disabled",
|
||||||
|
table: { category: "State" },
|
||||||
|
},
|
||||||
|
onChange: {
|
||||||
|
action: "changed",
|
||||||
|
table: { category: "Events" },
|
||||||
|
},
|
||||||
|
// Add question-specific argTypes here
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<StoryProps>;
|
||||||
|
|
||||||
|
// Decorator to apply CSS variables from story args
|
||||||
|
const withCSSVariables: Decorator<StoryProps> = (Story, context) => {
|
||||||
|
const args = context.args as StoryProps;
|
||||||
|
const {
|
||||||
|
questionHeadlineFontFamily,
|
||||||
|
questionHeadlineFontSize,
|
||||||
|
questionHeadlineFontWeight,
|
||||||
|
questionHeadlineColor,
|
||||||
|
questionDescriptionFontFamily,
|
||||||
|
questionDescriptionFontSize,
|
||||||
|
questionDescriptionFontWeight,
|
||||||
|
questionDescriptionColor,
|
||||||
|
// Extract component-specific styling options
|
||||||
|
} = args;
|
||||||
|
|
||||||
|
const cssVarStyle: React.CSSProperties & Record<string, string | undefined> = {
|
||||||
|
"--fb-question-headline-font-family": questionHeadlineFontFamily,
|
||||||
|
"--fb-question-headline-font-size": questionHeadlineFontSize,
|
||||||
|
"--fb-question-headline-font-weight": questionHeadlineFontWeight,
|
||||||
|
"--fb-question-headline-color": questionHeadlineColor,
|
||||||
|
"--fb-question-description-font-family": questionDescriptionFontFamily,
|
||||||
|
"--fb-question-description-font-size": questionDescriptionFontSize,
|
||||||
|
"--fb-question-description-font-weight": questionDescriptionFontWeight,
|
||||||
|
"--fb-question-description-color": questionDescriptionColor,
|
||||||
|
// Add component-specific CSS variables
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={cssVarStyle} className="w-[600px]">
|
||||||
|
<Story />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const StylingPlayground: Story = {
|
||||||
|
args: {
|
||||||
|
headline: "Example question?",
|
||||||
|
description: "Example description",
|
||||||
|
// Default styling values
|
||||||
|
questionHeadlineFontFamily: "system-ui, sans-serif",
|
||||||
|
questionHeadlineFontSize: "1.125rem",
|
||||||
|
questionHeadlineFontWeight: "600",
|
||||||
|
questionHeadlineColor: "#1e293b",
|
||||||
|
questionDescriptionFontFamily: "system-ui, sans-serif",
|
||||||
|
questionDescriptionFontSize: "0.875rem",
|
||||||
|
questionDescriptionFontWeight: "400",
|
||||||
|
questionDescriptionColor: "#64748b",
|
||||||
|
// Add component-specific default values
|
||||||
|
},
|
||||||
|
argTypes: {
|
||||||
|
// Question styling argTypes
|
||||||
|
questionHeadlineFontFamily: {
|
||||||
|
control: "text",
|
||||||
|
table: { category: "Question Styling" },
|
||||||
|
},
|
||||||
|
questionHeadlineFontSize: {
|
||||||
|
control: "text",
|
||||||
|
table: { category: "Question Styling" },
|
||||||
|
},
|
||||||
|
questionHeadlineFontWeight: {
|
||||||
|
control: "text",
|
||||||
|
table: { category: "Question Styling" },
|
||||||
|
},
|
||||||
|
questionHeadlineColor: {
|
||||||
|
control: "color",
|
||||||
|
table: { category: "Question Styling" },
|
||||||
|
},
|
||||||
|
questionDescriptionFontFamily: {
|
||||||
|
control: "text",
|
||||||
|
table: { category: "Question Styling" },
|
||||||
|
},
|
||||||
|
questionDescriptionFontSize: {
|
||||||
|
control: "text",
|
||||||
|
table: { category: "Question Styling" },
|
||||||
|
},
|
||||||
|
questionDescriptionFontWeight: {
|
||||||
|
control: "text",
|
||||||
|
table: { category: "Question Styling" },
|
||||||
|
},
|
||||||
|
questionDescriptionColor: {
|
||||||
|
control: "color",
|
||||||
|
table: { category: "Question Styling" },
|
||||||
|
},
|
||||||
|
// Add component-specific argTypes
|
||||||
|
},
|
||||||
|
decorators: [withCSSVariables],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
headline: "Example question?",
|
||||||
|
// Add default props
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithDescription: Story = {
|
||||||
|
args: {
|
||||||
|
headline: "Example question?",
|
||||||
|
description: "Example description text",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Required: Story = {
|
||||||
|
args: {
|
||||||
|
headline: "Example question?",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithError: Story = {
|
||||||
|
args: {
|
||||||
|
headline: "Example question?",
|
||||||
|
errorMessage: "This field is required",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Disabled: Story = {
|
||||||
|
args: {
|
||||||
|
headline: "Example question?",
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RTL: Story = {
|
||||||
|
args: {
|
||||||
|
headline: "مثال على السؤال؟",
|
||||||
|
description: "مثال على الوصف",
|
||||||
|
// Add RTL-specific props
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Add CSS variables** to `packages/survey-ui/src/styles/globals.css` if needed:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Component-specific CSS variables */
|
||||||
|
--fb-{component}-{property}: {default-value};
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Export from** `packages/survey-ui/src/index.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export { {QuestionType}, type {QuestionType}Props } from "./elements/{question-type}";
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Requirements
|
||||||
|
|
||||||
|
- ✅ Always use `ElementHeader` component for headline/description
|
||||||
|
- ✅ Always use `useTextDirection` hook for RTL support
|
||||||
|
- ✅ Always handle undefined/null values safely (e.g., `Array.isArray(value) ? value : []`)
|
||||||
|
- ✅ Always include error message display if applicable
|
||||||
|
- ✅ Always support disabled state if applicable
|
||||||
|
- ✅ Always add JSDoc comments to props interface
|
||||||
|
- ✅ Always create Storybook stories with styling playground
|
||||||
|
- ✅ Always export types from component file
|
||||||
|
- ✅ Always add to index.ts exports
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
- `open-text.tsx` - Text input/textarea question (string value)
|
||||||
|
- `multi-select.tsx` - Multiple checkbox selection (string[] value)
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
When creating a new question element, verify:
|
||||||
|
|
||||||
|
- [ ] Component file created with proper structure
|
||||||
|
- [ ] Props interface with JSDoc comments for all props
|
||||||
|
- [ ] Uses `ElementHeader` component (don't duplicate header logic)
|
||||||
|
- [ ] Uses `useTextDirection` hook for RTL support
|
||||||
|
- [ ] Handles undefined/null values safely
|
||||||
|
- [ ] Storybook file created with styling playground
|
||||||
|
- [ ] Includes common stories: Default, WithDescription, Required, WithError, Disabled, RTL
|
||||||
|
- [ ] CSS variables added to `globals.css` if component needs custom styling
|
||||||
|
- [ ] Exported from `index.ts` with types
|
||||||
|
- [ ] TypeScript types properly exported
|
||||||
|
- [ ] Error message display included if applicable
|
||||||
|
- [ ] Disabled state supported if applicable
|
||||||
|
|
||||||
@@ -9,8 +9,12 @@
|
|||||||
WEBAPP_URL=http://localhost:3000
|
WEBAPP_URL=http://localhost:3000
|
||||||
|
|
||||||
# Required for next-auth. Should be the same as WEBAPP_URL
|
# Required for next-auth. Should be the same as WEBAPP_URL
|
||||||
|
# If your pplication uses a custom base path, specify the route to the API endpoint in full, e.g. NEXTAUTH_URL=https://example.com/custom-route/api/auth
|
||||||
NEXTAUTH_URL=http://localhost:3000
|
NEXTAUTH_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Can be used to deploy the application under a sub-path of a domain. This can only be set at build time
|
||||||
|
# BASE_PATH=
|
||||||
|
|
||||||
# Encryption keys
|
# Encryption keys
|
||||||
# Please set both for now, we will change this in the future
|
# Please set both for now, we will change this in the future
|
||||||
|
|
||||||
@@ -189,8 +193,9 @@ REDIS_URL=redis://localhost:6379
|
|||||||
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
|
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
|
||||||
# REDIS_HTTP_URL:
|
# REDIS_HTTP_URL:
|
||||||
|
|
||||||
# INTERCOM_APP_ID=
|
# Chatwoot
|
||||||
# INTERCOM_SECRET_KEY=
|
# CHATWOOT_BASE_URL=
|
||||||
|
# CHATWOOT_WEBSITE_TOKEN=
|
||||||
|
|
||||||
# Enable Prometheus metrics
|
# Enable Prometheus metrics
|
||||||
# PROMETHEUS_ENABLED=
|
# PROMETHEUS_ENABLED=
|
||||||
|
|||||||
31
.github/workflows/chromatic.yml
vendored
31
.github/workflows/chromatic.yml
vendored
@@ -13,13 +13,12 @@ jobs:
|
|||||||
chromatic:
|
chromatic:
|
||||||
name: Run Chromatic
|
name: Run Chromatic
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
permissions:
|
permissions:
|
||||||
packages: write
|
contents: read
|
||||||
id-token: write
|
|
||||||
actions: read
|
|
||||||
steps:
|
steps:
|
||||||
- name: Harden the runner (Audit all outbound calls)
|
- name: Harden the runner (Audit all outbound calls)
|
||||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||||
with:
|
with:
|
||||||
egress-policy: audit
|
egress-policy: audit
|
||||||
|
|
||||||
@@ -27,16 +26,34 @@ jobs:
|
|||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||||
|
|
||||||
|
- name: Get pnpm store directory
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Setup pnpm cache
|
||||||
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||||
|
with:
|
||||||
|
path: ${{ env.STORE_PATH }}
|
||||||
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-pnpm-store-
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||||
|
|
||||||
- name: Run Chromatic
|
- name: Run Chromatic
|
||||||
uses: chromaui/action@c93e0bc3a63aa176e14a75b61a31847cbfdd341c # latest
|
uses: chromaui/action@4c20b95e9d3209ecfdf9cd6aace6bbde71ba1694 # v13.3.4
|
||||||
with:
|
with:
|
||||||
# ⚠️ Make sure to configure a `CHROMATIC_PROJECT_TOKEN` repository secret
|
|
||||||
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
|
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
|
||||||
workingDir: apps/storybook
|
workingDir: apps/storybook
|
||||||
|
zip: true
|
||||||
|
|||||||
53
.github/workflows/e2e.yml
vendored
53
.github/workflows/e2e.yml
vendored
@@ -3,14 +3,10 @@ name: E2E Tests
|
|||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
secrets:
|
secrets:
|
||||||
AZURE_CLIENT_ID:
|
|
||||||
required: false
|
|
||||||
AZURE_TENANT_ID:
|
|
||||||
required: false
|
|
||||||
AZURE_SUBSCRIPTION_ID:
|
|
||||||
required: false
|
|
||||||
PLAYWRIGHT_SERVICE_URL:
|
PLAYWRIGHT_SERVICE_URL:
|
||||||
required: false
|
required: false
|
||||||
|
PLAYWRIGHT_SERVICE_ACCESS_TOKEN:
|
||||||
|
required: false
|
||||||
ENTERPRISE_LICENSE_KEY:
|
ENTERPRISE_LICENSE_KEY:
|
||||||
required: true
|
required: true
|
||||||
# Add other secrets if necessary
|
# Add other secrets if necessary
|
||||||
@@ -21,7 +17,6 @@ env:
|
|||||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
id-token: write
|
|
||||||
contents: read
|
contents: read
|
||||||
actions: read
|
actions: read
|
||||||
|
|
||||||
@@ -114,7 +109,7 @@ jobs:
|
|||||||
- name: Start MinIO Server
|
- name: Start MinIO Server
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Start MinIO server in background
|
# Start MinIO server in background
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name minio-server \
|
--name minio-server \
|
||||||
@@ -124,7 +119,7 @@ jobs:
|
|||||||
-e MINIO_ROOT_PASSWORD=devminio123 \
|
-e MINIO_ROOT_PASSWORD=devminio123 \
|
||||||
minio/minio:RELEASE.2025-09-07T16-13-09Z \
|
minio/minio:RELEASE.2025-09-07T16-13-09Z \
|
||||||
server /data --console-address :9001
|
server /data --console-address :9001
|
||||||
|
|
||||||
echo "MinIO server started"
|
echo "MinIO server started"
|
||||||
|
|
||||||
- name: Wait for MinIO and create S3 bucket
|
- name: Wait for MinIO and create S3 bucket
|
||||||
@@ -207,32 +202,30 @@ jobs:
|
|||||||
- name: Install Playwright
|
- name: Install Playwright
|
||||||
run: pnpm exec playwright install --with-deps
|
run: pnpm exec playwright install --with-deps
|
||||||
|
|
||||||
- name: Set Azure Secret Variables
|
- name: Determine Playwright execution mode
|
||||||
run: |
|
shell: bash
|
||||||
if [[ -n "${{ secrets.AZURE_CLIENT_ID }}" && -n "${{ secrets.AZURE_TENANT_ID }}" && -n "${{ secrets.AZURE_SUBSCRIPTION_ID }}" ]]; then
|
|
||||||
echo "AZURE_ENABLED=true" >> $GITHUB_ENV
|
|
||||||
else
|
|
||||||
echo "AZURE_ENABLED=false" >> $GITHUB_ENV
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Azure login
|
|
||||||
if: env.AZURE_ENABLED == 'true'
|
|
||||||
uses: azure/login@a65d910e8af852a8061c627c456678983e180302 # v2.2.0
|
|
||||||
with:
|
|
||||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
|
||||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
|
||||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
|
||||||
|
|
||||||
- name: Run E2E Tests (Azure)
|
|
||||||
if: env.AZURE_ENABLED == 'true'
|
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
|
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
|
||||||
CI: true
|
PLAYWRIGHT_SERVICE_ACCESS_TOKEN: ${{ secrets.PLAYWRIGHT_SERVICE_ACCESS_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
pnpm test-e2e:azure
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [[ -n "${PLAYWRIGHT_SERVICE_URL}" && -n "${PLAYWRIGHT_SERVICE_ACCESS_TOKEN}" ]]; then
|
||||||
|
echo "PW_MODE=service" >> "$GITHUB_ENV"
|
||||||
|
else
|
||||||
|
echo "PW_MODE=local" >> "$GITHUB_ENV"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Run E2E Tests (Playwright Service)
|
||||||
|
if: env.PW_MODE == 'service'
|
||||||
|
env:
|
||||||
|
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
|
||||||
|
PLAYWRIGHT_SERVICE_ACCESS_TOKEN: ${{ secrets.PLAYWRIGHT_SERVICE_ACCESS_TOKEN }}
|
||||||
|
CI: true
|
||||||
|
run: pnpm test-e2e:azure
|
||||||
|
|
||||||
- name: Run E2E Tests (Local)
|
- name: Run E2E Tests (Local)
|
||||||
if: env.AZURE_ENABLED == 'false'
|
if: env.PW_MODE == 'local'
|
||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -203,6 +203,14 @@ Here are a few options:
|
|||||||
|
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
## Thanks
|
||||||
|
|
||||||
|
Formbricks is supported by the following companies who provide us with their tools for free as part of their open-source support:
|
||||||
|
|
||||||
|
<a href="https://www.chromatic.com/"><img src="https://user-images.githubusercontent.com/321738/84662277-e3db4f80-af1b-11ea-88f5-91d67a5e59f6.png" width="153" height="30" alt="Chromatic" /></a>
|
||||||
|
|
||||||
|
<a href="https://sentry.io/"><img src="https://github.com/user-attachments/assets/d743ffd4-b575-4802-a29a-10136be9227e" width="150" height="30" alt="Sentry" /></a>
|
||||||
|
|
||||||
<a id="contact-us"></a>
|
<a id="contact-us"></a>
|
||||||
|
|
||||||
## 📆 Contact us
|
## 📆 Contact us
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import type { StorybookConfig } from "@storybook/react-vite";
|
import type { StorybookConfig } from "@storybook/react-vite";
|
||||||
import { createRequire } from "module";
|
import { createRequire } from "module";
|
||||||
import { dirname, join } from "path";
|
import { dirname, join, resolve } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This function is used to resolve the absolute path of a package.
|
* This function is used to resolve the absolute path of a package.
|
||||||
@@ -13,7 +16,7 @@ function getAbsolutePath(value: string): any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const config: StorybookConfig = {
|
const config: StorybookConfig = {
|
||||||
stories: ["../src/**/*.mdx", "../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)"],
|
stories: ["../src/**/*.mdx", "../../../packages/survey-ui/src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
|
||||||
addons: [
|
addons: [
|
||||||
getAbsolutePath("@storybook/addon-onboarding"),
|
getAbsolutePath("@storybook/addon-onboarding"),
|
||||||
getAbsolutePath("@storybook/addon-links"),
|
getAbsolutePath("@storybook/addon-links"),
|
||||||
@@ -25,5 +28,25 @@ const config: StorybookConfig = {
|
|||||||
name: getAbsolutePath("@storybook/react-vite"),
|
name: getAbsolutePath("@storybook/react-vite"),
|
||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
|
async viteFinal(config) {
|
||||||
|
const surveyUiPath = resolve(__dirname, "../../../packages/survey-ui/src");
|
||||||
|
const rootPath = resolve(__dirname, "../../../");
|
||||||
|
|
||||||
|
// Configure server to allow files from outside the storybook directory
|
||||||
|
config.server = config.server || {};
|
||||||
|
config.server.fs = {
|
||||||
|
...config.server.fs,
|
||||||
|
allow: [...(config.server.fs?.allow || []), rootPath],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Configure simple alias resolution
|
||||||
|
config.resolve = config.resolve || {};
|
||||||
|
config.resolve.alias = {
|
||||||
|
...config.resolve.alias,
|
||||||
|
"@": surveyUiPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
return config;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@@ -1,19 +1,6 @@
|
|||||||
import type { Preview } from "@storybook/react-vite";
|
import type { Preview } from "@storybook/react-vite";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { I18nProvider } from "../../web/lingodotdev/client";
|
import "../../../packages/survey-ui/src/styles/globals.css";
|
||||||
import "../../web/modules/ui/globals.css";
|
|
||||||
|
|
||||||
// Create a Storybook-specific Lingodot Dev decorator
|
|
||||||
const withLingodotDev = (Story: any) => {
|
|
||||||
return React.createElement(
|
|
||||||
I18nProvider,
|
|
||||||
{
|
|
||||||
language: "en-US",
|
|
||||||
defaultLanguage: "en-US",
|
|
||||||
} as any,
|
|
||||||
React.createElement(Story)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const preview: Preview = {
|
const preview: Preview = {
|
||||||
parameters: {
|
parameters: {
|
||||||
@@ -22,9 +9,23 @@ const preview: Preview = {
|
|||||||
color: /(background|color)$/i,
|
color: /(background|color)$/i,
|
||||||
date: /Date$/i,
|
date: /Date$/i,
|
||||||
},
|
},
|
||||||
|
expanded: true,
|
||||||
|
},
|
||||||
|
backgrounds: {
|
||||||
|
default: "light",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
decorators: [withLingodotDev],
|
decorators: [
|
||||||
|
(Story) =>
|
||||||
|
React.createElement(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
id: "fbjs",
|
||||||
|
className: "w-full h-full min-h-screen p-4 bg-background font-sans antialiased text-foreground",
|
||||||
|
},
|
||||||
|
React.createElement(Story)
|
||||||
|
),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default preview;
|
export default preview;
|
||||||
|
|||||||
@@ -11,22 +11,24 @@
|
|||||||
"clean": "rimraf .turbo node_modules dist storybook-static"
|
"clean": "rimraf .turbo node_modules dist storybook-static"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"eslint-plugin-react-refresh": "0.4.20"
|
"@formbricks/survey-ui": "workspace:*",
|
||||||
|
"eslint-plugin-react-refresh": "0.4.24"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@chromatic-com/storybook": "^4.0.1",
|
"@chromatic-com/storybook": "^4.1.3",
|
||||||
"@storybook/addon-a11y": "9.0.15",
|
"@storybook/addon-a11y": "10.0.8",
|
||||||
"@storybook/addon-links": "9.0.15",
|
"@storybook/addon-links": "10.0.8",
|
||||||
"@storybook/addon-onboarding": "9.0.15",
|
"@storybook/addon-onboarding": "10.0.8",
|
||||||
"@storybook/react-vite": "9.0.15",
|
"@storybook/react-vite": "10.0.8",
|
||||||
"@typescript-eslint/eslint-plugin": "8.32.0",
|
"@typescript-eslint/eslint-plugin": "8.48.0",
|
||||||
"@typescript-eslint/parser": "8.32.0",
|
"@tailwindcss/vite": "4.1.17",
|
||||||
"@vitejs/plugin-react": "4.4.1",
|
"@typescript-eslint/parser": "8.48.0",
|
||||||
"esbuild": "0.25.4",
|
"@vitejs/plugin-react": "5.1.1",
|
||||||
"eslint-plugin-storybook": "9.0.15",
|
"esbuild": "0.27.0",
|
||||||
|
"eslint-plugin-storybook": "10.0.8",
|
||||||
"prop-types": "15.8.1",
|
"prop-types": "15.8.1",
|
||||||
"storybook": "9.0.15",
|
"storybook": "10.0.8",
|
||||||
"vite": "6.4.1",
|
"vite": "7.2.4",
|
||||||
"@storybook/addon-docs": "9.0.15"
|
"@storybook/addon-docs": "10.0.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,7 +1,15 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
import base from "../web/tailwind.config";
|
import surveyUi from "../../packages/survey-ui/tailwind.config";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
...base,
|
content: [
|
||||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}", "../web/modules/ui/**/*.{js,ts,jsx,tsx}"],
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
"../../packages/survey-ui/src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
...surveyUi.theme?.extend,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react(), tailwindcss()],
|
||||||
define: {
|
define: {
|
||||||
"process.env": {},
|
"process.env": {},
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "../web"),
|
"@formbricks/survey-ui": path.resolve(__dirname, "../../packages/survey-ui/src"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ ENV NODE_OPTIONS=${NODE_OPTIONS}
|
|||||||
# but needs explicit declaration for some build systems (like Depot)
|
# but needs explicit declaration for some build systems (like Depot)
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
|
|
||||||
|
# Base path for the application (optional)
|
||||||
|
ARG BASE_PATH=""
|
||||||
|
ENV BASE_PATH=${BASE_PATH}
|
||||||
|
|
||||||
# Set the working directory
|
# Set the working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -73,8 +77,8 @@ RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_ver
|
|||||||
#
|
#
|
||||||
FROM base AS runner
|
FROM base AS runner
|
||||||
|
|
||||||
RUN npm install --ignore-scripts -g corepack@latest
|
RUN npm install --ignore-scripts -g corepack@latest && \
|
||||||
RUN corepack enable
|
corepack enable
|
||||||
|
|
||||||
RUN apk add --no-cache curl \
|
RUN apk add --no-cache curl \
|
||||||
&& apk add --no-cache supercronic \
|
&& apk add --no-cache supercronic \
|
||||||
@@ -134,12 +138,13 @@ EXPOSE 3000
|
|||||||
ENV HOSTNAME="0.0.0.0"
|
ENV HOSTNAME="0.0.0.0"
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
# Prepare volume for uploads
|
# Prepare pnpm as the nextjs user to ensure it's available at runtime
|
||||||
RUN mkdir -p /home/nextjs/apps/web/uploads/
|
# Prepare volumes for uploads and SAML connections
|
||||||
VOLUME /home/nextjs/apps/web/uploads/
|
RUN corepack prepare pnpm@9.15.9 --activate && \
|
||||||
|
mkdir -p /home/nextjs/apps/web/uploads/ && \
|
||||||
|
mkdir -p /home/nextjs/apps/web/saml-connection
|
||||||
|
|
||||||
# Prepare volume for SAML preloaded connection
|
VOLUME /home/nextjs/apps/web/uploads/
|
||||||
RUN mkdir -p /home/nextjs/apps/web/saml-connection
|
|
||||||
VOLUME /home/nextjs/apps/web/saml-connection
|
VOLUME /home/nextjs/apps/web/saml-connection
|
||||||
|
|
||||||
CMD ["/home/nextjs/start.sh"]
|
CMD ["/home/nextjs/start.sh"]
|
||||||
@@ -25,7 +25,7 @@ const Page = async (props: ConnectPageProps) => {
|
|||||||
|
|
||||||
const project = await getProjectByEnvironmentId(environment.id);
|
const project = await getProjectByEnvironmentId(environment.id);
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new Error(t("common.project_not_found"));
|
throw new Error(t("common.workspace_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const channel = project.config.channel || null;
|
const channel = project.config.channel || null;
|
||||||
@@ -46,7 +46,7 @@ const Page = async (props: ConnectPageProps) => {
|
|||||||
channel={channel}
|
channel={channel}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={`/environments/${environment.id}`}>
|
<Link href={`/environments/${environment.id}`}>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const Page = async (props: XMTemplatePageProps) => {
|
|||||||
|
|
||||||
const project = await getProjectByEnvironmentId(environment.id);
|
const project = await getProjectByEnvironmentId(environment.id);
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new Error(t("common.project_not_found"));
|
throw new Error(t("common.workspace_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const projects = await getUserProjects(session.user.id, organizationId);
|
const projects = await getUserProjects(session.user.id, organizationId);
|
||||||
@@ -49,7 +49,7 @@ const Page = async (props: XMTemplatePageProps) => {
|
|||||||
<XMTemplateList project={project} user={user} environmentId={environment.id} />
|
<XMTemplateList project={project} user={user} environmentId={environment.id} />
|
||||||
{projects.length >= 2 && (
|
{projects.length >= 2 && (
|
||||||
<Button
|
<Button
|
||||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={`/environments/${environment.id}/surveys`}>
|
<Link href={`/environments/${environment.id}/surveys`}>
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ const Page = async (props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex h-full flex-col items-center justify-center space-y-12">
|
<div className="flex h-full flex-col items-center justify-center space-y-12">
|
||||||
<Header
|
<Header
|
||||||
title={t("organizations.landing.no_projects_warning_title")}
|
title={t("organizations.landing.no_workspaces_warning_title")}
|
||||||
subtitle={t("organizations.landing.no_projects_warning_subtitle")}
|
subtitle={t("organizations.landing.no_workspaces_warning_subtitle")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,16 +26,16 @@ const Page = async (props: ChannelPageProps) => {
|
|||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const channelOptions = [
|
const channelOptions = [
|
||||||
{
|
{
|
||||||
title: t("organizations.projects.new.channel.link_and_email_surveys"),
|
title: t("organizations.workspaces.new.channel.link_and_email_surveys"),
|
||||||
description: t("organizations.projects.new.channel.link_and_email_surveys_description"),
|
description: t("organizations.workspaces.new.channel.link_and_email_surveys_description"),
|
||||||
icon: SendIcon,
|
icon: SendIcon,
|
||||||
href: `/organizations/${params.organizationId}/projects/new/settings?channel=link`,
|
href: `/organizations/${params.organizationId}/workspaces/new/settings?channel=link`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("organizations.projects.new.channel.in_product_surveys"),
|
title: t("organizations.workspaces.new.channel.in_product_surveys"),
|
||||||
description: t("organizations.projects.new.channel.in_product_surveys_description"),
|
description: t("organizations.workspaces.new.channel.in_product_surveys_description"),
|
||||||
icon: PictureInPicture2Icon,
|
icon: PictureInPicture2Icon,
|
||||||
href: `/organizations/${params.organizationId}/projects/new/settings?channel=app`,
|
href: `/organizations/${params.organizationId}/workspaces/new/settings?channel=app`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -44,13 +44,13 @@ const Page = async (props: ChannelPageProps) => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||||
<Header
|
<Header
|
||||||
title={t("organizations.projects.new.channel.channel_select_title")}
|
title={t("organizations.workspaces.new.channel.channel_select_title")}
|
||||||
subtitle={t("organizations.projects.new.channel.channel_select_subtitle")}
|
subtitle={t("organizations.workspaces.new.channel.channel_select_subtitle")}
|
||||||
/>
|
/>
|
||||||
<OnboardingOptionsContainer options={channelOptions} />
|
<OnboardingOptionsContainer options={channelOptions} />
|
||||||
{projects.length >= 1 && (
|
{projects.length >= 1 && (
|
||||||
<Button
|
<Button
|
||||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={"/"}>
|
<Link href={"/"}>
|
||||||
@@ -26,16 +26,16 @@ const Page = async (props: ModePageProps) => {
|
|||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
const channelOptions = [
|
const channelOptions = [
|
||||||
{
|
{
|
||||||
title: t("organizations.projects.new.mode.formbricks_surveys"),
|
title: t("organizations.workspaces.new.mode.formbricks_surveys"),
|
||||||
description: t("organizations.projects.new.mode.formbricks_surveys_description"),
|
description: t("organizations.workspaces.new.mode.formbricks_surveys_description"),
|
||||||
icon: ListTodoIcon,
|
icon: ListTodoIcon,
|
||||||
href: `/organizations/${params.organizationId}/projects/new/channel`,
|
href: `/organizations/${params.organizationId}/workspaces/new/channel`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("organizations.projects.new.mode.formbricks_cx"),
|
title: t("organizations.workspaces.new.mode.formbricks_cx"),
|
||||||
description: t("organizations.projects.new.mode.formbricks_cx_description"),
|
description: t("organizations.workspaces.new.mode.formbricks_cx_description"),
|
||||||
icon: HeartIcon,
|
icon: HeartIcon,
|
||||||
href: `/organizations/${params.organizationId}/projects/new/settings?mode=cx`,
|
href: `/organizations/${params.organizationId}/workspaces/new/settings?mode=cx`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -43,11 +43,11 @@ const Page = async (props: ModePageProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||||
<Header title={t("organizations.projects.new.mode.what_are_you_here_for")} />
|
<Header title={t("organizations.workspaces.new.mode.what_are_you_here_for")} />
|
||||||
<OnboardingOptionsContainer options={channelOptions} />
|
<OnboardingOptionsContainer options={channelOptions} />
|
||||||
{projects.length >= 1 && (
|
{projects.length >= 1 && (
|
||||||
<Button
|
<Button
|
||||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={"/"}>
|
<Link href={"/"}>
|
||||||
@@ -44,6 +44,7 @@ interface ProjectSettingsProps {
|
|||||||
organizationTeams: TOrganizationTeam[];
|
organizationTeams: TOrganizationTeam[];
|
||||||
isAccessControlAllowed: boolean;
|
isAccessControlAllowed: boolean;
|
||||||
userProjectsCount: number;
|
userProjectsCount: number;
|
||||||
|
publicDomain: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProjectSettings = ({
|
export const ProjectSettings = ({
|
||||||
@@ -55,6 +56,7 @@ export const ProjectSettings = ({
|
|||||||
organizationTeams,
|
organizationTeams,
|
||||||
isAccessControlAllowed = false,
|
isAccessControlAllowed = false,
|
||||||
userProjectsCount,
|
userProjectsCount,
|
||||||
|
publicDomain,
|
||||||
}: ProjectSettingsProps) => {
|
}: ProjectSettingsProps) => {
|
||||||
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
|
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
|
||||||
|
|
||||||
@@ -77,7 +79,7 @@ export const ProjectSettings = ({
|
|||||||
(environment) => environment.type === "production"
|
(environment) => environment.type === "production"
|
||||||
);
|
);
|
||||||
if (productionEnvironment) {
|
if (productionEnvironment) {
|
||||||
if (typeof window !== "undefined") {
|
if (globalThis.window !== undefined) {
|
||||||
// Rmove filters when creating a new project
|
// Rmove filters when creating a new project
|
||||||
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
|
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
|
||||||
}
|
}
|
||||||
@@ -94,7 +96,7 @@ export const ProjectSettings = ({
|
|||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(t("organizations.projects.new.settings.project_creation_failed"));
|
toast.error(t("organizations.workspaces.new.settings.workspace_creation_failed"));
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -105,7 +107,6 @@ export const ProjectSettings = ({
|
|||||||
styling: { allowStyleOverwrite: true, brandColor: { light: defaultBrandColor } },
|
styling: { allowStyleOverwrite: true, brandColor: { light: defaultBrandColor } },
|
||||||
teamIds: [],
|
teamIds: [],
|
||||||
},
|
},
|
||||||
|
|
||||||
resolver: zodResolver(ZProjectUpdateInput),
|
resolver: zodResolver(ZProjectUpdateInput),
|
||||||
});
|
});
|
||||||
const projectName = form.watch("name");
|
const projectName = form.watch("name");
|
||||||
@@ -129,9 +130,9 @@ export const ProjectSettings = ({
|
|||||||
render={({ field, fieldState: { error } }) => (
|
render={({ field, fieldState: { error } }) => (
|
||||||
<FormItem className="w-full space-y-4">
|
<FormItem className="w-full space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<FormLabel>{t("organizations.projects.new.settings.brand_color")}</FormLabel>
|
<FormLabel>{t("organizations.workspaces.new.settings.brand_color")}</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t("organizations.projects.new.settings.brand_color_description")}
|
{t("organizations.workspaces.new.settings.brand_color_description")}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -153,9 +154,9 @@ export const ProjectSettings = ({
|
|||||||
render={({ field, fieldState: { error } }) => (
|
render={({ field, fieldState: { error } }) => (
|
||||||
<FormItem className="w-full space-y-4">
|
<FormItem className="w-full space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<FormLabel>{t("organizations.projects.new.settings.project_name")}</FormLabel>
|
<FormLabel>{t("organizations.workspaces.new.settings.workspace_name")}</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t("organizations.projects.new.settings.project_name_description")}
|
{t("organizations.workspaces.new.settings.workspace_name_description")}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -184,7 +185,7 @@ export const ProjectSettings = ({
|
|||||||
<div>
|
<div>
|
||||||
<FormLabel>{t("common.teams")}</FormLabel>
|
<FormLabel>{t("common.teams")}</FormLabel>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t("organizations.projects.new.settings.team_description")}
|
{t("organizations.workspaces.new.settings.team_description")}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -192,7 +193,7 @@ export const ProjectSettings = ({
|
|||||||
size="sm"
|
size="sm"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCreateTeamModalOpen(true)}>
|
onClick={() => setCreateTeamModalOpen(true)}>
|
||||||
{t("organizations.projects.new.settings.create_new_team")}
|
{t("organizations.workspaces.new.settings.create_new_team")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -225,12 +226,13 @@ export const ProjectSettings = ({
|
|||||||
alt="Logo"
|
alt="Logo"
|
||||||
width={256}
|
width={256}
|
||||||
height={56}
|
height={56}
|
||||||
className="absolute left-2 top-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
|
className="absolute top-2 left-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<p className="text-sm text-slate-400">{t("common.preview")}</p>
|
<p className="text-sm text-slate-400">{t("common.preview")}</p>
|
||||||
<div className="z-0 h-3/4 w-3/4">
|
<div className="z-0 h-3/4 w-3/4">
|
||||||
<SurveyInline
|
<SurveyInline
|
||||||
|
appUrl={publicDomain}
|
||||||
isPreviewMode={true}
|
isPreviewMode={true}
|
||||||
survey={previewSurvey(projectName || "my Product", t)}
|
survey={previewSurvey(projectName || "my Product", t)}
|
||||||
styling={{ brandColor: { light: brandColor } }}
|
styling={{ brandColor: { light: brandColor } }}
|
||||||
@@ -3,8 +3,9 @@ import Link from "next/link";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
|
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
|
||||||
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
||||||
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings";
|
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
|
||||||
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
|
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
|
||||||
|
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||||
import { getUserProjects } from "@/lib/project/service";
|
import { getUserProjects } from "@/lib/project/service";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
||||||
@@ -47,11 +48,13 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
|||||||
throw new Error(t("common.organization_teams_not_found"));
|
throw new Error(t("common.organization_teams_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const publicDomain = getPublicDomain();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||||
<Header
|
<Header
|
||||||
title={t("organizations.projects.new.settings.project_settings_title")}
|
title={t("organizations.workspaces.new.settings.workspace_settings_title")}
|
||||||
subtitle={t("organizations.projects.new.settings.project_settings_subtitle")}
|
subtitle={t("organizations.workspaces.new.settings.workspace_settings_subtitle")}
|
||||||
/>
|
/>
|
||||||
<ProjectSettings
|
<ProjectSettings
|
||||||
organizationId={params.organizationId}
|
organizationId={params.organizationId}
|
||||||
@@ -62,10 +65,11 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
|||||||
organizationTeams={organizationTeams}
|
organizationTeams={organizationTeams}
|
||||||
isAccessControlAllowed={isAccessControlAllowed}
|
isAccessControlAllowed={isAccessControlAllowed}
|
||||||
userProjectsCount={projects.length}
|
userProjectsCount={projects.length}
|
||||||
|
publicDomain={publicDomain}
|
||||||
/>
|
/>
|
||||||
{projects.length >= 1 && (
|
{projects.length >= 1 && (
|
||||||
<Button
|
<Button
|
||||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
asChild>
|
asChild>
|
||||||
<Link href={"/"}>
|
<Link href={"/"}>
|
||||||
@@ -57,7 +57,7 @@ export const createProjectAction = authenticatedActionClient.schema(ZCreateProje
|
|||||||
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
|
const organizationProjectsCount = await getOrganizationProjectsCount(organization.id);
|
||||||
|
|
||||||
if (organizationProjectsCount >= organizationProjectsLimit) {
|
if (organizationProjectsCount >= organizationProjectsLimit) {
|
||||||
throw new OperationNotAllowedError("Organization project limit reached");
|
throw new OperationNotAllowedError("Organization workspace limit reached");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
|
if (parsedInput.data.teamIds && parsedInput.data.teamIds.length > 0) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
|
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
|
||||||
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
|
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
|
||||||
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||||
|
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||||
import { getAccessFlags } from "@/lib/membership/utils";
|
import { getAccessFlags } from "@/lib/membership/utils";
|
||||||
import { getTranslate } from "@/lingodotdev/server";
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||||
@@ -15,6 +16,7 @@ interface EnvironmentLayoutProps {
|
|||||||
|
|
||||||
export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLayoutProps) => {
|
export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLayoutProps) => {
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
|
const publicDomain = getPublicDomain();
|
||||||
|
|
||||||
// Destructure all data from props (NO database queries)
|
// Destructure all data from props (NO database queries)
|
||||||
const {
|
const {
|
||||||
@@ -41,7 +43,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
|||||||
|
|
||||||
// Validate that project permission exists for members
|
// Validate that project permission exists for members
|
||||||
if (isMember && !projectPermission) {
|
if (isMember && !projectPermission) {
|
||||||
throw new Error(t("common.project_permission_not_found"));
|
throw new Error(t("common.workspace_permission_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -72,6 +74,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
|||||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||||
isDevelopment={IS_DEVELOPMENT}
|
isDevelopment={IS_DEVELOPMENT}
|
||||||
membershipRole={membership.role}
|
membershipRole={membership.role}
|
||||||
|
publicDomain={publicDomain}
|
||||||
/>
|
/>
|
||||||
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
|
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
|
||||||
<TopControlBar
|
<TopControlBar
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ interface NavigationProps {
|
|||||||
isFormbricksCloud: boolean;
|
isFormbricksCloud: boolean;
|
||||||
isDevelopment: boolean;
|
isDevelopment: boolean;
|
||||||
membershipRole?: TOrganizationRole;
|
membershipRole?: TOrganizationRole;
|
||||||
|
publicDomain: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MainNavigation = ({
|
export const MainNavigation = ({
|
||||||
@@ -56,6 +57,7 @@ export const MainNavigation = ({
|
|||||||
membershipRole,
|
membershipRole,
|
||||||
isFormbricksCloud,
|
isFormbricksCloud,
|
||||||
isDevelopment,
|
isDevelopment,
|
||||||
|
publicDomain,
|
||||||
}: NavigationProps) => {
|
}: NavigationProps) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -111,7 +113,7 @@ export const MainNavigation = ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: t("common.configuration"),
|
name: t("common.configuration"),
|
||||||
href: `/environments/${environment.id}/project/general`,
|
href: `/environments/${environment.id}/workspace/general`,
|
||||||
icon: Cog,
|
icon: Cog,
|
||||||
isActive: pathname?.includes("/project"),
|
isActive: pathname?.includes("/project"),
|
||||||
},
|
},
|
||||||
@@ -162,7 +164,7 @@ export const MainNavigation = ({
|
|||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100",
|
"z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100",
|
||||||
!isCollapsed ? "w-sidebar-collapsed" : "w-sidebar-expanded"
|
isCollapsed ? "w-sidebar-expanded" : "w-sidebar-collapsed"
|
||||||
)}>
|
)}>
|
||||||
<div>
|
<div>
|
||||||
{/* Logo and Toggle */}
|
{/* Logo and Toggle */}
|
||||||
@@ -183,7 +185,7 @@ export const MainNavigation = ({
|
|||||||
size="icon"
|
size="icon"
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
|
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
|
||||||
)}>
|
)}>
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
<PanelLeftOpenIcon strokeWidth={1.5} />
|
<PanelLeftOpenIcon strokeWidth={1.5} />
|
||||||
@@ -286,15 +288,16 @@ export const MainNavigation = ({
|
|||||||
{/* Logout */}
|
{/* Logout */}
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
|
const loginUrl = `${publicDomain}/auth/login`;
|
||||||
const route = await signOutWithAudit({
|
const route = await signOutWithAudit({
|
||||||
reason: "user_initiated",
|
reason: "user_initiated",
|
||||||
redirectUrl: "/auth/login",
|
redirectUrl: loginUrl,
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
redirect: false,
|
redirect: false,
|
||||||
callbackUrl: "/auth/login",
|
callbackUrl: loginUrl,
|
||||||
clearEnvironmentId: true,
|
clearEnvironmentId: true,
|
||||||
});
|
});
|
||||||
router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings
|
router.push(route?.url || loginUrl); // NOSONAR // We want to check for empty strings
|
||||||
}}
|
}}
|
||||||
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
||||||
{t("common.logout")}
|
{t("common.logout")}
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
|
|||||||
const stati = {
|
const stati = {
|
||||||
notImplemented: {
|
notImplemented: {
|
||||||
icon: AlertTriangleIcon,
|
icon: AlertTriangleIcon,
|
||||||
title: t("environments.project.app-connection.formbricks_sdk_not_connected"),
|
title: t("environments.workspace.app-connection.formbricks_sdk_not_connected"),
|
||||||
subtitle: t("environments.project.app-connection.formbricks_sdk_not_connected_description"),
|
subtitle: t("environments.workspace.app-connection.formbricks_sdk_not_connected_description"),
|
||||||
},
|
},
|
||||||
running: {
|
running: {
|
||||||
icon: CheckIcon,
|
icon: CheckIcon,
|
||||||
title: t("environments.project.app-connection.receiving_data"),
|
title: t("environments.workspace.app-connection.receiving_data"),
|
||||||
subtitle: t("environments.project.app-connection.formbricks_sdk_connected"),
|
subtitle: t("environments.workspace.app-connection.formbricks_sdk_connected"),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,11 +53,11 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
|
|||||||
<currentStatus.icon />
|
<currentStatus.icon />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
|
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
|
||||||
<p className="w-2/3 text-balance text-sm text-slate-600">{currentStatus.subtitle}</p>
|
<p className="w-2/3 text-sm text-balance text-slate-600">{currentStatus.subtitle}</p>
|
||||||
{status === "notImplemented" && (
|
{status === "notImplemented" && (
|
||||||
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
|
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
|
||||||
<RotateCcwIcon />
|
<RotateCcwIcon />
|
||||||
{t("environments.project.app-connection.recheck")}
|
{t("environments.workspace.app-connection.recheck")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import * as Sentry from "@sentry/nextjs";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import {
|
import {
|
||||||
BuildingIcon,
|
Building2Icon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -135,7 +135,7 @@ export const OrganizationBreadcrumb = ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "teams",
|
id: "teams",
|
||||||
label: t("common.teams"),
|
label: t("common.members_and_teams"),
|
||||||
href: `/environments/${currentEnvironmentId}/settings/teams`,
|
href: `/environments/${currentEnvironmentId}/settings/teams`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -144,6 +144,12 @@ export const OrganizationBreadcrumb = ({
|
|||||||
href: `/environments/${currentEnvironmentId}/settings/api-keys`,
|
href: `/environments/${currentEnvironmentId}/settings/api-keys`,
|
||||||
hidden: !isOwnerOrManager,
|
hidden: !isOwnerOrManager,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "domain",
|
||||||
|
label: t("common.domain"),
|
||||||
|
href: `/environments/${currentEnvironmentId}/settings/domain`,
|
||||||
|
hidden: isFormbricksCloud,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "billing",
|
id: "billing",
|
||||||
label: t("common.billing"),
|
label: t("common.billing"),
|
||||||
@@ -166,7 +172,7 @@ export const OrganizationBreadcrumb = ({
|
|||||||
id="organizationDropdownTrigger"
|
id="organizationDropdownTrigger"
|
||||||
asChild>
|
asChild>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<BuildingIcon className="h-3 w-3" strokeWidth={1.5} />
|
<Building2Icon className="h-3 w-3" strokeWidth={1.5} />
|
||||||
<span>{organizationName}</span>
|
<span>{organizationName}</span>
|
||||||
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||||
{isOrganizationDropdownOpen ? (
|
{isOrganizationDropdownOpen ? (
|
||||||
@@ -180,7 +186,7 @@ export const OrganizationBreadcrumb = ({
|
|||||||
{showOrganizationDropdown && (
|
{showOrganizationDropdown && (
|
||||||
<>
|
<>
|
||||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||||
<BuildingIcon className="mr-2 inline h-4 w-4" />
|
<Building2Icon className="mr-2 inline h-4 w-4" />
|
||||||
{t("common.choose_organization")}
|
{t("common.choose_organization")}
|
||||||
</div>
|
</div>
|
||||||
{isLoadingOrganizations && (
|
{isLoadingOrganizations && (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as Sentry from "@sentry/nextjs";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import { ChevronDownIcon, ChevronRightIcon, CogIcon, FolderOpenIcon, Loader2, PlusIcon } from "lucide-react";
|
import { ChevronDownIcon, ChevronRightIcon, CogIcon, HotelIcon, Loader2, PlusIcon } from "lucide-react";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useEffect, useState, useTransition } from "react";
|
import { useEffect, useState, useTransition } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -36,12 +36,12 @@ interface ProjectBreadcrumbProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
|
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
|
||||||
// Match /project/{settingId} or /project/{settingId}/... but exclude settings paths
|
// Match /workspace/{settingId} or /workspace/{settingId}/... but exclude settings paths
|
||||||
if (pathname.includes("/settings/")) {
|
if (pathname.includes("/settings/")) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Check if path matches /project/{settingId} (with optional trailing path)
|
// Check if path matches /workspace/{settingId} (with optional trailing path)
|
||||||
const pattern = new RegExp(`/project/${settingId}(?:/|$)`);
|
const pattern = new RegExp(`/workspace/${settingId}(?:/|$)`);
|
||||||
return pattern.test(pathname);
|
return pattern.test(pathname);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ export const ProjectBreadcrumb = ({
|
|||||||
const error = new Error(errorMessage);
|
const error = new Error(errorMessage);
|
||||||
logger.error(error, "Failed to load projects");
|
logger.error(error, "Failed to load projects");
|
||||||
Sentry.captureException(error);
|
Sentry.captureException(error);
|
||||||
setLoadError(errorMessage || t("common.failed_to_load_projects"));
|
setLoadError(errorMessage || t("common.failed_to_load_workspaces"));
|
||||||
}
|
}
|
||||||
setIsLoadingProjects(false);
|
setIsLoadingProjects(false);
|
||||||
});
|
});
|
||||||
@@ -101,42 +101,42 @@ export const ProjectBreadcrumb = ({
|
|||||||
{
|
{
|
||||||
id: "general",
|
id: "general",
|
||||||
label: t("common.general"),
|
label: t("common.general"),
|
||||||
href: `/environments/${currentEnvironmentId}/project/general`,
|
href: `/environments/${currentEnvironmentId}/workspace/general`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "look",
|
id: "look",
|
||||||
label: t("common.look_and_feel"),
|
label: t("common.look_and_feel"),
|
||||||
href: `/environments/${currentEnvironmentId}/project/look`,
|
href: `/environments/${currentEnvironmentId}/workspace/look`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "app-connection",
|
id: "app-connection",
|
||||||
label: t("common.website_and_app_connection"),
|
label: t("common.website_and_app_connection"),
|
||||||
href: `/environments/${currentEnvironmentId}/project/app-connection`,
|
href: `/environments/${currentEnvironmentId}/workspace/app-connection`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "integrations",
|
id: "integrations",
|
||||||
label: t("common.integrations"),
|
label: t("common.integrations"),
|
||||||
href: `/environments/${currentEnvironmentId}/project/integrations`,
|
href: `/environments/${currentEnvironmentId}/workspace/integrations`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "teams",
|
id: "teams",
|
||||||
label: t("common.team_access"),
|
label: t("common.team_access"),
|
||||||
href: `/environments/${currentEnvironmentId}/project/teams`,
|
href: `/environments/${currentEnvironmentId}/workspace/teams`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "languages",
|
id: "languages",
|
||||||
label: t("common.survey_languages"),
|
label: t("common.survey_languages"),
|
||||||
href: `/environments/${currentEnvironmentId}/project/languages`,
|
href: `/environments/${currentEnvironmentId}/workspace/languages`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "tags",
|
id: "tags",
|
||||||
label: t("common.tags"),
|
label: t("common.tags"),
|
||||||
href: `/environments/${currentEnvironmentId}/project/tags`,
|
href: `/environments/${currentEnvironmentId}/workspace/tags`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!currentProject) {
|
if (!currentProject) {
|
||||||
const errorMessage = `Project not found for project id: ${currentProjectId}`;
|
const errorMessage = `Workspace not found for workspace id: ${currentProjectId}`;
|
||||||
logger.error(errorMessage);
|
logger.error(errorMessage);
|
||||||
Sentry.captureException(new Error(errorMessage));
|
Sentry.captureException(new Error(errorMessage));
|
||||||
return;
|
return;
|
||||||
@@ -145,7 +145,7 @@ export const ProjectBreadcrumb = ({
|
|||||||
const handleProjectChange = (projectId: string) => {
|
const handleProjectChange = (projectId: string) => {
|
||||||
if (projectId === currentProjectId) return;
|
if (projectId === currentProjectId) return;
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
router.push(`/projects/${projectId}/`);
|
router.push(`/workspaces/${projectId}/`);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -159,7 +159,7 @@ export const ProjectBreadcrumb = ({
|
|||||||
|
|
||||||
const handleProjectSettingsNavigation = (settingId: string) => {
|
const handleProjectSettingsNavigation = (settingId: string) => {
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
router.push(`/environments/${currentEnvironmentId}/project/${settingId}`);
|
router.push(`/environments/${currentEnvironmentId}/workspace/${settingId}`);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -198,21 +198,21 @@ export const ProjectBreadcrumb = ({
|
|||||||
id="projectDropdownTrigger"
|
id="projectDropdownTrigger"
|
||||||
asChild>
|
asChild>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<FolderOpenIcon className="h-3 w-3" strokeWidth={1.5} />
|
<HotelIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||||
<span>{projectName}</span>
|
<span>{projectName}</span>
|
||||||
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
{isPending && <Loader2 className="h-3 w-3 animate-spin" strokeWidth={1.5} />}
|
||||||
{isProjectDropdownOpen ? (
|
{isEnvironmentBreadcrumbVisible && !isProjectDropdownOpen ? (
|
||||||
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
<ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||||
) : (
|
) : (
|
||||||
isEnvironmentBreadcrumbVisible && <ChevronRightIcon className="h-3 w-3" strokeWidth={1.5} />
|
<ChevronDownIcon className="h-3 w-3" strokeWidth={1.5} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|
||||||
<DropdownMenuContent align="start" className="mt-2">
|
<DropdownMenuContent align="start" className="mt-2">
|
||||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||||
<FolderOpenIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
<HotelIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||||
{t("common.choose_project")}
|
{t("common.choose_workspace")}
|
||||||
</div>
|
</div>
|
||||||
{isLoadingProjects && (
|
{isLoadingProjects && (
|
||||||
<div className="flex items-center justify-center py-2">
|
<div className="flex items-center justify-center py-2">
|
||||||
@@ -251,7 +251,7 @@ export const ProjectBreadcrumb = ({
|
|||||||
<DropdownMenuCheckboxItem
|
<DropdownMenuCheckboxItem
|
||||||
onClick={handleAddProject}
|
onClick={handleAddProject}
|
||||||
className="w-full cursor-pointer justify-between">
|
className="w-full cursor-pointer justify-between">
|
||||||
<span>{t("common.add_new_project")}</span>
|
<span>{t("common.add_new_workspace")}</span>
|
||||||
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
||||||
</DropdownMenuCheckboxItem>
|
</DropdownMenuCheckboxItem>
|
||||||
)}
|
)}
|
||||||
@@ -261,7 +261,7 @@ export const ProjectBreadcrumb = ({
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
<div className="px-2 py-1.5 text-sm font-medium text-slate-500">
|
||||||
<CogIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
<CogIcon className="mr-2 inline h-4 w-4" strokeWidth={1.5} />
|
||||||
{t("common.project_configuration")}
|
{t("common.workspace_configuration")}
|
||||||
</div>
|
</div>
|
||||||
{projectSettings.map((setting) => (
|
{projectSettings.map((setting) => (
|
||||||
<DropdownMenuCheckboxItem
|
<DropdownMenuCheckboxItem
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const AccountSettingsLayout = async (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new Error(t("common.project_not_found"));
|
throw new Error(t("common.workspace_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const IntegrationsTip = ({ environmentId }: IntegrationsTipProps) => {
|
|||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
{t("environments.settings.notifications.need_slack_or_discord_notifications")}?
|
{t("environments.settings.notifications.need_slack_or_discord_notifications")}?
|
||||||
<a
|
<a
|
||||||
href={`/environments/${environmentId}/project/integrations`}
|
href={`/environments/${environmentId}/workspace/integrations`}
|
||||||
className="ml-1 cursor-pointer text-sm underline">
|
className="ml-1 cursor-pointer text-sm underline">
|
||||||
{t("environments.settings.notifications.use_the_integration")}
|
{t("environments.settings.notifications.use_the_integration")}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const OrganizationSettingsNavbar = ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "teams",
|
id: "teams",
|
||||||
label: t("common.teams"),
|
label: t("common.members_and_teams"),
|
||||||
href: `/environments/${environmentId}/settings/teams`,
|
href: `/environments/${environmentId}/settings/teams`,
|
||||||
current: pathname?.includes("/teams"),
|
current: pathname?.includes("/teams"),
|
||||||
},
|
},
|
||||||
@@ -47,6 +47,13 @@ export const OrganizationSettingsNavbar = ({
|
|||||||
current: pathname?.includes("/api-keys"),
|
current: pathname?.includes("/api-keys"),
|
||||||
hidden: !isOwner,
|
hidden: !isOwner,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "domain",
|
||||||
|
label: t("common.domain"),
|
||||||
|
href: `/environments/${environmentId}/settings/domain`,
|
||||||
|
current: pathname?.includes("/domain"),
|
||||||
|
hidden: isFormbricksCloud,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "billing",
|
id: "billing",
|
||||||
label: t("common.billing"),
|
label: t("common.billing"),
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { TSurveyStatus } from "@formbricks/types/surveys/types";
|
||||||
|
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||||
|
|
||||||
|
interface SurveyWithSlug {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string | null;
|
||||||
|
status: TSurveyStatus;
|
||||||
|
environment: {
|
||||||
|
id: string;
|
||||||
|
type: "production" | "development";
|
||||||
|
project: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PrettyUrlsTableProps {
|
||||||
|
surveys: SurveyWithSlug[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PrettyUrlsTable = ({ surveys }: PrettyUrlsTableProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const getEnvironmentBadgeColor = (type: string) => {
|
||||||
|
return type === "production" ? "bg-green-100 text-green-800" : "bg-blue-100 text-blue-800";
|
||||||
|
};
|
||||||
|
|
||||||
|
const tableHeaders = [
|
||||||
|
{
|
||||||
|
label: t("environments.settings.domain.survey_name"),
|
||||||
|
key: "name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("environments.settings.domain.workspace"),
|
||||||
|
key: "project",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("environments.settings.domain.pretty_url"),
|
||||||
|
key: "slug",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t("common.environment"),
|
||||||
|
key: "environment",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-lg">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-slate-100">
|
||||||
|
{tableHeaders.map((header) => (
|
||||||
|
<TableHead key={header.key} className="font-medium text-slate-500">
|
||||||
|
{header.label}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody className="[&_tr:last-child]:border-b">
|
||||||
|
{surveys.length === 0 && (
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
<TableCell colSpan={4} className="text-center text-slate-500">
|
||||||
|
{t("environments.settings.domain.no_pretty_urls")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{surveys.map((survey) => (
|
||||||
|
<TableRow key={survey.id} className="border-slate-200 hover:bg-transparent">
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<Link
|
||||||
|
href={`/environments/${survey.environment.id}/surveys/${survey.id}/summary`}
|
||||||
|
className="text-slate-900 hover:text-slate-700 hover:underline">
|
||||||
|
{survey.name}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{survey.environment.project.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<IdBadge id={survey.slug ?? ""} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span
|
||||||
|
className={`rounded px-2 py-1 text-xs font-medium ${getEnvironmentBadgeColor(survey.environment.type)}`}>
|
||||||
|
{survey.environment.type === "production"
|
||||||
|
? t("common.production")
|
||||||
|
: t("common.development")}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||||
|
import { getTranslate } from "@/lingodotdev/server";
|
||||||
|
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
|
||||||
|
import { getSurveysWithSlugsByOrganizationId } from "@/modules/survey/lib/slug";
|
||||||
|
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||||
|
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||||
|
import { SettingsCard } from "../../components/SettingsCard";
|
||||||
|
import { OrganizationSettingsNavbar } from "../components/OrganizationSettingsNavbar";
|
||||||
|
import { PrettyUrlsTable } from "./components/pretty-urls-table";
|
||||||
|
|
||||||
|
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||||
|
const params = await props.params;
|
||||||
|
const t = await getTranslate();
|
||||||
|
|
||||||
|
if (IS_FORMBRICKS_CLOUD) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { session, currentUserMembership, organization } = await getEnvironmentAuth(params.environmentId);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new Error(t("common.session_not_found"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await getSurveysWithSlugsByOrganizationId(organization.id);
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new Error(t("common.something_went_wrong"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const surveys = result.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContentWrapper>
|
||||||
|
<PageHeader pageTitle={t("environments.settings.general.organization_settings")}>
|
||||||
|
<OrganizationSettingsNavbar
|
||||||
|
environmentId={params.environmentId}
|
||||||
|
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||||
|
membershipRole={currentUserMembership?.role}
|
||||||
|
activeId="domain"
|
||||||
|
/>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<SettingsCard
|
||||||
|
title={t("environments.settings.domain.title")}
|
||||||
|
description={t("environments.settings.domain.description")}>
|
||||||
|
<PrettyUrlsTable surveys={surveys} />
|
||||||
|
</SettingsCard>
|
||||||
|
</PageContentWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Page;
|
||||||
@@ -39,7 +39,7 @@ const Page = async (props) => {
|
|||||||
onRequest: false,
|
onRequest: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t("environments.project.languages.multi_language_surveys"),
|
title: t("environments.workspace.languages.multi_language_surveys"),
|
||||||
comingSoon: false,
|
comingSoon: false,
|
||||||
onRequest: false,
|
onRequest: false,
|
||||||
},
|
},
|
||||||
@@ -118,7 +118,7 @@ const Page = async (props) => {
|
|||||||
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
|
<div className="relative isolate mt-8 overflow-hidden rounded-lg bg-slate-900 px-3 pt-8 shadow-2xl sm:px-8 md:pt-12 lg:flex lg:gap-x-10 lg:px-12 lg:pt-0">
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 1024 1024"
|
viewBox="0 0 1024 1024"
|
||||||
className="absolute left-1/2 top-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
|
className="absolute top-1/2 left-1/2 -z-10 h-[64rem] w-[64rem] -translate-y-1/2 [mask-image:radial-gradient(closest-side,white,transparent)] sm:left-full sm:-ml-80 lg:left-1/2 lg:ml-0 lg:-translate-x-1/2 lg:translate-y-0"
|
||||||
aria-hidden="true">
|
aria-hidden="true">
|
||||||
<circle
|
<circle
|
||||||
cx={512}
|
cx={512}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const Layout = async (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new Error(t("common.project_not_found"));
|
throw new Error(t("common.workspace_not_found"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export const EmptyAppSurveys = ({ environment }: TEmptyAppSurveysProps) => {
|
|||||||
{t("environments.surveys.summary.connect_your_website_or_app_with_formbricks_to_get_started")}
|
{t("environments.surveys.summary.connect_your_website_or_app_with_formbricks_to_get_started")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Link className="mt-2" href={`/environments/${environment.id}/project/app-connection`}>
|
<Link className="mt-2" href={`/environments/${environment.id}/workspace/app-connection`}>
|
||||||
<Button size="sm" className="flex w-[120px] justify-center">
|
<Button size="sm" className="flex w-[120px] justify-center">
|
||||||
{t("common.connect")}
|
{t("common.connect")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||||
import {
|
import {
|
||||||
Code2Icon,
|
Code2Icon,
|
||||||
LinkIcon,
|
Link2Icon,
|
||||||
MailIcon,
|
MailIcon,
|
||||||
QrCodeIcon,
|
QrCodeIcon,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -22,6 +22,7 @@ import { DynamicPopupTab } from "@/app/(app)/environments/[environmentId]/survey
|
|||||||
import { EmailTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/email-tab";
|
import { EmailTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/email-tab";
|
||||||
import { LinkSettingsTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/link-settings-tab";
|
import { LinkSettingsTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/link-settings-tab";
|
||||||
import { PersonalLinksTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab";
|
import { PersonalLinksTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab";
|
||||||
|
import { PrettyUrlTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/pretty-url-tab";
|
||||||
import { QRCodeTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/qr-code-tab";
|
import { QRCodeTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/qr-code-tab";
|
||||||
import { SocialMediaTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/social-media-tab";
|
import { SocialMediaTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/social-media-tab";
|
||||||
import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container";
|
import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container";
|
||||||
@@ -80,13 +81,13 @@ export const ShareSurveyModal = ({
|
|||||||
componentType: React.ComponentType<unknown>;
|
componentType: React.ComponentType<unknown>;
|
||||||
componentProps: unknown;
|
componentProps: unknown;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}[] = useMemo(
|
}[] = useMemo(() => {
|
||||||
() => [
|
const tabs = [
|
||||||
{
|
{
|
||||||
id: ShareViaType.ANON_LINKS,
|
id: ShareViaType.ANON_LINKS,
|
||||||
type: LinkTabsType.SHARE_VIA,
|
type: LinkTabsType.SHARE_VIA,
|
||||||
label: t("environments.surveys.share.anonymous_links.nav_title"),
|
label: t("environments.surveys.share.anonymous_links.nav_title"),
|
||||||
icon: LinkIcon,
|
icon: Link2Icon,
|
||||||
title: t("environments.surveys.share.anonymous_links.nav_title"),
|
title: t("environments.surveys.share.anonymous_links.nav_title"),
|
||||||
description: t("environments.surveys.share.anonymous_links.description"),
|
description: t("environments.surveys.share.anonymous_links.description"),
|
||||||
componentType: AnonymousLinksTab,
|
componentType: AnonymousLinksTab,
|
||||||
@@ -180,22 +181,33 @@ export const ShareSurveyModal = ({
|
|||||||
componentType: LinkSettingsTab,
|
componentType: LinkSettingsTab,
|
||||||
componentProps: { isReadOnly, locale: user.locale, isStorageConfigured },
|
componentProps: { isReadOnly, locale: user.locale, isStorageConfigured },
|
||||||
},
|
},
|
||||||
],
|
{
|
||||||
[
|
id: ShareSettingsType.PRETTY_URL,
|
||||||
t,
|
type: LinkTabsType.SHARE_SETTING,
|
||||||
survey,
|
label: t("environments.surveys.share.pretty_url.title"),
|
||||||
publicDomain,
|
icon: Link2Icon,
|
||||||
user.locale,
|
title: t("environments.surveys.share.pretty_url.title"),
|
||||||
surveyUrl,
|
description: t("environments.surveys.share.pretty_url.description"),
|
||||||
isReadOnly,
|
componentType: PrettyUrlTab,
|
||||||
environmentId,
|
componentProps: { publicDomain, isReadOnly },
|
||||||
segments,
|
},
|
||||||
isContactsEnabled,
|
];
|
||||||
isFormbricksCloud,
|
|
||||||
email,
|
return isFormbricksCloud ? tabs.filter((tab) => tab.id !== ShareSettingsType.PRETTY_URL) : tabs;
|
||||||
isStorageConfigured,
|
}, [
|
||||||
]
|
t,
|
||||||
);
|
survey,
|
||||||
|
publicDomain,
|
||||||
|
user.locale,
|
||||||
|
surveyUrl,
|
||||||
|
isReadOnly,
|
||||||
|
environmentId,
|
||||||
|
segments,
|
||||||
|
isContactsEnabled,
|
||||||
|
isFormbricksCloud,
|
||||||
|
email,
|
||||||
|
isStorageConfigured,
|
||||||
|
]);
|
||||||
|
|
||||||
const getDefaultActiveId = useCallback(() => {
|
const getDefaultActiveId = useCallback(() => {
|
||||||
if (survey.type !== "link") {
|
if (survey.type !== "link") {
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ export const AppTab = () => {
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
{!environment.appSetupCompleted && (
|
{!environment.appSetupCompleted && (
|
||||||
<AlertButton asChild>
|
<AlertButton asChild>
|
||||||
<Link href={`/environments/${environment.id}/project/app-connection`}>
|
<Link href={`/environments/${environment.id}/workspace/app-connection`}>
|
||||||
{t("common.connect_formbricks")}
|
{t("common.connect_formbricks")}
|
||||||
</Link>
|
</Link>
|
||||||
</AlertButton>
|
</AlertButton>
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Input } from "@/modules/ui/components/input";
|
||||||
|
|
||||||
|
interface PrettyUrlInputProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
publicDomain: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PrettyUrlInput = ({ value, onChange, publicDomain, disabled = false }: PrettyUrlInputProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center overflow-hidden rounded-md border border-slate-300 bg-white">
|
||||||
|
<span className="flex-shrink-0 border-r border-slate-300 bg-slate-50 px-3 py-2 text-sm text-slate-600">
|
||||||
|
{publicDomain}/p/
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value.toLowerCase().replaceAll(/[^a-z0-9-]/g, ""))}
|
||||||
|
placeholder={t("environments.surveys.share.pretty_url.slug_placeholder")}
|
||||||
|
disabled={disabled}
|
||||||
|
className="border-0 bg-white focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Copy, Trash2 } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
|
||||||
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
|
import { removeSurveySlugAction, updateSurveySlugAction } from "@/modules/survey/slug/actions";
|
||||||
|
import { Button } from "@/modules/ui/components/button";
|
||||||
|
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormProvider,
|
||||||
|
} from "@/modules/ui/components/form";
|
||||||
|
import { PrettyUrlInput } from "./pretty-url-input";
|
||||||
|
|
||||||
|
interface PrettyUrlTabProps {
|
||||||
|
publicDomain: string;
|
||||||
|
isReadOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PrettyUrlFormData {
|
||||||
|
slug: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PrettyUrlTab = ({ publicDomain, isReadOnly = false }: PrettyUrlTabProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
const { survey } = useSurvey();
|
||||||
|
const [isEditing, setIsEditing] = useState(!survey.slug);
|
||||||
|
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Initialize form with current values - memoize to prevent re-initialization
|
||||||
|
const initialFormData = useMemo(() => {
|
||||||
|
return {
|
||||||
|
slug: survey.slug || "",
|
||||||
|
};
|
||||||
|
}, [survey.slug]);
|
||||||
|
|
||||||
|
const form = useForm<PrettyUrlFormData>({
|
||||||
|
defaultValues: initialFormData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { handleSubmit, reset } = form;
|
||||||
|
|
||||||
|
// Sync isEditing state and form with survey.slug changes
|
||||||
|
useEffect(() => {
|
||||||
|
setIsEditing(!survey.slug);
|
||||||
|
reset({ slug: survey.slug || "" });
|
||||||
|
}, [survey.slug, reset]);
|
||||||
|
|
||||||
|
const onSubmit = async (data: PrettyUrlFormData) => {
|
||||||
|
if (!data.slug.trim()) {
|
||||||
|
toast.error(t("environments.surveys.share.pretty_url.slug_required"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
const result = await updateSurveySlugAction({
|
||||||
|
surveyId: survey.id,
|
||||||
|
slug: data.slug,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.data) {
|
||||||
|
if (result.data.ok) {
|
||||||
|
toast.success(t("environments.surveys.share.pretty_url.save_success"));
|
||||||
|
router.refresh();
|
||||||
|
setIsEditing(false);
|
||||||
|
} else {
|
||||||
|
toast.error(result.data.error.message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const errorMessage = getFormattedErrorMessage(result);
|
||||||
|
toast.error(errorMessage || "Failed to update slug");
|
||||||
|
}
|
||||||
|
setIsSubmitting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
setIsEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
reset({ slug: survey.slug || "" });
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = async () => {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
const result = await removeSurveySlugAction({ surveyId: survey.id });
|
||||||
|
|
||||||
|
if (result?.data) {
|
||||||
|
if (result.data.ok) {
|
||||||
|
setShowRemoveDialog(false);
|
||||||
|
reset({ slug: "" });
|
||||||
|
router.refresh();
|
||||||
|
setIsEditing(true);
|
||||||
|
toast.success(t("environments.surveys.share.pretty_url.remove_success"));
|
||||||
|
} else {
|
||||||
|
toast.error(result.data.error.message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const errorMessage = getFormattedErrorMessage(result);
|
||||||
|
toast.error(errorMessage || "Failed to remove slug");
|
||||||
|
}
|
||||||
|
setIsSubmitting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyUrl = () => {
|
||||||
|
if (!survey.slug) return;
|
||||||
|
const prettyUrl = `${publicDomain}/p/${survey.slug}`;
|
||||||
|
navigator.clipboard.writeText(prettyUrl);
|
||||||
|
toast.success(t("common.copied_to_clipboard"));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-1">
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="slug"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("environments.surveys.share.pretty_url.slug_label")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<PrettyUrlInput
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
publicDomain={publicDomain}
|
||||||
|
disabled={isReadOnly || !isEditing}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>{t("environments.surveys.share.pretty_url.slug_help")}</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<Button type="submit" disabled={isReadOnly || isSubmitting}>
|
||||||
|
{t("common.save")}
|
||||||
|
</Button>
|
||||||
|
{survey.slug && (
|
||||||
|
<Button type="button" variant="secondary" onClick={handleCancel} disabled={isSubmitting}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button type="button" variant="secondary" onClick={handleEdit} disabled={isReadOnly}>
|
||||||
|
{t("common.edit")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{survey.slug && !isEditing && (
|
||||||
|
<>
|
||||||
|
<Button type="button" variant="default" onClick={handleCopyUrl} disabled={isReadOnly}>
|
||||||
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
|
{t("common.copy")} URL
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setShowRemoveDialog(true)}
|
||||||
|
disabled={isReadOnly}>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
{t("common.remove")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</FormProvider>
|
||||||
|
|
||||||
|
<DeleteDialog
|
||||||
|
open={showRemoveDialog}
|
||||||
|
setOpen={setShowRemoveDialog}
|
||||||
|
deleteWhat={t("environments.surveys.share.pretty_url.title")}
|
||||||
|
onDelete={handleRemove}
|
||||||
|
text={t("environments.surveys.share.pretty_url.remove_description")}></DeleteDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -66,7 +66,7 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
|
|||||||
className="relative flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
|
className="relative flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
|
||||||
<UserIcon className="h-8 w-8 stroke-1 text-slate-900" />
|
<UserIcon className="h-8 w-8 stroke-1 text-slate-900" />
|
||||||
{t("environments.surveys.summary.use_personal_links")}
|
{t("environments.surveys.summary.use_personal_links")}
|
||||||
<Badge size="normal" type="success" className="absolute right-3 top-3" text={t("common.new")} />
|
<Badge size="normal" type="success" className="absolute top-3 right-3" text={t("common.new")} />
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<Link
|
||||||
href={`/environments/${environmentId}/settings/notifications`}
|
href={`/environments/${environmentId}/settings/notifications`}
|
||||||
@@ -75,7 +75,7 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
|
|||||||
{t("environments.surveys.summary.configure_alerts")}
|
{t("environments.surveys.summary.configure_alerts")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href={`/environments/${environmentId}/project/integrations`}
|
href={`/environments/${environmentId}/workspace/integrations`}
|
||||||
className="flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
|
className="flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
|
||||||
<BlocksIcon className="h-8 w-8 stroke-1 text-slate-900" />
|
<BlocksIcon className="h-8 w-8 stroke-1 text-slate-900" />
|
||||||
{t("environments.surveys.summary.setup_integrations")}
|
{t("environments.surveys.summary.setup_integrations")}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const getEmailTemplateHtml = async (surveyId: string, locale: string) =>
|
|||||||
}
|
}
|
||||||
const project = await getProjectByEnvironmentId(survey.environmentId);
|
const project = await getProjectByEnvironmentId(survey.environmentId);
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new Error("Project not found");
|
throw new Error("Workspace not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const styling = getStyling(project, survey);
|
const styling = getStyling(project, survey);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export enum ShareViaType {
|
|||||||
|
|
||||||
export enum ShareSettingsType {
|
export enum ShareSettingsType {
|
||||||
LINK_SETTINGS = "link-settings",
|
LINK_SETTINGS = "link-settings",
|
||||||
|
PRETTY_URL = "pretty-url",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum LinkTabsType {
|
export enum LinkTabsType {
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ import {
|
|||||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||||
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/BaseSelectDropdown";
|
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/BaseSelectDropdown";
|
||||||
import { fetchTables } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/lib/airtable";
|
import { fetchTables } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/lib/airtable";
|
||||||
import AirtableLogo from "@/images/airtableLogo.svg";
|
import AirtableLogo from "@/images/airtableLogo.svg";
|
||||||
import { recallToHeadline } from "@/lib/utils/recall";
|
import { recallToHeadline } from "@/lib/utils/recall";
|
||||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||||
@@ -5,8 +5,8 @@ import { TIntegrationItem } from "@formbricks/types/integration";
|
|||||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/ManageIntegration";
|
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/ManageIntegration";
|
||||||
import { authorize } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/lib/airtable";
|
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/lib/airtable";
|
||||||
import airtableLogo from "@/images/airtableLogo.svg";
|
import airtableLogo from "@/images/airtableLogo.svg";
|
||||||
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
||||||
|
|
||||||
@@ -8,8 +8,8 @@ import { TIntegrationItem } from "@formbricks/types/integration";
|
|||||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||||
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/AddIntegrationModal";
|
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AddIntegrationModal";
|
||||||
import { timeSince } from "@/lib/time";
|
import { timeSince } from "@/lib/time";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||||
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/AirtableWrapper";
|
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AirtableWrapper";
|
||||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
|
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||||
import { getAirtableTables } from "@/lib/airtable/service";
|
import { getAirtableTables } from "@/lib/airtable/service";
|
||||||
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
|
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
|
||||||
import { getIntegrations } from "@/lib/integration/service";
|
import { getIntegrations } from "@/lib/integration/service";
|
||||||
@@ -42,7 +42,7 @@ const Page = async (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/project/integrations`} />
|
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/workspace/integrations`} />
|
||||||
<PageHeader pageTitle={t("environments.integrations.airtable.airtable_integration")} />
|
<PageHeader pageTitle={t("environments.integrations.airtable.airtable_integration")} />
|
||||||
<div className="h-[75vh] w-full">
|
<div className="h-[75vh] w-full">
|
||||||
<AirtableWrapper
|
<AirtableWrapper
|
||||||
@@ -12,13 +12,13 @@ import {
|
|||||||
} from "@formbricks/types/integration/google-sheet";
|
} from "@formbricks/types/integration/google-sheet";
|
||||||
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||||
import { getSpreadsheetNameByIdAction } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/actions";
|
import { getSpreadsheetNameByIdAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/actions";
|
||||||
import {
|
import {
|
||||||
constructGoogleSheetsUrl,
|
constructGoogleSheetsUrl,
|
||||||
extractSpreadsheetIdFromUrl,
|
extractSpreadsheetIdFromUrl,
|
||||||
isValidGoogleSheetsUrl,
|
isValidGoogleSheetsUrl,
|
||||||
} from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/lib/util";
|
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/util";
|
||||||
import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
|
import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { recallToHeadline } from "@/lib/utils/recall";
|
import { recallToHeadline } from "@/lib/utils/recall";
|
||||||
@@ -266,7 +266,7 @@ export const AddIntegrationModal = ({
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
||||||
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden rounded-lg border border-slate-200">
|
<div className="mt-1 max-h-[15vh] overflow-x-hidden overflow-y-auto rounded-lg border border-slate-200">
|
||||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||||
{surveyElements.map((question) => (
|
{surveyElements.map((question) => (
|
||||||
<div key={question.id} className="my-1 flex items-center space-x-2">
|
<div key={question.id} className="my-1 flex items-center space-x-2">
|
||||||
@@ -8,8 +8,8 @@ import {
|
|||||||
} from "@formbricks/types/integration/google-sheet";
|
} from "@formbricks/types/integration/google-sheet";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/components/ManageIntegration";
|
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/ManageIntegration";
|
||||||
import { authorize } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/lib/google";
|
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/google";
|
||||||
import googleSheetLogo from "@/images/googleSheetsLogo.png";
|
import googleSheetLogo from "@/images/googleSheetsLogo.png";
|
||||||
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
||||||
import { AddIntegrationModal } from "./AddIntegrationModal";
|
import { AddIntegrationModal } from "./AddIntegrationModal";
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
TIntegrationGoogleSheetsConfigData,
|
TIntegrationGoogleSheetsConfigData,
|
||||||
} from "@formbricks/types/integration/google-sheet";
|
} from "@formbricks/types/integration/google-sheet";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||||
import { timeSince } from "@/lib/time";
|
import { timeSince } from "@/lib/time";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
@@ -10,7 +10,7 @@ const Loading = () => {
|
|||||||
<div className="mt-6 p-6">
|
<div className="mt-6 p-6">
|
||||||
<GoBackButton />
|
<GoBackButton />
|
||||||
<div className="mb-6 text-right">
|
<div className="mb-6 text-right">
|
||||||
<Button className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200">
|
<Button className="pointer-events-none animate-pulse cursor-not-allowed bg-slate-200 select-none">
|
||||||
{t("environments.integrations.google_sheets.link_new_sheet")}
|
{t("environments.integrations.google_sheets.link_new_sheet")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,7 +51,7 @@ const Loading = () => {
|
|||||||
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-slate-200"></div>
|
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-slate-200"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2 my-auto flex items-center justify-center whitespace-nowrap text-center text-sm text-slate-500">
|
<div className="col-span-2 my-auto flex items-center justify-center text-center text-sm whitespace-nowrap text-slate-500">
|
||||||
<div className="h-4 w-16 animate-pulse rounded-full bg-slate-200"></div>
|
<div className="h-4 w-16 animate-pulse rounded-full bg-slate-200"></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center"></div>
|
<div className="text-center"></div>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
|
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
|
||||||
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/components/GoogleSheetWrapper";
|
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/GoogleSheetWrapper";
|
||||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
|
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||||
import {
|
import {
|
||||||
GOOGLE_SHEETS_CLIENT_ID,
|
GOOGLE_SHEETS_CLIENT_ID,
|
||||||
GOOGLE_SHEETS_CLIENT_SECRET,
|
GOOGLE_SHEETS_CLIENT_SECRET,
|
||||||
@@ -40,7 +40,7 @@ const Page = async (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/project/integrations`} />
|
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/workspace/integrations`} />
|
||||||
<PageHeader pageTitle={t("environments.integrations.google_sheets.google_sheets_integration")} />
|
<PageHeader pageTitle={t("environments.integrations.google_sheets.google_sheets_integration")} />
|
||||||
<div className="h-[75vh] w-full">
|
<div className="h-[75vh] w-full">
|
||||||
<GoogleSheetWrapper
|
<GoogleSheetWrapper
|
||||||
@@ -15,12 +15,12 @@ import {
|
|||||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||||
import {
|
import {
|
||||||
ERRORS,
|
ERRORS,
|
||||||
TYPE_MAPPING,
|
TYPE_MAPPING,
|
||||||
UNSUPPORTED_TYPES_BY_NOTION,
|
UNSUPPORTED_TYPES_BY_NOTION,
|
||||||
} from "@/app/(app)/environments/[environmentId]/project/integrations/notion/constants";
|
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/constants";
|
||||||
import NotionLogo from "@/images/notion.png";
|
import NotionLogo from "@/images/notion.png";
|
||||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||||
import { recallToHeadline } from "@/lib/utils/recall";
|
import { recallToHeadline } from "@/lib/utils/recall";
|
||||||
@@ -6,7 +6,7 @@ import toast from "react-hot-toast";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
|
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||||
import { timeSince } from "@/lib/time";
|
import { timeSince } from "@/lib/time";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
@@ -9,8 +9,8 @@ import {
|
|||||||
} from "@formbricks/types/integration/notion";
|
} from "@formbricks/types/integration/notion";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/project/integrations/notion/components/AddIntegrationModal";
|
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/AddIntegrationModal";
|
||||||
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/project/integrations/notion/components/ManageIntegration";
|
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/ManageIntegration";
|
||||||
import notionLogo from "@/images/notion.png";
|
import notionLogo from "@/images/notion.png";
|
||||||
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
||||||
import { authorize } from "../lib/notion";
|
import { authorize } from "../lib/notion";
|
||||||
@@ -10,7 +10,7 @@ const Loading = () => {
|
|||||||
<div className="mt-6 p-6">
|
<div className="mt-6 p-6">
|
||||||
<GoBackButton />
|
<GoBackButton />
|
||||||
<div className="mb-6 text-right">
|
<div className="mb-6 text-right">
|
||||||
<Button className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200">
|
<Button className="pointer-events-none animate-pulse cursor-not-allowed bg-slate-200 select-none">
|
||||||
{t("environments.integrations.notion.link_database")}
|
{t("environments.integrations.notion.link_database")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -48,7 +48,7 @@ const Loading = () => {
|
|||||||
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-slate-200"></div>
|
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-slate-200"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2 my-auto flex items-center justify-center whitespace-nowrap text-center text-sm text-slate-500">
|
<div className="col-span-2 my-auto flex items-center justify-center text-center text-sm whitespace-nowrap text-slate-500">
|
||||||
<div className="h-4 w-16 animate-pulse rounded-full bg-slate-200"></div>
|
<div className="h-4 w-16 animate-pulse rounded-full bg-slate-200"></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center"></div>
|
<div className="text-center"></div>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
|
import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
|
||||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
|
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||||
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/project/integrations/notion/components/NotionWrapper";
|
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/NotionWrapper";
|
||||||
import {
|
import {
|
||||||
NOTION_AUTH_URL,
|
NOTION_AUTH_URL,
|
||||||
NOTION_OAUTH_CLIENT_ID,
|
NOTION_OAUTH_CLIENT_ID,
|
||||||
@@ -2,7 +2,7 @@ import { TFunction } from "i18next";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { TIntegrationType } from "@formbricks/types/integration";
|
import { TIntegrationType } from "@formbricks/types/integration";
|
||||||
import { getWebhookCountBySource } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/webhook";
|
import { getWebhookCountBySource } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/webhook";
|
||||||
import ActivePiecesLogo from "@/images/activepieces.webp";
|
import ActivePiecesLogo from "@/images/activepieces.webp";
|
||||||
import AirtableLogo from "@/images/airtableLogo.svg";
|
import AirtableLogo from "@/images/airtableLogo.svg";
|
||||||
import GoogleSheetsLogo from "@/images/googleSheetsLogo.png";
|
import GoogleSheetsLogo from "@/images/googleSheetsLogo.png";
|
||||||
@@ -79,7 +79,7 @@ const Page = async (props) => {
|
|||||||
disabled: isReadOnly,
|
disabled: isReadOnly,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
connectHref: `/environments/${params.environmentId}/project/integrations/webhooks`,
|
connectHref: `/environments/${params.environmentId}/workspace/integrations/webhooks`,
|
||||||
connectText: t("environments.integrations.manage_webhooks"),
|
connectText: t("environments.integrations.manage_webhooks"),
|
||||||
connectNewTab: false,
|
connectNewTab: false,
|
||||||
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/webhooks",
|
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/webhooks",
|
||||||
@@ -93,7 +93,7 @@ const Page = async (props) => {
|
|||||||
disabled: false,
|
disabled: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
connectHref: `/environments/${params.environmentId}/project/integrations/google-sheets`,
|
connectHref: `/environments/${params.environmentId}/workspace/integrations/google-sheets`,
|
||||||
connectText: `${isGoogleSheetsIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
connectText: `${isGoogleSheetsIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
||||||
connectNewTab: false,
|
connectNewTab: false,
|
||||||
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/google-sheets",
|
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/google-sheets",
|
||||||
@@ -107,7 +107,7 @@ const Page = async (props) => {
|
|||||||
disabled: isReadOnly,
|
disabled: isReadOnly,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
connectHref: `/environments/${params.environmentId}/project/integrations/airtable`,
|
connectHref: `/environments/${params.environmentId}/workspace/integrations/airtable`,
|
||||||
connectText: `${isAirtableIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
connectText: `${isAirtableIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
||||||
connectNewTab: false,
|
connectNewTab: false,
|
||||||
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/airtable",
|
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/airtable",
|
||||||
@@ -121,7 +121,7 @@ const Page = async (props) => {
|
|||||||
disabled: isReadOnly,
|
disabled: isReadOnly,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
connectHref: `/environments/${params.environmentId}/project/integrations/slack`,
|
connectHref: `/environments/${params.environmentId}/workspace/integrations/slack`,
|
||||||
connectText: `${isSlackIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
connectText: `${isSlackIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
||||||
connectNewTab: false,
|
connectNewTab: false,
|
||||||
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/slack",
|
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/slack",
|
||||||
@@ -163,7 +163,7 @@ const Page = async (props) => {
|
|||||||
disabled: isReadOnly,
|
disabled: isReadOnly,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
connectHref: `/environments/${params.environmentId}/project/integrations/notion`,
|
connectHref: `/environments/${params.environmentId}/workspace/integrations/notion`,
|
||||||
connectText: `${isNotionIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
connectText: `${isNotionIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
||||||
connectNewTab: false,
|
connectNewTab: false,
|
||||||
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/notion",
|
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/notion",
|
||||||
@@ -196,7 +196,7 @@ const Page = async (props) => {
|
|||||||
docsHref: "https://formbricks.com/docs/app-surveys/quickstart",
|
docsHref: "https://formbricks.com/docs/app-surveys/quickstart",
|
||||||
docsText: t("common.docs"),
|
docsText: t("common.docs"),
|
||||||
docsNewTab: true,
|
docsNewTab: true,
|
||||||
connectHref: `/environments/${params.environmentId}/project/app-connection`,
|
connectHref: `/environments/${params.environmentId}/workspace/app-connection`,
|
||||||
connectText: t("common.connect"),
|
connectText: t("common.connect"),
|
||||||
connectNewTab: false,
|
connectNewTab: false,
|
||||||
label: "Javascript SDK",
|
label: "Javascript SDK",
|
||||||
@@ -209,7 +209,7 @@ const Page = async (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
<PageHeader pageTitle={t("common.project_configuration")}>
|
<PageHeader pageTitle={t("common.workspace_configuration")}>
|
||||||
<ProjectConfigNavigation environmentId={params.environmentId} activeId="integrations" />
|
<ProjectConfigNavigation environmentId={params.environmentId} activeId="integrations" />
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<div className="grid grid-cols-3 place-content-stretch gap-4 lg:grid-cols-3">
|
<div className="grid grid-cols-3 place-content-stretch gap-4 lg:grid-cols-3">
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
} from "@formbricks/types/integration/slack";
|
} from "@formbricks/types/integration/slack";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||||
import SlackLogo from "@/images/slacklogo.png";
|
import SlackLogo from "@/images/slacklogo.png";
|
||||||
import { recallToHeadline } from "@/lib/utils/recall";
|
import { recallToHeadline } from "@/lib/utils/recall";
|
||||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||||
@@ -6,7 +6,7 @@ import toast from "react-hot-toast";
|
|||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
|
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||||
import { timeSince } from "@/lib/time";
|
import { timeSince } from "@/lib/time";
|
||||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||||
import { Button } from "@/modules/ui/components/button";
|
import { Button } from "@/modules/ui/components/button";
|
||||||
@@ -6,10 +6,10 @@ import { TIntegrationItem } from "@formbricks/types/integration";
|
|||||||
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
|
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
import { TUserLocale } from "@formbricks/types/user";
|
import { TUserLocale } from "@formbricks/types/user";
|
||||||
import { getSlackChannelsAction } from "@/app/(app)/environments/[environmentId]/project/integrations/slack/actions";
|
import { getSlackChannelsAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/actions";
|
||||||
import { AddChannelMappingModal } from "@/app/(app)/environments/[environmentId]/project/integrations/slack/components/AddChannelMappingModal";
|
import { AddChannelMappingModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/components/AddChannelMappingModal";
|
||||||
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/project/integrations/slack/components/ManageIntegration";
|
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/components/ManageIntegration";
|
||||||
import { authorize } from "@/app/(app)/environments/[environmentId]/project/integrations/slack/lib/slack";
|
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/lib/slack";
|
||||||
import slackLogo from "@/images/slacklogo.png";
|
import slackLogo from "@/images/slacklogo.png";
|
||||||
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
|
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
|
||||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
|
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||||
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/project/integrations/slack/components/SlackWrapper";
|
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/components/SlackWrapper";
|
||||||
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
|
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
|
||||||
import { getIntegrationByType } from "@/lib/integration/service";
|
import { getIntegrationByType } from "@/lib/integration/service";
|
||||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||||
@@ -32,7 +32,7 @@ const Page = async (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContentWrapper>
|
<PageContentWrapper>
|
||||||
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/project/integrations`} />
|
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/workspace/integrations`} />
|
||||||
<PageHeader pageTitle={t("environments.integrations.slack.slack_integration")} />
|
<PageHeader pageTitle={t("environments.integrations.slack.slack_integration")} />
|
||||||
<div className="h-[75vh] w-full">
|
<div className="h-[75vh] w-full">
|
||||||
<SlackWrapper
|
<SlackWrapper
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
|
import { ChatwootWidget } from "@/app/chatwoot/ChatwootWidget";
|
||||||
|
import { CHATWOOT_BASE_URL, CHATWOOT_WEBSITE_TOKEN, IS_CHATWOOT_CONFIGURED } from "@/lib/constants";
|
||||||
import { getUser } from "@/lib/user/service";
|
import { getUser } from "@/lib/user/service";
|
||||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||||
import { ClientLogout } from "@/modules/ui/components/client-logout";
|
import { ClientLogout } from "@/modules/ui/components/client-logout";
|
||||||
@@ -18,7 +19,15 @@ const AppLayout = async ({ children }) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NoMobileOverlay />
|
<NoMobileOverlay />
|
||||||
<IntercomClientWrapper user={user} />
|
{IS_CHATWOOT_CONFIGURED && (
|
||||||
|
<ChatwootWidget
|
||||||
|
userEmail={user?.email}
|
||||||
|
userName={user?.name}
|
||||||
|
userId={user?.id}
|
||||||
|
chatwootWebsiteToken={CHATWOOT_WEBSITE_TOKEN}
|
||||||
|
chatwootBaseUrl={CHATWOOT_BASE_URL}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<ToasterClient />
|
<ToasterClient />
|
||||||
{children}
|
{children}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
|
|
||||||
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
|
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
|
||||||
|
|
||||||
const AppLayout = async ({ children }) => {
|
const AppLayout = async ({ children }) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<NoMobileOverlay />
|
<NoMobileOverlay />
|
||||||
<IntercomClientWrapper />
|
|
||||||
{children}
|
{children}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { IntegrationType } from "@prisma/client";
|
import { IntegrationType } from "@prisma/client";
|
||||||
import { createHash } from "node:crypto";
|
|
||||||
import { type CacheKey, getCacheService } from "@formbricks/cache";
|
import { type CacheKey, getCacheService } from "@formbricks/cache";
|
||||||
import { prisma } from "@formbricks/database";
|
import { prisma } from "@formbricks/database";
|
||||||
import { logger } from "@formbricks/logger";
|
import { logger } from "@formbricks/logger";
|
||||||
import { env } from "@/lib/env";
|
import { env } from "@/lib/env";
|
||||||
|
import { getInstanceInfo } from "@/lib/instance";
|
||||||
import packageJson from "@/package.json";
|
import packageJson from "@/package.json";
|
||||||
|
|
||||||
const TELEMETRY_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
const TELEMETRY_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||||
@@ -129,15 +129,12 @@ export const sendTelemetryEvents = async () => {
|
|||||||
* @param lastSent - Timestamp of last telemetry send (used to calculate incremental metrics)
|
* @param lastSent - Timestamp of last telemetry send (used to calculate incremental metrics)
|
||||||
*/
|
*/
|
||||||
const sendTelemetry = async (lastSent: number) => {
|
const sendTelemetry = async (lastSent: number) => {
|
||||||
// Get the oldest organization to generate a stable, anonymized instance ID.
|
// Get the instance info (hashed oldest organization ID and creation date).
|
||||||
// Using the oldest org ensures the ID doesn't change over time.
|
// Using the oldest org ensures the ID doesn't change over time.
|
||||||
const oldestOrg = await prisma.organization.findFirst({
|
const instanceInfo = await getInstanceInfo();
|
||||||
orderBy: { createdAt: "asc" },
|
if (!instanceInfo) return; // No organization exists, nothing to report
|
||||||
select: { id: true, createdAt: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!oldestOrg) return; // No organization exists, nothing to report
|
const { instanceId, createdAt: instanceCreatedAt } = instanceInfo;
|
||||||
const instanceId = createHash("sha256").update(oldestOrg.id).digest("hex");
|
|
||||||
|
|
||||||
// Optimize database queries to reduce connection pool usage:
|
// Optimize database queries to reduce connection pool usage:
|
||||||
// Instead of 15 parallel queries (which could exhaust the connection pool),
|
// Instead of 15 parallel queries (which could exhaust the connection pool),
|
||||||
@@ -248,7 +245,7 @@ const sendTelemetry = async (lastSent: number) => {
|
|||||||
version: packageJson.version, // Formbricks version for compatibility tracking
|
version: packageJson.version, // Formbricks version for compatibility tracking
|
||||||
},
|
},
|
||||||
temporal: {
|
temporal: {
|
||||||
instanceCreatedAt: oldestOrg.createdAt.toISOString(), // When instance was first created
|
instanceCreatedAt: instanceCreatedAt.toISOString(), // When instance was first created
|
||||||
newestResponseAt: newestResponse?.createdAt.toISOString() || null, // Most recent activity
|
newestResponseAt: newestResponse?.createdAt.toISOString() || null, // Most recent activity
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -51,6 +51,22 @@ export const POST = async (request: Request) => {
|
|||||||
throw new ResourceNotFoundError("Organization", "Organization not found");
|
throw new ResourceNotFoundError("Organization", "Organization not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch survey for webhook payload
|
||||||
|
const survey = await getSurvey(surveyId);
|
||||||
|
if (!survey) {
|
||||||
|
logger.error({ url: request.url, surveyId }, `Survey with id ${surveyId} not found`);
|
||||||
|
|
||||||
|
return responses.notFoundResponse("Survey", surveyId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (survey.environmentId !== environmentId) {
|
||||||
|
logger.error(
|
||||||
|
{ url: request.url, surveyId, environmentId, surveyEnvironmentId: survey.environmentId },
|
||||||
|
`Survey ${surveyId} does not belong to environment ${environmentId}`
|
||||||
|
);
|
||||||
|
return responses.badRequestResponse("Survey not found in this environment");
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch webhooks
|
// Fetch webhooks
|
||||||
const getWebhooksForPipeline = async (environmentId: string, event: PipelineTriggers, surveyId: string) => {
|
const getWebhooksForPipeline = async (environmentId: string, event: PipelineTriggers, surveyId: string) => {
|
||||||
const webhooks = await prisma.webhook.findMany({
|
const webhooks = await prisma.webhook.findMany({
|
||||||
@@ -81,7 +97,16 @@ export const POST = async (request: Request) => {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
webhookId: webhook.id,
|
webhookId: webhook.id,
|
||||||
event,
|
event,
|
||||||
data: response,
|
data: {
|
||||||
|
...response,
|
||||||
|
survey: {
|
||||||
|
title: survey.name,
|
||||||
|
type: survey.type,
|
||||||
|
status: survey.status,
|
||||||
|
createdAt: survey.createdAt,
|
||||||
|
updatedAt: survey.updatedAt,
|
||||||
|
},
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
|
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
|
||||||
@@ -89,18 +114,12 @@ export const POST = async (request: Request) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (event === "responseFinished") {
|
if (event === "responseFinished") {
|
||||||
// Fetch integrations, survey, and responseCount in parallel
|
// Fetch integrations and responseCount in parallel
|
||||||
const [integrations, survey, responseCount] = await Promise.all([
|
const [integrations, responseCount] = await Promise.all([
|
||||||
getIntegrations(environmentId),
|
getIntegrations(environmentId),
|
||||||
getSurvey(surveyId),
|
|
||||||
getResponseCountBySurveyId(surveyId),
|
getResponseCountBySurveyId(surveyId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!survey) {
|
|
||||||
logger.error({ url: request.url, surveyId }, `Survey with id ${surveyId} not found`);
|
|
||||||
return new Response("Survey not found", { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (integrations.length > 0) {
|
if (integrations.length > 0) {
|
||||||
await handleIntegrations(integrations, inputValidation.data, survey);
|
await handleIntegrations(integrations, inputValidation.data, survey);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export const GET = async (req: Request) => {
|
|||||||
const result = await createOrUpdateIntegration(environmentId, googleSheetIntegration);
|
const result = await createOrUpdateIntegration(environmentId, googleSheetIntegration);
|
||||||
if (result) {
|
if (result) {
|
||||||
return Response.redirect(
|
return Response.redirect(
|
||||||
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/google-sheets`
|
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user