mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-26 08:20:29 -06:00
Compare commits
7 Commits
feat/proje
...
release/4.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c7867ccd1 | ||
|
|
39499ffb95 | ||
|
|
06beb30bb2 | ||
|
|
b8b5d320bc | ||
|
|
1f0042b55c | ||
|
|
eb14cc0593 | ||
|
|
b64beb83ad |
@@ -1,352 +0,0 @@
|
||||
# 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,12 +9,8 @@
|
||||
WEBAPP_URL=http://localhost:3000
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
# Please set both for now, we will change this in the future
|
||||
|
||||
@@ -193,9 +189,8 @@ 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)
|
||||
# REDIS_HTTP_URL:
|
||||
|
||||
# Chatwoot
|
||||
# CHATWOOT_BASE_URL=
|
||||
# CHATWOOT_WEBSITE_TOKEN=
|
||||
# INTERCOM_APP_ID=
|
||||
# INTERCOM_SECRET_KEY=
|
||||
|
||||
# Enable Prometheus metrics
|
||||
# PROMETHEUS_ENABLED=
|
||||
|
||||
31
.github/workflows/chromatic.yml
vendored
31
.github/workflows/chromatic.yml
vendored
@@ -13,12 +13,13 @@ jobs:
|
||||
chromatic:
|
||||
name: Run Chromatic
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
actions: read
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
@@ -26,34 +27,16 @@ jobs:
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
- uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install pnpm
|
||||
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
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
|
||||
- name: Run Chromatic
|
||||
uses: chromaui/action@4c20b95e9d3209ecfdf9cd6aace6bbde71ba1694 # v13.3.4
|
||||
uses: chromaui/action@c93e0bc3a63aa176e14a75b61a31847cbfdd341c # latest
|
||||
with:
|
||||
# ⚠️ Make sure to configure a `CHROMATIC_PROJECT_TOKEN` repository secret
|
||||
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
|
||||
workingDir: apps/storybook
|
||||
zip: true
|
||||
|
||||
45
.github/workflows/e2e.yml
vendored
45
.github/workflows/e2e.yml
vendored
@@ -3,9 +3,13 @@ name: E2E Tests
|
||||
on:
|
||||
workflow_call:
|
||||
secrets:
|
||||
PLAYWRIGHT_SERVICE_URL:
|
||||
AZURE_CLIENT_ID:
|
||||
required: false
|
||||
PLAYWRIGHT_SERVICE_ACCESS_TOKEN:
|
||||
AZURE_TENANT_ID:
|
||||
required: false
|
||||
AZURE_SUBSCRIPTION_ID:
|
||||
required: false
|
||||
PLAYWRIGHT_SERVICE_URL:
|
||||
required: false
|
||||
ENTERPRISE_LICENSE_KEY:
|
||||
required: true
|
||||
@@ -17,6 +21,7 @@ env:
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
@@ -109,7 +114,7 @@ jobs:
|
||||
- name: Start MinIO Server
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
|
||||
# Start MinIO server in background
|
||||
docker run -d \
|
||||
--name minio-server \
|
||||
@@ -119,7 +124,7 @@ jobs:
|
||||
-e MINIO_ROOT_PASSWORD=devminio123 \
|
||||
minio/minio:RELEASE.2025-09-07T16-13-09Z \
|
||||
server /data --console-address :9001
|
||||
|
||||
|
||||
echo "MinIO server started"
|
||||
|
||||
- name: Wait for MinIO and create S3 bucket
|
||||
@@ -202,30 +207,32 @@ jobs:
|
||||
- name: Install Playwright
|
||||
run: pnpm exec playwright install --with-deps
|
||||
|
||||
- name: Determine Playwright execution mode
|
||||
shell: bash
|
||||
env:
|
||||
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
|
||||
PLAYWRIGHT_SERVICE_ACCESS_TOKEN: ${{ secrets.PLAYWRIGHT_SERVICE_ACCESS_TOKEN }}
|
||||
- name: Set Azure Secret Variables
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -n "${PLAYWRIGHT_SERVICE_URL}" && -n "${PLAYWRIGHT_SERVICE_ACCESS_TOKEN}" ]]; then
|
||||
echo "PW_MODE=service" >> "$GITHUB_ENV"
|
||||
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 "PW_MODE=local" >> "$GITHUB_ENV"
|
||||
echo "AZURE_ENABLED=false" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Run E2E Tests (Playwright Service)
|
||||
if: env.PW_MODE == 'service'
|
||||
- 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:
|
||||
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
|
||||
PLAYWRIGHT_SERVICE_ACCESS_TOKEN: ${{ secrets.PLAYWRIGHT_SERVICE_ACCESS_TOKEN }}
|
||||
CI: true
|
||||
run: pnpm test-e2e:azure
|
||||
run: |
|
||||
pnpm test-e2e:azure
|
||||
|
||||
- name: Run E2E Tests (Local)
|
||||
if: env.PW_MODE == 'local'
|
||||
if: env.AZURE_ENABLED == 'false'
|
||||
env:
|
||||
CI: true
|
||||
run: |
|
||||
|
||||
@@ -203,14 +203,6 @@ Here are a few options:
|
||||
|
||||
</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>
|
||||
|
||||
## 📆 Contact us
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import type { StorybookConfig } from "@storybook/react-vite";
|
||||
import { createRequire } from "module";
|
||||
import { dirname, join, resolve } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, join } from "path";
|
||||
|
||||
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.
|
||||
@@ -16,7 +13,7 @@ function getAbsolutePath(value: string): any {
|
||||
}
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ["../src/**/*.mdx", "../../../packages/survey-ui/src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
|
||||
stories: ["../src/**/*.mdx", "../../web/modules/ui/**/stories.@(js|jsx|mjs|ts|tsx)"],
|
||||
addons: [
|
||||
getAbsolutePath("@storybook/addon-onboarding"),
|
||||
getAbsolutePath("@storybook/addon-links"),
|
||||
@@ -28,25 +25,5 @@ const config: StorybookConfig = {
|
||||
name: getAbsolutePath("@storybook/react-vite"),
|
||||
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;
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import type { Preview } from "@storybook/react-vite";
|
||||
import React from "react";
|
||||
import "../../../packages/survey-ui/src/styles/globals.css";
|
||||
import { I18nProvider } from "../../web/lingodotdev/client";
|
||||
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 = {
|
||||
parameters: {
|
||||
@@ -9,23 +22,9 @@ const preview: Preview = {
|
||||
color: /(background|color)$/i,
|
||||
date: /Date$/i,
|
||||
},
|
||||
expanded: true,
|
||||
},
|
||||
backgrounds: {
|
||||
default: "light",
|
||||
},
|
||||
},
|
||||
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)
|
||||
),
|
||||
],
|
||||
decorators: [withLingodotDev],
|
||||
};
|
||||
|
||||
export default preview;
|
||||
|
||||
@@ -11,24 +11,22 @@
|
||||
"clean": "rimraf .turbo node_modules dist storybook-static"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formbricks/survey-ui": "workspace:*",
|
||||
"eslint-plugin-react-refresh": "0.4.24"
|
||||
"eslint-plugin-react-refresh": "0.4.20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^4.1.3",
|
||||
"@storybook/addon-a11y": "10.0.8",
|
||||
"@storybook/addon-links": "10.0.8",
|
||||
"@storybook/addon-onboarding": "10.0.8",
|
||||
"@storybook/react-vite": "10.0.8",
|
||||
"@typescript-eslint/eslint-plugin": "8.48.0",
|
||||
"@tailwindcss/vite": "4.1.17",
|
||||
"@typescript-eslint/parser": "8.48.0",
|
||||
"@vitejs/plugin-react": "5.1.1",
|
||||
"esbuild": "0.27.0",
|
||||
"eslint-plugin-storybook": "10.0.8",
|
||||
"@chromatic-com/storybook": "^4.0.1",
|
||||
"@storybook/addon-a11y": "9.0.15",
|
||||
"@storybook/addon-links": "9.0.15",
|
||||
"@storybook/addon-onboarding": "9.0.15",
|
||||
"@storybook/react-vite": "9.0.15",
|
||||
"@typescript-eslint/eslint-plugin": "8.32.0",
|
||||
"@typescript-eslint/parser": "8.32.0",
|
||||
"@vitejs/plugin-react": "4.4.1",
|
||||
"esbuild": "0.25.4",
|
||||
"eslint-plugin-storybook": "9.0.15",
|
||||
"prop-types": "15.8.1",
|
||||
"storybook": "10.0.8",
|
||||
"vite": "7.2.4",
|
||||
"@storybook/addon-docs": "10.0.8"
|
||||
"storybook": "9.0.15",
|
||||
"vite": "6.4.1",
|
||||
"@storybook/addon-docs": "9.0.15"
|
||||
}
|
||||
}
|
||||
|
||||
6
apps/storybook/postcss.config.js
Normal file
6
apps/storybook/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -1,15 +1,7 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
import surveyUi from "../../packages/survey-ui/tailwind.config";
|
||||
import base from "../web/tailwind.config";
|
||||
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
"../../packages/survey-ui/src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
...surveyUi.theme?.extend,
|
||||
},
|
||||
},
|
||||
...base,
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}", "../web/modules/ui/**/*.{js,ts,jsx,tsx}"],
|
||||
};
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import path from "path";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
plugins: [react()],
|
||||
define: {
|
||||
"process.env": {},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@formbricks/survey-ui": path.resolve(__dirname, "../../packages/survey-ui/src"),
|
||||
"@": path.resolve(__dirname, "../web"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -37,10 +37,6 @@ ENV NODE_OPTIONS=${NODE_OPTIONS}
|
||||
# but needs explicit declaration for some build systems (like Depot)
|
||||
ARG TARGETARCH
|
||||
|
||||
# Base path for the application (optional)
|
||||
ARG BASE_PATH=""
|
||||
ENV BASE_PATH=${BASE_PATH}
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
@@ -77,8 +73,8 @@ RUN jq -r '.devDependencies.prisma' packages/database/package.json > /prisma_ver
|
||||
#
|
||||
FROM base AS runner
|
||||
|
||||
RUN npm install --ignore-scripts -g corepack@latest && \
|
||||
corepack enable
|
||||
RUN npm install --ignore-scripts -g corepack@latest
|
||||
RUN corepack enable
|
||||
|
||||
RUN apk add --no-cache curl \
|
||||
&& apk add --no-cache supercronic \
|
||||
@@ -138,13 +134,12 @@ EXPOSE 3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
USER nextjs
|
||||
|
||||
# Prepare pnpm as the nextjs user to ensure it's available at runtime
|
||||
# Prepare volumes for uploads and SAML connections
|
||||
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 uploads
|
||||
RUN mkdir -p /home/nextjs/apps/web/uploads/
|
||||
VOLUME /home/nextjs/apps/web/uploads/
|
||||
|
||||
# Prepare volume for SAML preloaded connection
|
||||
RUN mkdir -p /home/nextjs/apps/web/saml-connection
|
||||
VOLUME /home/nextjs/apps/web/saml-connection
|
||||
|
||||
CMD ["/home/nextjs/start.sh"]
|
||||
@@ -25,7 +25,7 @@ const Page = async (props: ConnectPageProps) => {
|
||||
|
||||
const project = await getProjectByEnvironmentId(environment.id);
|
||||
if (!project) {
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
throw new Error(t("common.project_not_found"));
|
||||
}
|
||||
|
||||
const channel = project.config.channel || null;
|
||||
@@ -46,7 +46,7 @@ const Page = async (props: ConnectPageProps) => {
|
||||
channel={channel}
|
||||
/>
|
||||
<Button
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={`/environments/${environment.id}`}>
|
||||
|
||||
@@ -38,7 +38,7 @@ const Page = async (props: XMTemplatePageProps) => {
|
||||
|
||||
const project = await getProjectByEnvironmentId(environment.id);
|
||||
if (!project) {
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
throw new Error(t("common.project_not_found"));
|
||||
}
|
||||
|
||||
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} />
|
||||
{projects.length >= 2 && (
|
||||
<Button
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={`/environments/${environment.id}/surveys`}>
|
||||
|
||||
@@ -50,8 +50,8 @@ const Page = async (props) => {
|
||||
</div>
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-12">
|
||||
<Header
|
||||
title={t("organizations.landing.no_workspaces_warning_title")}
|
||||
subtitle={t("organizations.landing.no_workspaces_warning_subtitle")}
|
||||
title={t("organizations.landing.no_projects_warning_title")}
|
||||
subtitle={t("organizations.landing.no_projects_warning_subtitle")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,16 +26,16 @@ const Page = async (props: ChannelPageProps) => {
|
||||
const t = await getTranslate();
|
||||
const channelOptions = [
|
||||
{
|
||||
title: t("organizations.workspaces.new.channel.link_and_email_surveys"),
|
||||
description: t("organizations.workspaces.new.channel.link_and_email_surveys_description"),
|
||||
title: t("organizations.projects.new.channel.link_and_email_surveys"),
|
||||
description: t("organizations.projects.new.channel.link_and_email_surveys_description"),
|
||||
icon: SendIcon,
|
||||
href: `/organizations/${params.organizationId}/workspaces/new/settings?channel=link`,
|
||||
href: `/organizations/${params.organizationId}/projects/new/settings?channel=link`,
|
||||
},
|
||||
{
|
||||
title: t("organizations.workspaces.new.channel.in_product_surveys"),
|
||||
description: t("organizations.workspaces.new.channel.in_product_surveys_description"),
|
||||
title: t("organizations.projects.new.channel.in_product_surveys"),
|
||||
description: t("organizations.projects.new.channel.in_product_surveys_description"),
|
||||
icon: PictureInPicture2Icon,
|
||||
href: `/organizations/${params.organizationId}/workspaces/new/settings?channel=app`,
|
||||
href: `/organizations/${params.organizationId}/projects/new/settings?channel=app`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -44,13 +44,13 @@ const Page = async (props: ChannelPageProps) => {
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||
<Header
|
||||
title={t("organizations.workspaces.new.channel.channel_select_title")}
|
||||
subtitle={t("organizations.workspaces.new.channel.channel_select_subtitle")}
|
||||
title={t("organizations.projects.new.channel.channel_select_title")}
|
||||
subtitle={t("organizations.projects.new.channel.channel_select_subtitle")}
|
||||
/>
|
||||
<OnboardingOptionsContainer options={channelOptions} />
|
||||
{projects.length >= 1 && (
|
||||
<Button
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={"/"}>
|
||||
@@ -26,16 +26,16 @@ const Page = async (props: ModePageProps) => {
|
||||
const t = await getTranslate();
|
||||
const channelOptions = [
|
||||
{
|
||||
title: t("organizations.workspaces.new.mode.formbricks_surveys"),
|
||||
description: t("organizations.workspaces.new.mode.formbricks_surveys_description"),
|
||||
title: t("organizations.projects.new.mode.formbricks_surveys"),
|
||||
description: t("organizations.projects.new.mode.formbricks_surveys_description"),
|
||||
icon: ListTodoIcon,
|
||||
href: `/organizations/${params.organizationId}/workspaces/new/channel`,
|
||||
href: `/organizations/${params.organizationId}/projects/new/channel`,
|
||||
},
|
||||
{
|
||||
title: t("organizations.workspaces.new.mode.formbricks_cx"),
|
||||
description: t("organizations.workspaces.new.mode.formbricks_cx_description"),
|
||||
title: t("organizations.projects.new.mode.formbricks_cx"),
|
||||
description: t("organizations.projects.new.mode.formbricks_cx_description"),
|
||||
icon: HeartIcon,
|
||||
href: `/organizations/${params.organizationId}/workspaces/new/settings?mode=cx`,
|
||||
href: `/organizations/${params.organizationId}/projects/new/settings?mode=cx`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -43,11 +43,11 @@ const Page = async (props: ModePageProps) => {
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||
<Header title={t("organizations.workspaces.new.mode.what_are_you_here_for")} />
|
||||
<Header title={t("organizations.projects.new.mode.what_are_you_here_for")} />
|
||||
<OnboardingOptionsContainer options={channelOptions} />
|
||||
{projects.length >= 1 && (
|
||||
<Button
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={"/"}>
|
||||
@@ -44,7 +44,6 @@ interface ProjectSettingsProps {
|
||||
organizationTeams: TOrganizationTeam[];
|
||||
isAccessControlAllowed: boolean;
|
||||
userProjectsCount: number;
|
||||
publicDomain: string;
|
||||
}
|
||||
|
||||
export const ProjectSettings = ({
|
||||
@@ -56,7 +55,6 @@ export const ProjectSettings = ({
|
||||
organizationTeams,
|
||||
isAccessControlAllowed = false,
|
||||
userProjectsCount,
|
||||
publicDomain,
|
||||
}: ProjectSettingsProps) => {
|
||||
const [createTeamModalOpen, setCreateTeamModalOpen] = useState(false);
|
||||
|
||||
@@ -79,7 +77,7 @@ export const ProjectSettings = ({
|
||||
(environment) => environment.type === "production"
|
||||
);
|
||||
if (productionEnvironment) {
|
||||
if (globalThis.window !== undefined) {
|
||||
if (typeof window !== "undefined") {
|
||||
// Rmove filters when creating a new project
|
||||
localStorage.removeItem(FORMBRICKS_SURVEYS_FILTERS_KEY_LS);
|
||||
}
|
||||
@@ -96,7 +94,7 @@ export const ProjectSettings = ({
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("organizations.workspaces.new.settings.workspace_creation_failed"));
|
||||
toast.error(t("organizations.projects.new.settings.project_creation_failed"));
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
@@ -131,9 +129,9 @@ export const ProjectSettings = ({
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem className="w-full space-y-4">
|
||||
<div>
|
||||
<FormLabel>{t("organizations.workspaces.new.settings.brand_color")}</FormLabel>
|
||||
<FormLabel>{t("organizations.projects.new.settings.brand_color")}</FormLabel>
|
||||
<FormDescription>
|
||||
{t("organizations.workspaces.new.settings.brand_color_description")}
|
||||
{t("organizations.projects.new.settings.brand_color_description")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
@@ -155,9 +153,9 @@ export const ProjectSettings = ({
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormItem className="w-full space-y-4">
|
||||
<div>
|
||||
<FormLabel>{t("organizations.workspaces.new.settings.workspace_name")}</FormLabel>
|
||||
<FormLabel>{t("organizations.projects.new.settings.project_name")}</FormLabel>
|
||||
<FormDescription>
|
||||
{t("organizations.workspaces.new.settings.workspace_name_description")}
|
||||
{t("organizations.projects.new.settings.project_name_description")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
@@ -186,7 +184,7 @@ export const ProjectSettings = ({
|
||||
<div>
|
||||
<FormLabel>{t("common.teams")}</FormLabel>
|
||||
<FormDescription>
|
||||
{t("organizations.workspaces.new.settings.team_description")}
|
||||
{t("organizations.projects.new.settings.team_description")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<Button
|
||||
@@ -194,7 +192,7 @@ export const ProjectSettings = ({
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => setCreateTeamModalOpen(true)}>
|
||||
{t("organizations.workspaces.new.settings.create_new_team")}
|
||||
{t("organizations.projects.new.settings.create_new_team")}
|
||||
</Button>
|
||||
</div>
|
||||
<FormControl>
|
||||
@@ -227,13 +225,12 @@ export const ProjectSettings = ({
|
||||
alt="Logo"
|
||||
width={256}
|
||||
height={56}
|
||||
className="absolute top-2 left-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
|
||||
className="absolute left-2 top-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>
|
||||
<div className="z-0 h-3/4 w-3/4">
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={previewSurvey(projectName || "my Product", t)}
|
||||
styling={{ brandColor: { light: brandColor } }}
|
||||
@@ -3,9 +3,8 @@ import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { TProjectConfigChannel, TProjectConfigIndustry, TProjectMode } from "@formbricks/types/project";
|
||||
import { getTeamsByOrganizationId } from "@/app/(app)/(onboarding)/lib/onboarding";
|
||||
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/workspaces/new/settings/components/ProjectSettings";
|
||||
import { ProjectSettings } from "@/app/(app)/(onboarding)/organizations/[organizationId]/projects/new/settings/components/ProjectSettings";
|
||||
import { DEFAULT_BRAND_COLOR } from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getUserProjects } from "@/lib/project/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -48,13 +47,11 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
||||
throw new Error(t("common.organization_teams_not_found"));
|
||||
}
|
||||
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full min-w-full flex-col items-center justify-center space-y-12">
|
||||
<Header
|
||||
title={t("organizations.workspaces.new.settings.workspace_settings_title")}
|
||||
subtitle={t("organizations.workspaces.new.settings.workspace_settings_subtitle")}
|
||||
title={t("organizations.projects.new.settings.project_settings_title")}
|
||||
subtitle={t("organizations.projects.new.settings.project_settings_subtitle")}
|
||||
/>
|
||||
<ProjectSettings
|
||||
organizationId={params.organizationId}
|
||||
@@ -65,11 +62,10 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
||||
organizationTeams={organizationTeams}
|
||||
isAccessControlAllowed={isAccessControlAllowed}
|
||||
userProjectsCount={projects.length}
|
||||
publicDomain={publicDomain}
|
||||
/>
|
||||
{projects.length >= 1 && (
|
||||
<Button
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={"/"}>
|
||||
@@ -1,7 +1,6 @@
|
||||
import { MainNavigation } from "@/app/(app)/environments/[environmentId]/components/MainNavigation";
|
||||
import { TopControlBar } from "@/app/(app)/environments/[environmentId]/components/TopControlBar";
|
||||
import { IS_DEVELOPMENT, IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getOrganizationProjectsLimit } from "@/modules/ee/license-check/lib/utils";
|
||||
@@ -16,7 +15,6 @@ interface EnvironmentLayoutProps {
|
||||
|
||||
export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLayoutProps) => {
|
||||
const t = await getTranslate();
|
||||
const publicDomain = getPublicDomain();
|
||||
|
||||
// Destructure all data from props (NO database queries)
|
||||
const {
|
||||
@@ -43,7 +41,7 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
||||
|
||||
// Validate that project permission exists for members
|
||||
if (isMember && !projectPermission) {
|
||||
throw new Error(t("common.workspace_permission_not_found"));
|
||||
throw new Error(t("common.project_permission_not_found"));
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -74,7 +72,6 @@ export const EnvironmentLayout = async ({ layoutData, children }: EnvironmentLay
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
isDevelopment={IS_DEVELOPMENT}
|
||||
membershipRole={membership.role}
|
||||
publicDomain={publicDomain}
|
||||
/>
|
||||
<div id="mainContent" className="flex flex-1 flex-col overflow-hidden bg-slate-50">
|
||||
<TopControlBar
|
||||
|
||||
@@ -46,7 +46,6 @@ interface NavigationProps {
|
||||
isFormbricksCloud: boolean;
|
||||
isDevelopment: boolean;
|
||||
membershipRole?: TOrganizationRole;
|
||||
publicDomain: string;
|
||||
}
|
||||
|
||||
export const MainNavigation = ({
|
||||
@@ -57,7 +56,6 @@ export const MainNavigation = ({
|
||||
membershipRole,
|
||||
isFormbricksCloud,
|
||||
isDevelopment,
|
||||
publicDomain,
|
||||
}: NavigationProps) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -113,7 +111,7 @@ export const MainNavigation = ({
|
||||
},
|
||||
{
|
||||
name: t("common.configuration"),
|
||||
href: `/environments/${environment.id}/workspace/general`,
|
||||
href: `/environments/${environment.id}/project/general`,
|
||||
icon: Cog,
|
||||
isActive: pathname?.includes("/project"),
|
||||
},
|
||||
@@ -164,7 +162,7 @@ export const MainNavigation = ({
|
||||
<aside
|
||||
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",
|
||||
isCollapsed ? "w-sidebar-expanded" : "w-sidebar-collapsed"
|
||||
!isCollapsed ? "w-sidebar-collapsed" : "w-sidebar-expanded"
|
||||
)}>
|
||||
<div>
|
||||
{/* Logo and Toggle */}
|
||||
@@ -185,7 +183,7 @@ export const MainNavigation = ({
|
||||
size="icon"
|
||||
onClick={toggleSidebar}
|
||||
className={cn(
|
||||
"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"
|
||||
"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"
|
||||
)}>
|
||||
{isCollapsed ? (
|
||||
<PanelLeftOpenIcon strokeWidth={1.5} />
|
||||
@@ -288,16 +286,15 @@ export const MainNavigation = ({
|
||||
{/* Logout */}
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
const loginUrl = `${publicDomain}/auth/login`;
|
||||
const route = await signOutWithAudit({
|
||||
reason: "user_initiated",
|
||||
redirectUrl: loginUrl,
|
||||
redirectUrl: "/auth/login",
|
||||
organizationId: organization.id,
|
||||
redirect: false,
|
||||
callbackUrl: loginUrl,
|
||||
callbackUrl: "/auth/login",
|
||||
clearEnvironmentId: true,
|
||||
});
|
||||
router.push(route?.url || loginUrl); // NOSONAR // We want to check for empty strings
|
||||
router.push(route?.url || "/auth/login"); // NOSONAR // We want to check for empty strings
|
||||
}}
|
||||
icon={<LogOutIcon className="mr-2 h-4 w-4" strokeWidth={1.5} />}>
|
||||
{t("common.logout")}
|
||||
|
||||
@@ -17,13 +17,13 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
|
||||
const stati = {
|
||||
notImplemented: {
|
||||
icon: AlertTriangleIcon,
|
||||
title: t("environments.workspace.app-connection.formbricks_sdk_not_connected"),
|
||||
subtitle: t("environments.workspace.app-connection.formbricks_sdk_not_connected_description"),
|
||||
title: t("environments.project.app-connection.formbricks_sdk_not_connected"),
|
||||
subtitle: t("environments.project.app-connection.formbricks_sdk_not_connected_description"),
|
||||
},
|
||||
running: {
|
||||
icon: CheckIcon,
|
||||
title: t("environments.workspace.app-connection.receiving_data"),
|
||||
subtitle: t("environments.workspace.app-connection.formbricks_sdk_connected"),
|
||||
title: t("environments.project.app-connection.receiving_data"),
|
||||
subtitle: t("environments.project.app-connection.formbricks_sdk_connected"),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -53,11 +53,11 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
|
||||
<currentStatus.icon />
|
||||
</div>
|
||||
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
|
||||
<p className="w-2/3 text-sm text-balance text-slate-600">{currentStatus.subtitle}</p>
|
||||
<p className="w-2/3 text-balance text-sm text-slate-600">{currentStatus.subtitle}</p>
|
||||
{status === "notImplemented" && (
|
||||
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
|
||||
<RotateCcwIcon />
|
||||
{t("environments.workspace.app-connection.recheck")}
|
||||
{t("environments.project.app-connection.recheck")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -135,7 +135,7 @@ export const OrganizationBreadcrumb = ({
|
||||
},
|
||||
{
|
||||
id: "teams",
|
||||
label: t("common.members_and_teams"),
|
||||
label: t("common.teams"),
|
||||
href: `/environments/${currentEnvironmentId}/settings/teams`,
|
||||
},
|
||||
{
|
||||
@@ -144,12 +144,6 @@ export const OrganizationBreadcrumb = ({
|
||||
href: `/environments/${currentEnvironmentId}/settings/api-keys`,
|
||||
hidden: !isOwnerOrManager,
|
||||
},
|
||||
{
|
||||
id: "domain",
|
||||
label: t("common.domain"),
|
||||
href: `/environments/${currentEnvironmentId}/settings/domain`,
|
||||
hidden: isFormbricksCloud,
|
||||
},
|
||||
{
|
||||
id: "billing",
|
||||
label: t("common.billing"),
|
||||
|
||||
@@ -36,12 +36,12 @@ interface ProjectBreadcrumbProps {
|
||||
}
|
||||
|
||||
const isActiveProjectSetting = (pathname: string, settingId: string): boolean => {
|
||||
// Match /workspace/{settingId} or /workspace/{settingId}/... but exclude settings paths
|
||||
// Match /project/{settingId} or /project/{settingId}/... but exclude settings paths
|
||||
if (pathname.includes("/settings/")) {
|
||||
return false;
|
||||
}
|
||||
// Check if path matches /workspace/{settingId} (with optional trailing path)
|
||||
const pattern = new RegExp(`/workspace/${settingId}(?:/|$)`);
|
||||
// Check if path matches /project/{settingId} (with optional trailing path)
|
||||
const pattern = new RegExp(`/project/${settingId}(?:/|$)`);
|
||||
return pattern.test(pathname);
|
||||
};
|
||||
|
||||
@@ -90,7 +90,7 @@ export const ProjectBreadcrumb = ({
|
||||
const error = new Error(errorMessage);
|
||||
logger.error(error, "Failed to load projects");
|
||||
Sentry.captureException(error);
|
||||
setLoadError(errorMessage || t("common.failed_to_load_workspaces"));
|
||||
setLoadError(errorMessage || t("common.failed_to_load_projects"));
|
||||
}
|
||||
setIsLoadingProjects(false);
|
||||
});
|
||||
@@ -101,37 +101,37 @@ export const ProjectBreadcrumb = ({
|
||||
{
|
||||
id: "general",
|
||||
label: t("common.general"),
|
||||
href: `/environments/${currentEnvironmentId}/workspace/general`,
|
||||
href: `/environments/${currentEnvironmentId}/project/general`,
|
||||
},
|
||||
{
|
||||
id: "look",
|
||||
label: t("common.look_and_feel"),
|
||||
href: `/environments/${currentEnvironmentId}/workspace/look`,
|
||||
href: `/environments/${currentEnvironmentId}/project/look`,
|
||||
},
|
||||
{
|
||||
id: "app-connection",
|
||||
label: t("common.website_and_app_connection"),
|
||||
href: `/environments/${currentEnvironmentId}/workspace/app-connection`,
|
||||
href: `/environments/${currentEnvironmentId}/project/app-connection`,
|
||||
},
|
||||
{
|
||||
id: "integrations",
|
||||
label: t("common.integrations"),
|
||||
href: `/environments/${currentEnvironmentId}/workspace/integrations`,
|
||||
href: `/environments/${currentEnvironmentId}/project/integrations`,
|
||||
},
|
||||
{
|
||||
id: "teams",
|
||||
label: t("common.team_access"),
|
||||
href: `/environments/${currentEnvironmentId}/workspace/teams`,
|
||||
href: `/environments/${currentEnvironmentId}/project/teams`,
|
||||
},
|
||||
{
|
||||
id: "languages",
|
||||
label: t("common.survey_languages"),
|
||||
href: `/environments/${currentEnvironmentId}/workspace/languages`,
|
||||
href: `/environments/${currentEnvironmentId}/project/languages`,
|
||||
},
|
||||
{
|
||||
id: "tags",
|
||||
label: t("common.tags"),
|
||||
href: `/environments/${currentEnvironmentId}/workspace/tags`,
|
||||
href: `/environments/${currentEnvironmentId}/project/tags`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -159,7 +159,7 @@ export const ProjectBreadcrumb = ({
|
||||
|
||||
const handleProjectSettingsNavigation = (settingId: string) => {
|
||||
startTransition(() => {
|
||||
router.push(`/environments/${currentEnvironmentId}/workspace/${settingId}`);
|
||||
router.push(`/environments/${currentEnvironmentId}/project/${settingId}`);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -212,7 +212,7 @@ export const ProjectBreadcrumb = ({
|
||||
<DropdownMenuContent align="start" className="mt-2">
|
||||
<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} />
|
||||
{t("common.choose_workspace")}
|
||||
{t("common.choose_project")}
|
||||
</div>
|
||||
{isLoadingProjects && (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
@@ -251,7 +251,7 @@ export const ProjectBreadcrumb = ({
|
||||
<DropdownMenuCheckboxItem
|
||||
onClick={handleAddProject}
|
||||
className="w-full cursor-pointer justify-between">
|
||||
<span>{t("common.add_new_workspace")}</span>
|
||||
<span>{t("common.add_new_project")}</span>
|
||||
<PlusIcon className="ml-2 h-4 w-4" strokeWidth={1.5} />
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
@@ -261,7 +261,7 @@ export const ProjectBreadcrumb = ({
|
||||
<DropdownMenuSeparator />
|
||||
<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} />
|
||||
{t("common.workspace_configuration")}
|
||||
{t("common.project_configuration")}
|
||||
</div>
|
||||
{projectSettings.map((setting) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
|
||||
@@ -17,9 +17,9 @@ import {
|
||||
import { TSurveyElement } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/BaseSelectDropdown";
|
||||
import { fetchTables } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/lib/airtable";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
import { BaseSelectDropdown } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/BaseSelectDropdown";
|
||||
import { fetchTables } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/lib/airtable";
|
||||
import AirtableLogo from "@/images/airtableLogo.svg";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
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 { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/ManageIntegration";
|
||||
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/lib/airtable";
|
||||
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/ManageIntegration";
|
||||
import { authorize } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/lib/airtable";
|
||||
import airtableLogo from "@/images/airtableLogo.svg";
|
||||
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 { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AddIntegrationModal";
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/AddIntegrationModal";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -1,8 +1,8 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { TIntegrationItem } from "@formbricks/types/integration";
|
||||
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
|
||||
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/airtable/components/AirtableWrapper";
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||
import { AirtableWrapper } from "@/app/(app)/environments/[environmentId]/project/integrations/airtable/components/AirtableWrapper";
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
|
||||
import { getAirtableTables } from "@/lib/airtable/service";
|
||||
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@/lib/constants";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
@@ -42,7 +42,7 @@ const Page = async (props) => {
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/workspace/integrations`} />
|
||||
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/project/integrations`} />
|
||||
<PageHeader pageTitle={t("environments.integrations.airtable.airtable_integration")} />
|
||||
<div className="h-[75vh] w-full">
|
||||
<AirtableWrapper
|
||||
@@ -12,13 +12,13 @@ import {
|
||||
} from "@formbricks/types/integration/google-sheet";
|
||||
import { TSurvey, TSurveyQuestionId } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||
import { getSpreadsheetNameByIdAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/actions";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
import { getSpreadsheetNameByIdAction } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/actions";
|
||||
import {
|
||||
constructGoogleSheetsUrl,
|
||||
extractSpreadsheetIdFromUrl,
|
||||
isValidGoogleSheetsUrl,
|
||||
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/util";
|
||||
} from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/lib/util";
|
||||
import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
@@ -266,7 +266,7 @@ export const AddIntegrationModal = ({
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="Surveys">{t("common.questions")}</Label>
|
||||
<div className="mt-1 max-h-[15vh] overflow-x-hidden overflow-y-auto rounded-lg border border-slate-200">
|
||||
<div className="mt-1 max-h-[15vh] overflow-y-auto overflow-x-hidden 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">
|
||||
{surveyElements.map((question) => (
|
||||
<div key={question.id} className="my-1 flex items-center space-x-2">
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
} from "@formbricks/types/integration/google-sheet";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/ManageIntegration";
|
||||
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/google";
|
||||
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/components/ManageIntegration";
|
||||
import { authorize } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/lib/google";
|
||||
import googleSheetLogo from "@/images/googleSheetsLogo.png";
|
||||
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
||||
import { AddIntegrationModal } from "./AddIntegrationModal";
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
TIntegrationGoogleSheetsConfigData,
|
||||
} from "@formbricks/types/integration/google-sheet";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -10,7 +10,7 @@ const Loading = () => {
|
||||
<div className="mt-6 p-6">
|
||||
<GoBackButton />
|
||||
<div className="mb-6 text-right">
|
||||
<Button className="pointer-events-none animate-pulse cursor-not-allowed bg-slate-200 select-none">
|
||||
<Button className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200">
|
||||
{t("environments.integrations.google_sheets.link_new_sheet")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -51,7 +51,7 @@ const Loading = () => {
|
||||
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-slate-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto flex items-center justify-center text-center text-sm whitespace-nowrap text-slate-500">
|
||||
<div className="col-span-2 my-auto flex items-center justify-center whitespace-nowrap text-center text-sm text-slate-500">
|
||||
<div className="h-4 w-16 animate-pulse rounded-full bg-slate-200"></div>
|
||||
</div>
|
||||
<div className="text-center"></div>
|
||||
@@ -1,7 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
|
||||
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/GoogleSheetWrapper";
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||
import { GoogleSheetWrapper } from "@/app/(app)/environments/[environmentId]/project/integrations/google-sheets/components/GoogleSheetWrapper";
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
|
||||
import {
|
||||
GOOGLE_SHEETS_CLIENT_ID,
|
||||
GOOGLE_SHEETS_CLIENT_SECRET,
|
||||
@@ -40,7 +40,7 @@ const Page = async (props) => {
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/workspace/integrations`} />
|
||||
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/project/integrations`} />
|
||||
<PageHeader pageTitle={t("environments.integrations.google_sheets.google_sheets_integration")} />
|
||||
<div className="h-[75vh] w-full">
|
||||
<GoogleSheetWrapper
|
||||
@@ -15,12 +15,12 @@ import {
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
import {
|
||||
ERRORS,
|
||||
TYPE_MAPPING,
|
||||
UNSUPPORTED_TYPES_BY_NOTION,
|
||||
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/constants";
|
||||
} from "@/app/(app)/environments/[environmentId]/project/integrations/notion/constants";
|
||||
import NotionLogo from "@/images/notion.png";
|
||||
import { structuredClone } from "@/lib/pollyfills/structuredClone";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
@@ -6,7 +6,7 @@ import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TIntegrationNotion, TIntegrationNotionConfigData } from "@formbricks/types/integration/notion";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
} from "@formbricks/types/integration/notion";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/AddIntegrationModal";
|
||||
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/ManageIntegration";
|
||||
import { AddIntegrationModal } from "@/app/(app)/environments/[environmentId]/project/integrations/notion/components/AddIntegrationModal";
|
||||
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/project/integrations/notion/components/ManageIntegration";
|
||||
import notionLogo from "@/images/notion.png";
|
||||
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
||||
import { authorize } from "../lib/notion";
|
||||
@@ -10,7 +10,7 @@ const Loading = () => {
|
||||
<div className="mt-6 p-6">
|
||||
<GoBackButton />
|
||||
<div className="mb-6 text-right">
|
||||
<Button className="pointer-events-none animate-pulse cursor-not-allowed bg-slate-200 select-none">
|
||||
<Button className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200">
|
||||
{t("environments.integrations.notion.link_database")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -48,7 +48,7 @@ const Loading = () => {
|
||||
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-slate-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto flex items-center justify-center text-center text-sm whitespace-nowrap text-slate-500">
|
||||
<div className="col-span-2 my-auto flex items-center justify-center whitespace-nowrap text-center text-sm text-slate-500">
|
||||
<div className="h-4 w-16 animate-pulse rounded-full bg-slate-200"></div>
|
||||
</div>
|
||||
<div className="text-center"></div>
|
||||
@@ -1,7 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { TIntegrationNotion, TIntegrationNotionDatabase } from "@formbricks/types/integration/notion";
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/notion/components/NotionWrapper";
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
|
||||
import { NotionWrapper } from "@/app/(app)/environments/[environmentId]/project/integrations/notion/components/NotionWrapper";
|
||||
import {
|
||||
NOTION_AUTH_URL,
|
||||
NOTION_OAUTH_CLIENT_ID,
|
||||
@@ -2,7 +2,7 @@ import { TFunction } from "i18next";
|
||||
import Image from "next/image";
|
||||
import { redirect } from "next/navigation";
|
||||
import { TIntegrationType } from "@formbricks/types/integration";
|
||||
import { getWebhookCountBySource } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/webhook";
|
||||
import { getWebhookCountBySource } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/webhook";
|
||||
import ActivePiecesLogo from "@/images/activepieces.webp";
|
||||
import AirtableLogo from "@/images/airtableLogo.svg";
|
||||
import GoogleSheetsLogo from "@/images/googleSheetsLogo.png";
|
||||
@@ -79,7 +79,7 @@ const Page = async (props) => {
|
||||
disabled: isReadOnly,
|
||||
},
|
||||
{
|
||||
connectHref: `/environments/${params.environmentId}/workspace/integrations/webhooks`,
|
||||
connectHref: `/environments/${params.environmentId}/project/integrations/webhooks`,
|
||||
connectText: t("environments.integrations.manage_webhooks"),
|
||||
connectNewTab: false,
|
||||
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/webhooks",
|
||||
@@ -93,7 +93,7 @@ const Page = async (props) => {
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
connectHref: `/environments/${params.environmentId}/workspace/integrations/google-sheets`,
|
||||
connectHref: `/environments/${params.environmentId}/project/integrations/google-sheets`,
|
||||
connectText: `${isGoogleSheetsIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
||||
connectNewTab: false,
|
||||
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/google-sheets",
|
||||
@@ -107,7 +107,7 @@ const Page = async (props) => {
|
||||
disabled: isReadOnly,
|
||||
},
|
||||
{
|
||||
connectHref: `/environments/${params.environmentId}/workspace/integrations/airtable`,
|
||||
connectHref: `/environments/${params.environmentId}/project/integrations/airtable`,
|
||||
connectText: `${isAirtableIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
||||
connectNewTab: false,
|
||||
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/airtable",
|
||||
@@ -121,7 +121,7 @@ const Page = async (props) => {
|
||||
disabled: isReadOnly,
|
||||
},
|
||||
{
|
||||
connectHref: `/environments/${params.environmentId}/workspace/integrations/slack`,
|
||||
connectHref: `/environments/${params.environmentId}/project/integrations/slack`,
|
||||
connectText: `${isSlackIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
||||
connectNewTab: false,
|
||||
docsHref: "https://formbricks.com/docs/xm-and-surveys/core-features/integrations/slack",
|
||||
@@ -163,7 +163,7 @@ const Page = async (props) => {
|
||||
disabled: isReadOnly,
|
||||
},
|
||||
{
|
||||
connectHref: `/environments/${params.environmentId}/workspace/integrations/notion`,
|
||||
connectHref: `/environments/${params.environmentId}/project/integrations/notion`,
|
||||
connectText: `${isNotionIntegrationConnected ? t("common.manage") : t("common.connect")}`,
|
||||
connectNewTab: false,
|
||||
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",
|
||||
docsText: t("common.docs"),
|
||||
docsNewTab: true,
|
||||
connectHref: `/environments/${params.environmentId}/workspace/app-connection`,
|
||||
connectHref: `/environments/${params.environmentId}/project/app-connection`,
|
||||
connectText: t("common.connect"),
|
||||
connectNewTab: false,
|
||||
label: "Javascript SDK",
|
||||
@@ -209,7 +209,7 @@ const Page = async (props) => {
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.workspace_configuration")}>
|
||||
<PageHeader pageTitle={t("common.project_configuration")}>
|
||||
<ProjectConfigNavigation environmentId={params.environmentId} activeId="integrations" />
|
||||
</PageHeader>
|
||||
<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";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||
import { createOrUpdateIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
import SlackLogo from "@/images/slacklogo.png";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
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 { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/project/integrations/actions";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
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 { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { getSlackChannelsAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/actions";
|
||||
import { AddChannelMappingModal } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/components/AddChannelMappingModal";
|
||||
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/components/ManageIntegration";
|
||||
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/lib/slack";
|
||||
import { getSlackChannelsAction } from "@/app/(app)/environments/[environmentId]/project/integrations/slack/actions";
|
||||
import { AddChannelMappingModal } from "@/app/(app)/environments/[environmentId]/project/integrations/slack/components/AddChannelMappingModal";
|
||||
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/project/integrations/slack/components/ManageIntegration";
|
||||
import { authorize } from "@/app/(app)/environments/[environmentId]/project/integrations/slack/lib/slack";
|
||||
import slackLogo from "@/images/slacklogo.png";
|
||||
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { TIntegrationSlack } from "@formbricks/types/integration/slack";
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/workspace/integrations/lib/surveys";
|
||||
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/workspace/integrations/slack/components/SlackWrapper";
|
||||
import { getSurveys } from "@/app/(app)/environments/[environmentId]/project/integrations/lib/surveys";
|
||||
import { SlackWrapper } from "@/app/(app)/environments/[environmentId]/project/integrations/slack/components/SlackWrapper";
|
||||
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
|
||||
import { getIntegrationByType } from "@/lib/integration/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
@@ -32,7 +32,7 @@ const Page = async (props) => {
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/workspace/integrations`} />
|
||||
<GoBackButton url={`${WEBAPP_URL}/environments/${params.environmentId}/project/integrations`} />
|
||||
<PageHeader pageTitle={t("environments.integrations.slack.slack_integration")} />
|
||||
<div className="h-[75vh] w-full">
|
||||
<SlackWrapper
|
||||
@@ -21,7 +21,7 @@ const AccountSettingsLayout = async (props) => {
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
throw new Error(t("common.project_not_found"));
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
|
||||
@@ -16,7 +16,7 @@ export const IntegrationsTip = ({ environmentId }: IntegrationsTipProps) => {
|
||||
<p className="text-sm">
|
||||
{t("environments.settings.notifications.need_slack_or_discord_notifications")}?
|
||||
<a
|
||||
href={`/environments/${environmentId}/workspace/integrations`}
|
||||
href={`/environments/${environmentId}/project/integrations`}
|
||||
className="ml-1 cursor-pointer text-sm underline">
|
||||
{t("environments.settings.notifications.use_the_integration")}
|
||||
</a>
|
||||
|
||||
@@ -36,7 +36,7 @@ export const OrganizationSettingsNavbar = ({
|
||||
},
|
||||
{
|
||||
id: "teams",
|
||||
label: t("common.members_and_teams"),
|
||||
label: t("common.teams"),
|
||||
href: `/environments/${environmentId}/settings/teams`,
|
||||
current: pathname?.includes("/teams"),
|
||||
},
|
||||
@@ -47,13 +47,6 @@ export const OrganizationSettingsNavbar = ({
|
||||
current: pathname?.includes("/api-keys"),
|
||||
hidden: !isOwner,
|
||||
},
|
||||
{
|
||||
id: "domain",
|
||||
label: t("common.domain"),
|
||||
href: `/environments/${environmentId}/settings/domain`,
|
||||
current: pathname?.includes("/domain"),
|
||||
hidden: isFormbricksCloud,
|
||||
},
|
||||
{
|
||||
id: "billing",
|
||||
label: t("common.billing"),
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
};
|
||||
@@ -1,53 +0,0 @@
|
||||
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,
|
||||
},
|
||||
{
|
||||
title: t("environments.workspace.languages.multi_language_surveys"),
|
||||
title: t("environments.project.languages.multi_language_surveys"),
|
||||
comingSoon: 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">
|
||||
<svg
|
||||
viewBox="0 0 1024 1024"
|
||||
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"
|
||||
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"
|
||||
aria-hidden="true">
|
||||
<circle
|
||||
cx={512}
|
||||
|
||||
@@ -21,7 +21,7 @@ const Layout = async (props) => {
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
throw new Error(t("common.workspace_not_found"));
|
||||
throw new Error(t("common.project_not_found"));
|
||||
}
|
||||
|
||||
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")}
|
||||
</p>
|
||||
|
||||
<Link className="mt-2" href={`/environments/${environment.id}/workspace/app-connection`}>
|
||||
<Link className="mt-2" href={`/environments/${environment.id}/project/app-connection`}>
|
||||
<Button size="sm" className="flex w-[120px] justify-center">
|
||||
{t("common.connect")}
|
||||
</Button>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
import {
|
||||
Code2Icon,
|
||||
Link2Icon,
|
||||
LinkIcon,
|
||||
MailIcon,
|
||||
QrCodeIcon,
|
||||
Settings,
|
||||
@@ -22,7 +22,6 @@ import { DynamicPopupTab } from "@/app/(app)/environments/[environmentId]/survey
|
||||
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 { 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 { 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";
|
||||
@@ -81,13 +80,13 @@ export const ShareSurveyModal = ({
|
||||
componentType: React.ComponentType<unknown>;
|
||||
componentProps: unknown;
|
||||
disabled?: boolean;
|
||||
}[] = useMemo(() => {
|
||||
const tabs = [
|
||||
}[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: ShareViaType.ANON_LINKS,
|
||||
type: LinkTabsType.SHARE_VIA,
|
||||
label: t("environments.surveys.share.anonymous_links.nav_title"),
|
||||
icon: Link2Icon,
|
||||
icon: LinkIcon,
|
||||
title: t("environments.surveys.share.anonymous_links.nav_title"),
|
||||
description: t("environments.surveys.share.anonymous_links.description"),
|
||||
componentType: AnonymousLinksTab,
|
||||
@@ -181,33 +180,22 @@ export const ShareSurveyModal = ({
|
||||
componentType: LinkSettingsTab,
|
||||
componentProps: { isReadOnly, locale: user.locale, isStorageConfigured },
|
||||
},
|
||||
{
|
||||
id: ShareSettingsType.PRETTY_URL,
|
||||
type: LinkTabsType.SHARE_SETTING,
|
||||
label: t("environments.surveys.share.pretty_url.title"),
|
||||
icon: Link2Icon,
|
||||
title: t("environments.surveys.share.pretty_url.title"),
|
||||
description: t("environments.surveys.share.pretty_url.description"),
|
||||
componentType: PrettyUrlTab,
|
||||
componentProps: { publicDomain, isReadOnly },
|
||||
},
|
||||
];
|
||||
|
||||
return isFormbricksCloud ? tabs.filter((tab) => tab.id !== ShareSettingsType.PRETTY_URL) : tabs;
|
||||
}, [
|
||||
t,
|
||||
survey,
|
||||
publicDomain,
|
||||
user.locale,
|
||||
surveyUrl,
|
||||
isReadOnly,
|
||||
environmentId,
|
||||
segments,
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
email,
|
||||
isStorageConfigured,
|
||||
]);
|
||||
],
|
||||
[
|
||||
t,
|
||||
survey,
|
||||
publicDomain,
|
||||
user.locale,
|
||||
surveyUrl,
|
||||
isReadOnly,
|
||||
environmentId,
|
||||
segments,
|
||||
isContactsEnabled,
|
||||
isFormbricksCloud,
|
||||
email,
|
||||
isStorageConfigured,
|
||||
]
|
||||
);
|
||||
|
||||
const getDefaultActiveId = useCallback(() => {
|
||||
if (survey.type !== "link") {
|
||||
|
||||
@@ -163,7 +163,7 @@ export const AppTab = () => {
|
||||
</AlertDescription>
|
||||
{!environment.appSetupCompleted && (
|
||||
<AlertButton asChild>
|
||||
<Link href={`/environments/${environment.id}/workspace/app-connection`}>
|
||||
<Link href={`/environments/${environment.id}/project/app-connection`}>
|
||||
{t("common.connect_formbricks")}
|
||||
</Link>
|
||||
</AlertButton>
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
};
|
||||
@@ -1,194 +0,0 @@
|
||||
"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">
|
||||
<UserIcon className="h-8 w-8 stroke-1 text-slate-900" />
|
||||
{t("environments.surveys.summary.use_personal_links")}
|
||||
<Badge size="normal" type="success" className="absolute top-3 right-3" text={t("common.new")} />
|
||||
<Badge size="normal" type="success" className="absolute right-3 top-3" text={t("common.new")} />
|
||||
</button>
|
||||
<Link
|
||||
href={`/environments/${environmentId}/settings/notifications`}
|
||||
@@ -75,7 +75,7 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
|
||||
{t("environments.surveys.summary.configure_alerts")}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/environments/${environmentId}/workspace/integrations`}
|
||||
href={`/environments/${environmentId}/project/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">
|
||||
<BlocksIcon className="h-8 w-8 stroke-1 text-slate-900" />
|
||||
{t("environments.surveys.summary.setup_integrations")}
|
||||
|
||||
@@ -12,7 +12,6 @@ export enum ShareViaType {
|
||||
|
||||
export enum ShareSettingsType {
|
||||
LINK_SETTINGS = "link-settings",
|
||||
PRETTY_URL = "pretty-url",
|
||||
}
|
||||
|
||||
export enum LinkTabsType {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { ChatwootWidget } from "@/app/chatwoot/ChatwootWidget";
|
||||
import { CHATWOOT_BASE_URL, CHATWOOT_WEBSITE_TOKEN, IS_CHATWOOT_CONFIGURED } from "@/lib/constants";
|
||||
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { ClientLogout } from "@/modules/ui/components/client-logout";
|
||||
@@ -19,15 +18,7 @@ const AppLayout = async ({ children }) => {
|
||||
return (
|
||||
<>
|
||||
<NoMobileOverlay />
|
||||
{IS_CHATWOOT_CONFIGURED && (
|
||||
<ChatwootWidget
|
||||
userEmail={user?.email}
|
||||
userName={user?.name}
|
||||
userId={user?.id}
|
||||
chatwootWebsiteToken={CHATWOOT_WEBSITE_TOKEN}
|
||||
chatwootBaseUrl={CHATWOOT_BASE_URL}
|
||||
/>
|
||||
)}
|
||||
<IntercomClientWrapper user={user} />
|
||||
<ToasterClient />
|
||||
{children}
|
||||
</>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { IntercomClientWrapper } from "@/app/intercom/IntercomClientWrapper";
|
||||
import { NoMobileOverlay } from "@/modules/ui/components/no-mobile-overlay";
|
||||
|
||||
const AppLayout = async ({ children }) => {
|
||||
return (
|
||||
<>
|
||||
<NoMobileOverlay />
|
||||
<IntercomClientWrapper />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { IntegrationType } from "@prisma/client";
|
||||
import { createHash } from "node:crypto";
|
||||
import { type CacheKey, getCacheService } from "@formbricks/cache";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { env } from "@/lib/env";
|
||||
import { getInstanceInfo } from "@/lib/instance";
|
||||
import packageJson from "@/package.json";
|
||||
|
||||
const TELEMETRY_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
@@ -129,12 +129,15 @@ export const sendTelemetryEvents = async () => {
|
||||
* @param lastSent - Timestamp of last telemetry send (used to calculate incremental metrics)
|
||||
*/
|
||||
const sendTelemetry = async (lastSent: number) => {
|
||||
// Get the instance info (hashed oldest organization ID and creation date).
|
||||
// Get the oldest organization to generate a stable, anonymized instance ID.
|
||||
// Using the oldest org ensures the ID doesn't change over time.
|
||||
const instanceInfo = await getInstanceInfo();
|
||||
if (!instanceInfo) return; // No organization exists, nothing to report
|
||||
const oldestOrg = await prisma.organization.findFirst({
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { id: true, createdAt: true },
|
||||
});
|
||||
|
||||
const { instanceId, createdAt: instanceCreatedAt } = instanceInfo;
|
||||
if (!oldestOrg) return; // No organization exists, nothing to report
|
||||
const instanceId = createHash("sha256").update(oldestOrg.id).digest("hex");
|
||||
|
||||
// Optimize database queries to reduce connection pool usage:
|
||||
// Instead of 15 parallel queries (which could exhaust the connection pool),
|
||||
@@ -245,7 +248,7 @@ const sendTelemetry = async (lastSent: number) => {
|
||||
version: packageJson.version, // Formbricks version for compatibility tracking
|
||||
},
|
||||
temporal: {
|
||||
instanceCreatedAt: instanceCreatedAt.toISOString(), // When instance was first created
|
||||
instanceCreatedAt: oldestOrg.createdAt.toISOString(), // When instance was first created
|
||||
newestResponseAt: newestResponse?.createdAt.toISOString() || null, // Most recent activity
|
||||
},
|
||||
};
|
||||
|
||||
@@ -51,22 +51,6 @@ export const POST = async (request: Request) => {
|
||||
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
|
||||
const getWebhooksForPipeline = async (environmentId: string, event: PipelineTriggers, surveyId: string) => {
|
||||
const webhooks = await prisma.webhook.findMany({
|
||||
@@ -97,16 +81,7 @@ export const POST = async (request: Request) => {
|
||||
body: JSON.stringify({
|
||||
webhookId: webhook.id,
|
||||
event,
|
||||
data: {
|
||||
...response,
|
||||
survey: {
|
||||
title: survey.name,
|
||||
type: survey.type,
|
||||
status: survey.status,
|
||||
createdAt: survey.createdAt,
|
||||
updatedAt: survey.updatedAt,
|
||||
},
|
||||
},
|
||||
data: response,
|
||||
}),
|
||||
}).catch((error) => {
|
||||
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
|
||||
@@ -114,12 +89,18 @@ export const POST = async (request: Request) => {
|
||||
);
|
||||
|
||||
if (event === "responseFinished") {
|
||||
// Fetch integrations and responseCount in parallel
|
||||
const [integrations, responseCount] = await Promise.all([
|
||||
// Fetch integrations, survey, and responseCount in parallel
|
||||
const [integrations, survey, responseCount] = await Promise.all([
|
||||
getIntegrations(environmentId),
|
||||
getSurvey(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) {
|
||||
await handleIntegrations(integrations, inputValidation.data, survey);
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ export const GET = async (req: Request) => {
|
||||
const result = await createOrUpdateIntegration(environmentId, googleSheetIntegration);
|
||||
if (result) {
|
||||
return Response.redirect(
|
||||
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets`
|
||||
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/google-sheets`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ export const GET = withV1ApiWrapper({
|
||||
await createOrUpdateIntegration(environmentId, airtableIntegrationInput);
|
||||
return {
|
||||
response: Response.redirect(
|
||||
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/airtable`
|
||||
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/airtable`
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -87,14 +87,14 @@ export const GET = withV1ApiWrapper({
|
||||
if (result) {
|
||||
return {
|
||||
response: Response.redirect(
|
||||
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/notion`
|
||||
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/notion`
|
||||
),
|
||||
};
|
||||
}
|
||||
} else if (error) {
|
||||
return {
|
||||
response: Response.redirect(
|
||||
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/notion?error=${error}`
|
||||
`${WEBAPP_URL}/environments/${environmentId}/project/integrations/notion?error=${error}`
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user