mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-02 10:30:23 -06:00
Compare commits
26 Commits
shubham/fi
...
@formbrick
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acc6674ec5 | ||
|
|
dd0d296c6a | ||
|
|
f5110fe9c1 | ||
|
|
8244a5fa48 | ||
|
|
59936e54a0 | ||
|
|
5468287f9b | ||
|
|
22e55677ae | ||
|
|
8d422eeda0 | ||
|
|
62dbd9e121 | ||
|
|
c950c96934 | ||
|
|
1fa12d473c | ||
|
|
b9def78d2e | ||
|
|
626356be55 | ||
|
|
85f5425d89 | ||
|
|
d2c703ef60 | ||
|
|
4e8e6390b1 | ||
|
|
9271e375af | ||
|
|
35a9685b71 | ||
|
|
723ea558fa | ||
|
|
8a4a635ee3 | ||
|
|
1a30e9fd11 | ||
|
|
dc8e1c764b | ||
|
|
48e9148728 | ||
|
|
25525e0b03 | ||
|
|
9720c0ecba | ||
|
|
33cbe7cf22 |
@@ -4,8 +4,8 @@ on:
|
||||
# "Scheduled workflows run on the latest commit on the default or base branch."
|
||||
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
|
||||
schedule:
|
||||
# This will run the job at 23:00 UTC every day of every month.
|
||||
- cron: "0 21 * * *"
|
||||
# This will run the job at 22:00 UTC every day of every month.
|
||||
- cron: "0 22 * * *"
|
||||
jobs:
|
||||
cron-reportUsageToStripe:
|
||||
env:
|
||||
@@ -19,4 +19,5 @@ jobs:
|
||||
curl ${{ env.APP_URL }}/api/cron/report-usage \
|
||||
-X POST \
|
||||
-H 'x-api-key: ${{ env.CRON_SECRET }}' \
|
||||
-H 'Cache-Control: no-cache' \
|
||||
--fail
|
||||
|
||||
37
.github/workflows/playwright.yml
vendored
Normal file
37
.github/workflows/playwright.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
name: E2E Tests
|
||||
on:
|
||||
workflow_call:
|
||||
jobs:
|
||||
build:
|
||||
name: Run E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install Docker Compose
|
||||
run: sudo apt-get update && sudo apt-get install -y docker-compose
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install -g pnpm && pnpm install
|
||||
|
||||
- name: Build Formbricks Image & Run
|
||||
run: docker-compose up -d
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
run: pnpm exec playwright install --with-deps
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: pnpm test:e2e
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
7
.github/workflows/pr.yml
vendored
7
.github/workflows/pr.yml
vendored
@@ -27,8 +27,13 @@ jobs:
|
||||
uses: ./.github/workflows/build-web.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-test:
|
||||
name: Run E2E Tests
|
||||
uses: ./.github/workflows/playwright.yml
|
||||
secrets: inherit
|
||||
|
||||
required:
|
||||
needs: [lint, test, build]
|
||||
needs: [lint, test, build, e2e-test]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
16
.github/workflows/release-docker-github.yml
vendored
16
.github/workflows/release-docker-github.yml
vendored
@@ -52,19 +52,22 @@ jobs:
|
||||
with:
|
||||
cosign-release: "v2.1.1"
|
||||
|
||||
# Add support for more platforms with QEMU (optional)
|
||||
# https://github.com/docker/setup-qemu-action
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
# Set up BuildKit Docker container builder to be able to build
|
||||
# multi-platform images and export cache
|
||||
# https://github.com/docker/setup-buildx-action
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
uses: docker/setup-buildx-action@v3 # v3.0.0
|
||||
|
||||
# Login against a Docker registry except on PR
|
||||
# https://github.com/docker/login-action
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
|
||||
uses: docker/login-action@v3 # v3.0.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
@@ -74,7 +77,7 @@ jobs:
|
||||
# https://github.com/docker/metadata-action
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
|
||||
uses: docker/metadata-action@v5 # v5.0.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
|
||||
@@ -82,10 +85,11 @@ jobs:
|
||||
# https://github.com/docker/build-push-action
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
|
||||
uses: docker/build-push-action@v5 # v5.0.0
|
||||
with:
|
||||
context: .
|
||||
file: ./apps/web/Dockerfile
|
||||
# platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -44,4 +44,10 @@ packages/database/zod
|
||||
# nixos stuff
|
||||
.direnv
|
||||
|
||||
Zone.Identifier
|
||||
Zone.Identifier
|
||||
|
||||
# Playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
|
||||
@@ -22,11 +22,13 @@ export default function AppPage({}) {
|
||||
if (process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID && process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST) {
|
||||
const isUserId = window.location.href.includes("userId=true");
|
||||
const userId = isUserId ? "THIS-IS-A-VERY-LONG-USER-ID-FOR-TESTING" : undefined;
|
||||
const attributes = isUserId ? { "Init Attribute 1": "eight", "Init Attribute 2": "two" } : undefined;
|
||||
formbricks.init({
|
||||
environmentId: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID,
|
||||
apiHost: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST,
|
||||
userId,
|
||||
debug: true,
|
||||
attributes,
|
||||
});
|
||||
window.formbricks = formbricks;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@ Checkout the [API Key Setup](/docs/api/management/api-key-setup) - to generate,
|
||||
|
||||
The Public Client API is designed for the JavaScript SDK and does not require authentication. It's primarily used for creating persons, sessions, and responses within the Formbricks platform. This API is ideal for client-side interactions, as it doesn't expose sensitive information.
|
||||
|
||||
- [Actions API](/docs/api/client/actions) - Create actions for a person
|
||||
- [Displays API](/docs/api/client/displays) - Mark Survey as Displayed or Responded for a Person
|
||||
- [People API](/docs/api/client/people) - Create & update people (e.g. attributes)
|
||||
- [Responses API](/docs/api/client/responses) - Create & update responses for a survey
|
||||
|
||||
## Management API
|
||||
|
||||
@@ -10,9 +10,31 @@ export const metadata = {
|
||||
|
||||
One way to send attributes to Formbricks is in your code. In Formbricks, there are two special attributes for [user identification](/docs/attributes/identify-users)(user ID & email) and custom attributes. An example:
|
||||
|
||||
## Setting Custom User Attributes
|
||||
## Setting during Initialization
|
||||
|
||||
It's recommended to set custom user attributes directly during the initialization of Formbricks for better user identification.
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Set custom attributes during initialization">
|
||||
|
||||
```javascript
|
||||
formbricks.init({
|
||||
environmentId: "<environment-id>",
|
||||
apiHost: "<api-host>",
|
||||
userId: "<user_id>",
|
||||
attributes: {
|
||||
plan: "free",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
## Setting independently
|
||||
|
||||
You can use the setAttribute function to set any custom attribute for the user (e.g. name, plan, etc.) anywhere in the user journey. Formbricks maintains a state of the current user inside the browser and makes sure attributes aren't sent to the backend twice.
|
||||
|
||||
You can use the setAttribute function to set any custom attribute for the user (e.g. name, plan, etc.):
|
||||
<Col>
|
||||
<CodeGroup title="Setting Plan to Pro">
|
||||
|
||||
|
||||
@@ -29,6 +29,29 @@ formbricks.init({
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
## Enhanced Initialization with User Attributes
|
||||
|
||||
In addition to setting the `userId`, Formbricks allows you to set user attributes right at the initialization. This ensures that your user data is seamlessly integrated from the start. Here's how you can include user attributes in the `init()` function:
|
||||
|
||||
<Col>
|
||||
<CodeGroup title="Enhanced Initialization with User Attributes">
|
||||
|
||||
```javascript
|
||||
formbricks.init({
|
||||
environmentId: "<environment-id>",
|
||||
apiHost: "<api-host>",
|
||||
userId: "<user_id>",
|
||||
attributes: {
|
||||
// your custom attributes
|
||||
Plan: "premium",
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
## Setting User Email
|
||||
|
||||
The `userId` is the main identifier used in Formbricks and user identification is only enabled when it is set. In addition to the userId you can also set attributes that describes the user better. The email address can be set using the setEmail function:
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { GettingStarted } from "@/components/docs/GettingStarted";
|
||||
import BestPractices from "@/components/docs/BestPractices";
|
||||
import { HeroPattern } from "@/components/docs/HeroPattern";
|
||||
import { Button } from "@/components/docs/Button";
|
||||
|
||||
@@ -9,10 +8,7 @@ export const metadata = {
|
||||
"Enhance your product with Formbricks – the leading open-source solution for in-product micro-surveys. Dive deep into user research, amplify product-market fit, and uncover the 'why' behind your analytics.",
|
||||
};
|
||||
|
||||
export const sections = [
|
||||
{ title: "Getting Started", id: "getting-started" },
|
||||
{ title: "Best Practices", id: "best-practices" },
|
||||
];
|
||||
export const sections = [];
|
||||
|
||||
<HeroPattern />
|
||||
|
||||
@@ -20,7 +16,7 @@ export const sections = [
|
||||
|
||||
Welcome to Formbricks, your go-to solution for in-product micro-surveys that will supercharge your product experience! 🚀 {{ className: 'lead' }}
|
||||
|
||||
<div className="mb-16 mt-6 flex gap-3">
|
||||
<div className="mb-16 mt-6 flex gap-3" id="why-formbricks">
|
||||
<Button href="/docs/getting-started/quickstart-in-app-survey" arrow="right" children="Quickstart" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -39,17 +39,24 @@ Integrating Google OAuth with your Formbricks instance allows users to log in us
|
||||
- Select the application type **Web application** for your project and enter any additional information required.
|
||||
- Ensure to specify authorized JavaScript origins and authorized redirect URIs.
|
||||
|
||||
```
|
||||
<Col>
|
||||
<CodeGroup title="Configuration URLs">
|
||||
``` {{ title: 'Redirect & Origin URLs' }}
|
||||
Authorized JavaScript origins: {WEBAPP_URL}
|
||||
Authorized redirect URIs: {WEBAPP_URL}/api/auth/callback/google
|
||||
```
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
5. **Update Environment Variables in Docker**:
|
||||
- To integrate the Google OAuth, you have two options: either update the environment variables in the docker-compose file or directly add them to the running container.
|
||||
- In your Docker setup directory, open the `.env` file, and add or update the following lines with the `Client ID` and `Client Secret` obtained from Google Cloud Platform:
|
||||
- Alternatively, you can add the environment variables directly to the running container using the following commands (replace `container_id` with your actual Docker container ID):
|
||||
|
||||
```
|
||||
<Col>
|
||||
<CodeGroup title="Set env vars">
|
||||
|
||||
```sh {{ title: 'Shell commands' }}
|
||||
docker exec -it container_id /bin/bash
|
||||
export GOOGLE_AUTH_ENABLED=1
|
||||
export GOOGLE_CLIENT_ID=your-client-id-here
|
||||
@@ -57,12 +64,15 @@ export GOOGLE_CLIENT_SECRET=your-client-secret-here
|
||||
exit
|
||||
```
|
||||
|
||||
```
|
||||
```sh {{ title: 'env file' }}
|
||||
GOOGLE_AUTH_ENABLED=1
|
||||
GOOGLE_CLIENT_ID=your-client-id-here
|
||||
GOOGLE_CLIENT_SECRET=your-client-secret-here
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
|
||||
6. **Restart Your Formbricks Instance**:
|
||||
- **Note:** Restarting your Docker containers may cause a brief period of downtime. Plan accordingly.
|
||||
- Once the environment variables have been updated, it's crucial to restart your Docker containers to apply the changes. This ensures that your Formbricks instance can utilize the new Google OAuth configuration for user authentication. Here's how you can do it:
|
||||
|
||||
@@ -18,7 +18,6 @@ Formbricks v1.2 ships a lot of features targeting our Link Surveys. We have also
|
||||
| -------------------- | -------- | ------------------------------ | ----------------------------------------------------------- |
|
||||
| ENCRYPTION_KEY | true | `openssl rand -hex 32` | Needed for 2 Factor Authentication |
|
||||
| SHORT_URL_BASE | false | `<your-short-base-url>` | Needed if you want to enable shorter links for Link Surveys |
|
||||
| ASSET_PREFIX_URL | false | `<your-asset-hosted-base-url>` | Needed if you have a separate URL for hosted assets |
|
||||
|
||||
### Deprecated / Removed Environment Variables
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export const Hero: React.FC = ({}) => {
|
||||
<ChevronRightIcon className="mb-1 ml-1 inline h-4 w-4 text-slate-300" />
|
||||
</a>
|
||||
<h1 className="mt-10 text-3xl font-bold tracking-tight text-slate-800 dark:text-slate-200 sm:text-4xl md:text-5xl">
|
||||
<span className="xl:inline">Prviacy-first Experience Management</span>
|
||||
<span className="xl:inline">Privacy-first Experience Management</span>
|
||||
</h1>
|
||||
|
||||
<p className="xs:max-w-none mx-auto mt-3 max-w-xs text-base text-slate-500 dark:text-slate-400 sm:text-lg md:mt-5 md:text-xl">
|
||||
|
||||
@@ -83,8 +83,8 @@ export default function BestPracticeNavigation() {
|
||||
return (
|
||||
<div className="mx-auto grid grid-cols-1 gap-6 px-2 md:grid-cols-3">
|
||||
{BestPractices.map((bestPractice) => (
|
||||
<Link href={bestPractice.href} key={bestPractice.name}>
|
||||
<div className="drop-shadow-card duration-120 hover:border-brand-dark relative rounded-lg border border-slate-100 bg-slate-100 p-6 transition-all ease-in-out hover:scale-105 hover:cursor-pointer dark:border-slate-600 dark:bg-slate-800">
|
||||
<Link className="relative block" href={bestPractice.href} key={bestPractice.name}>
|
||||
<div className="drop-shadow-card duration-120 hover:border-brand-dark relative h-full rounded-lg border border-slate-100 bg-slate-100 p-6 transition-all ease-in-out hover:scale-105 hover:cursor-pointer dark:border-slate-600 dark:bg-slate-800">
|
||||
<div
|
||||
className={clsx(
|
||||
// base styles independent what type of button it is
|
||||
@@ -105,7 +105,9 @@ export default function BestPracticeNavigation() {
|
||||
<h3 className="mb-1 mt-3 text-xl font-bold text-slate-700 dark:text-slate-200">
|
||||
{bestPractice.name}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">{bestPractice.description}</p>
|
||||
<p className="flex self-end text-sm text-slate-600 dark:text-slate-400">
|
||||
{bestPractice.description}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
@@ -15,23 +15,23 @@
|
||||
"@formbricks/ui": "workspace:*",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"storybook": "^7.4.6"
|
||||
"storybook": "^7.6.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/tsconfig": "workspace:*",
|
||||
"@storybook/addon-essentials": "^7.5.0-alpha.5",
|
||||
"@storybook/addon-interactions": "^7.5.0-alpha.5",
|
||||
"@storybook/addon-links": "^7.5.0-alpha.5",
|
||||
"@storybook/addon-onboarding": "^1.0.8",
|
||||
"@storybook/blocks": "^7.5.0-alpha.5",
|
||||
"@storybook/react": "^7.5.0-alpha.5",
|
||||
"@storybook/react-vite": "^7.5.0-alpha.5",
|
||||
"@storybook/addon-essentials": "^7.6.3",
|
||||
"@storybook/addon-interactions": "^7.6.3",
|
||||
"@storybook/addon-links": "^7.6.3",
|
||||
"@storybook/addon-onboarding": "^1.0.9",
|
||||
"@storybook/blocks": "^7.6.3",
|
||||
"@storybook/react": "^7.6.3",
|
||||
"@storybook/react-vite": "^7.6.3",
|
||||
"@storybook/testing-library": "^0.2.2",
|
||||
"@typescript-eslint/eslint-plugin": "^6.8.0",
|
||||
"@typescript-eslint/parser": "^6.8.0",
|
||||
"@vitejs/plugin-react": "^4.1.0",
|
||||
"esbuild": "^0.19.5",
|
||||
"tsup": "^7.2.0",
|
||||
"vite": "^4.4.11"
|
||||
"@typescript-eslint/eslint-plugin": "^6.13.2",
|
||||
"@typescript-eslint/parser": "^6.13.2",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"esbuild": "^0.19.8",
|
||||
"tsup": "^8.0.1",
|
||||
"vite": "^5.0.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
# Installer stage: Building the application
|
||||
FROM node:20-alpine AS installer
|
||||
|
||||
# Enable corepack and prepare pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
# Install necessary build tools and compilers
|
||||
RUN apk update && apk add --no-cache g++ cmake make gcc python3 openssl-dev
|
||||
|
||||
# Install Supercronic (cron for containers without super user privileges)
|
||||
RUN apk add --no-cache curl \
|
||||
&& curl -fsSLo /tmp/supercronic \
|
||||
"https://github.com/aptible/supercronic/releases/download/v0.2.27/supercronic-linux-amd64" \
|
||||
&& chmod +x /tmp/supercronic
|
||||
|
||||
# Set environment variables
|
||||
ARG DATABASE_URL
|
||||
ENV DATABASE_URL=$DATABASE_URL
|
||||
|
||||
@@ -17,13 +23,19 @@ ENV NEXTAUTH_SECRET=$NEXTAUTH_SECRET
|
||||
ARG ENCRYPTION_KEY
|
||||
ENV ENCRYPTION_KEY=$ENCRYPTION_KEY
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the application files
|
||||
COPY . .
|
||||
|
||||
# Create a .env file
|
||||
RUN touch /app/apps/web/.env
|
||||
|
||||
# Install the dependencies
|
||||
RUN pnpm install
|
||||
|
||||
|
||||
# Build the project
|
||||
RUN pnpm post-install --filter=web...
|
||||
RUN pnpm turbo run build --filter=web...
|
||||
|
||||
@@ -204,6 +204,7 @@ export async function copyToOtherEnvironmentAction(
|
||||
singleUse: existingSurvey.singleUse ?? prismaClient.JsonNull,
|
||||
productOverwrites: existingSurvey.productOverwrites ?? prismaClient.JsonNull,
|
||||
verifyEmail: existingSurvey.verifyEmail ?? prismaClient.JsonNull,
|
||||
styling: existingSurvey.styling ?? prismaClient.JsonNull,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import React, { useState } from "react";
|
||||
import { getPersonIdentifier } from "@formbricks/lib/person/util";
|
||||
import Headline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/Headline";
|
||||
import { timeSince } from "@formbricks/lib/time";
|
||||
import type { TSurveyDateQuestion, TSurveyQuestionSummary } from "@formbricks/types/surveys";
|
||||
import { PersonAvatar } from "@formbricks/ui/Avatars";
|
||||
import { InboxStackIcon } from "@heroicons/react/24/solid";
|
||||
import Link from "next/link";
|
||||
import { questionTypes } from "@/app/lib/questions";
|
||||
import { formatDateWithOrdinal } from "@formbricks/lib/utils/datetime";
|
||||
|
||||
interface DateQuestionSummary {
|
||||
questionSummary: TSurveyQuestionSummary<TSurveyDateQuestion>;
|
||||
environmentId: string;
|
||||
responsesPerPage: number;
|
||||
}
|
||||
|
||||
export default function DateQuestionSummary({
|
||||
questionSummary,
|
||||
environmentId,
|
||||
responsesPerPage,
|
||||
}: DateQuestionSummary) {
|
||||
const questionTypeInfo = questionTypes.find((type) => type.id === questionSummary.question.type);
|
||||
const [displayCount, setDisplayCount] = useState(responsesPerPage);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-200 bg-slate-50 shadow-sm">
|
||||
<div className="space-y-2 px-4 pb-5 pt-6 md:px-6">
|
||||
<Headline headline={questionSummary.question.headline} required={questionSummary.question.required} />
|
||||
|
||||
<div className="flex space-x-2 text-xs font-semibold text-slate-600 md:text-sm">
|
||||
<div className="flex items-center rounded-lg bg-slate-100 p-2 ">
|
||||
{questionTypeInfo && <questionTypeInfo.icon className="mr-2 h-4 w-4 " />}
|
||||
{questionTypeInfo ? questionTypeInfo.label : "Unknown Question Type"} Question
|
||||
</div>
|
||||
<div className=" flex items-center rounded-lg bg-slate-100 p-2">
|
||||
<InboxStackIcon className="mr-2 h-4 w-4" />
|
||||
{questionSummary.responses.length} Responses
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-b-lg bg-white ">
|
||||
<div className="grid h-10 grid-cols-4 items-center border-y border-slate-200 bg-slate-100 text-sm font-bold text-slate-600">
|
||||
<div className="pl-4 md:pl-6">User</div>
|
||||
<div className="col-span-2 pl-4 md:pl-6">Response</div>
|
||||
<div className="px-4 md:px-6">Time</div>
|
||||
</div>
|
||||
{questionSummary.responses.slice(0, displayCount).map((response) => {
|
||||
const displayIdentifier = getPersonIdentifier(response.person!);
|
||||
return (
|
||||
<div
|
||||
key={response.id}
|
||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
|
||||
<div className="pl-4 md:pl-6">
|
||||
{response.person ? (
|
||||
<Link
|
||||
className="ph-no-capture group flex items-center"
|
||||
href={`/environments/${environmentId}/people/${response.person.id}`}>
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId={response.person.id} />
|
||||
</div>
|
||||
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
|
||||
{displayIdentifier}
|
||||
</p>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="group flex items-center">
|
||||
<div className="hidden md:flex">
|
||||
<PersonAvatar personId="anonymous" />
|
||||
</div>
|
||||
<p className="break-all text-slate-600 md:ml-2">Anonymous</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
|
||||
{formatDateWithOrdinal(new Date(response.value as string))}
|
||||
</div>
|
||||
<div className="px-4 text-slate-500 md:px-6">{timeSince(response.updatedAt.toISOString())}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="my-1 flex justify-center">
|
||||
{displayCount < questionSummary.responses.length && (
|
||||
<button
|
||||
onClick={() => setDisplayCount((prevCount) => prevCount + responsesPerPage)}
|
||||
className="my-2 flex h-8 items-center justify-center rounded-lg border border-gray-300 bg-white px-3 text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-700">
|
||||
Show more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import HiddenFieldsSummary from "@/app/(app)/environments/[environmentId]/survey
|
||||
import EmptySpaceFiller from "@formbricks/ui/EmptySpaceFiller";
|
||||
import { TSurveyQuestionType } from "@formbricks/types/surveys";
|
||||
import type {
|
||||
TSurveyDateQuestion,
|
||||
TSurveyFileUploadQuestion,
|
||||
TSurveyPictureSelectionQuestion,
|
||||
TSurveyQuestionSummary,
|
||||
@@ -26,6 +27,7 @@ import MultipleChoiceSummary from "./MultipleChoiceSummary";
|
||||
import NPSSummary from "./NPSSummary";
|
||||
import OpenTextSummary from "./OpenTextSummary";
|
||||
import RatingSummary from "./RatingSummary";
|
||||
import DateQuestionSummary from "./DateQuestionSummary";
|
||||
import FileUploadSummary from "./FileUploadSummary";
|
||||
import PictureChoiceSummary from "./PictureChoiceSummary";
|
||||
|
||||
@@ -47,6 +49,7 @@ export default function SummaryList({ environment, survey, responses, responsesP
|
||||
updatedAt: r.updatedAt,
|
||||
person: r.person,
|
||||
}));
|
||||
|
||||
return {
|
||||
question,
|
||||
responses: questionResponses,
|
||||
@@ -54,115 +57,123 @@ export default function SummaryList({ environment, survey, responses, responsesP
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-10 space-y-8">
|
||||
{survey.type === "web" && responses.length === 0 && !environment.widgetSetupCompleted ? (
|
||||
<EmptyInAppSurveys environment={environment} />
|
||||
) : responses.length === 0 ? (
|
||||
<EmptySpaceFiller
|
||||
type="response"
|
||||
environment={environment}
|
||||
noWidgetRequired={survey.type === "link"}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{getSummaryData().map((questionSummary) => {
|
||||
if (questionSummary.question.type === TSurveyQuestionType.OpenText) {
|
||||
return (
|
||||
<OpenTextSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyOpenTextQuestion>}
|
||||
environmentId={environment.id}
|
||||
responsesPerPage={responsesPerPage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (
|
||||
questionSummary.question.type === TSurveyQuestionType.MultipleChoiceSingle ||
|
||||
questionSummary.question.type === TSurveyQuestionType.MultipleChoiceMulti
|
||||
) {
|
||||
return (
|
||||
<MultipleChoiceSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={
|
||||
questionSummary as TSurveyQuestionSummary<
|
||||
TSurveyMultipleChoiceMultiQuestion | TSurveyMultipleChoiceSingleQuestion
|
||||
>
|
||||
}
|
||||
environmentId={environment.id}
|
||||
surveyType={survey.type}
|
||||
responsesPerPage={responsesPerPage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.question.type === TSurveyQuestionType.NPS) {
|
||||
return (
|
||||
<NPSSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyNPSQuestion>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.question.type === TSurveyQuestionType.CTA) {
|
||||
return (
|
||||
<CTASummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyCTAQuestion>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.question.type === TSurveyQuestionType.Rating) {
|
||||
return (
|
||||
<RatingSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyRatingQuestion>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.question.type === TSurveyQuestionType.Consent) {
|
||||
return (
|
||||
<ConsentSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyConsentQuestion>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.question.type === TSurveyQuestionType.FileUpload) {
|
||||
return (
|
||||
<FileUploadSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyFileUploadQuestion>}
|
||||
environmentId={environment.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.question.type === TSurveyQuestionType.PictureSelection) {
|
||||
return (
|
||||
<PictureChoiceSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={
|
||||
questionSummary as TSurveyQuestionSummary<TSurveyPictureSelectionQuestion>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
<div className="mt-10 space-y-8">
|
||||
{survey.type === "web" && responses.length === 0 && !environment.widgetSetupCompleted ? (
|
||||
<EmptyInAppSurveys environment={environment} />
|
||||
) : responses.length === 0 ? (
|
||||
<EmptySpaceFiller
|
||||
type="response"
|
||||
environment={environment}
|
||||
noWidgetRequired={survey.type === "link"}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{getSummaryData().map((questionSummary) => {
|
||||
if (questionSummary.question.type === TSurveyQuestionType.OpenText) {
|
||||
return (
|
||||
<OpenTextSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyOpenTextQuestion>}
|
||||
environmentId={environment.id}
|
||||
responsesPerPage={responsesPerPage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (
|
||||
questionSummary.question.type === TSurveyQuestionType.MultipleChoiceSingle ||
|
||||
questionSummary.question.type === TSurveyQuestionType.MultipleChoiceMulti
|
||||
) {
|
||||
return (
|
||||
<MultipleChoiceSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={
|
||||
questionSummary as TSurveyQuestionSummary<
|
||||
TSurveyMultipleChoiceMultiQuestion | TSurveyMultipleChoiceSingleQuestion
|
||||
>
|
||||
}
|
||||
environmentId={environment.id}
|
||||
surveyType={survey.type}
|
||||
responsesPerPage={responsesPerPage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.question.type === TSurveyQuestionType.NPS) {
|
||||
return (
|
||||
<NPSSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyNPSQuestion>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.question.type === TSurveyQuestionType.CTA) {
|
||||
return (
|
||||
<CTASummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyCTAQuestion>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.question.type === TSurveyQuestionType.Rating) {
|
||||
return (
|
||||
<RatingSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyRatingQuestion>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.question.type === TSurveyQuestionType.Consent) {
|
||||
return (
|
||||
<ConsentSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyConsentQuestion>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.question.type === TSurveyQuestionType.PictureSelection) {
|
||||
return (
|
||||
<PictureChoiceSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyPictureSelectionQuestion>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.question.type === TSurveyQuestionType.Date) {
|
||||
return (
|
||||
<DateQuestionSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyDateQuestion>}
|
||||
environmentId={environment.id}
|
||||
responsesPerPage={responsesPerPage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (questionSummary.question.type === TSurveyQuestionType.FileUpload) {
|
||||
return (
|
||||
<FileUploadSummary
|
||||
key={questionSummary.question.id}
|
||||
questionSummary={questionSummary as TSurveyQuestionSummary<TSurveyFileUploadQuestion>}
|
||||
environmentId={environment.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
|
||||
{survey.hiddenFields?.enabled &&
|
||||
survey.hiddenFields.fieldIds?.map((question) => {
|
||||
return (
|
||||
<HiddenFieldsSummary
|
||||
environment={environment}
|
||||
question={question}
|
||||
responses={responses}
|
||||
survey={survey}
|
||||
key={question}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{survey.hiddenFields?.enabled &&
|
||||
survey.hiddenFields.fieldIds?.map((question) => {
|
||||
return (
|
||||
<HiddenFieldsSummary
|
||||
environment={environment}
|
||||
question={question}
|
||||
responses={responses}
|
||||
survey={survey}
|
||||
key={question}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function SummaryMetadata({
|
||||
const ttc = useMemo(() => {
|
||||
let validTtcResponsesCountAcc = 0; //stores the count of responses that contains a _total value
|
||||
const ttc = responses.reduce((acc, response) => {
|
||||
if (response.ttc._total) {
|
||||
if (response.ttc?._total) {
|
||||
validTtcResponsesCountAcc++;
|
||||
return acc + response.ttc._total;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import { render } from "@react-email/render";
|
||||
import { CalendarDaysIcon } from "lucide-react";
|
||||
|
||||
interface EmailTemplateProps {
|
||||
survey: TSurvey;
|
||||
@@ -283,6 +284,22 @@ const EmailTemplate = ({ survey, surveyUrl, brandColor }: EmailTemplateProps) =>
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
case TSurveyQuestionType.Date:
|
||||
return (
|
||||
<EmailTemplateWrapper surveyUrl={url} brandColor={brandColor}>
|
||||
<Text className="m-0 mr-8 block p-0 text-base font-semibold leading-6 text-slate-800">
|
||||
{firstQuestion.headline}
|
||||
</Text>
|
||||
<Text className="m-0 block p-0 text-sm font-normal leading-6 text-slate-500">
|
||||
{firstQuestion.subheader}
|
||||
</Text>
|
||||
<Section className="mt-4 flex h-12 w-full items-center justify-center rounded-lg border border-solid border-gray-200 bg-white">
|
||||
<CalendarDaysIcon className="mb-1 inline h-4 w-4" />
|
||||
<Text className="inline text-sm font-medium">Select a date</Text>
|
||||
</Section>
|
||||
<EmailFooter />
|
||||
</EmailTemplateWrapper>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
import { useState } from "react";
|
||||
|
||||
interface AnimatedSurveyBgProps {
|
||||
localSurvey?: TSurvey;
|
||||
handleBgChange: (bg: string, bgType: string) => void;
|
||||
}
|
||||
|
||||
export default function AnimatedSurveyBg({ localSurvey, handleBgChange }: AnimatedSurveyBgProps) {
|
||||
const [color, setColor] = useState(localSurvey?.styling?.background?.bg || "#ffff");
|
||||
const [hoveredVideo, setHoveredVideo] = useState<number | null>(null);
|
||||
|
||||
const animationFiles = {
|
||||
"/animated-bgs/Thumbnails/1_Thumb.mp4": "/animated-bgs/4K/1_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/2_Thumb.mp4": "/animated-bgs/4K/2_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/3_Thumb.mp4": "/animated-bgs/4K/3_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/4_Thumb.mp4": "/animated-bgs/4K/4_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/5_Thumb.mp4": "/animated-bgs/4K/5_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/6_Thumb.mp4": "/animated-bgs/4K/6_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/7_Thumb.mp4": "/animated-bgs/4K/7_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/8_Thumb.mp4": "/animated-bgs/4K/8_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/9_Thumb.mp4": "/animated-bgs/4K/9_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/10_Thumb.mp4": "/animated-bgs/4K/10_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/11_Thumb.mp4": "/animated-bgs/4K/11_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/12_Thumb.mp4": "/animated-bgs/4K/12_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/13_Thumb.mp4": "/animated-bgs/4K/13_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/14_Thumb.mp4": "/animated-bgs/4K/14_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/15_Thumb.mp4": "/animated-bgs/4K/15_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/16_Thumb.mp4": "/animated-bgs/4K/16_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/17_Thumb.mp4": "/animated-bgs/4K/17_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/18_Thumb.mp4": "/animated-bgs/4K/18_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/19_Thumb.mp4": "/animated-bgs/4K/19_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/20_Thumb.mp4": "/animated-bgs/4K/20_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/21_Thumb.mp4": "/animated-bgs/4K/21_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/22_Thumb.mp4": "/animated-bgs/4K/22_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/23_Thumb.mp4": "/animated-bgs/4K/23_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/24_Thumb.mp4": "/animated-bgs/4K/24_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/25_Thumb.mp4": "/animated-bgs/4K/25_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/26_Thumb.mp4": "/animated-bgs/4K/26_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/27_Thumb.mp4": "/animated-bgs/4K/27_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/28_Thumb.mp4": "/animated-bgs/4K/28_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/29_Thumb.mp4": "/animated-bgs/4K/29_4k.mp4",
|
||||
"/animated-bgs/Thumbnails/30_Thumb.mp4": "/animated-bgs/4K/30_4k.mp4",
|
||||
};
|
||||
|
||||
const handleMouseEnter = (index: number) => {
|
||||
setHoveredVideo(index);
|
||||
playVideo(index);
|
||||
};
|
||||
|
||||
const handleMouseLeave = (index: number) => {
|
||||
setHoveredVideo(null);
|
||||
pauseVideo(index);
|
||||
};
|
||||
|
||||
// Function to play the video
|
||||
const playVideo = (index: number) => {
|
||||
const video = document.getElementById(`video-${index}`) as HTMLVideoElement;
|
||||
if (video) {
|
||||
video.play();
|
||||
}
|
||||
};
|
||||
|
||||
// Function to pause the video
|
||||
const pauseVideo = (index: number) => {
|
||||
const video = document.getElementById(`video-${index}`) as HTMLVideoElement;
|
||||
if (video) {
|
||||
video.pause();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBg = (x: string) => {
|
||||
setColor(x);
|
||||
handleBgChange(x, "animation");
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<div className="mt-4 grid grid-cols-6 gap-4">
|
||||
{Object.keys(animationFiles).map((key, index) => {
|
||||
const value = animationFiles[key];
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
onMouseEnter={() => handleMouseEnter(index)}
|
||||
onMouseLeave={() => handleMouseLeave(index)}
|
||||
onClick={() => handleBg(value)}
|
||||
className="relative cursor-pointer overflow-hidden rounded-lg">
|
||||
<video
|
||||
disablePictureInPicture
|
||||
id={`video-${index}`}
|
||||
autoPlay={hoveredVideo === index}
|
||||
className="h-46 w-96 origin-center scale-105 transform">
|
||||
<source src={`${key}`} type="video/mp4" />
|
||||
</video>
|
||||
<input
|
||||
className="absolute right-2 top-2 h-4 w-4 rounded-sm bg-white "
|
||||
type="checkbox"
|
||||
checked={color === value}
|
||||
onChange={() => handleBg(value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
import { ColorPicker } from "@formbricks/ui/ColorPicker";
|
||||
import { useState } from "react";
|
||||
|
||||
interface ColorSurveyBgBgProps {
|
||||
localSurvey?: TSurvey;
|
||||
handleBgChange: (bg: string, bgType: string) => void;
|
||||
colours: string[];
|
||||
}
|
||||
|
||||
export default function ColorSurveyBg({ localSurvey, handleBgChange, colours }: ColorSurveyBgBgProps) {
|
||||
const [color, setColor] = useState(localSurvey?.styling?.background?.bg || "#ffff");
|
||||
|
||||
const handleBg = (x: string) => {
|
||||
setColor(x);
|
||||
handleBgChange(x, "color");
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<div className="w-full max-w-xs py-2">
|
||||
<ColorPicker color={color} onChange={handleBg} />
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-4 md:grid-cols-5 xl:grid-cols-8 2xl:grid-cols-10">
|
||||
{colours.map((x) => {
|
||||
return (
|
||||
<div
|
||||
className={`h-16 w-16 cursor-pointer rounded-lg ${
|
||||
color === x ? "border-4 border-slate-500" : ""
|
||||
}`}
|
||||
key={x}
|
||||
style={{ backgroundColor: `${x}` }}
|
||||
onClick={() => handleBg(x)}></div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { TSurvey, TSurveyDateQuestion } from "@formbricks/types/surveys";
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { PlusIcon, TrashIcon } from "@heroicons/react/24/solid";
|
||||
import { useState } from "react";
|
||||
import QuestionFormInput from "./QuestionFormInput";
|
||||
import { OptionsSwitcher } from "@formbricks/ui/QuestionTypeSelector";
|
||||
|
||||
interface IDateQuestionFormProps {
|
||||
localSurvey: TSurvey;
|
||||
question: TSurveyDateQuestion;
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: any) => void;
|
||||
lastQuestion: boolean;
|
||||
isInValid: boolean;
|
||||
}
|
||||
|
||||
const dateOptions = [
|
||||
{
|
||||
value: "M-d-y",
|
||||
label: "MM-DD-YYYY",
|
||||
},
|
||||
{
|
||||
value: "d-M-y",
|
||||
label: "DD-MM-YYYY",
|
||||
},
|
||||
{
|
||||
value: "y-M-d",
|
||||
label: "YYYY-MM-DD",
|
||||
},
|
||||
];
|
||||
|
||||
export default function DateQuestionForm({
|
||||
question,
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
isInValid,
|
||||
localSurvey,
|
||||
}: IDateQuestionFormProps): JSX.Element {
|
||||
const [showSubheader, setShowSubheader] = useState(!!question.subheader);
|
||||
|
||||
return (
|
||||
<form>
|
||||
<QuestionFormInput
|
||||
environmentId={localSurvey.environmentId}
|
||||
isInValid={isInValid}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
/>
|
||||
<div className="mt-3">
|
||||
{showSubheader && (
|
||||
<>
|
||||
<Label htmlFor="subheader">Description</Label>
|
||||
<div className="mt-2 inline-flex w-full items-center">
|
||||
<Input
|
||||
id="subheader"
|
||||
name="subheader"
|
||||
value={question.subheader}
|
||||
onChange={(e) => updateQuestion(questionIdx, { subheader: e.target.value })}
|
||||
/>
|
||||
<TrashIcon
|
||||
className="ml-2 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
|
||||
onClick={() => {
|
||||
setShowSubheader(false);
|
||||
updateQuestion(questionIdx, { subheader: "" });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!showSubheader && (
|
||||
<Button size="sm" variant="minimal" type="button" onClick={() => setShowSubheader(true)}>
|
||||
<PlusIcon className="mr-1 h-4 w-4" />
|
||||
Add Description
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<Label htmlFor="questionType">Date Format</Label>
|
||||
<div className="mt-2 flex items-center">
|
||||
<OptionsSwitcher
|
||||
options={dateOptions}
|
||||
currentOption={question.format}
|
||||
handleTypeChange={(value) => updateQuestion(questionIdx, { format: value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import FileInput from "@formbricks/ui/FileInput";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
|
||||
interface ImageSurveyBgBgProps {
|
||||
localSurvey?: TSurvey;
|
||||
handleBgChange: (url: string, bgType: string) => void;
|
||||
}
|
||||
|
||||
export default function ImageSurveyBg({ localSurvey, handleBgChange }: ImageSurveyBgBgProps) {
|
||||
const isUrl = (str: string) => {
|
||||
try {
|
||||
new URL(str);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const fileUrl = isUrl(localSurvey?.styling?.background?.bg ?? "")
|
||||
? localSurvey?.styling?.background?.bg ?? ""
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div className="mb-2 mt-4 w-full rounded-lg border bg-slate-50 p-4">
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<FileInput
|
||||
id="survey-bg-file-input"
|
||||
allowedFileExtensions={["png", "jpeg", "jpg"]}
|
||||
environmentId={localSurvey?.environmentId}
|
||||
onFileUpload={(url: string[]) => {
|
||||
if (url.length > 0) {
|
||||
handleBgChange(url[0], "image");
|
||||
} else {
|
||||
handleBgChange("#ffff", "color");
|
||||
}
|
||||
}}
|
||||
fileUrl={fileUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,16 +9,23 @@ import {
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { Input } from "@formbricks/ui/Input";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { QuestionTypeSelector } from "@formbricks/ui/QuestionTypeSelector";
|
||||
import { OptionsSwitcher } from "@formbricks/ui/QuestionTypeSelector";
|
||||
import { PlusIcon, TrashIcon } from "@heroicons/react/24/solid";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ChatBubbleBottomCenterTextIcon,
|
||||
EnvelopeIcon,
|
||||
HashtagIcon,
|
||||
LinkIcon,
|
||||
PhoneIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
|
||||
const questionTypes = [
|
||||
{ value: "text", label: "Text" },
|
||||
{ value: "email", label: "Email" },
|
||||
{ value: "url", label: "URL" },
|
||||
{ value: "number", label: "Number" },
|
||||
{ value: "phone", label: "Phone" },
|
||||
{ value: "text", label: "Text", icon: <ChatBubbleBottomCenterTextIcon /> },
|
||||
{ value: "email", label: "Email", icon: <EnvelopeIcon /> },
|
||||
{ value: "url", label: "URL", icon: <LinkIcon /> },
|
||||
{ value: "number", label: "Number", icon: <HashtagIcon /> },
|
||||
{ value: "phone", label: "Phone", icon: <PhoneIcon /> },
|
||||
];
|
||||
|
||||
interface OpenQuestionFormProps {
|
||||
@@ -106,9 +113,9 @@ export default function OpenQuestionForm({
|
||||
<div className="mt-3">
|
||||
<Label htmlFor="questionType">Input Type</Label>
|
||||
<div className="mt-2 flex items-center">
|
||||
<QuestionTypeSelector
|
||||
questionTypes={questionTypes}
|
||||
currentType={question.inputType}
|
||||
<OptionsSwitcher
|
||||
options={questionTypes}
|
||||
currentOption={question.inputType}
|
||||
handleTypeChange={handleInputChange} // Use the merged function
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
StarIcon,
|
||||
ArrowUpTrayIcon,
|
||||
PhotoIcon,
|
||||
CalendarDaysIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { useState } from "react";
|
||||
@@ -33,6 +34,7 @@ import NPSQuestionForm from "./NPSQuestionForm";
|
||||
import OpenQuestionForm from "./OpenQuestionForm";
|
||||
import QuestionDropdown from "./QuestionMenu";
|
||||
import RatingQuestionForm from "./RatingQuestionForm";
|
||||
import DateQuestionForm from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/DateQuestionForm";
|
||||
import PictureSelectionForm from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/edit/components/PictureSelectionForm";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
|
||||
@@ -146,6 +148,8 @@ export default function QuestionCard({
|
||||
<CheckIcon />
|
||||
) : question.type === TSurveyQuestionType.PictureSelection ? (
|
||||
<PhotoIcon />
|
||||
) : question.type === TSurveyQuestionType.Date ? (
|
||||
<CalendarDaysIcon />
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
@@ -234,6 +238,15 @@ export default function QuestionCard({
|
||||
updateQuestion={updateQuestion}
|
||||
isInValid={isInValid}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.Date ? (
|
||||
<DateQuestionForm
|
||||
localSurvey={localSurvey}
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
isInValid={isInValid}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.PictureSelection ? (
|
||||
<PictureSelectionForm
|
||||
localSurvey={localSurvey}
|
||||
|
||||
@@ -18,6 +18,7 @@ interface SettingsViewProps {
|
||||
attributeClasses: TAttributeClass[];
|
||||
responseCount: number;
|
||||
membershipRole?: TMembershipRole;
|
||||
colours: string[];
|
||||
}
|
||||
|
||||
export default function SettingsView({
|
||||
@@ -28,6 +29,7 @@ export default function SettingsView({
|
||||
attributeClasses,
|
||||
responseCount,
|
||||
membershipRole,
|
||||
colours,
|
||||
}: SettingsViewProps) {
|
||||
return (
|
||||
<div className="mt-12 space-y-3 p-5">
|
||||
@@ -60,7 +62,7 @@ export default function SettingsView({
|
||||
environmentId={environment.id}
|
||||
/>
|
||||
|
||||
<StylingCard localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} />
|
||||
<StylingCard localSurvey={localSurvey} setLocalSurvey={setLocalSurvey} colours={colours} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { TPlacement } from "@formbricks/types/common";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
import { TSurvey, TSurveyBackgroundBgType } from "@formbricks/types/surveys";
|
||||
import { ColorPicker } from "@formbricks/ui/ColorPicker";
|
||||
import { Label } from "@formbricks/ui/Label";
|
||||
import { Switch } from "@formbricks/ui/Switch";
|
||||
@@ -9,18 +9,28 @@ import { CheckCircleIcon } from "@heroicons/react/24/solid";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { useState } from "react";
|
||||
import Placement from "./Placement";
|
||||
import SurveyBgSelectorTab from "./SurveyBgSelectorTab";
|
||||
|
||||
interface StylingCardProps {
|
||||
localSurvey: TSurvey;
|
||||
setLocalSurvey: React.Dispatch<React.SetStateAction<TSurvey>>;
|
||||
colours: string[];
|
||||
}
|
||||
|
||||
export default function StylingCard({ localSurvey, setLocalSurvey }: StylingCardProps) {
|
||||
export default function StylingCard({ localSurvey, setLocalSurvey, colours }: StylingCardProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { type, productOverwrites } = localSurvey;
|
||||
const { type, productOverwrites, styling } = localSurvey;
|
||||
const { brandColor, clickOutsideClose, darkOverlay, placement, highlightBorderColor } =
|
||||
productOverwrites ?? {};
|
||||
const { bg, bgType, brightness } = styling?.background ?? {};
|
||||
|
||||
const [inputValue, setInputValue] = useState(100);
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
setInputValue(e.target.value);
|
||||
handleBrightnessChange(parseInt(e.target.value));
|
||||
};
|
||||
|
||||
const togglePlacement = () => {
|
||||
setLocalSurvey({
|
||||
@@ -28,6 +38,8 @@ export default function StylingCard({ localSurvey, setLocalSurvey }: StylingCard
|
||||
productOverwrites: {
|
||||
...localSurvey.productOverwrites,
|
||||
placement: !!placement ? null : "bottomRight",
|
||||
clickOutsideClose: false,
|
||||
darkOverlay: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -42,6 +54,34 @@ export default function StylingCard({ localSurvey, setLocalSurvey }: StylingCard
|
||||
});
|
||||
};
|
||||
|
||||
const toggleBackgroundColor = () => {
|
||||
setLocalSurvey({
|
||||
...localSurvey,
|
||||
styling: {
|
||||
...localSurvey.styling,
|
||||
background: {
|
||||
...localSurvey.styling?.background,
|
||||
bg: !!bg ? undefined : "#ffff",
|
||||
bgType: !!bg ? undefined : "color",
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const toggleBrightness = () => {
|
||||
setLocalSurvey({
|
||||
...localSurvey,
|
||||
styling: {
|
||||
...localSurvey.styling,
|
||||
background: {
|
||||
...localSurvey.styling?.background,
|
||||
brightness: !!brightness ? undefined : 100,
|
||||
},
|
||||
},
|
||||
});
|
||||
setInputValue(100);
|
||||
};
|
||||
|
||||
const toggleHighlightBorderColor = () => {
|
||||
setLocalSurvey({
|
||||
...localSurvey,
|
||||
@@ -62,6 +102,35 @@ export default function StylingCard({ localSurvey, setLocalSurvey }: StylingCard
|
||||
});
|
||||
};
|
||||
|
||||
const handleBgChange = (color: string, type: TSurveyBackgroundBgType) => {
|
||||
setInputValue(100);
|
||||
setLocalSurvey({
|
||||
...localSurvey,
|
||||
styling: {
|
||||
...localSurvey.styling,
|
||||
background: {
|
||||
...localSurvey.styling?.background,
|
||||
bg: color,
|
||||
bgType: type,
|
||||
brightness: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleBrightnessChange = (percent: number) => {
|
||||
setLocalSurvey({
|
||||
...localSurvey,
|
||||
styling: {
|
||||
...(localSurvey.styling || {}),
|
||||
background: {
|
||||
...localSurvey.styling?.background,
|
||||
brightness: percent,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleBorderColorChange = (color: string) => {
|
||||
setLocalSurvey({
|
||||
...localSurvey,
|
||||
@@ -143,6 +212,66 @@ export default function StylingCard({ localSurvey, setLocalSurvey }: StylingCard
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{type == "link" && (
|
||||
<>
|
||||
{/* Background */}
|
||||
<div className="p-3">
|
||||
<div className="ml-2 flex items-center space-x-1">
|
||||
<Switch id="autoCompleteBg" checked={!!bg} onCheckedChange={toggleBackgroundColor} />
|
||||
<Label htmlFor="autoCompleteBg" className="cursor-pointer">
|
||||
<div className="ml-2">
|
||||
<h3 className="text-sm font-semibold text-slate-700">Change Background</h3>
|
||||
<p className="text-xs font-normal text-slate-500">
|
||||
Pick a background from our library or upload your own.
|
||||
</p>
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
{bg && (
|
||||
<SurveyBgSelectorTab
|
||||
localSurvey={localSurvey}
|
||||
handleBgChange={handleBgChange}
|
||||
colours={colours}
|
||||
bgType={bgType}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Overlay */}
|
||||
<div className="p-3">
|
||||
<div className="ml-2 flex items-center space-x-1">
|
||||
<Switch
|
||||
id="autoCompleteOverlay"
|
||||
checked={!!brightness}
|
||||
onCheckedChange={toggleBrightness}
|
||||
/>
|
||||
<Label htmlFor="autoCompleteOverlay" className="cursor-pointer">
|
||||
<div className="ml-2">
|
||||
<h3 className="text-sm font-semibold text-slate-700">Background Overlay</h3>
|
||||
<p className="text-xs font-normal text-slate-500">
|
||||
Darken or lighten background of your choice.
|
||||
</p>
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
{brightness && (
|
||||
<div>
|
||||
<div className="mt-4 flex flex-col justify-center rounded-lg border bg-slate-50 p-4 px-8">
|
||||
<h3 className="mb-4 text-sm font-semibold text-slate-700">Transparency</h3>
|
||||
<input
|
||||
id="small-range"
|
||||
type="range"
|
||||
min="1"
|
||||
max="200"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
className="range-sm mb-6 h-1 w-full cursor-pointer appearance-none rounded-lg bg-gray-200 dark:bg-gray-700"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* positioning */}
|
||||
{type !== "link" && (
|
||||
<div className="p-3 ">
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
import { useState } from "react";
|
||||
import AnimatedSurveyBg from "./AnimatedSurveyBg";
|
||||
import ColorSurveyBg from "./ColorSurveyBg";
|
||||
import ImageSurveyBg from "./ImageSurveyBg";
|
||||
|
||||
interface SurveyBgSelectorTabProps {
|
||||
localSurvey: TSurvey;
|
||||
handleBgChange: (bg: string, bgType: string) => void;
|
||||
colours: string[];
|
||||
bgType: string | null | undefined;
|
||||
}
|
||||
|
||||
const TabButton = ({ isActive, onClick, children }) => (
|
||||
<button
|
||||
className={`w-1/4 rounded-md p-2 text-sm font-medium leading-none text-slate-800 ${
|
||||
isActive ? "bg-white shadow-sm" : ""
|
||||
}`}
|
||||
onClick={onClick}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
export default function SurveyBgSelectorTab({
|
||||
localSurvey,
|
||||
handleBgChange,
|
||||
colours,
|
||||
bgType,
|
||||
}: SurveyBgSelectorTabProps) {
|
||||
const [tab, setTab] = useState(bgType || "image");
|
||||
|
||||
const renderContent = () => {
|
||||
switch (tab) {
|
||||
case "image":
|
||||
return <ImageSurveyBg localSurvey={localSurvey} handleBgChange={handleBgChange} />;
|
||||
case "animation":
|
||||
return <AnimatedSurveyBg localSurvey={localSurvey} handleBgChange={handleBgChange} />;
|
||||
case "color":
|
||||
return <ColorSurveyBg localSurvey={localSurvey} handleBgChange={handleBgChange} colours={colours} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4 flex flex-col items-center justify-center rounded-lg border bg-slate-50 p-4 px-8">
|
||||
<div className="flex w-full items-center justify-between rounded-lg border border-slate-300 bg-slate-50 px-6 py-1.5">
|
||||
<TabButton isActive={tab === "image"} onClick={() => setTab("image")}>
|
||||
Image
|
||||
</TabButton>
|
||||
<TabButton isActive={tab === "animation"} onClick={() => setTab("animation")}>
|
||||
Animation
|
||||
</TabButton>
|
||||
<TabButton isActive={tab === "color"} onClick={() => setTab("color")}>
|
||||
Color
|
||||
</TabButton>
|
||||
</div>
|
||||
{renderContent()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ interface SurveyEditorProps {
|
||||
attributeClasses: TAttributeClass[];
|
||||
responseCount: number;
|
||||
membershipRole?: TMembershipRole;
|
||||
colours: string[];
|
||||
}
|
||||
|
||||
export default function SurveyEditor({
|
||||
@@ -33,6 +34,7 @@ export default function SurveyEditor({
|
||||
attributeClasses,
|
||||
responseCount,
|
||||
membershipRole,
|
||||
colours,
|
||||
}: SurveyEditorProps): JSX.Element {
|
||||
const [activeView, setActiveView] = useState<"questions" | "settings">("questions");
|
||||
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
|
||||
@@ -99,6 +101,7 @@ export default function SurveyEditor({
|
||||
attributeClasses={attributeClasses}
|
||||
responseCount={responseCount}
|
||||
membershipRole={membershipRole}
|
||||
colours={colours}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
@@ -16,6 +16,7 @@ import toast from "react-hot-toast";
|
||||
import { validateQuestion } from "./Validation";
|
||||
import { deleteSurveyAction, updateSurveyAction } from "../actions";
|
||||
import SurveyStatusDropdown from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip";
|
||||
|
||||
interface SurveyMenuBarProps {
|
||||
localSurvey: TSurvey;
|
||||
@@ -48,6 +49,7 @@ export default function SurveyMenuBar({
|
||||
const [isSurveySaving, setIsSurveySaving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const cautionText = "This survey received responses, make changes with caution.";
|
||||
|
||||
let faultyQuestions: String[] = [];
|
||||
|
||||
@@ -222,6 +224,11 @@ export default function SurveyMenuBar({
|
||||
const { isDraft, ...rest } = question;
|
||||
return rest;
|
||||
}),
|
||||
attributeFilters: localSurvey.attributeFilters.filter((attributeFilter) => {
|
||||
if (attributeFilter.attributeClassId && attributeFilter.value) {
|
||||
return true;
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
if (!validateSurvey(localSurvey)) {
|
||||
@@ -252,6 +259,14 @@ export default function SurveyMenuBar({
|
||||
}
|
||||
};
|
||||
|
||||
function containsEmptyTriggers() {
|
||||
return (
|
||||
localSurvey.type === "web" &&
|
||||
localSurvey.triggers &&
|
||||
(localSurvey.triggers[0] === "" || localSurvey.triggers.length === 0)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{environment?.type === "development" && (
|
||||
@@ -282,11 +297,20 @@ export default function SurveyMenuBar({
|
||||
/>
|
||||
</div>
|
||||
{responseCount > 0 && (
|
||||
<div className="mx-auto flex items-center rounded-full border border-amber-200 bg-amber-100 p-2 text-amber-700 shadow-sm">
|
||||
<ExclamationTriangleIcon className=" h-5 w-5 text-amber-400" />
|
||||
<p className=" pl-1 text-xs lg:text-sm">
|
||||
This survey received responses, make changes with caution.
|
||||
</p>
|
||||
<div className="ju flex items-center rounded-lg border border-amber-200 bg-amber-100 p-2 text-amber-700 shadow-sm lg:mx-auto">
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<ExclamationTriangleIcon className=" h-5 w-5 text-amber-400" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={"top"} className="lg:hidden">
|
||||
<p className="py-2 text-center text-xs text-slate-500 dark:text-slate-400 ">
|
||||
{cautionText}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<p className=" hidden pl-1 text-xs md:text-sm lg:block">{cautionText}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 flex sm:ml-4 sm:mt-0">
|
||||
@@ -298,7 +322,7 @@ export default function SurveyMenuBar({
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
disabled={isSurveyPublishing}
|
||||
disabled={isSurveyPublishing || containsEmptyTriggers()}
|
||||
variant={localSurvey.status === "draft" ? "secondary" : "darkCTA"}
|
||||
className="mr-3"
|
||||
loading={isSurveySaving}
|
||||
@@ -318,11 +342,7 @@ export default function SurveyMenuBar({
|
||||
)}
|
||||
{localSurvey.status === "draft" && !audiencePrompt && (
|
||||
<Button
|
||||
disabled={
|
||||
localSurvey.type === "web" &&
|
||||
localSurvey.triggers &&
|
||||
(localSurvey.triggers[0] === "" || localSurvey.triggers.length === 0 || isSurveySaving)
|
||||
}
|
||||
disabled={isSurveySaving || containsEmptyTriggers()}
|
||||
variant="darkCTA"
|
||||
loading={isSurveyPublishing}
|
||||
onClick={async () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ export const revalidate = REVALIDATION_INTERVAL;
|
||||
import { getActionClasses } from "@formbricks/lib/actionClass/service";
|
||||
import { getAttributeClasses } from "@formbricks/lib/attributeClass/service";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
|
||||
import { REVALIDATION_INTERVAL, colours } from "@formbricks/lib/constants";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
@@ -58,16 +58,15 @@ export default async function SurveysEditPage({ params }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SurveyEditor
|
||||
survey={survey}
|
||||
product={product}
|
||||
environment={environment}
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
responseCount={responseCount}
|
||||
membershipRole={currentUserMembership?.role}
|
||||
/>
|
||||
</>
|
||||
<SurveyEditor
|
||||
survey={survey}
|
||||
product={product}
|
||||
environment={environment}
|
||||
actionClasses={actionClasses}
|
||||
attributeClasses={attributeClasses}
|
||||
responseCount={responseCount}
|
||||
membershipRole={currentUserMembership?.role}
|
||||
colours={colours}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,11 +20,14 @@ export default function Modal({
|
||||
const modalRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const highlightBorderColorStyle = useMemo(() => {
|
||||
if (!highlightBorderColor) return {};
|
||||
if (!highlightBorderColor)
|
||||
return {
|
||||
overflow: "visible",
|
||||
};
|
||||
|
||||
return {
|
||||
border: `2px solid ${highlightBorderColor}`,
|
||||
overflow: "hidden",
|
||||
overflow: "visible",
|
||||
};
|
||||
}, [highlightBorderColor]);
|
||||
|
||||
@@ -51,12 +54,12 @@ export default function Modal({
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div aria-live="assertive" className="relative h-full w-full overflow-hidden">
|
||||
<div aria-live="assertive" className="relative h-full w-full overflow-visible">
|
||||
<div
|
||||
ref={modalRef}
|
||||
style={highlightBorderColorStyle}
|
||||
className={cn(
|
||||
"pointer-events-auto absolute h-fit max-h-[90%] w-full max-w-sm overflow-y-auto rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5 transition-all duration-500 ease-in-out",
|
||||
"pointer-events-auto absolute h-auto max-h-[90%] w-full max-w-sm overflow-y-auto rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5 transition-all duration-500 ease-in-out ",
|
||||
previewMode === "desktop" ? getPlacementStyle(placement) : "max-w-full ",
|
||||
slidingAnimationClass
|
||||
)}>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Modal from "@/app/(app)/environments/[environmentId]/surveys/components/Modal";
|
||||
import TabOption from "@/app/(app)/environments/[environmentId]/surveys/components/TabOption";
|
||||
|
||||
import { MediaBackground } from "@/app/s/[surveyId]/components/MediaBackground";
|
||||
import type { TEnvironment } from "@formbricks/types/environment";
|
||||
import type { TProduct } from "@formbricks/types/product";
|
||||
import { TUploadFileConfig } from "@formbricks/types/storage";
|
||||
@@ -155,6 +155,20 @@ export default function PreviewSurvey({
|
||||
setActiveQuestionId(survey.welcomeCard.enabled ? "start" : survey?.questions[0]?.id);
|
||||
}
|
||||
|
||||
function animationTrigger() {
|
||||
let storePreviewMode = previewMode;
|
||||
setPreviewMode("null");
|
||||
setTimeout(() => {
|
||||
setPreviewMode(storePreviewMode);
|
||||
}, 10);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (survey.styling?.background?.bgType === "animation") {
|
||||
animationTrigger();
|
||||
}
|
||||
}, [survey.styling?.background?.bg]);
|
||||
|
||||
useEffect(() => {
|
||||
if (environment && environment.widgetSetupCompleted) {
|
||||
setWidgetSetupCompleted(true);
|
||||
@@ -194,7 +208,7 @@ export default function PreviewSurvey({
|
||||
<div className="absolute right-0 top-0 m-2">
|
||||
<ResetProgressButton resetQuestionProgress={resetQuestionProgress} />
|
||||
</div>
|
||||
<div className="relative h-[90%] max-h-[40rem] w-80 overflow-hidden rounded-[3rem] border-8 border-slate-500 bg-slate-400">
|
||||
<MediaBackground survey={survey} ContentRef={ContentRef} isMobilePreview>
|
||||
{/* below element is use to create notch for the mobile device mockup */}
|
||||
<div className="absolute left-1/2 right-1/2 top-0 z-20 h-4 w-1/2 -translate-x-1/2 transform rounded-b-md bg-slate-500"></div>
|
||||
{previewType === "modal" ? (
|
||||
@@ -214,25 +228,19 @@ export default function PreviewSurvey({
|
||||
/>
|
||||
</Modal>
|
||||
) : (
|
||||
<div
|
||||
className="absolute top-0 z-10 flex h-full w-full flex-grow flex-col overflow-y-auto"
|
||||
ref={ContentRef}>
|
||||
<div className="flex w-full flex-grow flex-col items-center justify-center bg-white py-6">
|
||||
<div className="w-full max-w-md px-4">
|
||||
<SurveyInline
|
||||
survey={survey}
|
||||
brandColor={brandColor}
|
||||
activeQuestionId={activeQuestionId || undefined}
|
||||
isBrandingEnabled={product.linkSurveyBranding}
|
||||
onActiveQuestionChange={setActiveQuestionId}
|
||||
onFileUpload={onFileUpload}
|
||||
responseCount={42}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative z-10 w-full max-w-md px-4">
|
||||
<SurveyInline
|
||||
survey={survey}
|
||||
brandColor={brandColor}
|
||||
activeQuestionId={activeQuestionId || undefined}
|
||||
isBrandingEnabled={product.linkSurveyBranding}
|
||||
onActiveQuestionChange={setActiveQuestionId}
|
||||
onFileUpload={onFileUpload}
|
||||
responseCount={42}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</MediaBackground>
|
||||
</>
|
||||
)}
|
||||
{previewMode === "desktop" && (
|
||||
@@ -287,22 +295,20 @@ export default function PreviewSurvey({
|
||||
/>
|
||||
</Modal>
|
||||
) : (
|
||||
<div className="flex flex-grow flex-col overflow-y-auto rounded-b-lg" ref={ContentRef}>
|
||||
<div className="flex w-full flex-grow flex-col items-center justify-center bg-white p-4 py-6">
|
||||
<div className="w-full max-w-md">
|
||||
<SurveyInline
|
||||
survey={survey}
|
||||
brandColor={brandColor}
|
||||
activeQuestionId={activeQuestionId || undefined}
|
||||
isBrandingEnabled={product.linkSurveyBranding}
|
||||
onActiveQuestionChange={setActiveQuestionId}
|
||||
isRedirectDisabled={true}
|
||||
onFileUpload={onFileUpload}
|
||||
responseCount={42}
|
||||
/>
|
||||
</div>
|
||||
<MediaBackground survey={survey} ContentRef={ContentRef} isEditorView>
|
||||
<div className="z-0 w-full max-w-md rounded-lg p-4">
|
||||
<SurveyInline
|
||||
survey={survey}
|
||||
brandColor={brandColor}
|
||||
activeQuestionId={activeQuestionId || undefined}
|
||||
isBrandingEnabled={product.linkSurveyBranding}
|
||||
onActiveQuestionChange={setActiveQuestionId}
|
||||
isRedirectDisabled={true}
|
||||
onFileUpload={onFileUpload}
|
||||
responseCount={42}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</MediaBackground>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2526,4 +2526,5 @@ export const minimalSurvey: TSurvey = {
|
||||
},
|
||||
productOverwrites: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ErrorComponent } from "@formbricks/ui/ErrorComponent";
|
||||
|
||||
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.log(error.message);
|
||||
console.error(error.message);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,13 +2,14 @@ import { TSurveyQuestionType as QuestionId } from "@formbricks/types/surveys";
|
||||
import {
|
||||
ArrowUpTrayIcon,
|
||||
ChatBubbleBottomCenterTextIcon,
|
||||
CheckIcon,
|
||||
CursorArrowRippleIcon,
|
||||
ListBulletIcon,
|
||||
PhotoIcon,
|
||||
PresentationChartBarIcon,
|
||||
QueueListIcon,
|
||||
StarIcon,
|
||||
CheckIcon,
|
||||
CalendarDaysIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { replaceQuestionPresetPlaceholders } from "./templates";
|
||||
@@ -133,6 +134,16 @@ export const questionTypes: TSurveyQuestionType[] = [
|
||||
dismissButtonLabel: "Skip",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: QuestionId.Date,
|
||||
label: "Date",
|
||||
description: "Ask your users to select a date",
|
||||
icon: CalendarDaysIcon,
|
||||
preset: {
|
||||
headline: "When is your birthday?",
|
||||
format: "M-d-y",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: QuestionId.FileUpload,
|
||||
label: "File Upload",
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
import { IMPRINT_URL, PRIVACY_URL } from "@formbricks/lib/constants";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function LegalFooter() {
|
||||
interface LegalFooterProps {
|
||||
bgColor?: string | null;
|
||||
}
|
||||
|
||||
export default function LegalFooter({ bgColor }: LegalFooterProps) {
|
||||
if (!IMPRINT_URL && !PRIVACY_URL) return null;
|
||||
|
||||
return (
|
||||
<div className="h-10 w-full border-t border-slate-200">
|
||||
<div className="mx-auto max-w-lg p-3 text-center text-sm text-slate-400">
|
||||
<div
|
||||
className={`fixed bottom-0 h-12 w-full`}
|
||||
style={{
|
||||
backgroundColor: `${bgColor}`,
|
||||
}}>
|
||||
<div className="mx-auto max-w-lg p-3 text-center text-xs text-slate-400">
|
||||
{IMPRINT_URL && (
|
||||
<Link href={IMPRINT_URL} target="_blank">
|
||||
<Link href={IMPRINT_URL} target="_blank" className="hover:underline">
|
||||
Imprint
|
||||
</Link>
|
||||
)}
|
||||
{IMPRINT_URL && PRIVACY_URL && <span> | </span>}
|
||||
{IMPRINT_URL && PRIVACY_URL && <span className="px-2">|</span>}
|
||||
{PRIVACY_URL && (
|
||||
<Link href={PRIVACY_URL} target="_blank">
|
||||
<Link href={PRIVACY_URL} target="_blank" className="hover:underline">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
)}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import ContentWrapper from "@formbricks/ui/ContentWrapper";
|
||||
import { SurveyInline } from "@formbricks/ui/Survey";
|
||||
import SurveyLinkUsed from "@/app/s/[surveyId]/components/SurveyLinkUsed";
|
||||
import VerifyEmail from "@/app/s/[surveyId]/components/VerifyEmail";
|
||||
import { getPrefillResponseData } from "@/app/s/[surveyId]/lib/prefilling";
|
||||
import { FormbricksAPI } from "@formbricks/api";
|
||||
import { ResponseQueue } from "@formbricks/lib/responseQueue";
|
||||
import { SurveyState } from "@formbricks/lib/surveyState";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TResponse, TResponseData, TResponseUpdate } from "@formbricks/types/responses";
|
||||
import { TUploadFileConfig } from "@formbricks/types/storage";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
import ContentWrapper from "@formbricks/ui/ContentWrapper";
|
||||
import { SurveyInline } from "@formbricks/ui/Survey";
|
||||
import { ArrowPathIcon } from "@heroicons/react/24/solid";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { FormbricksAPI } from "@formbricks/api";
|
||||
import { TUploadFileConfig } from "@formbricks/types/storage";
|
||||
|
||||
interface LinkSurveyProps {
|
||||
survey: TSurvey;
|
||||
@@ -119,7 +119,7 @@ export default function LinkSurvey({
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContentWrapper className="h-full w-full p-0 md:max-w-lg">
|
||||
<ContentWrapper className="h-full w-full p-0 md:max-w-md">
|
||||
{isPreview && (
|
||||
<div className="fixed left-0 top-0 flex w-full items-center justify-between bg-slate-600 p-2 px-4 text-center text-sm text-white shadow-sm">
|
||||
<div />
|
||||
|
||||
92
apps/web/app/s/[surveyId]/components/MediaBackground.tsx
Normal file
92
apps/web/app/s/[surveyId]/components/MediaBackground.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
import React from "react";
|
||||
|
||||
interface MediaBackgroundProps {
|
||||
children: React.ReactNode;
|
||||
survey: TSurvey;
|
||||
isEditorView?: boolean;
|
||||
isMobilePreview?: boolean;
|
||||
ContentRef?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const MediaBackground: React.FC<MediaBackgroundProps> = ({
|
||||
children,
|
||||
survey,
|
||||
isEditorView = false,
|
||||
isMobilePreview = false,
|
||||
ContentRef,
|
||||
}) => {
|
||||
const getFilterStyle = () => {
|
||||
return survey.styling?.background?.brightness
|
||||
? `brightness(${survey.styling?.background?.brightness}%)`
|
||||
: "brightness(100%)";
|
||||
};
|
||||
|
||||
const renderBackground = () => {
|
||||
const filterStyle = getFilterStyle();
|
||||
const baseClasses = "absolute inset-0 h-full w-full";
|
||||
|
||||
switch (survey.styling?.background?.bgType) {
|
||||
case "color":
|
||||
return (
|
||||
<div
|
||||
className={`${baseClasses}`}
|
||||
style={{ backgroundColor: survey.styling?.background?.bg || "#ffff", filter: `${filterStyle}` }}
|
||||
/>
|
||||
);
|
||||
case "animation":
|
||||
return (
|
||||
<video
|
||||
muted
|
||||
loop
|
||||
autoPlay
|
||||
className={`${baseClasses} object-cover`}
|
||||
style={{ filter: `${filterStyle}` }}>
|
||||
<source src={survey.styling?.background?.bg || ""} type="video/mp4" />
|
||||
</video>
|
||||
);
|
||||
case "image":
|
||||
return (
|
||||
<div
|
||||
className={`${baseClasses} bg-cover bg-center`}
|
||||
style={{ backgroundImage: `url(${survey.styling?.background?.bg})`, filter: `${filterStyle}` }}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <div className={`${baseClasses} bg-white`} />;
|
||||
}
|
||||
};
|
||||
|
||||
const renderContent = () => (
|
||||
<div className="absolute flex h-full w-full items-center justify-center overflow-y-auto">{children}</div>
|
||||
);
|
||||
|
||||
if (isMobilePreview) {
|
||||
return (
|
||||
<div
|
||||
ref={ContentRef}
|
||||
className={`relative h-[90%] max-h-[40rem] w-80 overflow-hidden rounded-3xl border-8 border-slate-500 ${getFilterStyle()}`}>
|
||||
{renderBackground()}
|
||||
{renderContent()}
|
||||
</div>
|
||||
);
|
||||
} else if (isEditorView) {
|
||||
return (
|
||||
<div ref={ContentRef} className="flex flex-grow flex-col overflow-y-auto rounded-b-lg">
|
||||
<div className="relative flex w-full flex-grow flex-col items-center justify-center p-4 py-6">
|
||||
{renderBackground()}
|
||||
<div className="flex h-full w-full items-center justify-center">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center px-2">
|
||||
{renderBackground()}
|
||||
<div className="relative w-full">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,15 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import type { NextPage } from "next";
|
||||
import { validateSurveyPinAction } from "@/app/s/[surveyId]/actions";
|
||||
import LinkSurvey from "@/app/s/[surveyId]/components/LinkSurvey";
|
||||
import { TSurveyPinValidationResponseError } from "@/app/s/[surveyId]/types";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { TProduct } from "@formbricks/types/product";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { OTPInput } from "@formbricks/ui/OTPInput";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { validateSurveyPinAction } from "@/app/s/[surveyId]/actions";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
import { TSurveyPinValidationResponseError } from "@/app/s/[surveyId]/types";
|
||||
import LinkSurvey from "@/app/s/[surveyId]/components/LinkSurvey";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { OTPInput } from "@formbricks/ui/OTPInput";
|
||||
import type { NextPage } from "next";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
interface LinkSurveyPinScreenProps {
|
||||
surveyId: string;
|
||||
|
||||
@@ -1,10 +1,3 @@
|
||||
import LegalFooter from "@/app/s/[surveyId]/components/LegalFooter";
|
||||
|
||||
export default async function SurveyLayout({ children }) {
|
||||
return (
|
||||
<div className="flex h-full flex-col justify-between bg-white">
|
||||
<div className="h-full overflow-y-auto">{children}</div>
|
||||
<LegalFooter />
|
||||
</div>
|
||||
);
|
||||
return <div>{children}</div>;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
export const revalidate = REVALIDATION_INTERVAL;
|
||||
|
||||
import { validateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
|
||||
import LegalFooter from "@/app/s/[surveyId]/components/LegalFooter";
|
||||
import LinkSurvey from "@/app/s/[surveyId]/components/LinkSurvey";
|
||||
import { MediaBackground } from "@/app/s/[surveyId]/components/MediaBackground";
|
||||
import PinScreen from "@/app/s/[surveyId]/components/PinScreen";
|
||||
import SurveyInactive from "@/app/s/[surveyId]/components/SurveyInactive";
|
||||
import { checkValidity } from "@/app/s/[surveyId]/lib/prefilling";
|
||||
@@ -12,6 +14,7 @@ import { getResponseBySingleUseId } from "@formbricks/lib/response/service";
|
||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { notFound } from "next/navigation";
|
||||
import { getEmailVerificationStatus } from "./lib/helpers";
|
||||
import { ZId } from "@formbricks/types/environment";
|
||||
@@ -183,17 +186,22 @@ export default async function LinkSurveyPage({ params, searchParams }: LinkSurve
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LinkSurvey
|
||||
survey={survey}
|
||||
product={product}
|
||||
userId={userId}
|
||||
emailVerificationStatus={emailVerificationStatus}
|
||||
prefillAnswer={isPrefilledAnswerValid ? prefillAnswer : null}
|
||||
singleUseId={isSingleUseSurvey ? singleUseId : undefined}
|
||||
singleUseResponse={singleUseResponse ? singleUseResponse : undefined}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
responseCount={survey.welcomeCard.showResponseCount ? responseCount : undefined}
|
||||
/>
|
||||
);
|
||||
return survey ? (
|
||||
<div>
|
||||
<MediaBackground survey={survey}>
|
||||
<LinkSurvey
|
||||
survey={survey}
|
||||
product={product}
|
||||
userId={userId}
|
||||
emailVerificationStatus={emailVerificationStatus}
|
||||
prefillAnswer={isPrefilledAnswerValid ? prefillAnswer : null}
|
||||
singleUseId={isSingleUseSurvey ? singleUseId : undefined}
|
||||
singleUseResponse={singleUseResponse ? singleUseResponse : undefined}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
responseCount={survey.welcomeCard.showResponseCount ? responseCount : undefined}
|
||||
/>
|
||||
</MediaBackground>
|
||||
<LegalFooter bgColor={survey.styling?.background?.bg || "#ffff"} />
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@ import "@formbricks/lib/env.mjs";
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
|
||||
function getHostname(url) {
|
||||
const urlObj = new URL(url);
|
||||
return urlObj.hostname;
|
||||
}
|
||||
|
||||
const nextConfig = {
|
||||
assetPrefix: process.env.ASSET_PREFIX_URL || undefined,
|
||||
output: "standalone",
|
||||
@@ -107,6 +112,10 @@ if (process.env.WEBAPP_URL) {
|
||||
nextConfig.experimental.serverActions = {
|
||||
allowedOrigins: [process.env.WEBAPP_URL.replace(/https?:\/\//, "")],
|
||||
};
|
||||
nextConfig.images.remotePatterns.push({
|
||||
protocol: "https",
|
||||
hostname: getHostname(process.env.WEBAPP_URL),
|
||||
});
|
||||
}
|
||||
|
||||
const sentryOptions = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@formbricks/web",
|
||||
"version": "1.3.4",
|
||||
"version": "1.3.5",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"clean": "rimraf .turbo node_modules .next",
|
||||
@@ -25,13 +25,13 @@
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@react-email/components": "^0.0.11",
|
||||
"@sentry/nextjs": "^7.84.0",
|
||||
"@react-email/components": "^0.0.12",
|
||||
"@sentry/nextjs": "^7.85.0",
|
||||
"@vercel/og": "^0.5.20",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"dotenv": "^16.3.1",
|
||||
"encoding": "^0.1.13",
|
||||
"framer-motion": "10.16.9",
|
||||
"framer-motion": "10.16.14",
|
||||
"googleapis": "^129.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
@@ -41,13 +41,13 @@
|
||||
"next": "13.5.6",
|
||||
"nodemailer": "^6.9.7",
|
||||
"otplib": "^12.0.1",
|
||||
"posthog-js": "^1.93.3",
|
||||
"posthog-js": "^1.93.6",
|
||||
"prismjs": "^1.29.0",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "18.2.0",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-email": "^1.9.5",
|
||||
"react-email": "^1.10.0",
|
||||
"react-hook-form": "^7.48.2",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-icons": "^4.12.0",
|
||||
|
||||
17
apps/web/playwright/lib/user.ts
Normal file
17
apps/web/playwright/lib/user.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
let user: {
|
||||
name: string;
|
||||
email: string;
|
||||
password: string;
|
||||
} | null;
|
||||
|
||||
export const getUser = () => {
|
||||
if (!user) {
|
||||
const name = randomBytes(4).toString("hex");
|
||||
const email = `${name}@gmail.com`;
|
||||
const password = "Test@123";
|
||||
user = { name, email, password };
|
||||
}
|
||||
return user;
|
||||
};
|
||||
95
apps/web/playwright/signup.spec.ts
Normal file
95
apps/web/playwright/signup.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { getUser } from "@/playwright/lib/user";
|
||||
import { test } from "@playwright/test";
|
||||
|
||||
const { name, email, password } = getUser();
|
||||
|
||||
test.describe("Signup Flow Test", async () => {
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
test("Valid User", async ({ page }) => {
|
||||
await page.goto("/auth/signup");
|
||||
await page.getByText("Continue with Email").click();
|
||||
|
||||
await page.waitForSelector('input[name="name"]');
|
||||
await page.fill('input[name="name"]', name);
|
||||
await page.press('input[name="name"]', "Tab");
|
||||
|
||||
await page.fill('input[name="email"]', email);
|
||||
await page.press('input[name="email"]', "Tab");
|
||||
|
||||
await page.fill('input[name="password"]', password);
|
||||
await page.press('input[name="password"]', "Enter");
|
||||
|
||||
await page.waitForURL("/auth/signup-without-verification-success");
|
||||
});
|
||||
|
||||
test("Email is taken", async ({ page }) => {
|
||||
await page.goto("/auth/signup");
|
||||
await page.getByText("Continue with Email").click();
|
||||
|
||||
await page.waitForSelector('input[name="name"]');
|
||||
await page.fill('input[name="name"]', name);
|
||||
await page.press('input[name="name"]', "Tab");
|
||||
|
||||
await page.fill('input[name="email"]', email);
|
||||
await page.press('input[name="email"]', "Tab");
|
||||
|
||||
await page.fill('input[name="password"]', password);
|
||||
await page.press('input[name="password"]', "Enter");
|
||||
|
||||
let alertMessage = "user with this email address already exists";
|
||||
|
||||
await (await page.waitForSelector(`text=${alertMessage}`)).isVisible();
|
||||
});
|
||||
|
||||
test("No Name", async ({ page }) => {
|
||||
await page.goto("/auth/signup");
|
||||
await page.getByText("Continue with Email").click();
|
||||
|
||||
await page.waitForSelector('input[name="name"]');
|
||||
await page.fill('input[name="name"]', "");
|
||||
await page.press('input[name="name"]', "Tab");
|
||||
|
||||
await page.fill('input[name="email"]', email);
|
||||
await page.press('input[name="email"]', "Tab");
|
||||
|
||||
await page.fill('input[name="password"]', password);
|
||||
await page.press('input[name="password"]', "Enter");
|
||||
|
||||
await page.getByText("Continue with Email").isDisabled();
|
||||
});
|
||||
|
||||
test("Invalid Email", async ({ page }) => {
|
||||
await page.goto("/auth/signup");
|
||||
await page.getByText("Continue with Email").click();
|
||||
|
||||
await page.waitForSelector('input[name="name"]');
|
||||
await page.fill('input[name="name"]', name);
|
||||
await page.press('input[name="name"]', "Tab");
|
||||
|
||||
await page.fill('input[name="email"]', "invalid");
|
||||
await page.press('input[name="email"]', "Tab");
|
||||
|
||||
await page.fill('input[name="password"]', password);
|
||||
await page.press('input[name="password"]', "Enter");
|
||||
|
||||
await page.getByText("Continue with Email").isDisabled();
|
||||
});
|
||||
|
||||
test("Invalid Password", async ({ page }) => {
|
||||
await page.goto("/auth/signup");
|
||||
await page.getByText("Continue with Email").click();
|
||||
|
||||
await page.waitForSelector('input[name="name"]');
|
||||
await page.fill('input[name="name"]', name);
|
||||
await page.press('input[name="name"]', "Tab");
|
||||
|
||||
await page.fill('input[name="email"]', email);
|
||||
await page.press('input[name="email"]', "Tab");
|
||||
|
||||
await page.fill('input[name="password"]', "invalid");
|
||||
await page.press('input[name="password"]', "Enter");
|
||||
|
||||
await page.getByText("Continue with Email").isDisabled();
|
||||
});
|
||||
});
|
||||
@@ -24,18 +24,15 @@ x-encryption-key: &encryption_key 1b3d888592454d23b520040950654669
|
||||
|
||||
x-mail-from: &mail_from
|
||||
x-smtp-host: &smtp_host
|
||||
x-smtp-port: &smtp_port # Enable SMTP_SECURE_ENABLED for TLS (port 465)
|
||||
x-smtp-port: &smtp_port
|
||||
x-smtp-secure-enabled: &smtp_secure_enabled # Enable SMTP_SECURE_ENABLED for TLS (port 465)
|
||||
|
||||
|
||||
x-smtp-secure-enabled: &smtp_secure_enabled
|
||||
x-smtp-user: &smtp_user
|
||||
x-smtp-password: &smtp_password
|
||||
# Set the below value to your public-facing URL, e.g., https://example.com
|
||||
|
||||
# Set the below value if you have and want to share a shorter base URL than the x-survey-base-url
|
||||
|
||||
x-short-url-base:
|
||||
&short_url_base # Email Verification. If you enable Email Verification you have to setup SMTP-Settings, too.
|
||||
&short_url_base # Set the below value if you have and want to share a shorter base URL than the x-survey-base-url
|
||||
|
||||
|
||||
x-email-verification-disabled: &email_verification_disabled 1
|
||||
@@ -52,30 +49,21 @@ x-invite-disabled: &invite_disabled 0
|
||||
# Set the below values to display privacy policy, imprint and terms of service links in the footer of signup & public pages.
|
||||
x-privacy-url: &privacy_url
|
||||
x-terms-url: &terms_url
|
||||
x-imprint-url: &imprint_url # Configure Github Login
|
||||
x-imprint-url: &imprint_url
|
||||
|
||||
|
||||
x-github-auth-enabled: &github_auth_enabled 0
|
||||
x-github-auth-enabled: &github_auth_enabled 0 # Configure Github Login
|
||||
x-github-id: &github_id
|
||||
x-github-secret: &github_secret # Configure Google Login
|
||||
x-github-secret: &github_secret
|
||||
|
||||
|
||||
x-google-auth-enabled: &google_auth_enabled 0
|
||||
x-google-auth-enabled: &google_auth_enabled 0 # Configure Google Login
|
||||
x-google-client-id: &google_client_id
|
||||
x-google-client-secret: &google_client_secret # Disable Sentry warning
|
||||
x-google-client-secret: &google_client_secret
|
||||
|
||||
x-sentry-ignore-api-resolution-error: &sentry_ignore_api_resolution_error # Disable Sentry warning
|
||||
|
||||
x-sentry-ignore-api-resolution-error: &sentry_ignore_api_resolution_error # Enable Sentry Error Tracking
|
||||
x-next-public-sentry-dsn: &next_public_sentry_dsn # Enable Sentry Error Tracking
|
||||
|
||||
|
||||
x-next-public-sentry-dsn: &next_public_sentry_dsn # Cron Secret
|
||||
|
||||
# Set this to a random string to secure your cron endpoints
|
||||
x-cron-secret: &cron_secret YOUR_CRON_SECRET
|
||||
|
||||
|
||||
# Configure ASSET_PREFIX_URL when you want to ship JS & CSS files from a complete URL instead of the current domain
|
||||
x-asset-prefix-url: &asset_prefix_url
|
||||
x-cron-secret: &cron_secret YOUR_CRON_SECRET # Set this to a random string to secure your cron endpoints
|
||||
|
||||
services:
|
||||
postgres:
|
||||
@@ -130,7 +118,6 @@ services:
|
||||
GOOGLE_CLIENT_ID: *google_client_id
|
||||
GOOGLE_CLIENT_SECRET: *google_client_secret
|
||||
CRON_SECRET: *cron_secret
|
||||
ASSET_PREFIX_URL: *asset_prefix_url
|
||||
|
||||
volumes:
|
||||
postgres:
|
||||
|
||||
@@ -65,9 +65,6 @@ x-environment: &environment
|
||||
# GOOGLE_CLIENT_ID:
|
||||
# GOOGLE_CLIENT_SECRET:
|
||||
|
||||
# Configure ASSET_PREFIX_URL when you want to ship JS & CSS files from a complete URL instead of the current domain
|
||||
# ASSET_PREFIX_URL: *asset_prefix_url
|
||||
|
||||
services:
|
||||
postgres:
|
||||
restart: always
|
||||
|
||||
@@ -26,10 +26,12 @@
|
||||
"lint": "turbo run lint",
|
||||
"release": "turbo run build --filter=js... && turbo run build --filter=n8n-node... && changeset publish",
|
||||
"test": "turbo run test",
|
||||
"test:e2e": "playwright test",
|
||||
"prepare": "husky install"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.26.2",
|
||||
"@playwright/test": "^1.40.1",
|
||||
"eslint-config-formbricks": "workspace:*",
|
||||
"husky": "^8.0.3",
|
||||
"lint-staged": "^15.1.0",
|
||||
@@ -52,11 +54,14 @@
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
},
|
||||
"packageManager": "pnpm@8.1.1",
|
||||
"packageManager": "pnpm@8.11.0",
|
||||
"nextBundleAnalysis": {
|
||||
"budget": 358400,
|
||||
"budgetPercentIncreaseRed": 20,
|
||||
"minimumChangeThreshold": 0,
|
||||
"showDetails": true
|
||||
},
|
||||
"dependencies": {
|
||||
"playwright": "^1.40.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,10 +33,10 @@
|
||||
"devDependencies": {
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@formbricks/tsconfig": "workspace:*",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-turbo": "latest",
|
||||
"terser": "^5.24.0",
|
||||
"vite": "^5.0.4",
|
||||
"terser": "^5.25.0",
|
||||
"vite": "^5.0.6",
|
||||
"vite-plugin-dts": "^3.6.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
TSurveyClosedMessage,
|
||||
TSurveyHiddenFields,
|
||||
TSurveyProductOverwrites,
|
||||
TSurveyStyling,
|
||||
TSurveyQuestions,
|
||||
TSurveySingleUse,
|
||||
TSurveyThankYouCard,
|
||||
@@ -27,6 +28,7 @@ declare global {
|
||||
export type SurveyThankYouCard = TSurveyThankYouCard;
|
||||
export type SurveyHiddenFields = TSurveyHiddenFields;
|
||||
export type SurveyProductOverwrites = TSurveyProductOverwrites;
|
||||
export type SurveyStyling = TSurveyStyling;
|
||||
export type SurveyClosedMessage = TSurveyClosedMessage;
|
||||
export type SurveySingleUse = TSurveySingleUse;
|
||||
export type SurveyVerifyEmail = TSurveyVerifyEmail;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Survey" ADD COLUMN "styling" JSONB;
|
||||
@@ -289,6 +289,9 @@ model Survey {
|
||||
/// @zod.custom(imports.ZSurveyProductOverwrites)
|
||||
/// [SurveyProductOverwrites]
|
||||
productOverwrites Json?
|
||||
/// @zod.custom(imports.ZSurveyStyling)
|
||||
/// [SurveyStyling]
|
||||
styling Json?
|
||||
/// @zod.custom(imports.ZSurveySingleUse)
|
||||
/// [SurveySingleUse]
|
||||
singleUse Json? @default("{\"enabled\": false, \"isEncrypted\": true}")
|
||||
|
||||
@@ -18,6 +18,7 @@ export {
|
||||
ZSurveyHiddenFields,
|
||||
ZSurveyClosedMessage,
|
||||
ZSurveyProductOverwrites,
|
||||
ZSurveyStyling,
|
||||
ZSurveyVerifyEmail,
|
||||
ZSurveySingleUse,
|
||||
} from "@formbricks/types/surveys";
|
||||
|
||||
@@ -18,6 +18,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@formbricks/lib": "workspace:*",
|
||||
"stripe": "^14.6.0"
|
||||
"stripe": "^14.7.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,13 @@
|
||||
"clean": "rimraf node_modules .turbo"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8.54.0",
|
||||
"eslint": "^8.55.0",
|
||||
"eslint-config-next": "^14.0.3",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-turbo": "latest",
|
||||
"eslint-plugin-react": "7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.4",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"eslint-plugin-storybook": "^0.6.15"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
{
|
||||
"name": "@formbricks/js",
|
||||
"license": "MIT",
|
||||
"version": "1.2.6",
|
||||
"version": "1.2.7",
|
||||
"description": "Formbricks-js allows you to connect your app to Formbricks, display surveys and trigger events.",
|
||||
"homepage": "https://formbricks.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/formbricks/formbricks"
|
||||
},
|
||||
"keywords": [
|
||||
"Formbricks",
|
||||
"surveys",
|
||||
@@ -42,19 +47,19 @@
|
||||
"@formbricks/surveys": "workspace:*",
|
||||
"@formbricks/tsconfig": "workspace:*",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@types/jest": "^29.5.10",
|
||||
"@typescript-eslint/eslint-plugin": "^6.13.1",
|
||||
"@typescript-eslint/parser": "^6.13.1",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@typescript-eslint/eslint-plugin": "^6.13.2",
|
||||
"@typescript-eslint/parser": "^6.13.2",
|
||||
"babel-jest": "^29.7.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"isomorphic-fetch": "^3.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"terser": "^5.24.0",
|
||||
"vite": "^5.0.4",
|
||||
"terser": "^5.25.0",
|
||||
"vite": "^5.0.6",
|
||||
"vite-plugin-dts": "^3.6.4",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-turbo": "latest"
|
||||
},
|
||||
"jest": {
|
||||
|
||||
@@ -7,6 +7,13 @@ export class Config {
|
||||
private static instance: Config | undefined;
|
||||
private config: TJsConfig | null = null;
|
||||
|
||||
private constructor() {
|
||||
const localConfig = this.loadFromLocalStorage();
|
||||
if (localConfig.ok) {
|
||||
this.config = localConfig.value;
|
||||
}
|
||||
}
|
||||
|
||||
static getInstance(): Config {
|
||||
if (!Config.instance) {
|
||||
Config.instance = new Config();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TJsConfigInput } from "@formbricks/types/js";
|
||||
import type { TJsConfig, TJsConfigInput } from "@formbricks/types/js";
|
||||
import { Config } from "./config";
|
||||
import {
|
||||
ErrorHandler,
|
||||
@@ -16,6 +16,8 @@ import { checkPageUrl } from "./noCodeActions";
|
||||
import { sync } from "./sync";
|
||||
import { addWidgetContainer, closeSurvey } from "./widget";
|
||||
import { trackAction } from "./actions";
|
||||
import { updatePersonAttributes } from "./person";
|
||||
import { TPersonAttributes } from "@formbricks/types/people";
|
||||
|
||||
const config = Config.getInstance();
|
||||
const logger = Logger.getInstance();
|
||||
@@ -63,19 +65,44 @@ export const initialize = async (
|
||||
logger.debug("Adding widget container to DOM");
|
||||
addWidgetContainer();
|
||||
|
||||
const localConfigResult = config.loadFromLocalStorage();
|
||||
if (!c.userId && c.attributes) {
|
||||
logger.error("No userId provided but attributes. Cannot update attributes without userId.");
|
||||
return err({
|
||||
code: "missing_field",
|
||||
field: "userId",
|
||||
});
|
||||
}
|
||||
|
||||
// if userId and attributes are available, set them in backend
|
||||
let updatedAttributes: TPersonAttributes | null = null;
|
||||
if (c.userId && c.attributes) {
|
||||
const res = await updatePersonAttributes(c.apiHost, c.environmentId, c.userId, c.attributes);
|
||||
|
||||
if (res.ok !== true) {
|
||||
return err(res.error);
|
||||
}
|
||||
updatedAttributes = res.value;
|
||||
}
|
||||
|
||||
let existingConfig: TJsConfig | undefined;
|
||||
try {
|
||||
existingConfig = config.get();
|
||||
} catch (e) {
|
||||
logger.debug("No existing configuration found.");
|
||||
}
|
||||
|
||||
if (
|
||||
localConfigResult.ok &&
|
||||
localConfigResult.value.state &&
|
||||
localConfigResult.value.environmentId === c.environmentId &&
|
||||
localConfigResult.value.apiHost === c.apiHost &&
|
||||
localConfigResult.value.userId === c.userId &&
|
||||
localConfigResult.value.expiresAt // only accept config when they follow new config version with expiresAt
|
||||
existingConfig &&
|
||||
existingConfig.state &&
|
||||
existingConfig.environmentId === c.environmentId &&
|
||||
existingConfig.apiHost === c.apiHost &&
|
||||
existingConfig.userId === c.userId &&
|
||||
existingConfig.expiresAt // only accept config when they follow new config version with expiresAt
|
||||
) {
|
||||
logger.debug("Found existing configuration.");
|
||||
if (localConfigResult.value.expiresAt < new Date()) {
|
||||
if (existingConfig.expiresAt < new Date()) {
|
||||
logger.debug("Configuration expired.");
|
||||
|
||||
await sync({
|
||||
apiHost: c.apiHost,
|
||||
environmentId: c.environmentId,
|
||||
@@ -83,14 +110,12 @@ export const initialize = async (
|
||||
});
|
||||
} else {
|
||||
logger.debug("Configuration not expired. Extending expiration.");
|
||||
config.update(localConfigResult.value);
|
||||
config.update(existingConfig);
|
||||
}
|
||||
} else {
|
||||
logger.debug("No valid configuration found or it has been expired. Creating new config.");
|
||||
logger.debug("Syncing.");
|
||||
|
||||
// when the local storage is expired / empty, we sync to get the latest config
|
||||
|
||||
await sync({
|
||||
apiHost: c.apiHost,
|
||||
environmentId: c.environmentId,
|
||||
@@ -98,7 +123,20 @@ export const initialize = async (
|
||||
});
|
||||
|
||||
// and track the new session event
|
||||
trackAction("New Session");
|
||||
await trackAction("New Session");
|
||||
}
|
||||
|
||||
// update attributes in config
|
||||
if (updatedAttributes && Object.keys(updatedAttributes).length > 0) {
|
||||
config.update({
|
||||
environmentId: config.get().environmentId,
|
||||
apiHost: config.get().apiHost,
|
||||
userId: config.get().userId,
|
||||
state: {
|
||||
...config.get().state,
|
||||
attributes: { ...config.get().state.attributes, ...c.attributes },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
logger.debug("Adding event listeners");
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { FormbricksAPI } from "@formbricks/api";
|
||||
import { TPersonUpdateInput } from "@formbricks/types/people";
|
||||
import { TPersonAttributes, TPersonUpdateInput } from "@formbricks/types/people";
|
||||
import { Config } from "./config";
|
||||
import { AttributeAlreadyExistsError, MissingPersonError, NetworkError, Result, err, okVoid } from "./errors";
|
||||
import {
|
||||
AttributeAlreadyExistsError,
|
||||
MissingPersonError,
|
||||
NetworkError,
|
||||
Result,
|
||||
err,
|
||||
ok,
|
||||
okVoid,
|
||||
} from "./errors";
|
||||
import { deinitalize, initialize } from "./initialize";
|
||||
import { Logger } from "./logger";
|
||||
import { sync } from "./sync";
|
||||
@@ -55,6 +63,66 @@ export const updatePersonAttribute = async (
|
||||
return okVoid();
|
||||
};
|
||||
|
||||
export const updatePersonAttributes = async (
|
||||
apiHost: string,
|
||||
environmentId: string,
|
||||
userId: string,
|
||||
attributes: TPersonAttributes
|
||||
): Promise<Result<TPersonAttributes, NetworkError | MissingPersonError>> => {
|
||||
if (!userId) {
|
||||
return err({
|
||||
code: "missing_person",
|
||||
message: "Unable to update attribute. User identification deactivated. No userId set.",
|
||||
});
|
||||
}
|
||||
|
||||
// clean attributes and remove existing attributes if config already exists
|
||||
const updatedAttributes = { ...attributes };
|
||||
try {
|
||||
const existingAttributes = config.get()?.state?.attributes;
|
||||
if (existingAttributes) {
|
||||
for (const [key, value] of Object.entries(existingAttributes)) {
|
||||
if (updatedAttributes[key] === value) {
|
||||
delete updatedAttributes[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.debug("config not set; sending all attributes to backend");
|
||||
}
|
||||
|
||||
// send to backend if updatedAttributes is not empty
|
||||
if (Object.keys(updatedAttributes).length === 0) {
|
||||
logger.debug("No attributes to update. Skipping update.");
|
||||
return ok(updatedAttributes);
|
||||
}
|
||||
|
||||
logger.debug("Updating attributes: " + JSON.stringify(updatedAttributes));
|
||||
|
||||
const input: TPersonUpdateInput = {
|
||||
attributes: updatedAttributes,
|
||||
};
|
||||
|
||||
const api = new FormbricksAPI({
|
||||
apiHost,
|
||||
environmentId,
|
||||
});
|
||||
|
||||
const res = await api.client.people.update(userId, input);
|
||||
|
||||
if (res.ok) {
|
||||
return ok(updatedAttributes);
|
||||
}
|
||||
|
||||
return err({
|
||||
code: "network_error",
|
||||
status: 500,
|
||||
message: `Error updating person with userId ${userId}`,
|
||||
url: `${apiHost}/api/v1/client/${environmentId}/people/${userId}`,
|
||||
responseMessage: res.error.message,
|
||||
});
|
||||
};
|
||||
|
||||
export const isExistingAttribute = (key: string, value: string): boolean => {
|
||||
if (config.get().state.attributes[key] === value) {
|
||||
return true;
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
import "server-only";
|
||||
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { SERVICES_REVALIDATION_INTERVAL, ITEMS_PER_PAGE } from "../constants";
|
||||
import { TActionClass, TActionClassInput, ZActionClassInput } from "@formbricks/types/actionClasses";
|
||||
import { ZId } from "@formbricks/types/environment";
|
||||
import { ZOptionalNumber, ZString } from "@formbricks/types/common";
|
||||
import { ZId } from "@formbricks/types/environment";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { actionClassCache } from "./cache";
|
||||
|
||||
@@ -198,6 +199,9 @@ export const updateActionClass = async (
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw new DatabaseError(`Database error when updating an action for environment ${environmentId}`);
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -154,7 +154,7 @@ export const authOptions: NextAuthOptions = {
|
||||
async signIn({ user, account }: any) {
|
||||
if (account.provider === "credentials" || account.provider === "token") {
|
||||
if (!user.emailVerified && !EMAIL_VERIFICATION_DISABLED) {
|
||||
return `/auth/verification-requested?email=${encodeURIComponent(user.email)}`;
|
||||
throw new Error("Email Verification is Pending");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -197,7 +197,9 @@ export const authOptions: NextAuthOptions = {
|
||||
await updateProfile(existingUserWithAccount.id, { email: user.email });
|
||||
return true;
|
||||
}
|
||||
return "/auth/login?error=Looks%20like%20you%20updated%20your%20email%20somewhere%20else.%0AA%20user%20with%20this%20new%20email%20exists%20already.";
|
||||
throw new Error(
|
||||
"Looks like you updated your email somewhere else. A user with this new email exists already."
|
||||
);
|
||||
}
|
||||
|
||||
// There is no existing account for this identity provider / account id
|
||||
@@ -206,7 +208,7 @@ export const authOptions: NextAuthOptions = {
|
||||
const existingUserWithEmail = await getProfileByEmail(user.email);
|
||||
|
||||
if (existingUserWithEmail) {
|
||||
return "/auth/login?error=A%20user%20with%20this%20email%20exists%20already.";
|
||||
throw new Error("A user with this email exists already.");
|
||||
}
|
||||
|
||||
const userProfile = await createProfile({
|
||||
|
||||
@@ -71,6 +71,34 @@ export const IS_S3_CONFIGURED: boolean =
|
||||
export const PRICING_USERTARGETING_FREE_MTU = 2500;
|
||||
export const PRICING_APPSURVEYS_FREE_RESPONSES = 250;
|
||||
|
||||
// Colors for Survey Bg
|
||||
export const colours = [
|
||||
"#FFF2D8",
|
||||
"#EAD7BB",
|
||||
"#BCA37F",
|
||||
"#113946",
|
||||
"#04364A",
|
||||
"#176B87",
|
||||
"#64CCC5",
|
||||
"#DAFFFB",
|
||||
"#132043",
|
||||
"#1F4172",
|
||||
"#F1B4BB",
|
||||
"#FDF0F0",
|
||||
"#001524",
|
||||
"#445D48",
|
||||
"#D6CC99",
|
||||
"#FDE5D4",
|
||||
"#BEADFA",
|
||||
"#D0BFFF",
|
||||
"#DFCCFB",
|
||||
"#FFF8C9",
|
||||
"#FF8080",
|
||||
"#FFCF96",
|
||||
"#F6FDC3",
|
||||
"#CDFAD5",
|
||||
];
|
||||
|
||||
// Rate Limiting
|
||||
export const SIGNUP_RATE_LIMIT = {
|
||||
interval: 60 * 60 * 1000, // 60 minutes
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
"lint:report": "eslint . --format json --output-file ../../lint-results/app-store.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/s3-presigned-post": "3.458.0",
|
||||
"@aws-sdk/client-s3": "3.458.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.458.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.465.0",
|
||||
"@aws-sdk/client-s3": "3.465.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.465.0",
|
||||
"@t3-oss/env-nextjs": "^0.7.1",
|
||||
"mime": "4.0.0",
|
||||
"@formbricks/api": "*",
|
||||
@@ -25,12 +25,12 @@
|
||||
"date-fns": "^2.30.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"markdown-it": "^13.0.2",
|
||||
"nanoid": "^5.0.3",
|
||||
"nanoid": "^5.0.4",
|
||||
"next-auth": "^4.24.5",
|
||||
"nodemailer": "^6.9.7",
|
||||
"posthog-node": "^3.1.3",
|
||||
"posthog-node": "^3.2.0",
|
||||
"server-only": "^0.0.1",
|
||||
"tailwind-merge": "^2.0.0"
|
||||
"tailwind-merge": "^2.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/tsconfig": "*",
|
||||
|
||||
@@ -14,10 +14,11 @@ import { Prisma } from "@prisma/client";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { SERVICES_REVALIDATION_INTERVAL } from "../constants";
|
||||
import { updateMembership } from "../membership/service";
|
||||
import { deleteTeam } from "../team/service";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { profileCache } from "./cache";
|
||||
import { updateMembership } from "../membership/service";
|
||||
import { formatProfileDateFields } from "./util";
|
||||
|
||||
const responseSelection = {
|
||||
id: true,
|
||||
@@ -34,8 +35,8 @@ const responseSelection = {
|
||||
};
|
||||
|
||||
// function to retrive basic information about a user's profile
|
||||
export const getProfile = async (id: string): Promise<TProfile | null> =>
|
||||
unstable_cache(
|
||||
export const getProfile = async (id: string): Promise<TProfile | null> => {
|
||||
const profile = await unstable_cache(
|
||||
async () => {
|
||||
validateInputs([id, ZId]);
|
||||
|
||||
@@ -67,8 +68,18 @@ export const getProfile = async (id: string): Promise<TProfile | null> =>
|
||||
}
|
||||
)();
|
||||
|
||||
export const getProfileByEmail = async (email: string): Promise<TProfile | null> =>
|
||||
unstable_cache(
|
||||
if (!profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...profile,
|
||||
...formatProfileDateFields(profile),
|
||||
} as TProfile;
|
||||
};
|
||||
|
||||
export const getProfileByEmail = async (email: string): Promise<TProfile | null> => {
|
||||
const profile = await unstable_cache(
|
||||
async () => {
|
||||
validateInputs([email, z.string().email()]);
|
||||
|
||||
@@ -100,6 +111,16 @@ export const getProfileByEmail = async (email: string): Promise<TProfile | null>
|
||||
}
|
||||
)();
|
||||
|
||||
if (!profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...profile,
|
||||
...formatProfileDateFields(profile),
|
||||
} as TProfile;
|
||||
};
|
||||
|
||||
const getAdminMemberships = (memberships: TMembership[]): TMembership[] =>
|
||||
memberships.filter((membership) => membership.role === "admin");
|
||||
|
||||
|
||||
15
packages/lib/profile/util.ts
Normal file
15
packages/lib/profile/util.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { TProfile } from "@formbricks/types/profile";
|
||||
|
||||
export const formatProfileDateFields = (profile: TProfile): TProfile => {
|
||||
if (typeof profile.createdAt === "string") {
|
||||
profile.createdAt = new Date(profile.createdAt);
|
||||
}
|
||||
if (typeof profile.updatedAt === "string") {
|
||||
profile.updatedAt = new Date(profile.updatedAt);
|
||||
}
|
||||
if (typeof profile.emailVerified === "string") {
|
||||
profile.emailVerified = new Date(profile.emailVerified);
|
||||
}
|
||||
|
||||
return profile;
|
||||
};
|
||||
@@ -270,14 +270,15 @@ export const createResponseLegacy = async (responseInput: TResponseLegacyInput):
|
||||
if (responseInput.personId) {
|
||||
person = await getPerson(responseInput.personId);
|
||||
}
|
||||
const ttcTemp = responseInput.ttc;
|
||||
const ttcTemp = responseInput.ttc ?? {};
|
||||
const questionId = Object.keys(ttcTemp)[0];
|
||||
const ttc = responseInput.finished
|
||||
? {
|
||||
...ttcTemp,
|
||||
_total: ttcTemp[questionId], // Add _total property with the same value
|
||||
}
|
||||
: ttcTemp;
|
||||
const ttc =
|
||||
responseInput.finished && responseInput.ttc
|
||||
? {
|
||||
...ttcTemp,
|
||||
_total: ttcTemp[questionId], // Add _total property with the same value
|
||||
}
|
||||
: ttcTemp;
|
||||
const responsePrisma = await prisma.response.create({
|
||||
data: {
|
||||
survey: {
|
||||
@@ -512,7 +513,11 @@ export const updateResponse = async (
|
||||
...currentResponse.data,
|
||||
...responseInput.data,
|
||||
};
|
||||
const ttc = responseInput.finished ? calculateTtcTotal(responseInput.ttc) : responseInput.ttc;
|
||||
const ttc = responseInput.ttc
|
||||
? responseInput.finished
|
||||
? calculateTtcTotal(responseInput.ttc)
|
||||
: responseInput.ttc
|
||||
: {};
|
||||
|
||||
const responsePrisma = await prisma.response.update({
|
||||
where: {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import "server-only";
|
||||
|
||||
import { TResponseDates, TResponseTtc } from "@formbricks/types/responses";
|
||||
import { TResponse, TResponseTtc } from "@formbricks/types/responses";
|
||||
|
||||
export const formatResponseDateFields = (response: TResponseDates): TResponseDates => {
|
||||
export const formatResponseDateFields = (response: TResponse): TResponse => {
|
||||
if (typeof response.createdAt === "string") {
|
||||
response.createdAt = new Date(response.createdAt);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getAttributeClasses } from "../attributeClass/service";
|
||||
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
|
||||
import { displayCache } from "../display/cache";
|
||||
import { getDisplaysByPersonId } from "../display/service";
|
||||
import { personCache } from "../person/cache";
|
||||
import { productCache } from "../product/cache";
|
||||
import { getProductByEnvironmentId } from "../product/service";
|
||||
import { responseCache } from "../response/cache";
|
||||
@@ -44,6 +45,7 @@ export const selectSurvey = {
|
||||
verifyEmail: true,
|
||||
redirectUrl: true,
|
||||
productOverwrites: true,
|
||||
styling: true,
|
||||
surveyClosedMessage: true,
|
||||
singleUse: true,
|
||||
pin: true,
|
||||
@@ -540,6 +542,7 @@ export const createSurvey = async (environmentId: string, surveyBody: TSurveyInp
|
||||
};
|
||||
|
||||
export const duplicateSurvey = async (environmentId: string, surveyId: string) => {
|
||||
validateInputs([environmentId, ZId], [surveyId, ZId]);
|
||||
const existingSurvey = await getSurvey(surveyId);
|
||||
|
||||
if (!existingSurvey) {
|
||||
@@ -585,6 +588,7 @@ export const duplicateSurvey = async (environmentId: string, surveyId: string) =
|
||||
productOverwrites: existingSurvey.productOverwrites
|
||||
? JSON.parse(JSON.stringify(existingSurvey.productOverwrites))
|
||||
: Prisma.JsonNull,
|
||||
styling: existingSurvey.styling ? JSON.parse(JSON.stringify(existingSurvey.styling)) : Prisma.JsonNull,
|
||||
verifyEmail: existingSurvey.verifyEmail
|
||||
? JSON.parse(JSON.stringify(existingSurvey.verifyEmail))
|
||||
: Prisma.JsonNull,
|
||||
@@ -605,8 +609,10 @@ export const duplicateSurvey = async (environmentId: string, surveyId: string) =
|
||||
return newSurvey;
|
||||
};
|
||||
|
||||
export const getSyncSurveys = (environmentId: string, person: TPerson): Promise<TSurvey[]> =>
|
||||
unstable_cache(
|
||||
export const getSyncSurveys = (environmentId: string, person: TPerson): Promise<TSurvey[]> => {
|
||||
validateInputs([environmentId, ZId]);
|
||||
|
||||
return unstable_cache(
|
||||
async () => {
|
||||
const product = await getProductByEnvironmentId(environmentId);
|
||||
|
||||
@@ -685,9 +691,10 @@ export const getSyncSurveys = (environmentId: string, person: TPerson): Promise<
|
||||
|
||||
return surveys;
|
||||
},
|
||||
[`getSyncSurveys-${environmentId}`],
|
||||
[`getSyncSurveys-${environmentId}-${person.userId}`],
|
||||
{
|
||||
tags: [
|
||||
personCache.tag.byEnvironmentIdAndUserId(environmentId, person.userId),
|
||||
displayCache.tag.byPersonId(person.id),
|
||||
surveyCache.tag.byEnvironmentId(environmentId),
|
||||
productCache.tag.byEnvironmentId(environmentId),
|
||||
@@ -695,3 +702,4 @@ export const getSyncSurveys = (environmentId: string, person: TPerson): Promise<
|
||||
revalidate: SERVICES_REVALIDATION_INTERVAL,
|
||||
}
|
||||
)();
|
||||
};
|
||||
|
||||
@@ -5,3 +5,34 @@ export const diffInDays = (date1: Date, date2: Date) => {
|
||||
const diffTime = Math.abs(date2.getTime() - date1.getTime());
|
||||
return Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||
};
|
||||
|
||||
export const formatDateWithOrdinal = (date: Date): string => {
|
||||
const getOrdinalSuffix = (day: number) => {
|
||||
const suffixes = ["th", "st", "nd", "rd"];
|
||||
const relevantDigits = day < 30 ? day % 20 : day % 30;
|
||||
return suffixes[relevantDigits <= 3 ? relevantDigits : 0];
|
||||
};
|
||||
|
||||
const dayOfWeekNames = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
||||
const monthNames = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
|
||||
const dayOfWeek = dayOfWeekNames[date.getDay()];
|
||||
const day = date.getDate();
|
||||
const monthIndex = date.getMonth();
|
||||
const year = date.getFullYear();
|
||||
|
||||
return `${dayOfWeek}, ${monthNames[monthIndex]} ${day}${getOrdinalSuffix(day)}, ${year}`;
|
||||
};
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
{
|
||||
"name": "@formbricks/surveys",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"version": "0.0.0",
|
||||
"version": "1.0.1",
|
||||
"description": "Formbricks-surveys is a helper library to embed surveys into your application",
|
||||
"homepage": "https://formbricks.com",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/formbricks/formbricks"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"source": "./src/index.ts",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.mjs",
|
||||
@@ -15,9 +23,11 @@
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite build --watch",
|
||||
"build": "tsc && vite build",
|
||||
"go": "vite build --watch",
|
||||
"dev": "SURVEYS_PACKAGE_MODE=development vite build --watch",
|
||||
"build": "pnpm run build:surveys && pnpm run build:question-date",
|
||||
"build:surveys": "tsc && SURVEYS_PACKAGE_BUILD=surveys vite build",
|
||||
"build:question-date": "tsc && SURVEYS_PACKAGE_BUILD=question-date vite build",
|
||||
"go": "concurrently \"pnpm dev\" \"serve dist -p 3003\"",
|
||||
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
|
||||
"preview": "vite preview",
|
||||
"clean": "rimraf .turbo node_modules dist"
|
||||
@@ -28,14 +38,17 @@
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@preact/preset-vite": "^2.7.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.31",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-turbo": "latest",
|
||||
"postcss": "^8.4.32",
|
||||
"preact": "^10.19.2",
|
||||
"tailwindcss": "^3.3.5",
|
||||
"terser": "^5.24.0",
|
||||
"vite": "^5.0.4",
|
||||
"react-date-picker": "^10.5.2",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"terser": "^5.25.0",
|
||||
"vite": "^5.0.6",
|
||||
"vite-plugin-dts": "^3.6.4",
|
||||
"vite-tsconfig-paths": "^4.2.1",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-config-turbo": "latest"
|
||||
"serve": "14.2.1",
|
||||
"concurrently": "8.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,10 @@ export default function Headline({
|
||||
}: HeadlineProps) {
|
||||
return (
|
||||
<label htmlFor={questionId} className="text-heading mb-1.5 block text-base font-semibold leading-6">
|
||||
<div
|
||||
className={`flex items-center ${alignTextCenter ? "justify-center" : "mr-[3ch] justify-between"}`}>
|
||||
<div className={`flex items-center ${alignTextCenter ? "justify-center" : "justify-between"}`}>
|
||||
{headline}
|
||||
{!required && (
|
||||
<span className="text-info-text self-start text-sm font-normal leading-7" tabIndex={-1}>
|
||||
<span className="text-info-text ml-2 self-start text-sm font-normal leading-7" tabIndex={-1}>
|
||||
Optional
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -7,6 +7,7 @@ import NPSQuestion from "@/components/questions/NPSQuestion";
|
||||
import OpenTextQuestion from "@/components/questions/OpenTextQuestion";
|
||||
import PictureSelectionQuestion from "@/components/questions/PictureSelectionQuestion";
|
||||
import RatingQuestion from "@/components/questions/RatingQuestion";
|
||||
import DateQuestion from "@/components/questions/DateQuestion";
|
||||
import { TResponseData, TResponseTtc } from "@formbricks/types/responses";
|
||||
import { TUploadFileConfig } from "@formbricks/types/storage";
|
||||
import { TSurveyQuestion, TSurveyQuestionType } from "@formbricks/types/surveys";
|
||||
@@ -125,6 +126,18 @@ export default function QuestionConditional({
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.Date ? (
|
||||
<DateQuestion
|
||||
question={question}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onSubmit={onSubmit}
|
||||
onBack={onBack}
|
||||
isFirstQuestion={isFirstQuestion}
|
||||
isLastQuestion={isLastQuestion}
|
||||
ttc={ttc}
|
||||
setTtc={setTtc}
|
||||
/>
|
||||
) : question.type === TSurveyQuestionType.PictureSelection ? (
|
||||
<PictureSelectionQuestion
|
||||
question={question}
|
||||
|
||||
@@ -174,7 +174,7 @@ export function Survey({
|
||||
return (
|
||||
<>
|
||||
<AutoCloseWrapper survey={survey} onClose={onClose}>
|
||||
<div className="flex h-full w-full flex-col justify-between bg-[--fb-survey-background-color] px-6 pb-3 pt-6">
|
||||
<div className="flex h-full w-full flex-col justify-between rounded-lg bg-[--fb-survey-background-color] px-6 pb-3 pt-6">
|
||||
<div ref={contentRef} className={cn(loadingElement ? "animate-pulse opacity-60" : "", "my-auto")}>
|
||||
{survey.questions.length === 0 && !survey.welcomeCard.enabled && !survey.thankYouCard.enabled ? (
|
||||
// Handle the case when there are no questions and both welcome and thank you cards are disabled
|
||||
|
||||
160
packages/surveys/src/components/questions/DateQuestion.tsx
Normal file
160
packages/surveys/src/components/questions/DateQuestion.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { BackButton } from "@/components/buttons/BackButton";
|
||||
import SubmitButton from "@/components/buttons/SubmitButton";
|
||||
import Headline from "@/components/general/Headline";
|
||||
import Subheader from "@/components/general/Subheader";
|
||||
import { getUpdatedTtc, useTtc } from "@/lib/ttc";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TResponseData, TResponseTtc } from "@formbricks/types/responses";
|
||||
import type { TSurveyDateQuestion } from "@formbricks/types/surveys";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
interface DateQuestionProps {
|
||||
question: TSurveyDateQuestion;
|
||||
value: string | number | string[];
|
||||
onChange: (responseData: TResponseData) => void;
|
||||
onSubmit: (data: TResponseData, ttc: TResponseTtc) => void;
|
||||
onBack: () => void;
|
||||
isFirstQuestion: boolean;
|
||||
isLastQuestion: boolean;
|
||||
autoFocus?: boolean;
|
||||
ttc: TResponseTtc;
|
||||
setTtc: (ttc: TResponseTtc) => void;
|
||||
}
|
||||
|
||||
export default function DateQuestion({
|
||||
question,
|
||||
value,
|
||||
onSubmit,
|
||||
onBack,
|
||||
isFirstQuestion,
|
||||
isLastQuestion,
|
||||
onChange,
|
||||
setTtc,
|
||||
ttc,
|
||||
}: DateQuestionProps) {
|
||||
const [startTime, setStartTime] = useState(performance.now());
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useTtc(question.id, ttc, setTtc, startTime, setStartTime);
|
||||
|
||||
const defaultDate = value ? new Date(value as string) : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
// Check if the DatePicker has already been loaded
|
||||
|
||||
if (!window.initDatePicker) {
|
||||
const script = document.createElement("script");
|
||||
|
||||
script.src =
|
||||
process.env.SURVEYS_PACKAGE_MODE === "development"
|
||||
? "http://localhost:3003/question-date.umd.js"
|
||||
: "https://unpkg.com/@formbricks/surveys@^1.0.1/dist/question-date.umd.js";
|
||||
|
||||
script.async = true;
|
||||
|
||||
document.body.appendChild(script);
|
||||
|
||||
script.onload = () => {
|
||||
// Initialize the DatePicker once the script is loaded
|
||||
// @ts-expect-error
|
||||
window.initDatePicker(document.getElementById("date-picker-root"), defaultDate, question.format);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return () => {
|
||||
document.body.removeChild(script);
|
||||
};
|
||||
} else {
|
||||
// If already loaded, remove the date picker and re-initialize it
|
||||
setLoading(false);
|
||||
|
||||
const datePickerContainer = document.getElementById("datePickerContainer");
|
||||
if (datePickerContainer) {
|
||||
datePickerContainer.remove();
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
window.initDatePicker(document.getElementById("date-picker-root"), defaultDate, question.format);
|
||||
}
|
||||
|
||||
return () => {};
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [question.format, question.id]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("dateChange", (e) => {
|
||||
// @ts-expect-error
|
||||
const date = e.detail as Date;
|
||||
|
||||
// Get the timezone offset in minutes and convert it to milliseconds
|
||||
const timezoneOffset = date.getTimezoneOffset() * 60000;
|
||||
|
||||
// Adjust the date by subtracting the timezone offset
|
||||
const adjustedDate = new Date(date.getTime() - timezoneOffset);
|
||||
|
||||
// Format the date as YYYY-MM-DD
|
||||
const dateString = adjustedDate.toISOString().split("T")[0];
|
||||
|
||||
onChange({ [question.id]: dateString });
|
||||
});
|
||||
}, [onChange, question.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (value && errorMessage) {
|
||||
setErrorMessage("");
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (question.required && !value) {
|
||||
// alert("Please select a date");
|
||||
setErrorMessage("Please select a date.");
|
||||
return;
|
||||
}
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
onSubmit({ [question.id]: value }, updatedTtcObj);
|
||||
}}
|
||||
className="w-full">
|
||||
<Headline headline={question.headline} questionId={question.id} required={question.required} />
|
||||
<Subheader subheader={question.subheader} questionId={question.id} />
|
||||
|
||||
<div className={"text-red-600"}>
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
|
||||
<div className={cn("my-4", errorMessage && "rounded-lg border-2 border-red-500")} id="date-picker-root">
|
||||
{loading && (
|
||||
<div className="relative flex h-12 w-full cursor-pointer appearance-none items-center justify-center rounded-lg border border-slate-300 bg-white text-left text-base font-normal text-slate-900 hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-1">
|
||||
<span
|
||||
className="h-6 w-6 animate-spin rounded-full border-b-2 border-neutral-900"
|
||||
style={{ borderTopColor: "transparent" }}></span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex w-full justify-between">
|
||||
<div>
|
||||
{!isFirstQuestion && (
|
||||
<BackButton
|
||||
backButtonLabel={question.backButtonLabel}
|
||||
onClick={() => {
|
||||
const updatedTtcObj = getUpdatedTtc(ttc, question.id, performance.now() - startTime);
|
||||
setTtc(updatedTtcObj);
|
||||
onBack();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SubmitButton isLastQuestion={isLastQuestion} onClick={() => {}} buttonLabel={question.buttonLabel} />
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -68,12 +68,14 @@ export default function Modal({
|
||||
};
|
||||
|
||||
const highlightBorderColorStyle = useMemo(() => {
|
||||
if (!highlightBorderColor) return {};
|
||||
if (!highlightBorderColor)
|
||||
return {
|
||||
overflow: "visible",
|
||||
};
|
||||
|
||||
return {
|
||||
borderRadius: "8px",
|
||||
border: "2px solid",
|
||||
overflow: "hidden",
|
||||
borderColor: highlightBorderColor,
|
||||
};
|
||||
}, [highlightBorderColor]);
|
||||
@@ -101,7 +103,7 @@ export default function Modal({
|
||||
className={cn(
|
||||
getPlacementStyle(placement),
|
||||
show ? "opacity-100" : "opacity-0",
|
||||
"border-border pointer-events-auto absolute bottom-0 h-fit w-full overflow-hidden rounded-lg border bg-white shadow-lg transition-all duration-500 ease-in-out sm:m-4 sm:max-w-sm"
|
||||
"border-border pointer-events-auto absolute bottom-0 h-fit w-full overflow-visible rounded-lg border bg-white shadow-lg transition-all duration-500 ease-in-out sm:m-4 sm:max-w-sm"
|
||||
)}>
|
||||
{!isCenter && (
|
||||
<div class="absolute right-0 top-0 block pr-2 pt-2">
|
||||
|
||||
126
packages/surveys/src/sideload/question-date/Question.tsx
Normal file
126
packages/surveys/src/sideload/question-date/Question.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useState, useEffect, useMemo } from "preact/hooks";
|
||||
import DatePicker from "react-date-picker";
|
||||
|
||||
const CalendarIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="h-5 w-5">
|
||||
<path d="M12.75 12.75a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM7.5 15.75a.75.75 0 100-1.5.75.75 0 000 1.5zM8.25 17.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM9.75 15.75a.75.75 0 100-1.5.75.75 0 000 1.5zM10.5 17.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM12 15.75a.75.75 0 100-1.5.75.75 0 000 1.5zM12.75 17.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM14.25 15.75a.75.75 0 100-1.5.75.75 0 000 1.5zM15 17.25a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM16.5 15.75a.75.75 0 100-1.5.75.75 0 000 1.5zM15 12.75a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM16.5 13.5a.75.75 0 100-1.5.75.75 0 000 1.5z" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M6.75 2.25A.75.75 0 017.5 3v1.5h9V3A.75.75 0 0118 3v1.5h.75a3 3 0 013 3v11.25a3 3 0 01-3 3H5.25a3 3 0 01-3-3V7.5a3 3 0 013-3H6V3a.75.75 0 01.75-.75zm13.5 9a1.5 1.5 0 00-1.5-1.5H5.25a1.5 1.5 0 00-1.5 1.5v7.5a1.5 1.5 0 001.5 1.5h13.5a1.5 1.5 0 001.5-1.5v-7.5z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export default function Question({ defaultDate, format }: { defaultDate?: Date; format?: string }) {
|
||||
const [datePickerOpen, setDatePickerOpen] = useState(false);
|
||||
const [selectedDate, setSelectedDate] = useState<Date | undefined>(defaultDate);
|
||||
const [hideInvalid, setHideInvalid] = useState(!selectedDate);
|
||||
|
||||
useEffect(() => {
|
||||
if (datePickerOpen) {
|
||||
const input = document.querySelector(".react-date-picker__inputGroup__input") as HTMLInputElement;
|
||||
if (input) {
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
}, [datePickerOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!!selectedDate) {
|
||||
if (hideInvalid) {
|
||||
setHideInvalid(false);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedDate]);
|
||||
|
||||
const formattedDate = useMemo(() => {
|
||||
if (!selectedDate) return "";
|
||||
|
||||
if (format === "M-d-y") {
|
||||
return `${selectedDate?.getMonth() + 1}-${selectedDate?.getDate()}-${selectedDate?.getFullYear()}`;
|
||||
}
|
||||
|
||||
if (format === "d-M-y") {
|
||||
return `${selectedDate?.getDate()}-${selectedDate?.getMonth() + 1}-${selectedDate?.getFullYear()}`;
|
||||
}
|
||||
|
||||
return `${selectedDate?.getFullYear()}-${selectedDate?.getMonth() + 1}-${selectedDate?.getDate()}`;
|
||||
}, [format, selectedDate]);
|
||||
|
||||
return (
|
||||
<div className="relative h-12">
|
||||
{!datePickerOpen && (
|
||||
<div
|
||||
onClick={() => setDatePickerOpen(true)}
|
||||
className="relative flex h-12 w-full cursor-pointer appearance-none items-center justify-center rounded-lg border border-slate-300 bg-white text-left text-base font-normal text-slate-900 hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<CalendarIcon />
|
||||
<span>{selectedDate ? formattedDate : "Select a date"}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* @ts-expect-error */}
|
||||
<DatePicker
|
||||
key={datePickerOpen}
|
||||
value={selectedDate}
|
||||
isOpen={datePickerOpen}
|
||||
onChange={(value) => {
|
||||
const event = new CustomEvent("dateChange", { detail: value });
|
||||
setSelectedDate(value as Date);
|
||||
window.dispatchEvent(event);
|
||||
}}
|
||||
minDate={new Date(new Date().getFullYear() - 100, new Date().getMonth(), new Date().getDate())}
|
||||
maxDate={new Date("3000-12-31")}
|
||||
dayPlaceholder="DD"
|
||||
monthPlaceholder="MM"
|
||||
yearPlaceholder="YYYY"
|
||||
format={format ?? "M-d-y"}
|
||||
className={`dp-input-root rounded-lg ${!datePickerOpen ? "wrapper-hide" : ""}
|
||||
${hideInvalid ? "hide-invalid" : ""}
|
||||
`}
|
||||
calendarClassName="calendar-root w-80 rounded-lg border border-[#e5e7eb] p-3 shadow-md"
|
||||
clearIcon={null}
|
||||
onCalendarOpen={() => {
|
||||
setDatePickerOpen(true);
|
||||
}}
|
||||
onCalendarClose={() => {
|
||||
// reset state
|
||||
setDatePickerOpen(false);
|
||||
setSelectedDate(selectedDate);
|
||||
}}
|
||||
// @ts-ignore
|
||||
calendarIcon={<CalendarIcon />}
|
||||
tileClassName={({ date }) => {
|
||||
const baseClass =
|
||||
"hover:bg-slate-200 rounded-md h-9 p-0 mt-1 font-normal text-slate-900 aria-selected:opacity-100";
|
||||
// today's date class
|
||||
if (
|
||||
date.getDate() === new Date().getDate() &&
|
||||
date.getMonth() === new Date().getMonth() &&
|
||||
date.getFullYear() === new Date().getFullYear()
|
||||
) {
|
||||
return `${baseClass} bg-slate-100`;
|
||||
}
|
||||
// active date class
|
||||
if (
|
||||
date.getDate() === selectedDate?.getDate() &&
|
||||
date.getMonth() === selectedDate?.getMonth() &&
|
||||
date.getFullYear() === selectedDate?.getFullYear()
|
||||
) {
|
||||
return `${baseClass} !bg-slate-900 !text-slate-100`;
|
||||
}
|
||||
|
||||
return baseClass;
|
||||
}}
|
||||
formatShortWeekday={(_, date) => {
|
||||
return date.toLocaleDateString("en-US", { weekday: "short" }).slice(0, 2);
|
||||
}}
|
||||
showNeighboringMonth={false}
|
||||
showLeadingZeros={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
packages/surveys/src/sideload/question-date/index.tsx
Normal file
31
packages/surveys/src/sideload/question-date/index.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { render } from "preact";
|
||||
import Question from "./Question.tsx";
|
||||
import globalCss from "./styles/globals.css?inline";
|
||||
import calendarCss from "react-calendar/dist/Calendar.css?inline";
|
||||
import datePickerCss from "react-date-picker/dist/DatePicker.css?inline";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
initDatePicker: (element: HTMLElement, selectedDate?: Date) => void;
|
||||
selectedDate: Date;
|
||||
}
|
||||
}
|
||||
|
||||
const addStylesToDom = () => {
|
||||
if (document.getElementById("formbricks__question_date_css") === null) {
|
||||
const styleElement = document.createElement("style");
|
||||
styleElement.id = "formbricks__question_date_css";
|
||||
styleElement.innerHTML = globalCss + datePickerCss + calendarCss;
|
||||
document.head.appendChild(styleElement);
|
||||
}
|
||||
};
|
||||
|
||||
const init = (element: HTMLElement, selectedDate?: Date, format?: string) => {
|
||||
addStylesToDom();
|
||||
const container = document.createElement("div");
|
||||
container.id = "datePickerContainer";
|
||||
element.appendChild(container);
|
||||
render(<Question defaultDate={selectedDate} format={format} />, container);
|
||||
};
|
||||
|
||||
window.initDatePicker = init;
|
||||
@@ -0,0 +1,75 @@
|
||||
.dp-input-root {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dp-input-root [class$="wrapper"] {
|
||||
height: 48px;
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgb(203 213 225) !important;
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.dp-input-root [class$="inputGroup"] {
|
||||
flex: none;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.wrapper-hide .react-date-picker__inputGroup {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dp-input-root .react-date-picker__inputGroup__input {
|
||||
background: #f1f5f9 !important;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.dp-input-root .react-date-picker__inputGroup__input:invalid {
|
||||
background: #fecaca !important;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.hide-invalid .react-date-picker__inputGroup__input:invalid {
|
||||
background: #f1f5f9 !important;
|
||||
}
|
||||
|
||||
.wrapper-hide .react-date-picker__wrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.calendar-root [class$="navigation"] {
|
||||
height: 36px;
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.calendar-root [class$="navigation"] button {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.calendar-root [class$="navigation"] button:hover {
|
||||
background: rgb(226 232 240) !important;
|
||||
}
|
||||
|
||||
.calendar-root [class$="navigation"] [class$="navigation__label"] {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.react-calendar__month-view__weekdays__weekday {
|
||||
color: rgb(100, 116, 139);
|
||||
font-weight: 400;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.react-calendar__month-view__weekdays__weekday > abbr {
|
||||
text-decoration: none;
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
"compilerOptions": {
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"baseUrl": ".",
|
||||
|
||||
@@ -1,23 +1,35 @@
|
||||
import { resolve } from "path";
|
||||
import { defineConfig } from "vite";
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import preact from "@preact/preset-vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
build: {
|
||||
emptyOutDir: false, // keep the dist folder to avoid errors with pnpm go when folder is empty during build
|
||||
minify: "terser",
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
// Could also be a dictionary or array of multiple entry points
|
||||
entry: resolve(__dirname, "src/index.ts"),
|
||||
name: "formbricks-surveys",
|
||||
formats: ["cjs", "es", "umd"],
|
||||
// the proper extensions will be added
|
||||
fileName: "index",
|
||||
const buildPackage = process.env.SURVEYS_PACKAGE_BUILD || "surveys";
|
||||
|
||||
const entryPoint = buildPackage === "surveys" ? "src/index.ts" : "src/sideload/question-date/index.tsx";
|
||||
const name = buildPackage === "surveys" ? "formbricks-surveys" : "formbricks-question-date";
|
||||
const fileName = buildPackage === "surveys" ? "index" : "question-date";
|
||||
|
||||
const config = ({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), "");
|
||||
|
||||
return defineConfig({
|
||||
define: {
|
||||
"process.env": env,
|
||||
},
|
||||
},
|
||||
plugins: [preact(), dts({ rollupTypes: true }), tsconfigPaths()],
|
||||
});
|
||||
build: {
|
||||
emptyOutDir: false,
|
||||
minify: "terser",
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
entry: resolve(__dirname, entryPoint),
|
||||
name,
|
||||
formats: ["cjs", "es", "umd"],
|
||||
fileName,
|
||||
},
|
||||
},
|
||||
plugins: [preact(), dts({ rollupTypes: true }), tsconfigPaths()],
|
||||
});
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.31",
|
||||
"tailwindcss": "^3.3.5"
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.3.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
"clean": "rimraf node_modules dist turbo"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "20.10.1",
|
||||
"@types/react": "18.2.39",
|
||||
"@types/node": "20.10.3",
|
||||
"@types/react": "18.2.42",
|
||||
"@types/react-dom": "18.2.17",
|
||||
"typescript": "^5.3.2"
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ export const ZJsConfigInput = z.object({
|
||||
debug: z.boolean().optional(),
|
||||
errorHandler: z.function().args(z.any()).returns(z.void()).optional(),
|
||||
userId: z.string().optional(),
|
||||
attributes: ZPersonAttributes.optional(),
|
||||
});
|
||||
|
||||
export type TJsConfigInput = z.infer<typeof ZJsConfigInput>;
|
||||
|
||||
@@ -37,11 +37,13 @@ export type TResponseNote = z.infer<typeof ZResponseNote>;
|
||||
export const ZResponseMeta = z.object({
|
||||
source: z.string().optional(),
|
||||
url: z.string().optional(),
|
||||
userAgent: z.object({
|
||||
browser: z.string().optional(),
|
||||
os: z.string().optional(),
|
||||
device: z.string().optional(),
|
||||
}),
|
||||
userAgent: z
|
||||
.object({
|
||||
browser: z.string().optional(),
|
||||
os: z.string().optional(),
|
||||
device: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type TResponseMeta = z.infer<typeof ZResponseMeta>;
|
||||
@@ -55,7 +57,7 @@ export const ZResponse = z.object({
|
||||
personAttributes: ZResponsePersonAttributes,
|
||||
finished: z.boolean(),
|
||||
data: ZResponseData,
|
||||
ttc: ZResponseTtc,
|
||||
ttc: ZResponseTtc.optional(),
|
||||
notes: z.array(ZResponseNote),
|
||||
tags: z.array(ZTag),
|
||||
meta: ZResponseMeta.nullable(),
|
||||
@@ -64,12 +66,6 @@ export const ZResponse = z.object({
|
||||
|
||||
export type TResponse = z.infer<typeof ZResponse>;
|
||||
|
||||
export type TResponseDates = {
|
||||
createdAt: TResponse["createdAt"];
|
||||
updatedAt: TResponse["updatedAt"];
|
||||
notes: TResponse["notes"];
|
||||
};
|
||||
|
||||
export const ZResponseInput = z.object({
|
||||
environmentId: z.string().cuid2(),
|
||||
surveyId: z.string().cuid2(),
|
||||
@@ -77,7 +73,7 @@ export const ZResponseInput = z.object({
|
||||
singleUseId: z.string().nullable().optional(),
|
||||
finished: z.boolean(),
|
||||
data: ZResponseData,
|
||||
ttc: ZResponseTtc,
|
||||
ttc: ZResponseTtc.optional(),
|
||||
meta: z
|
||||
.object({
|
||||
source: z.string().optional(),
|
||||
@@ -104,7 +100,7 @@ export type TResponseLegacyInput = z.infer<typeof ZResponseLegacyInput>;
|
||||
export const ZResponseUpdateInput = z.object({
|
||||
finished: z.boolean(),
|
||||
data: ZResponseData,
|
||||
ttc: ZResponseTtc,
|
||||
ttc: ZResponseTtc.optional(),
|
||||
});
|
||||
|
||||
export type TResponseUpdateInput = z.infer<typeof ZResponseUpdateInput>;
|
||||
@@ -118,7 +114,7 @@ export type TResponseWithSurvey = z.infer<typeof ZResponseWithSurvey>;
|
||||
export const ZResponseUpdate = z.object({
|
||||
finished: z.boolean(),
|
||||
data: ZResponseData,
|
||||
ttc: ZResponseTtc,
|
||||
ttc: ZResponseTtc.optional(),
|
||||
meta: z
|
||||
.object({
|
||||
url: z.string().optional(),
|
||||
|
||||
@@ -18,6 +18,7 @@ export enum TSurveyQuestionType {
|
||||
Rating = "rating",
|
||||
Consent = "consent",
|
||||
PictureSelection = "pictureSelection",
|
||||
Date = "date",
|
||||
}
|
||||
|
||||
export const ZSurveyWelcomeCard = z.object({
|
||||
@@ -45,6 +46,24 @@ export const ZSurveyProductOverwrites = z.object({
|
||||
|
||||
export type TSurveyProductOverwrites = z.infer<typeof ZSurveyProductOverwrites>;
|
||||
|
||||
export const ZSurveyBackgroundBgType = z.enum(["animation", "color", "image"]);
|
||||
|
||||
export type TSurveyBackgroundBgType = z.infer<typeof ZSurveyBackgroundBgType>;
|
||||
|
||||
export const ZSurveyStylingBackground = z.object({
|
||||
bg: z.string().nullish(),
|
||||
bgType: z.enum(["animation", "color", "image"]).nullish(),
|
||||
brightness: z.number().nullish(),
|
||||
});
|
||||
|
||||
export type TSurveyStylingBackground = z.infer<typeof ZSurveyStylingBackground>;
|
||||
|
||||
export const ZSurveyStyling = z.object({
|
||||
background: ZSurveyStylingBackground.nullish(),
|
||||
});
|
||||
|
||||
export type TSurveyStyling = z.infer<typeof ZSurveyStyling>;
|
||||
|
||||
export const ZSurveyClosedMessage = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
@@ -308,6 +327,14 @@ export const ZSurveyRatingQuestion = ZSurveyQuestionBase.extend({
|
||||
logic: z.array(ZSurveyRatingLogic).optional(),
|
||||
});
|
||||
|
||||
export const ZSurveyDateQuestion = ZSurveyQuestionBase.extend({
|
||||
type: z.literal(TSurveyQuestionType.Date),
|
||||
html: z.string().optional(),
|
||||
format: z.enum(["M-d-y", "d-M-y", "y-M-d"]),
|
||||
});
|
||||
|
||||
export type TSurveyDateQuestion = z.infer<typeof ZSurveyDateQuestion>;
|
||||
|
||||
export type TSurveyRatingQuestion = z.infer<typeof ZSurveyRatingQuestion>;
|
||||
|
||||
export const ZSurveyPictureSelectionQuestion = ZSurveyQuestionBase.extend({
|
||||
@@ -328,6 +355,7 @@ export const ZSurveyQuestion = z.union([
|
||||
ZSurveyCTAQuestion,
|
||||
ZSurveyRatingQuestion,
|
||||
ZSurveyPictureSelectionQuestion,
|
||||
ZSurveyDateQuestion,
|
||||
ZSurveyFileUploadQuestion,
|
||||
]);
|
||||
|
||||
@@ -379,6 +407,7 @@ export const ZSurvey = z.object({
|
||||
autoComplete: z.number().nullable(),
|
||||
closeOnDate: z.date().nullable(),
|
||||
productOverwrites: ZSurveyProductOverwrites.nullable(),
|
||||
styling: ZSurveyStyling.nullable(),
|
||||
surveyClosedMessage: ZSurveyClosedMessage.nullable(),
|
||||
singleUse: ZSurveySingleUse.nullable(),
|
||||
verifyEmail: ZSurveyVerifyEmail.nullable(),
|
||||
@@ -424,6 +453,7 @@ export const ZSurveyTSurveyQuestionType = z.union([
|
||||
z.literal("rating"),
|
||||
z.literal("consent"),
|
||||
z.literal("pictureSelection"),
|
||||
z.literal("date"),
|
||||
]);
|
||||
|
||||
export type TSurveyTSurveyQuestionType = z.infer<typeof ZSurveyTSurveyQuestionType>;
|
||||
|
||||
@@ -1,36 +1,22 @@
|
||||
import {
|
||||
ChatBubbleBottomCenterTextIcon,
|
||||
EnvelopeIcon,
|
||||
HashtagIcon,
|
||||
LinkIcon,
|
||||
PhoneIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import React from "react";
|
||||
|
||||
interface TSurveyQuestionType {
|
||||
interface TOption {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface QuestionTypeSelectorProps {
|
||||
questionTypes: TSurveyQuestionType[];
|
||||
currentType: string | undefined;
|
||||
options: TOption[];
|
||||
currentOption: string | undefined;
|
||||
handleTypeChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const typeIcons: { [key: string]: React.ReactNode } = {
|
||||
text: <ChatBubbleBottomCenterTextIcon />,
|
||||
email: <EnvelopeIcon />,
|
||||
url: <LinkIcon />,
|
||||
number: <HashtagIcon />,
|
||||
phone: <PhoneIcon />,
|
||||
};
|
||||
|
||||
export function QuestionTypeSelector({
|
||||
questionTypes,
|
||||
currentType,
|
||||
export function OptionsSwitcher({
|
||||
options: questionTypes,
|
||||
currentOption,
|
||||
handleTypeChange,
|
||||
}: QuestionTypeSelectorProps): JSX.Element {
|
||||
}: QuestionTypeSelectorProps) {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between rounded-md border p-1">
|
||||
{questionTypes.map((type) => (
|
||||
@@ -38,13 +24,15 @@ export function QuestionTypeSelector({
|
||||
key={type.value}
|
||||
onClick={() => handleTypeChange(type.value)}
|
||||
className={`flex-grow cursor-pointer rounded-md bg-${
|
||||
(currentType === undefined && type.value === "text") || currentType === type.value
|
||||
(currentOption === undefined && type.value === "text") || currentOption === type.value
|
||||
? "slate-100"
|
||||
: "white"
|
||||
} p-2 text-center`}>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<span className="text-sm text-slate-900">{type.label}</span>
|
||||
<div className="h-4 w-4 text-slate-600 hover:text-slate-800">{typeIcons[type.value]}</div>
|
||||
{type.icon ? (
|
||||
<div className="h-4 w-4 text-slate-600 hover:text-slate-800">{type.icon}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { FileUploadResponse } from "../FileUploadResponse";
|
||||
import { useMembershipRole } from "@formbricks/lib/membership/hooks/useMembershipRole";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { LoadingWrapper } from "../LoadingWrapper";
|
||||
import { formatDateWithOrdinal } from "@formbricks/lib/utils/datetime";
|
||||
|
||||
export interface SingleResponseCardProps {
|
||||
survey: TSurvey;
|
||||
@@ -61,6 +62,13 @@ function TooltipRenderer(props: TooltipRendererProps) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function DateResponse({ date }: { date?: string }) {
|
||||
if (!date) return null;
|
||||
|
||||
const formattedDateString = formatDateWithOrdinal(new Date(date));
|
||||
return <p className="ph-no-capture my-1 font-semibold text-slate-700">{formattedDateString}</p>;
|
||||
}
|
||||
|
||||
export default function SingleResponseCard({
|
||||
survey,
|
||||
response,
|
||||
@@ -321,6 +329,8 @@ export default function SingleResponseCard({
|
||||
range={question.range}
|
||||
/>
|
||||
</div>
|
||||
) : question.type === TSurveyQuestionType.Date ? (
|
||||
<DateResponse date={response.data[question.id] as string} />
|
||||
) : (
|
||||
<p className="ph-no-capture my-1 font-semibold text-slate-700">
|
||||
{response.data[question.id]}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"@formbricks/types": "workspace:*",
|
||||
"concurrently": "^8.2.2",
|
||||
"eslint-config-formbricks": "workspace:*",
|
||||
"postcss": "^8.4.31",
|
||||
"postcss": "^8.4.32",
|
||||
"react": "18.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -47,7 +47,7 @@
|
||||
"react-day-picker": "^8.9.1",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-radio-group": "^3.0.3",
|
||||
"react-use": "^17.4.1",
|
||||
"react-use": "^17.4.2",
|
||||
"mime": "^4.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
68
playwright.config.ts
Normal file
68
playwright.config.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./apps/web/playwright",
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Retry on CI only */
|
||||
retries: 2,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: 1,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "html",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: "http://localhost:3000",
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
|
||||
// {
|
||||
// name: "firefox",
|
||||
// use: { ...devices["Desktop Firefox"] },
|
||||
// },
|
||||
|
||||
// {
|
||||
// name: "webkit",
|
||||
// use: { ...devices["Desktop Safari"] },
|
||||
// },
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
// {
|
||||
// name: "Mobile Chrome",
|
||||
// use: { ...devices["Pixel 5"] },
|
||||
// },
|
||||
// {
|
||||
// name: "Mobile Safari",
|
||||
// use: { ...devices["iPhone 12"] },
|
||||
// },
|
||||
|
||||
/* Test against branded browsers. */
|
||||
// {
|
||||
// name: "Microsoft Edge",
|
||||
// use: { ...devices["Desktop Edge"], channel: "msedge" },
|
||||
// },
|
||||
// {
|
||||
// name: "Google Chrome",
|
||||
// use: { ...devices["Desktop Chrome"], channel: "chrome" },
|
||||
// },
|
||||
],
|
||||
});
|
||||
3914
pnpm-lock.yaml
generated
3914
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
71
turbo.json
71
turbo.json
@@ -46,14 +46,20 @@
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**", ".next/**"],
|
||||
"env": [
|
||||
"AIRTABLE_CLIENT_ID",
|
||||
"ASSET_PREFIX_URL",
|
||||
"AWS_ACCESS_KEY",
|
||||
"AWS_SECRET_KEY",
|
||||
"AZUREAD_AUTH_ENABLED",
|
||||
"AZUREAD_CLIENT_ID",
|
||||
"AZUREAD_CLIENT_SECRET",
|
||||
"AZUREAD_TENANT_ID",
|
||||
"CRON_SECRET",
|
||||
"ENCRYPTION_KEY",
|
||||
"DEBUG",
|
||||
"EMAIL_VERIFICATION_DISABLED",
|
||||
"ENCRYPTION_KEY",
|
||||
"ENTERPRISE_LICENSE_KEY",
|
||||
"FORMBRICKS_ENCRYPTION_KEY",
|
||||
"GITHUB_ID",
|
||||
"GITHUB_SECRET",
|
||||
"GOOGLE_CLIENT_ID",
|
||||
@@ -62,64 +68,55 @@
|
||||
"GOOGLE_SHEETS_CLIENT_SECRET",
|
||||
"GOOGLE_SHEETS_REDIRECT_URL",
|
||||
"HEROKU_APP_NAME",
|
||||
"IMPRINT_URL",
|
||||
"INSTANCE_ID",
|
||||
"INTERNAL_SECRET",
|
||||
"MAIL_FROM",
|
||||
"EMAIL_VERIFICATION_DISABLED",
|
||||
"FORMBRICKS_ENCRYPTION_KEY",
|
||||
"INVITE_DISABLED",
|
||||
"IS_FORMBRICKS_CLOUD",
|
||||
"GOOGLE_AUTH_ENABLED",
|
||||
"GITHUB_AUTH_ENABLED",
|
||||
"IS_FORMBRICKS_CLOUD",
|
||||
"PASSWORD_RESET_DISABLED",
|
||||
"PRIVACY_URL",
|
||||
"NEXT_PUBLIC_SENTRY_DSN",
|
||||
"INVITE_DISABLED",
|
||||
"SIGNUP_DISABLED",
|
||||
"NEXT_PUBLIC_STRIPE_PRICING_TABLE_ID",
|
||||
"TERMS_URL",
|
||||
"NEXTAUTH_SECRET",
|
||||
"NEXTAUTH_URL",
|
||||
"SMTP_HOST",
|
||||
"SMTP_PASSWORD",
|
||||
"SMTP_PORT",
|
||||
"SMTP_SECURE_ENABLED",
|
||||
"SMTP_USER",
|
||||
"MAIL_FROM",
|
||||
"NEXT_PUBLIC_DOCSEARCH_APP_ID",
|
||||
"NEXT_PUBLIC_DOCSEARCH_API_KEY",
|
||||
"NEXT_PUBLIC_DOCSEARCH_INDEX_NAME",
|
||||
"NEXT_PUBLIC_FORMBRICKS_API_HOST",
|
||||
"NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID",
|
||||
"NEXT_PUBLIC_FORMBRICKS_FORM_ID",
|
||||
"NEXT_PUBLIC_FORMBRICKS_FEEDBACK_FORM_ID",
|
||||
"NEXT_PUBLIC_FORMBRICKS_FEEDBACK_CUSTOM_FORM_ID",
|
||||
"NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID",
|
||||
"NEXT_PUBLIC_FORMBRICKS_PMF_FORM_ID",
|
||||
"NEXT_PUBLIC_FORMBRICKS_URL",
|
||||
"IMPRINT_URL",
|
||||
"NEXT_PUBLIC_SENTRY_DSN",
|
||||
"SHORT_URL_BASE",
|
||||
"NODE_ENV",
|
||||
"NEXT_PUBLIC_POSTHOG_API_HOST",
|
||||
"NEXT_PUBLIC_POSTHOG_API_KEY",
|
||||
"NEXT_PUBLIC_FORMBRICKS_COM_API_HOST",
|
||||
"NEXT_PUBLIC_FORMBRICKS_COM_ENVIRONMENT_ID",
|
||||
"NEXT_PUBLIC_FORMBRICKS_COM_DOCS_FEEDBACK_SURVEY_ID",
|
||||
"WEBAPP_URL",
|
||||
"SENTRY_DSN",
|
||||
"STRAPI_API_KEY",
|
||||
"STRIPE_SECRET_KEY",
|
||||
"STRIPE_WEBHOOK_SECRET",
|
||||
"TELEMETRY_DISABLED",
|
||||
"VERCEL_URL",
|
||||
"WEBAPP_URL",
|
||||
"AIRTABLE_CLIENT_ID",
|
||||
"AWS_ACCESS_KEY",
|
||||
"AWS_SECRET_KEY",
|
||||
"NEXTAUTH_SECRET",
|
||||
"NEXTAUTH_URL",
|
||||
"NODE_ENV",
|
||||
"PASSWORD_RESET_DISABLED",
|
||||
"PLAYWRIGHT_CI",
|
||||
"PRIVACY_URL",
|
||||
"S3_ACCESS_KEY",
|
||||
"S3_SECRET_KEY",
|
||||
"S3_REGION",
|
||||
"S3_BUCKET_NAME",
|
||||
"ENTERPRISE_LICENSE_KEY"
|
||||
"SENTRY_DSN",
|
||||
"SHORT_URL_BASE",
|
||||
"SIGNUP_DISABLED",
|
||||
"SMTP_HOST",
|
||||
"SMTP_PASSWORD",
|
||||
"SMTP_PORT",
|
||||
"SMTP_SECURE_ENABLED",
|
||||
"SMTP_USER",
|
||||
"STRAPI_API_KEY",
|
||||
"STRIPE_SECRET_KEY",
|
||||
"STRIPE_WEBHOOK_SECRET",
|
||||
"SURVEYS_PACKAGE_MODE",
|
||||
"SURVEYS_PACKAGE_BUILD",
|
||||
"TELEMETRY_DISABLED",
|
||||
"TERMS_URL",
|
||||
"VERCEL_URL",
|
||||
"WEBAPP_URL"
|
||||
]
|
||||
},
|
||||
"post-install": {
|
||||
|
||||
Reference in New Issue
Block a user