mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-24 00:45:50 -05:00
Compare commits
1 Commits
feature/1.
...
ReviewBot/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c62a4a5121 |
@@ -12,8 +12,8 @@
|
||||
// Configure properties specific to VS Code.
|
||||
"vscode": {
|
||||
// Add the IDs of extensions you want installed when the container is created.
|
||||
"extensions": ["dbaeumer.vscode-eslint"],
|
||||
},
|
||||
"extensions": ["dbaeumer.vscode-eslint"]
|
||||
}
|
||||
},
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
@@ -25,5 +25,5 @@
|
||||
"postAttachCommand": "pnpm dev --filter=web... --filter=demo...",
|
||||
|
||||
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
|
||||
"remoteUser": "node",
|
||||
"remoteUser": "node"
|
||||
}
|
||||
|
||||
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
1
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -32,6 +32,7 @@ Fixes # (issue)
|
||||
- [ ] Removed all `console.logs`
|
||||
- [ ] Merged the latest changes from main onto my branch with `git pull origin main`
|
||||
- [ ] My changes don't cause any responsiveness issues
|
||||
- [ ] First PR at Formbricks? [Please sign the CLA!](https://formbricks.com/clmyhzfrymr4ko00hycsg1tvx) Without it we wont be able to merge it 🙏
|
||||
|
||||
### Appreciated
|
||||
|
||||
|
||||
6
.github/workflows/build-formbricks-com.yml
vendored
6
.github/workflows/build-formbricks-com.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Build formbricks-com
|
||||
name: Build
|
||||
on:
|
||||
workflow_call:
|
||||
jobs:
|
||||
@@ -11,10 +11,10 @@ jobs:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Node.js 20.x
|
||||
- name: Setup Node.js 18.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20.x
|
||||
node-version: 18.x
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
|
||||
2
.github/workflows/build-web.yml
vendored
2
.github/workflows/build-web.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Build web
|
||||
name: Build
|
||||
on:
|
||||
workflow_call:
|
||||
jobs:
|
||||
|
||||
29
.github/workflows/ecs-deployment.yml
vendored
29
.github/workflows/ecs-deployment.yml
vendored
@@ -15,7 +15,7 @@ env:
|
||||
# Use docker.io for Docker Hub if empty
|
||||
REGISTRY: ghcr.io
|
||||
# github.repository as <account>/<repo>
|
||||
IMAGE_NAME: formbricks/formbricks-experimental
|
||||
IMAGE_NAME: formbricks-experimental
|
||||
DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/formbricks?schema=public"
|
||||
|
||||
jobs:
|
||||
@@ -66,16 +66,14 @@ jobs:
|
||||
uses: docker/metadata-action@v5 # v5.0.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=sha,format=long
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
# Build and push Docker image with Buildx
|
||||
|
||||
# https://github.com/docker/build-push-action
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: depot/build-push-action@v1
|
||||
env:
|
||||
env:
|
||||
NEXT_PUBLIC_SENTRY_DSN: ${{ secrets.NEXT_PUBLIC_SENTRY_DSN }}
|
||||
with:
|
||||
project: tw0fqmsx3c
|
||||
@@ -83,7 +81,6 @@ jobs:
|
||||
context: .
|
||||
file: ./apps/web/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
@@ -93,18 +90,16 @@ jobs:
|
||||
DATABASE_URL=${{ env.DATABASE_URL }}
|
||||
ENCRYPTION_KEY=${{ env.ENCRYPTION_KEY }}
|
||||
NEXT_PUBLIC_SENTRY_DSN=${{ env.NEXT_PUBLIC_SENTRY_DSN }}
|
||||
|
||||
- name: Sign the images with GitHub OIDC Token
|
||||
|
||||
- name: Sign the published Docker image
|
||||
env:
|
||||
DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
||||
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
run: |
|
||||
images=""
|
||||
for tag in ${TAGS}; do
|
||||
images+="${tag}@${DIGEST} "
|
||||
done
|
||||
cosign sign --yes ${images}
|
||||
|
||||
DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
||||
# This step uses the identity token to provision an ephemeral certificate
|
||||
# against the sigstore community Fulcio instance.
|
||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}${DIGEST}
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
@@ -126,7 +121,7 @@ jobs:
|
||||
with:
|
||||
task-definition: task-definition.json
|
||||
container-name: prod-webapp-container
|
||||
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:main
|
||||
|
||||
- name: Deploy Amazon ECS task definition
|
||||
uses: aws-actions/amazon-ecs-deploy-task-definition@v1
|
||||
|
||||
@@ -42,4 +42,6 @@ If you are at all unsure, just raise it as an enhancement issue first and tell u
|
||||
|
||||
To be able to keep working on Formbricks over the coming years, we need to collect a CLA from all relevant contributors.
|
||||
|
||||
Once you open a PR, you will get a message from the CLA bot to fill out the form. Please note that we can only get your contribution merged when we have a CLA signed by you.
|
||||
Please note that we can only get your contribution merged when we have a CLA signed by you.
|
||||
|
||||
To access the CLA form, please click [here](https://formbricks.com/clmyhzfrymr4ko00hycsg1tvx)
|
||||
|
||||
@@ -53,11 +53,12 @@ export default function TemplateList({ onTemplateClick, activeTemplate }: Templa
|
||||
{templates
|
||||
.filter((template) => selectedFilter === ALL_CATEGORY_NAME || template.category === selectedFilter)
|
||||
.map((template: TTemplate) => (
|
||||
<div
|
||||
key={template.name}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onTemplateClick(template); // Pass the 'template' object instead of 'activeTemplate'
|
||||
}}
|
||||
key={template.name}
|
||||
className={cn(
|
||||
activeTemplate?.name === template.name && "ring-brand ring-2",
|
||||
"duration-120 group relative rounded-lg bg-white p-6 shadow transition-all duration-150 hover:scale-105 dark:bg-slate-700"
|
||||
@@ -70,7 +71,7 @@ export default function TemplateList({ onTemplateClick, activeTemplate }: Templa
|
||||
{template.name}
|
||||
</h3>
|
||||
<p className="text-left text-xs text-slate-600 dark:text-slate-400">{template.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
|
||||
import { TSurveyQuestionType } from "@formbricks/types/surveys";
|
||||
import { TSurveyHiddenFields, TSurveyQuestionType } from "@formbricks/types/surveys";
|
||||
import { TTemplate } from "@formbricks/types/templates";
|
||||
import {
|
||||
AppPieChartIcon,
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
DashboardIcon,
|
||||
DogChaserIcon,
|
||||
DoorIcon,
|
||||
EmailIcon,
|
||||
FeedbackIcon,
|
||||
GaugeSpeedFastIcon,
|
||||
HeartCommentIcon,
|
||||
@@ -39,6 +38,11 @@ const welcomeCardDefault = {
|
||||
showResponseCount: false,
|
||||
};
|
||||
|
||||
const hiddenFieldsDefault: TSurveyHiddenFields = {
|
||||
enabled: true,
|
||||
fieldIds: [],
|
||||
};
|
||||
|
||||
export const customSurvey: TTemplate = {
|
||||
name: "Start from scratch",
|
||||
description: "Create a survey without template.",
|
||||
@@ -1227,12 +1231,12 @@ export const templates: TTemplate[] = [
|
||||
},
|
||||
{
|
||||
name: "Improve Newsletter Content",
|
||||
icon: EmailIcon,
|
||||
category: "Growth",
|
||||
description: "Find out how your subscribers like your newsletter content.",
|
||||
objectives: ["increase_conversion", "sharpen_marketing_messaging"],
|
||||
description: "Find out how your subscribers like your newsletter content.",
|
||||
preset: {
|
||||
name: "Improve Newsletter Content",
|
||||
welcomeCard: welcomeCardDefault,
|
||||
questions: [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -1273,11 +1277,8 @@ export const templates: TTemplate[] = [
|
||||
dismissButtonLabel: "Find your own friends",
|
||||
},
|
||||
],
|
||||
welcomeCard: welcomeCardDefault,
|
||||
thankYouCard: thankYouCardDefault,
|
||||
hiddenFields: {
|
||||
enabled: false,
|
||||
},
|
||||
hiddenFields: hiddenFieldsDefault,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -81,7 +81,7 @@ export default function BestPracticeNavigation() {
|
||||
},
|
||||
|
||||
{
|
||||
name: "Improve Newsletter Content",
|
||||
name: "Improve Newsletter Cotent",
|
||||
href: "/improve-newsletter-content",
|
||||
status: true,
|
||||
icon: FeedbackIcon,
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import AuthorBox from "@/components/shared/AuthorBox";
|
||||
import LayoutMdx from "@/components/shared/LayoutMdx";
|
||||
import Image from "next/image";
|
||||
|
||||
import HeaderImage from "./create-a-new-survey-with-formbricks.png";
|
||||
import LayoutMdx from "@/components/shared/LayoutMdx";
|
||||
import MonorepoImage from "./formbricks-monorepo-folder-structure.png";
|
||||
import PackagesFolderImage from "./formbricks-packages-folder.png";
|
||||
import HeaderImage from "./create-a-new-survey-with-formbricks.png";
|
||||
import GitpodImage from "./setup-formbricks-via-gitpod.png";
|
||||
import PackagesFolderImage from "./formbricks-packages-folder.png";
|
||||
import AuthorBox from "@/components/shared/AuthorBox";
|
||||
|
||||
export const meta = {
|
||||
title: "Join the FormTribe 🔥",
|
||||
@@ -17,7 +16,7 @@ export const meta = {
|
||||
tags: ["Open-Source", "No-Code", "Formbricks", "Geting started", "Welcome guide"],
|
||||
};
|
||||
|
||||
<AuthorBox name="Johannes" title="Co-Founder" date="October 1st, 2023" duration="4" author={"Johannes"} />
|
||||
<AuthorBox name="Johannes" title="Co-Founder" date="October 1st, 2023" duration="4" author={"Johannes"}/>
|
||||
|
||||
<Image src={HeaderImage} alt="Title Image" className="w-full rounded-lg" />
|
||||
|
||||
@@ -55,9 +54,9 @@ To get up and running we have 2 options: Gitpod and local.
|
||||
|
||||
With Gitpod you can run all of Formbricks in the cloud. With one click you can start coding right away in your browser:
|
||||
|
||||
<Image src={GitpodImage} alt="Setup Formbricks via Gitpod" className="w-full rounded-lg" />
|
||||
[](https://gitpod.io/#https://github.com/formbricks/formbricks)
|
||||
|
||||
[Read more in our docs](https://formbricks.com/docs/contributing/setup#gitpod-guide)
|
||||
<Image src={GitpodImage} alt="Setup Formbricks via Gitpod" className="w-full rounded-lg" />
|
||||
|
||||
#### Run on a local machine
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRouter } from "next/router";
|
||||
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
|
||||
import { FooterLogo } from "../shared/Logo";
|
||||
import { FooterLogo } from "../../components/shared/Logo";
|
||||
|
||||
export default function HeaderLight() {
|
||||
const plausible = usePlausible();
|
||||
@@ -1,5 +1,5 @@
|
||||
import Footer from "../shared/Footer";
|
||||
import MetaInformation from "../shared/MetaInformation";
|
||||
import Footer from "../../components/shared/Footer";
|
||||
import MetaInformation from "../../components/shared/MetaInformation";
|
||||
import HeaderLight from "./HeaderLight";
|
||||
|
||||
interface LayoutProps {
|
||||
@@ -1,12 +1,12 @@
|
||||
import Layout from "@/components/demo/LayoutLight";
|
||||
import DemoView from "@/components/dummyUI/DemoView";
|
||||
import LayoutWaitlist from "@/pages/demo/LayoutLight";
|
||||
|
||||
export default function DemoPage() {
|
||||
return (
|
||||
<Layout
|
||||
<LayoutWaitlist
|
||||
title="Formbricks Demo"
|
||||
description="Play around with our pre-defined 30+ templates and them to kick-start your survey & experience management.">
|
||||
<DemoView />
|
||||
</Layout>
|
||||
</LayoutWaitlist>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
"use server";
|
||||
|
||||
import { Team } from "@prisma/client";
|
||||
import { Prisma as prismaClient } from "@prisma/client/";
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { SHORT_URL_BASE, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { createMembership } from "@formbricks/lib/membership/service";
|
||||
import { createProduct } from "@formbricks/lib/product/service";
|
||||
import { createShortUrl } from "@formbricks/lib/shortUrl/service";
|
||||
import { canUserAccessSurvey, verifyUserRoleAccess } from "@formbricks/lib/survey/auth";
|
||||
import { surveyCache } from "@formbricks/lib/survey/cache";
|
||||
import { deleteSurvey, duplicateSurvey, getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { createTeam, getTeamByEnvironmentId } from "@formbricks/lib/team/service";
|
||||
import { updateUser } from "@formbricks/lib/user/service";
|
||||
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
|
||||
export const createShortUrlAction = async (url: string) => {
|
||||
@@ -40,28 +44,193 @@ export async function createTeamAction(teamName: string): Promise<Team> {
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
const product = await createProduct(newTeam.id, {
|
||||
await createProduct(newTeam.id, {
|
||||
name: "My Product",
|
||||
});
|
||||
|
||||
const updatedNotificationSettings = {
|
||||
...session.user.notificationSettings,
|
||||
alert: {
|
||||
...session.user.notificationSettings?.alert,
|
||||
},
|
||||
weeklySummary: {
|
||||
...session.user.notificationSettings?.weeklySummary,
|
||||
[product.id]: true,
|
||||
},
|
||||
};
|
||||
|
||||
await updateUser(session.user.id, {
|
||||
notificationSettings: updatedNotificationSettings,
|
||||
});
|
||||
|
||||
return newTeam;
|
||||
}
|
||||
|
||||
export async function duplicateSurveyAction(environmentId: string, surveyId: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const duplicatedSurvey = await duplicateSurvey(environmentId, surveyId);
|
||||
return duplicatedSurvey;
|
||||
}
|
||||
|
||||
export async function copyToOtherEnvironmentAction(
|
||||
environmentId: string,
|
||||
surveyId: string,
|
||||
targetEnvironmentId: string
|
||||
) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorizedToAccessSourceEnvironment = await hasUserEnvironmentAccess(
|
||||
session.user.id,
|
||||
environmentId
|
||||
);
|
||||
if (!isAuthorizedToAccessSourceEnvironment) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorizedToAccessTargetEnvironment = await hasUserEnvironmentAccess(
|
||||
session.user.id,
|
||||
targetEnvironmentId
|
||||
);
|
||||
if (!isAuthorizedToAccessTargetEnvironment) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const existingSurvey = await prisma.survey.findFirst({
|
||||
where: {
|
||||
id: surveyId,
|
||||
environmentId,
|
||||
},
|
||||
include: {
|
||||
triggers: {
|
||||
include: {
|
||||
actionClass: true,
|
||||
},
|
||||
},
|
||||
attributeFilters: {
|
||||
include: {
|
||||
attributeClass: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingSurvey) {
|
||||
throw new ResourceNotFoundError("Survey", surveyId);
|
||||
}
|
||||
|
||||
let targetEnvironmentTriggers: string[] = [];
|
||||
// map the local triggers to the target environment
|
||||
for (const trigger of existingSurvey.triggers) {
|
||||
const targetEnvironmentTrigger = await prisma.actionClass.findFirst({
|
||||
where: {
|
||||
name: trigger.actionClass.name,
|
||||
environment: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!targetEnvironmentTrigger) {
|
||||
// if the trigger does not exist in the target environment, create it
|
||||
const newTrigger = await prisma.actionClass.create({
|
||||
data: {
|
||||
name: trigger.actionClass.name,
|
||||
environment: {
|
||||
connect: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
description: trigger.actionClass.description,
|
||||
type: trigger.actionClass.type,
|
||||
noCodeConfig: trigger.actionClass.noCodeConfig
|
||||
? JSON.parse(JSON.stringify(trigger.actionClass.noCodeConfig))
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
targetEnvironmentTriggers.push(newTrigger.id);
|
||||
} else {
|
||||
targetEnvironmentTriggers.push(targetEnvironmentTrigger.id);
|
||||
}
|
||||
}
|
||||
|
||||
let targetEnvironmentAttributeFilters: string[] = [];
|
||||
// map the local attributeFilters to the target env
|
||||
for (const attributeFilter of existingSurvey.attributeFilters) {
|
||||
// check if attributeClass exists in target env.
|
||||
// if not, create it
|
||||
const targetEnvironmentAttributeClass = await prisma.attributeClass.findFirst({
|
||||
where: {
|
||||
name: attributeFilter.attributeClass.name,
|
||||
environment: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!targetEnvironmentAttributeClass) {
|
||||
const newAttributeClass = await prisma.attributeClass.create({
|
||||
data: {
|
||||
name: attributeFilter.attributeClass.name,
|
||||
description: attributeFilter.attributeClass.description,
|
||||
type: attributeFilter.attributeClass.type,
|
||||
environment: {
|
||||
connect: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
targetEnvironmentAttributeFilters.push(newAttributeClass.id);
|
||||
} else {
|
||||
targetEnvironmentAttributeFilters.push(targetEnvironmentAttributeClass.id);
|
||||
}
|
||||
}
|
||||
|
||||
// create new survey with the data of the existing survey
|
||||
const newSurvey = await prisma.survey.create({
|
||||
data: {
|
||||
...existingSurvey,
|
||||
id: undefined, // id is auto-generated
|
||||
environmentId: undefined, // environmentId is set below
|
||||
name: `${existingSurvey.name} (copy)`,
|
||||
status: "draft",
|
||||
questions: JSON.parse(JSON.stringify(existingSurvey.questions)),
|
||||
thankYouCard: JSON.parse(JSON.stringify(existingSurvey.thankYouCard)),
|
||||
triggers: {
|
||||
create: targetEnvironmentTriggers.map((actionClassId) => ({
|
||||
actionClassId: actionClassId,
|
||||
})),
|
||||
},
|
||||
attributeFilters: {
|
||||
create: existingSurvey.attributeFilters.map((attributeFilter, idx) => ({
|
||||
attributeClassId: targetEnvironmentAttributeFilters[idx],
|
||||
condition: attributeFilter.condition,
|
||||
value: attributeFilter.value,
|
||||
})),
|
||||
},
|
||||
environment: {
|
||||
connect: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
surveyClosedMessage: existingSurvey.surveyClosedMessage ?? prismaClient.JsonNull,
|
||||
singleUse: existingSurvey.singleUse ?? prismaClient.JsonNull,
|
||||
productOverwrites: existingSurvey.productOverwrites ?? prismaClient.JsonNull,
|
||||
verifyEmail: existingSurvey.verifyEmail ?? prismaClient.JsonNull,
|
||||
styling: existingSurvey.styling ?? prismaClient.JsonNull,
|
||||
},
|
||||
});
|
||||
|
||||
surveyCache.revalidate({
|
||||
id: newSurvey.id,
|
||||
environmentId: targetEnvironmentId,
|
||||
});
|
||||
return newSurvey;
|
||||
}
|
||||
|
||||
export const deleteSurveyAction = async (surveyId: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const survey = await getSurvey(surveyId);
|
||||
|
||||
const { hasDeleteAccess } = await verifyUserRoleAccess(survey!.environmentId, session.user.id);
|
||||
if (!hasDeleteAccess) throw new AuthorizationError("Not authorized");
|
||||
|
||||
await deleteSurvey(surveyId);
|
||||
};
|
||||
|
||||
export const createProductAction = async (environmentId: string, productName: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
@@ -75,20 +244,6 @@ export const createProductAction = async (environmentId: string, productName: st
|
||||
const product = await createProduct(team.id, {
|
||||
name: productName,
|
||||
});
|
||||
const updatedNotificationSettings = {
|
||||
...session.user.notificationSettings,
|
||||
alert: {
|
||||
...session.user.notificationSettings?.alert,
|
||||
},
|
||||
weeklySummary: {
|
||||
...session.user.notificationSettings?.weeklySummary,
|
||||
[product.id]: true,
|
||||
},
|
||||
};
|
||||
|
||||
await updateUser(session.user.id, {
|
||||
notificationSettings: updatedNotificationSettings,
|
||||
});
|
||||
|
||||
// get production environment
|
||||
const productionEnvironment = product.environments.find((environment) => environment.type === "production");
|
||||
|
||||
@@ -504,7 +504,7 @@ export default function Navigation({
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await signOut({ callbackUrl: "/auth/login" });
|
||||
await signOut();
|
||||
await formbricksLogout();
|
||||
}}>
|
||||
<div className="flex h-full w-full items-center">
|
||||
|
||||
@@ -98,11 +98,8 @@ export const leaveTeamAction = async (teamId: string) => {
|
||||
};
|
||||
|
||||
export const createInviteTokenAction = async (inviteId: string) => {
|
||||
const invite = await getInvite(inviteId);
|
||||
if (!invite) {
|
||||
throw new ValidationError("Invite not found");
|
||||
}
|
||||
const inviteToken = createInviteToken(inviteId, invite.email, {
|
||||
const { email } = await getInvite(inviteId);
|
||||
const inviteToken = createInviteToken(inviteId, email, {
|
||||
expiresIn: "7d",
|
||||
});
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { updateUser } from "@formbricks/lib/user/service";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { TUserNotificationSettings } from "@formbricks/types/user";
|
||||
|
||||
@@ -13,7 +13,9 @@ export async function updateNotificationSettingsAction(notificationSettings: TUs
|
||||
throw new AuthorizationError("Not authenticated");
|
||||
}
|
||||
|
||||
await updateUser(session.user.id, {
|
||||
notificationSettings,
|
||||
// update user with notification settings
|
||||
await prisma.user.update({
|
||||
where: { id: session.user.id },
|
||||
data: { notificationSettings },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,49 +1,27 @@
|
||||
import { QuestionMarkCircleIcon, UsersIcon } from "@heroicons/react/24/solid";
|
||||
import Link from "next/link";
|
||||
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui/Tooltip";
|
||||
|
||||
import { Membership } from "../types";
|
||||
import { Membership, User } from "../types";
|
||||
import { NotificationSwitch } from "./NotificationSwitch";
|
||||
|
||||
interface EditAlertsProps {
|
||||
memberships: Membership[];
|
||||
user: TUser;
|
||||
user: User;
|
||||
environmentId: string;
|
||||
autoDisableNotificationType: string;
|
||||
autoDisableNotificationElementId: string;
|
||||
}
|
||||
|
||||
export default function EditAlerts({
|
||||
memberships,
|
||||
user,
|
||||
environmentId,
|
||||
autoDisableNotificationType,
|
||||
autoDisableNotificationElementId,
|
||||
}: EditAlertsProps) {
|
||||
export default function EditAlerts({ memberships, user, environmentId }: EditAlertsProps) {
|
||||
return (
|
||||
<>
|
||||
{memberships.map((membership) => (
|
||||
<>
|
||||
<div className="mb-5 grid grid-cols-6 items-center space-x-3">
|
||||
<div className="col-span-3 flex items-center space-x-3">
|
||||
<div className="rounded-full bg-slate-100 p-1">
|
||||
<UsersIcon className="h-6 w-7 text-slate-600" />
|
||||
</div>
|
||||
<p className="font-semibold text-slate-800">{membership.team.name}</p>
|
||||
</div>
|
||||
|
||||
<div className="col-span-3 flex items-center justify-end pr-2">
|
||||
<p className="pr-4 text-sm text-slate-600">Auto-subscribe to new surveys</p>
|
||||
<NotificationSwitch
|
||||
surveyOrProductOrTeamId={membership.team.id}
|
||||
notificationSettings={user.notificationSettings!}
|
||||
notificationType={"unsubscribedTeamIds"}
|
||||
autoDisableNotificationType={autoDisableNotificationType}
|
||||
autoDisableNotificationElementId={autoDisableNotificationElementId}
|
||||
/>
|
||||
<div className="mb-5 flex items-center space-x-3 font-semibold">
|
||||
<div className="rounded-full bg-slate-100 p-1">
|
||||
<UsersIcon className="h-6 w-7 text-slate-600" />
|
||||
</div>
|
||||
<p className="text-slate-800">{membership.team.name}</p>
|
||||
</div>
|
||||
<div className="mb-6 rounded-lg border border-slate-200">
|
||||
<div className="grid h-12 grid-cols-3 content-center rounded-t-lg bg-slate-100 px-4 text-left text-sm font-semibold text-slate-900">
|
||||
@@ -79,11 +57,9 @@ export default function EditAlerts({
|
||||
</div>
|
||||
<div className="col-span-1 text-center">
|
||||
<NotificationSwitch
|
||||
surveyOrProductOrTeamId={survey.id}
|
||||
notificationSettings={user.notificationSettings!}
|
||||
surveyOrProductId={survey.id}
|
||||
notificationSettings={user.notificationSettings}
|
||||
notificationType={"alert"}
|
||||
autoDisableNotificationType={autoDisableNotificationType}
|
||||
autoDisableNotificationElementId={autoDisableNotificationElementId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { UsersIcon } from "@heroicons/react/24/solid";
|
||||
import Link from "next/link";
|
||||
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
|
||||
import { Membership } from "../types";
|
||||
import { Membership, User } from "../types";
|
||||
import { NotificationSwitch } from "./NotificationSwitch";
|
||||
|
||||
interface EditAlertsProps {
|
||||
memberships: Membership[];
|
||||
user: TUser;
|
||||
user: User;
|
||||
environmentId: string;
|
||||
}
|
||||
|
||||
@@ -36,8 +34,8 @@ export default function EditWeeklySummary({ memberships, user, environmentId }:
|
||||
<div className="col-span-2">{product?.name}</div>
|
||||
<div className="col-span-1 flex items-center justify-center">
|
||||
<NotificationSwitch
|
||||
surveyOrProductOrTeamId={product.id}
|
||||
notificationSettings={user.notificationSettings!}
|
||||
surveyOrProductId={product.id}
|
||||
notificationSettings={user.notificationSettings}
|
||||
notificationType={"weeklySummary"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { TUserNotificationSettings } from "@formbricks/types/user";
|
||||
@@ -9,90 +10,35 @@ import { Switch } from "@formbricks/ui/Switch";
|
||||
import { updateNotificationSettingsAction } from "../actions";
|
||||
|
||||
interface NotificationSwitchProps {
|
||||
surveyOrProductOrTeamId: string;
|
||||
surveyOrProductId: string;
|
||||
notificationSettings: TUserNotificationSettings;
|
||||
notificationType: "alert" | "weeklySummary" | "unsubscribedTeamIds";
|
||||
autoDisableNotificationType?: string;
|
||||
autoDisableNotificationElementId?: string;
|
||||
notificationType: "alert" | "weeklySummary";
|
||||
}
|
||||
|
||||
export function NotificationSwitch({
|
||||
surveyOrProductOrTeamId,
|
||||
surveyOrProductId,
|
||||
notificationSettings,
|
||||
notificationType,
|
||||
autoDisableNotificationType,
|
||||
autoDisableNotificationElementId,
|
||||
}: NotificationSwitchProps) {
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const isChecked =
|
||||
notificationType === "unsubscribedTeamIds"
|
||||
? !notificationSettings.unsubscribedTeamIds?.includes(surveyOrProductOrTeamId)
|
||||
: notificationSettings[notificationType][surveyOrProductOrTeamId] === true;
|
||||
|
||||
const handleSwitchChange = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
let updatedNotificationSettings = { ...notificationSettings };
|
||||
if (notificationType === "unsubscribedTeamIds") {
|
||||
const unsubscribedTeamIds = updatedNotificationSettings.unsubscribedTeamIds ?? [];
|
||||
if (unsubscribedTeamIds.includes(surveyOrProductOrTeamId)) {
|
||||
updatedNotificationSettings.unsubscribedTeamIds = unsubscribedTeamIds.filter(
|
||||
(id) => id !== surveyOrProductOrTeamId
|
||||
);
|
||||
} else {
|
||||
updatedNotificationSettings.unsubscribedTeamIds = [...unsubscribedTeamIds, surveyOrProductOrTeamId];
|
||||
}
|
||||
} else {
|
||||
updatedNotificationSettings[notificationType][surveyOrProductOrTeamId] =
|
||||
!updatedNotificationSettings[notificationType][surveyOrProductOrTeamId];
|
||||
}
|
||||
|
||||
await updateNotificationSettingsAction(updatedNotificationSettings);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
autoDisableNotificationType &&
|
||||
autoDisableNotificationElementId === surveyOrProductOrTeamId &&
|
||||
isChecked
|
||||
) {
|
||||
switch (notificationType) {
|
||||
case "alert":
|
||||
if (notificationSettings[notificationType][surveyOrProductOrTeamId] === true) {
|
||||
handleSwitchChange();
|
||||
toast.success("You will not receive any more emails for responses on this survey!", {
|
||||
id: "notification-switch",
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "unsubscribedTeamIds":
|
||||
if (!notificationSettings.unsubscribedTeamIds?.includes(surveyOrProductOrTeamId)) {
|
||||
handleSwitchChange();
|
||||
toast.success("You will not be auto-subscribed to this team's surveys anymore!", {
|
||||
id: "notification-switch",
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Switch
|
||||
id="notification-switch"
|
||||
aria-label={`toggle notification settings for ${notificationType}`}
|
||||
checked={isChecked}
|
||||
aria-label="toggle notification settings"
|
||||
checked={notificationSettings[notificationType][surveyOrProductId]}
|
||||
disabled={isLoading}
|
||||
onCheckedChange={async () => {
|
||||
await handleSwitchChange();
|
||||
toast.success("Notification settings updated", { id: "notification-switch" });
|
||||
setIsLoading(true);
|
||||
// update notificiation settings
|
||||
const updatedNotificationSettings = { ...notificationSettings };
|
||||
updatedNotificationSettings[notificationType][surveyOrProductId] =
|
||||
!updatedNotificationSettings[notificationType][surveyOrProductId];
|
||||
await updateNotificationSettingsAction(notificationSettings);
|
||||
setIsLoading(false);
|
||||
toast.success(`Notification settings updated`, { id: "notification-switch" });
|
||||
router.refresh();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -3,24 +3,42 @@ import { getServerSession } from "next-auth";
|
||||
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { getUser } from "@formbricks/lib/user/service";
|
||||
import { TUserNotificationSettings } from "@formbricks/types/user";
|
||||
|
||||
import SettingsTitle from "../components/SettingsTitle";
|
||||
import EditAlerts from "./components/EditAlerts";
|
||||
import EditWeeklySummary from "./components/EditWeeklySummary";
|
||||
import IntegrationsTip from "./components/IntegrationsTip";
|
||||
import type { Membership } from "./types";
|
||||
import type { Membership, User } from "./types";
|
||||
|
||||
function setCompleteNotificationSettings(
|
||||
async function getUser(userId: string | undefined): Promise<User> {
|
||||
if (!userId) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
const userData = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
notificationSettings: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!userData) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
const user = JSON.parse(JSON.stringify(userData)); // hack to remove the JsonValue type from the notificationSettings
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
function cleanNotificationSettings(
|
||||
notificationSettings: TUserNotificationSettings,
|
||||
memberships: Membership[]
|
||||
): TUserNotificationSettings {
|
||||
const newNotificationSettings = {
|
||||
alert: {},
|
||||
weeklySummary: {},
|
||||
unsubscribedTeamIds: notificationSettings.unsubscribedTeamIds || [],
|
||||
};
|
||||
) {
|
||||
const newNotificationSettings = { alert: {}, weeklySummary: {} };
|
||||
for (const membership of memberships) {
|
||||
for (const product of membership.team.products) {
|
||||
// set default values for weekly summary
|
||||
@@ -77,22 +95,13 @@ async function getMemberships(userId: string): Promise<Membership[]> {
|
||||
return memberships;
|
||||
}
|
||||
|
||||
export default async function ProfileSettingsPage({ params, searchParams }) {
|
||||
export default async function ProfileSettingsPage({ params }) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
const autoDisableNotificationType = searchParams["type"];
|
||||
const autoDisableNotificationElementId = searchParams["elementId"];
|
||||
|
||||
const [user, memberships] = await Promise.all([getUser(session.user.id), getMemberships(session.user.id)]);
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
if (user?.notificationSettings) {
|
||||
user.notificationSettings = setCompleteNotificationSettings(user.notificationSettings, memberships);
|
||||
}
|
||||
user.notificationSettings = cleanNotificationSettings(user.notificationSettings, memberships);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -100,16 +109,11 @@ export default async function ProfileSettingsPage({ params, searchParams }) {
|
||||
<SettingsCard
|
||||
title="Email alerts (Surveys)"
|
||||
description="Set up an alert to get an email on new responses.">
|
||||
<EditAlerts
|
||||
memberships={memberships}
|
||||
user={user}
|
||||
environmentId={params.environmentId}
|
||||
autoDisableNotificationType={autoDisableNotificationType}
|
||||
autoDisableNotificationElementId={autoDisableNotificationElementId}
|
||||
/>
|
||||
<EditAlerts memberships={memberships} user={user} environmentId={params.environmentId} />
|
||||
</SettingsCard>
|
||||
<IntegrationsTip environmentId={params.environmentId} />
|
||||
<SettingsCard
|
||||
beta
|
||||
title="Weekly summary (Products)"
|
||||
description="Stay up-to-date with a Weekly every Monday.">
|
||||
<EditWeeklySummary memberships={memberships} user={user} environmentId={params.environmentId} />
|
||||
|
||||
@@ -55,7 +55,7 @@ function DeleteAccountModal({ setOpen, open, session }: DeleteAccountModalProps)
|
||||
try {
|
||||
setDeleting(true);
|
||||
await deleteUserAction();
|
||||
await signOut({ callbackUrl: "/auth/login" });
|
||||
await signOut();
|
||||
await formbricksLogout();
|
||||
} catch (error) {
|
||||
toast.error("Something went wrong");
|
||||
|
||||
@@ -27,7 +27,10 @@ export default function SurveyStatusDropdown({
|
||||
<>
|
||||
{survey.status === "draft" ? (
|
||||
<div className="flex items-center">
|
||||
<p className="text-sm italic text-slate-600">Draft</p>
|
||||
{(survey.type === "link" || environment.widgetSetupCompleted) && (
|
||||
<SurveyStatusIndicator status={survey.status} />
|
||||
)}
|
||||
{survey.status === "draft" && <p className="text-sm italic text-slate-600">Draft</p>}
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
|
||||
@@ -257,12 +257,12 @@ export default function ResponseOptionsCard({
|
||||
surveyClosedMessage.subheading,
|
||||
]);
|
||||
|
||||
const toggleAutocomplete = () => {
|
||||
const handleCheckMark = () => {
|
||||
if (autoComplete) {
|
||||
const updatedSurvey = { ...localSurvey, autoComplete: null };
|
||||
setLocalSurvey(updatedSurvey);
|
||||
} else {
|
||||
const updatedSurvey = { ...localSurvey, autoComplete: Math.max(25, responseCount + 5) };
|
||||
const updatedSurvey = { ...localSurvey, autoComplete: 25 };
|
||||
setLocalSurvey(updatedSurvey);
|
||||
}
|
||||
};
|
||||
@@ -310,7 +310,7 @@ export default function ResponseOptionsCard({
|
||||
<AdvancedOptionToggle
|
||||
htmlId="closeOnNumberOfResponse"
|
||||
isChecked={autoComplete}
|
||||
onToggle={toggleAutocomplete}
|
||||
onToggle={handleCheckMark}
|
||||
title="Close survey on response limit"
|
||||
description="Automatically close the survey after a certain number of responses."
|
||||
childBorder={true}>
|
||||
@@ -411,7 +411,7 @@ export default function ResponseOptionsCard({
|
||||
htmlId="singleUserSurveyOptions"
|
||||
isChecked={!!localSurvey.singleUse?.enabled}
|
||||
onToggle={handleSingleUseSurveyToggle}
|
||||
title="Single-use survey links"
|
||||
title="Single-Use Survey Links"
|
||||
description="Allow only 1 response per survey link."
|
||||
childBorder={true}>
|
||||
<div className="flex w-full items-center space-x-1 p-4 pb-4">
|
||||
@@ -507,7 +507,7 @@ export default function ResponseOptionsCard({
|
||||
htmlId="protectSurveyWithPin"
|
||||
isChecked={isPinProtectionEnabled}
|
||||
onToggle={handleProtectSurveyWithPinToggle}
|
||||
title="Protect survey with a PIN"
|
||||
title="Protect Survey with a PIN"
|
||||
description="Only users who have the PIN can access the survey."
|
||||
childBorder={true}>
|
||||
<div className="flex w-full items-center space-x-1 p-4 pb-4">
|
||||
|
||||
@@ -223,6 +223,14 @@ export default function SurveyMenuBar({
|
||||
return false;
|
||||
}
|
||||
|
||||
/*
|
||||
Check whether the count for autocomplete responses is not less
|
||||
than the current count of accepted response and also it is not set to 0
|
||||
*/
|
||||
if ((survey.autoComplete && responseCount >= survey.autoComplete) || survey?.autoComplete === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
copyToOtherEnvironmentAction,
|
||||
deleteSurveyAction,
|
||||
duplicateSurveyAction,
|
||||
} from "@/app/(app)/environments/[environmentId]/actions";
|
||||
import {
|
||||
ArrowUpOnSquareStackIcon,
|
||||
DocumentDuplicateIcon,
|
||||
EllipsisHorizontalIcon,
|
||||
EyeIcon,
|
||||
LinkIcon,
|
||||
PencilSquareIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { MoreVertical } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
@@ -16,17 +21,15 @@ import toast from "react-hot-toast";
|
||||
|
||||
import type { TEnvironment } from "@formbricks/types/environment";
|
||||
import type { TSurvey } from "@formbricks/types/surveys";
|
||||
|
||||
import { DeleteDialog } from "../../DeleteDialog";
|
||||
import { DeleteDialog } from "@formbricks/ui/DeleteDialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "../../DropdownMenu";
|
||||
import LoadingSpinner from "../../LoadingSpinner";
|
||||
import { copyToOtherEnvironmentAction, deleteSurveyAction, duplicateSurveyAction } from "../actions";
|
||||
} from "@formbricks/ui/DropdownMenu";
|
||||
import LoadingSpinner from "@formbricks/ui/LoadingSpinner";
|
||||
|
||||
interface SurveyDropDownMenuProps {
|
||||
environmentId: string;
|
||||
@@ -49,12 +52,11 @@ export default function SurveyDropDownMenu({
|
||||
}: SurveyDropDownMenuProps) {
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const surveyUrl = useMemo(() => webAppUrl + "/s/" + survey.id, [survey.id, webAppUrl]);
|
||||
|
||||
const handleDeleteSurvey = async (survey: TSurvey) => {
|
||||
const handleDeleteSurvey = async (survey) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await deleteSurveyAction(survey.id);
|
||||
@@ -67,7 +69,7 @@ export default function SurveyDropDownMenu({
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const duplicateSurveyAndRefresh = async (surveyId: string) => {
|
||||
const duplicateSurveyAndRefresh = async (surveyId) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await duplicateSurveyAction(environmentId, surveyId);
|
||||
@@ -79,7 +81,7 @@ export default function SurveyDropDownMenu({
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const copyToOtherEnvironment = async (surveyId: string) => {
|
||||
const copyToOtherEnvironment = async (surveyId) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await copyToOtherEnvironmentAction(environmentId, surveyId, otherEnvironment.id);
|
||||
@@ -103,11 +105,11 @@ export default function SurveyDropDownMenu({
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu open={isDropDownOpen} onOpenChange={setIsDropDownOpen}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="z-10 cursor-pointer" asChild>
|
||||
<div className="rounded-lg border p-2 hover:bg-slate-50">
|
||||
<div>
|
||||
<span className="sr-only">Open options</span>
|
||||
<MoreVertical className="h-4 w-4" aria-hidden="true" />
|
||||
<EllipsisHorizontalIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-40">
|
||||
@@ -127,9 +129,7 @@ export default function SurveyDropDownMenu({
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
onClick={async () => {
|
||||
duplicateSurveyAndRefresh(survey.id);
|
||||
}}>
|
||||
<DocumentDuplicateIcon className="mr-2 h-4 w-4" />
|
||||
@@ -145,9 +145,7 @@ export default function SurveyDropDownMenu({
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
onClick={() => {
|
||||
copyToOtherEnvironment(survey.id);
|
||||
}}>
|
||||
<ArrowUpOnSquareStackIcon className="mr-2 h-4 w-4" />
|
||||
@@ -159,9 +157,7 @@ export default function SurveyDropDownMenu({
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
onClick={() => {
|
||||
copyToOtherEnvironment(survey.id);
|
||||
}}>
|
||||
<ArrowUpOnSquareStackIcon className="mr-2 h-4 w-4" />
|
||||
@@ -174,27 +170,23 @@ export default function SurveyDropDownMenu({
|
||||
{survey.type === "link" && survey.status !== "draft" && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<div
|
||||
className="flex w-full cursor-pointer items-center"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
const previewUrl = singleUseId
|
||||
<Link
|
||||
className="flex w-full items-center"
|
||||
href={
|
||||
singleUseId
|
||||
? `/s/${survey.id}?suId=${singleUseId}&preview=true`
|
||||
: `/s/${survey.id}?preview=true`;
|
||||
window.open(previewUrl, "_blank");
|
||||
}}>
|
||||
: `/s/${survey.id}?preview=true`
|
||||
}
|
||||
target="_blank">
|
||||
<EyeIcon className="mr-2 h-4 w-4" />
|
||||
Preview Survey
|
||||
</div>
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
singleUseId ? `${surveyUrl}?suId=${singleUseId}` : surveyUrl
|
||||
);
|
||||
@@ -212,9 +204,7 @@ export default function SurveyDropDownMenu({
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
onClick={() => {
|
||||
setDeleteDialogOpen(true);
|
||||
}}>
|
||||
<TrashIcon className="mr-2 h-4 w-4" />
|
||||
@@ -0,0 +1,143 @@
|
||||
import { UsageAttributesUpdater } from "@/app/(app)/components/FormbricksClient";
|
||||
import SurveyDropDownMenu from "@/app/(app)/environments/[environmentId]/surveys/components/SurveyDropDownMenu";
|
||||
import SurveyStarter from "@/app/(app)/environments/[environmentId]/surveys/components/SurveyStarter";
|
||||
import { generateSurveySingleUseId } from "@/app/lib/singleUseSurveys";
|
||||
import { ComputerDesktopIcon, LinkIcon, PlusIcon } from "@heroicons/react/24/solid";
|
||||
import { getServerSession } from "next-auth";
|
||||
import Link from "next/link";
|
||||
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { getEnvironment, getEnvironments } from "@formbricks/lib/environment/service";
|
||||
import { getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { getSurveys } from "@formbricks/lib/survey/service";
|
||||
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
|
||||
import type { TEnvironment } from "@formbricks/types/environment";
|
||||
import { Badge } from "@formbricks/ui/Badge";
|
||||
import { SurveyStatusIndicator } from "@formbricks/ui/SurveyStatusIndicator";
|
||||
|
||||
export default async function SurveysList({ environmentId }: { environmentId: string }) {
|
||||
const session = await getServerSession(authOptions);
|
||||
const product = await getProductByEnvironmentId(environmentId);
|
||||
const team = await getTeamByEnvironmentId(environmentId);
|
||||
|
||||
if (!session) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
throw new Error("Product not found");
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
throw new Error("Team not found");
|
||||
}
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdTeamId(session?.user.id, team.id);
|
||||
const { isViewer } = getAccessFlags(currentUserMembership?.role);
|
||||
const isSurveyCreationDeletionDisabled = isViewer;
|
||||
|
||||
const environment = await getEnvironment(environmentId);
|
||||
if (!environment) {
|
||||
throw new Error("Environment not found");
|
||||
}
|
||||
const surveys = await getSurveys(environmentId);
|
||||
|
||||
const environments: TEnvironment[] = await getEnvironments(product.id);
|
||||
const otherEnvironment = environments.find((e) => e.type !== environment.type)!;
|
||||
|
||||
if (surveys.length === 0) {
|
||||
return (
|
||||
<SurveyStarter
|
||||
environmentId={environmentId}
|
||||
environment={environment}
|
||||
product={product}
|
||||
user={session.user}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className="grid place-content-stretch gap-6 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-5 ">
|
||||
{!isSurveyCreationDeletionDisabled && (
|
||||
<Link href={`/environments/${environmentId}/surveys/templates`}>
|
||||
<li className="col-span-1 h-56">
|
||||
<div className="delay-50 flex h-full items-center justify-center overflow-hidden rounded-md bg-gradient-to-br from-slate-900 to-slate-800 font-light text-white shadow transition ease-in-out hover:scale-105 hover:from-slate-800 hover:to-slate-700">
|
||||
<div id="main-cta" className="px-4 py-8 sm:p-14 xl:p-10">
|
||||
<PlusIcon className="stroke-thin mx-auto h-14 w-14" />
|
||||
Create Survey
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</Link>
|
||||
)}
|
||||
{surveys
|
||||
.sort((a, b) => b.updatedAt?.getTime() - a.updatedAt?.getTime())
|
||||
.map((survey) => {
|
||||
const isSingleUse = survey.singleUse?.enabled ?? false;
|
||||
const isEncrypted = survey.singleUse?.isEncrypted ?? false;
|
||||
const singleUseId = isSingleUse ? generateSurveySingleUseId(isEncrypted) : undefined;
|
||||
return (
|
||||
<li key={survey.id} className="relative col-span-1 h-56">
|
||||
<div className="delay-50 flex h-full flex-col justify-between rounded-md bg-white shadow transition ease-in-out hover:scale-105">
|
||||
<div className="px-6 py-4">
|
||||
<Badge
|
||||
StartIcon={survey.type === "link" ? LinkIcon : ComputerDesktopIcon}
|
||||
startIconClassName="mr-2"
|
||||
text={
|
||||
survey.type === "link"
|
||||
? "Link Survey"
|
||||
: survey.type === "web"
|
||||
? "In-Product Survey"
|
||||
: ""
|
||||
}
|
||||
type="gray"
|
||||
size={"tiny"}
|
||||
className="font-base"></Badge>
|
||||
<p className="my-2 line-clamp-3 text-lg">{survey.name}</p>
|
||||
</div>
|
||||
<Link
|
||||
href={
|
||||
survey.status === "draft"
|
||||
? `/environments/${environmentId}/surveys/${survey.id}/edit`
|
||||
: `/environments/${environmentId}/surveys/${survey.id}/summary`
|
||||
}
|
||||
className="absolute h-full w-full"></Link>
|
||||
<div className="divide-y divide-slate-100">
|
||||
<div className="flex justify-between px-4 py-2 text-right sm:px-6">
|
||||
<div className="flex items-center">
|
||||
{survey.status !== "draft" && (
|
||||
<>
|
||||
{(survey.type === "link" || environment.widgetSetupCompleted) && (
|
||||
<SurveyStatusIndicator status={survey.status} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{survey.status === "draft" && (
|
||||
<span className="text-xs italic text-slate-400">Draft</span>
|
||||
)}
|
||||
</div>
|
||||
<SurveyDropDownMenu
|
||||
survey={survey}
|
||||
key={`surveys-${survey.id}`}
|
||||
environmentId={environmentId}
|
||||
environment={environment}
|
||||
otherEnvironment={otherEnvironment!}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
singleUseId={singleUseId}
|
||||
isSurveyCreationDeletionDisabled={isSurveyCreationDeletionDisabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<UsageAttributesUpdater numSurveys={surveys.length} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -31,12 +31,11 @@ export default function SurveyStarter({
|
||||
setIsCreateSurveyLoading(true);
|
||||
const surveyType = environment?.widgetSetupCompleted ? "web" : "link";
|
||||
const autoComplete = surveyType === "web" ? 50 : null;
|
||||
const augmentedTemplate: TSurveyInput = {
|
||||
const augmentedTemplate = {
|
||||
...template.preset,
|
||||
type: surveyType,
|
||||
autoComplete: autoComplete || undefined,
|
||||
createdBy: user.id,
|
||||
};
|
||||
autoComplete,
|
||||
} as TSurveyInput;
|
||||
try {
|
||||
const survey = await createSurveyAction(environmentId, augmentedTemplate);
|
||||
router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`);
|
||||
|
||||
@@ -1,71 +1,18 @@
|
||||
import WidgetStatusIndicator from "@/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator";
|
||||
import SurveyStarter from "@/app/(app)/environments/[environmentId]/surveys/components/SurveyStarter";
|
||||
import { Metadata } from "next";
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getEnvironments } from "@formbricks/lib/environment/service";
|
||||
import { getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { getSurveys } from "@formbricks/lib/survey/service";
|
||||
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
|
||||
import ContentWrapper from "@formbricks/ui/ContentWrapper";
|
||||
import SurveysList from "@formbricks/ui/SurveysList";
|
||||
|
||||
import SurveysList from "./components/SurveyList";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Your Surveys",
|
||||
};
|
||||
|
||||
export default async function SurveysPage({ params }) {
|
||||
const session = await getServerSession(authOptions);
|
||||
const product = await getProductByEnvironmentId(params.environmentId);
|
||||
const team = await getTeamByEnvironmentId(params.environmentId);
|
||||
if (!session) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
throw new Error("Product not found");
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
throw new Error("Team not found");
|
||||
}
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdTeamId(session?.user.id, team.id);
|
||||
const { isViewer } = getAccessFlags(currentUserMembership?.role);
|
||||
|
||||
const environment = await getEnvironment(params.environmentId);
|
||||
if (!environment) {
|
||||
throw new Error("Environment not found");
|
||||
}
|
||||
const surveys = await getSurveys(params.environmentId);
|
||||
|
||||
const environments = await getEnvironments(product.id);
|
||||
const otherEnvironment = environments.find((e) => e.type !== environment.type)!;
|
||||
|
||||
return (
|
||||
<ContentWrapper className="flex h-full flex-col justify-between">
|
||||
{surveys.length > 0 ? (
|
||||
<SurveysList
|
||||
environment={environment}
|
||||
surveys={surveys}
|
||||
otherEnvironment={otherEnvironment}
|
||||
isViewer={isViewer}
|
||||
WEBAPP_URL={WEBAPP_URL}
|
||||
userId={session.user.id}
|
||||
/>
|
||||
) : (
|
||||
<SurveyStarter
|
||||
environmentId={params.environmentId}
|
||||
environment={environment}
|
||||
product={product}
|
||||
user={session.user}
|
||||
/>
|
||||
)}
|
||||
<SurveysList environmentId={params.environmentId} />
|
||||
<WidgetStatusIndicator environmentId={params.environmentId} type="mini" />
|
||||
</ContentWrapper>
|
||||
);
|
||||
|
||||
@@ -70,12 +70,11 @@ export default function TemplateList({
|
||||
setLoading(true);
|
||||
const surveyType = environment?.widgetSetupCompleted ? "web" : "link";
|
||||
const autoComplete = surveyType === "web" ? 50 : null;
|
||||
const augmentedTemplate: TSurveyInput = {
|
||||
const augmentedTemplate = {
|
||||
...activeTemplate.preset,
|
||||
type: surveyType,
|
||||
autoComplete,
|
||||
createdBy: user.id,
|
||||
};
|
||||
} as TSurveyInput;
|
||||
const survey = await createSurveyAction(environmentId, augmentedTemplate);
|
||||
router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`);
|
||||
};
|
||||
|
||||
@@ -2505,7 +2505,6 @@ export const minimalSurvey: TSurvey = {
|
||||
name: "Minimal Survey",
|
||||
type: "web",
|
||||
environmentId: "someEnvId1",
|
||||
createdBy: null,
|
||||
status: "draft",
|
||||
attributeFilters: [],
|
||||
displayOption: "displayOnce",
|
||||
|
||||
@@ -55,7 +55,7 @@ export default function Onboarding({ session, environmentId, user, product }: On
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const updatedProfile = { onboardingCompleted: true };
|
||||
const updatedProfile = { ...user, onboardingCompleted: true };
|
||||
await updateUserAction(updatedProfile);
|
||||
|
||||
if (environmentId) {
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
|
||||
interface ContentLayoutProps {
|
||||
headline: string;
|
||||
description: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const ContentLayout = ({ headline, description, children }: ContentLayoutProps) => {
|
||||
const ContentLayout = ({ headline, description, children }) => {
|
||||
return (
|
||||
<div className="flex h-screen">
|
||||
<div className="m-auto flex flex-col gap-7 text-center text-slate-700">
|
||||
@@ -63,17 +57,9 @@ export const ExpiredContent = () => {
|
||||
return (
|
||||
<ContentLayout
|
||||
headline="Invite expired 😥"
|
||||
description="Invites are valid for 7 days. Please request a new invite."
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const InvitationNotFound = () => {
|
||||
return (
|
||||
<ContentLayout
|
||||
headline="Invite not found 😥"
|
||||
description="The invitation code cannot be found or has already been used."
|
||||
/>
|
||||
description="Invites are valid for 7 days. Please request a new invite.">
|
||||
<div></div>
|
||||
</ContentLayout>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,54 +1,48 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { sendInviteAcceptedEmail } from "@formbricks/lib/emails/emails";
|
||||
import { env } from "@formbricks/lib/env.mjs";
|
||||
import { deleteInvite, getInvite } from "@formbricks/lib/invite/service";
|
||||
import { verifyInviteToken } from "@formbricks/lib/jwt";
|
||||
import { createMembership } from "@formbricks/lib/membership/service";
|
||||
|
||||
import {
|
||||
ExpiredContent,
|
||||
InvitationNotFound,
|
||||
NotLoggedInContent,
|
||||
RightAccountContent,
|
||||
UsedContent,
|
||||
WrongAccountContent,
|
||||
} from "./components/InviteContentComponents";
|
||||
|
||||
export default async function InvitePage({ searchParams }) {
|
||||
const session = await getServerSession(authOptions);
|
||||
export default async function JoinTeam({ searchParams }) {
|
||||
const currentUser = await getServerSession(authOptions);
|
||||
|
||||
try {
|
||||
const { inviteId, email } = verifyInviteToken(searchParams.token);
|
||||
|
||||
const invite = await getInvite(inviteId);
|
||||
|
||||
if (!invite) {
|
||||
return <InvitationNotFound />;
|
||||
}
|
||||
|
||||
const isInviteExpired = new Date(invite.expiresAt) < new Date();
|
||||
|
||||
if (isInviteExpired) {
|
||||
if (!invite || isInviteExpired) {
|
||||
return <ExpiredContent />;
|
||||
} else if (invite.accepted) {
|
||||
return <UsedContent />;
|
||||
} else if (!session) {
|
||||
const redirectUrl = WEBAPP_URL + "/invite?token=" + searchParams.token;
|
||||
} else if (!currentUser) {
|
||||
const redirectUrl = env.NEXTAUTH_URL + "/invite?token=" + searchParams.token;
|
||||
return <NotLoggedInContent email={email} token={searchParams.token} redirectUrl={redirectUrl} />;
|
||||
} else if (session.user?.email !== email) {
|
||||
} else if (currentUser.user?.email !== email) {
|
||||
return <WrongAccountContent />;
|
||||
} else {
|
||||
await createMembership(invite.teamId, session.user.id, { accepted: true, role: invite.role });
|
||||
await createMembership(invite.teamId, currentUser.user.id, { accepted: true, role: invite.role });
|
||||
await deleteInvite(inviteId);
|
||||
|
||||
sendInviteAcceptedEmail(invite.creator.name ?? "", session.user?.name ?? "", invite.creator.email);
|
||||
sendInviteAcceptedEmail(invite.creator.name ?? "", currentUser.user?.name ?? "", invite.creator.email);
|
||||
|
||||
return <RightAccountContent />;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return <InvitationNotFound />;
|
||||
return <ExpiredContent />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,27 +159,30 @@ const createSurveyFields = (surveyResponses: SurveyResponse[]) => {
|
||||
return surveyFields;
|
||||
};
|
||||
|
||||
const notificationFooter = (environmentId: string) => {
|
||||
const notificationFooter = () => {
|
||||
return `
|
||||
<p style="margin-bottom:0px; padding-top:1em; font-weight:500">All the best,</p>
|
||||
<p style="margin-top:0px;">The Formbricks Team 🤍</p>
|
||||
<div style="margin-top:0.8em; background-color:#f1f5f9; border-radius:8px; padding:0.01em 1.6em; text-align:center; font-size:0.8em; line-height:1.2em;">
|
||||
<p><i>To halt Weekly Updates, <a href="${WEBAPP_URL}/environments/${environmentId}/settings/notifications">please turn them off</a> in your settings 🙏</i></p>
|
||||
</div>
|
||||
`;
|
||||
<p style="margin-bottom:0px; padding-top:1em; font-weight:500">All the best,</p>
|
||||
<p style="margin-top:0px;">The Formbricks Team 🤍</p>
|
||||
<div style="margin-top:0.8em; background-color:#f1f5f9; border-radius:8px; padding:0.01em 1.6em; text-align:center; font-size:0.8em; line-height:1.2em;"><p><i>This is a Beta feature. If you experience any issues, please let us know by replying to this email 🙏</i></p></div>
|
||||
`;
|
||||
};
|
||||
|
||||
const createReminderNotificationBody = (notificationData: NotificationResponse) => {
|
||||
const createReminderNotificationBody = (notificationData: NotificationResponse, webUrl) => {
|
||||
return `
|
||||
<p>We’d love to send you a Weekly Summary, but currently there are no surveys running for ${notificationData.productName}.</p>
|
||||
|
||||
<p style="font-weight: bold; padding-top:1em;">Don’t let a week pass without learning about your users:</p>
|
||||
|
||||
<a class="button" href="${WEBAPP_URL}/environments/${notificationData.environmentId}/surveys?utm_source=weekly&utm_medium=email&utm_content=SetupANewSurveyCTA">Setup a new survey</a>
|
||||
|
||||
<a class="button" href="${webUrl}/environments/${notificationData.environmentId}/surveys?utm_source=weekly&utm_medium=email&utm_content=SetupANewSurveyCTA">Setup a new survey</a>
|
||||
|
||||
<br/>
|
||||
<p style="padding-top:1em;">Need help finding the right survey for your product? Pick a 15-minute slot <a href="https://cal.com/johannes/15">in our CEOs calendar</a> or reply to this email :)</p>
|
||||
${notificationFooter(notificationData.environmentId)}
|
||||
|
||||
|
||||
<p style="margin-bottom:0px; padding-top:1em; font-weight:500">All the best,</p>
|
||||
<p style="margin-top:0px;">The Formbricks Team</p>
|
||||
|
||||
<div style="margin-top:0.8em; background-color:#f1f5f9; border-radius:99px; margin:1em; padding:0.01em 1.6em; text-align:center;"><p><i>This is a Beta feature. If you experience any issues, please let us know by replying to this email 🙏</i></p></div>
|
||||
`;
|
||||
};
|
||||
|
||||
@@ -204,7 +207,7 @@ export const sendWeeklySummaryNotificationEmail = async (
|
||||
${notificationHeader(notificationData.productName, startDate, endDate, startYear, endYear)}
|
||||
${notificationInsight(notificationData.insights)}
|
||||
${notificationLiveSurveys(notificationData.surveys, notificationData.environmentId)}
|
||||
${notificationFooter(notificationData.environmentId)}
|
||||
${notificationFooter()}
|
||||
`),
|
||||
});
|
||||
};
|
||||
@@ -228,7 +231,7 @@ export const sendNoLiveSurveyNotificationEmail = async (
|
||||
subject: getEmailSubject(notificationData.productName),
|
||||
html: withEmailTemplate(`
|
||||
${notificationHeader(notificationData.productName, startDate, endDate, startYear, endYear)}
|
||||
${createReminderNotificationBody(notificationData)}
|
||||
${createReminderNotificationBody(notificationData, WEBAPP_URL)}
|
||||
`),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -7,7 +7,6 @@ import { prisma } from "@formbricks/database";
|
||||
import { INTERNAL_SECRET } from "@formbricks/lib/constants";
|
||||
import { sendResponseFinishedEmail } from "@formbricks/lib/emails/emails";
|
||||
import { getIntegrations } from "@formbricks/lib/integration/service";
|
||||
import { getResponseCountBySurveyId } from "@formbricks/lib/response/service";
|
||||
import { getSurvey, updateSurvey } from "@formbricks/lib/survey/service";
|
||||
import { convertDatesInObject } from "@formbricks/lib/time";
|
||||
import { ZPipelineInput } from "@formbricks/types/pipelines";
|
||||
@@ -126,9 +125,6 @@ export async function POST(request: Request) {
|
||||
return false;
|
||||
});
|
||||
|
||||
// Exclude current response
|
||||
const responseCount = await getResponseCountBySurveyId(surveyId);
|
||||
|
||||
if (usersWithNotifications.length > 0) {
|
||||
// get survey
|
||||
if (!surveyData) {
|
||||
@@ -159,7 +155,7 @@ export async function POST(request: Request) {
|
||||
// send email to all users
|
||||
await Promise.all(
|
||||
usersWithNotifications.map(async (user) => {
|
||||
await sendResponseFinishedEmail(user.email, environmentId, survey, response, responseCount);
|
||||
await sendResponseFinishedEmail(user.email, environmentId, survey, response);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,11 +23,7 @@ export async function POST(request: Request): Promise<NextResponse> {
|
||||
responseInput.personId = null;
|
||||
}
|
||||
const agent = UAParser(request.headers.get("user-agent"));
|
||||
const country =
|
||||
headers().get("CF-IPCountry") ||
|
||||
headers().get("X-Vercel-IP-Country") ||
|
||||
headers().get("CloudFront-Viewer-Country") ||
|
||||
undefined;
|
||||
const country = headers().get("CF-IPCountry") || headers().get("X-Vercel-IP-Country") || undefined;
|
||||
const inputValidation = ZResponseLegacyInput.safeParse(responseInput);
|
||||
|
||||
if (!inputValidation.success) {
|
||||
|
||||
@@ -46,11 +46,7 @@ export async function POST(request: Request, context: Context): Promise<NextResp
|
||||
}
|
||||
|
||||
const agent = UAParser(request.headers.get("user-agent"));
|
||||
const country =
|
||||
headers().get("CF-IPCountry") ||
|
||||
headers().get("X-Vercel-IP-Country") ||
|
||||
headers().get("CloudFront-Viewer-Country") ||
|
||||
undefined;
|
||||
const country = headers().get("CF-IPCountry") || headers().get("X-Vercel-IP-Country") || undefined;
|
||||
const inputValidation = ZResponseInput.safeParse({ ...responseInput, environmentId });
|
||||
|
||||
if (!inputValidation.success) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { verifyInviteToken } from "@formbricks/lib/jwt";
|
||||
import { createMembership } from "@formbricks/lib/membership/service";
|
||||
import { createProduct } from "@formbricks/lib/product/service";
|
||||
import { createTeam, getTeam } from "@formbricks/lib/team/service";
|
||||
import { createUser, updateUser } from "@formbricks/lib/user/service";
|
||||
import { createUser } from "@formbricks/lib/user/service";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let { inviteToken, ...user } = await request.json();
|
||||
@@ -76,22 +76,7 @@ export async function POST(request: Request) {
|
||||
else {
|
||||
const team = await createTeam({ name: user.name + "'s Team" });
|
||||
await createMembership(team.id, user.id, { role: "owner", accepted: true });
|
||||
const product = await createProduct(team.id, { name: "My Product" });
|
||||
|
||||
const updatedNotificationSettings = {
|
||||
...user.notificationSettings,
|
||||
alert: {
|
||||
...user.notificationSettings?.alert,
|
||||
},
|
||||
weeklySummary: {
|
||||
...user.notificationSettings?.weeklySummary,
|
||||
[product.id]: true,
|
||||
},
|
||||
};
|
||||
|
||||
await updateUser(user.id, {
|
||||
notificationSettings: updatedNotificationSettings,
|
||||
});
|
||||
await createProduct(team.id, { name: "My Product" });
|
||||
}
|
||||
// send verification email amd return user
|
||||
if (!EMAIL_VERIFICATION_DISABLED) {
|
||||
|
||||
@@ -112,14 +112,6 @@ input[type="search"]::-ms-reveal {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.surveyFilterDropdown[data-state="open"]{
|
||||
background-color: #0f172a;
|
||||
color: white;
|
||||
}
|
||||
.surveyFilterDropdown:hover * {
|
||||
background-color: #0f172a;
|
||||
color: white;
|
||||
}
|
||||
|
||||
input[type='range']::-webkit-slider-thumb {
|
||||
background: #0f172a;
|
||||
|
||||
@@ -13,6 +13,3 @@ export const shareUrlRoute = (url: string): boolean => {
|
||||
const regex = /\/share\/[A-Za-z0-9]+\/(summary|responses)/;
|
||||
return regex.test(url);
|
||||
};
|
||||
|
||||
export const isWebAppRoute = (url: string): boolean =>
|
||||
url.startsWith("/environments") && url !== "/api/auth/signout";
|
||||
|
||||
@@ -6,33 +6,14 @@ import {
|
||||
} from "@/app/middleware/bucket";
|
||||
import {
|
||||
clientSideApiRoute,
|
||||
isWebAppRoute,
|
||||
loginRoute,
|
||||
shareUrlRoute,
|
||||
signupRoute,
|
||||
} from "@/app/middleware/endpointValidator";
|
||||
import { getToken } from "next-auth/jwt";
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const token = await getToken({ req: request });
|
||||
|
||||
if (isWebAppRoute(request.nextUrl.pathname) && !token) {
|
||||
const loginUrl = new URL(
|
||||
`/auth/login?callbackUrl=${encodeURIComponent(request.nextUrl.toString())}`,
|
||||
WEBAPP_URL
|
||||
);
|
||||
return NextResponse.redirect(loginUrl.href);
|
||||
}
|
||||
|
||||
const callbackUrl = request.nextUrl.searchParams.get("callbackUrl");
|
||||
if (token && callbackUrl) {
|
||||
return NextResponse.redirect(WEBAPP_URL + callbackUrl);
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
return NextResponse.next();
|
||||
}
|
||||
@@ -73,8 +54,5 @@ export const config = {
|
||||
"/api/v1/js/actions",
|
||||
"/api/v1/client/storage",
|
||||
"/share/(.*)/:path",
|
||||
"/environments/:path*",
|
||||
"/api/auth/signout",
|
||||
"/auth/login",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@formbricks/web",
|
||||
"version": "1.5.1",
|
||||
"version": "1.5.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"clean": "rimraf .turbo node_modules .next",
|
||||
@@ -27,9 +27,9 @@
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@react-email/components": "^0.0.14",
|
||||
"@sentry/nextjs": "^7.99.0",
|
||||
"@sentry/nextjs": "^7.98.0",
|
||||
"@vercel/og": "^0.6.2",
|
||||
"@vercel/speed-insights": "^1.0.9",
|
||||
"@vercel/speed-insights": "^1.0.8",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"dotenv": "^16.4.1",
|
||||
"encoding": "^0.1.13",
|
||||
@@ -37,25 +37,25 @@
|
||||
"googleapis": "^131.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
"lru-cache": "^10.2.0",
|
||||
"lucide-react": "^0.321.0",
|
||||
"lru-cache": "^10.1.0",
|
||||
"lucide-react": "^0.315.0",
|
||||
"mime": "^4.0.1",
|
||||
"next": "14.1.0",
|
||||
"nodemailer": "^6.9.9",
|
||||
"nodemailer": "^6.9.8",
|
||||
"otplib": "^12.0.1",
|
||||
"posthog-js": "^1.104.4",
|
||||
"posthog-js": "^1.102.1",
|
||||
"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": "^2.0.0",
|
||||
"react-hook-form": "^7.50.0",
|
||||
"react-hook-form": "^7.49.3",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-icons": "^5.0.1",
|
||||
"sharp": "^0.33.2",
|
||||
"ua-parser-js": "^1.0.37",
|
||||
"webpack": "^5.90.1",
|
||||
"webpack": "^5.90.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -63,14 +63,13 @@ test.describe("JS Package Test", async () => {
|
||||
|
||||
// Formbricks Modal is visible
|
||||
await expect(page.getByRole("link", { name: "Powered by Formbricks" })).toBeVisible();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
});
|
||||
|
||||
test("Admin checks Display", async ({ page }) => {
|
||||
await login(page, email, password);
|
||||
|
||||
await page.getByRole("link", { name: "In-app Open options Product" }).click();
|
||||
await page.locator("li").filter({ hasText: "In-Product SurveyProduct" }).getByRole("link").click();
|
||||
|
||||
(await page.waitForSelector("text=Responses")).isVisible();
|
||||
|
||||
// Survey should have 1 Display
|
||||
@@ -121,7 +120,8 @@ test.describe("JS Package Test", async () => {
|
||||
test("Admin validates Response", async ({ page }) => {
|
||||
await login(page, email, password);
|
||||
|
||||
await page.getByRole("link", { name: "In-app Open options Product" }).click();
|
||||
await page.locator("li").filter({ hasText: "In-Product SurveyProduct" }).getByRole("link").click();
|
||||
|
||||
(await page.waitForSelector("text=Responses")).isVisible();
|
||||
|
||||
// Survey should have 2 Displays
|
||||
|
||||
@@ -40,7 +40,6 @@ export const skipOnboarding = async (page: Page): Promise<void> => {
|
||||
await page.waitForURL("/onboarding");
|
||||
await expect(page).toHaveURL("/onboarding");
|
||||
await page.getByRole("button", { name: "I'll do it later" }).click();
|
||||
await page.waitForTimeout(500);
|
||||
await page.getByRole("button", { name: "I'll do it later" }).click();
|
||||
await page.waitForURL(/\/environments\/[^/]+\/surveys/);
|
||||
await expect(page).toHaveURL(/\/environments\/[^/]+\/surveys/);
|
||||
|
||||
10
package.json
10
package.json
@@ -32,13 +32,13 @@
|
||||
"prepare": "husky install"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.41.2",
|
||||
"@playwright/test": "^1.41.1",
|
||||
"eslint-config-formbricks": "workspace:*",
|
||||
"husky": "^9.0.10",
|
||||
"lint-staged": "^15.2.1",
|
||||
"husky": "^9.0.5",
|
||||
"lint-staged": "^15.2.0",
|
||||
"rimraf": "^5.0.5",
|
||||
"tsx": "^4.7.0",
|
||||
"turbo": "^1.12.2"
|
||||
"turbo": "^1.11.3"
|
||||
},
|
||||
"lint-staged": {
|
||||
"(apps|packages)/**/*.{js,ts,jsx,tsx}": [
|
||||
@@ -64,6 +64,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@changesets/cli": "^2.27.1",
|
||||
"playwright": "^1.41.2"
|
||||
"playwright": "^1.41.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Survey" ADD COLUMN "createdBy" TEXT;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Survey" ADD CONSTRAINT "Survey_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -1,5 +0,0 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "Survey" DROP CONSTRAINT "Survey_createdBy_fkey";
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Survey" ADD CONSTRAINT "Survey_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -25,15 +25,15 @@
|
||||
"predev": "pnpm generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.9.1",
|
||||
"@prisma/extension-accelerate": "^0.6.3",
|
||||
"@prisma/client": "^5.8.1",
|
||||
"@prisma/extension-accelerate": "^0.6.2",
|
||||
"dotenv-cli": "^7.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/tsconfig": "workspace:*",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"eslint-config-formbricks": "workspace:*",
|
||||
"prisma": "^5.9.1",
|
||||
"prisma": "^5.8.1",
|
||||
"prisma-dbml-generator": "^0.10.0",
|
||||
"prisma-json-types-generator": "^3.0.3",
|
||||
"zod": "^3.22.4",
|
||||
|
||||
@@ -257,8 +257,6 @@ model Survey {
|
||||
type SurveyType @default(web)
|
||||
environment Environment @relation(fields: [environmentId], references: [id], onDelete: Cascade)
|
||||
environmentId String
|
||||
creator User? @relation(fields: [createdBy], references: [id])
|
||||
createdBy String?
|
||||
status SurveyStatus @default(draft)
|
||||
/// @zod.custom(imports.ZSurveyWelcomeCard)
|
||||
/// [SurveyWelcomeCard]
|
||||
@@ -566,7 +564,6 @@ model User {
|
||||
/// @zod.custom(imports.ZUserNotificationSettings)
|
||||
/// [UserNotificationSettings]
|
||||
notificationSettings Json @default("{}")
|
||||
surveys Survey[]
|
||||
|
||||
@@index([email])
|
||||
}
|
||||
|
||||
@@ -31,12 +31,17 @@ export const AddMemberRole = ({ control, canDoRoleManagement }: AddMemberRolePro
|
||||
<div>
|
||||
<Label>Role</Label>
|
||||
<Select
|
||||
defaultValue="admin"
|
||||
value={value}
|
||||
onValueChange={(v) => onChange(v as MembershipRole)}
|
||||
disabled={!canDoRoleManagement}>
|
||||
<SelectTrigger className="capitalize">
|
||||
<SelectValue />
|
||||
<SelectValue
|
||||
placeholder={
|
||||
<span className="text-slate-400">
|
||||
{canDoRoleManagement ? "Select role" : "Select role (Pro Feature)"}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
|
||||
@@ -18,6 +18,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@formbricks/lib": "workspace:*",
|
||||
"stripe": "^14.14.0"
|
||||
"stripe": "^14.13.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@formbricks/js",
|
||||
"license": "MIT",
|
||||
"version": "1.5.1",
|
||||
"version": "1.5.0",
|
||||
"description": "Formbricks-js allows you to connect your app to Formbricks, display surveys and trigger events.",
|
||||
"homepage": "https://formbricks.com",
|
||||
"repository": {
|
||||
@@ -40,17 +40,17 @@
|
||||
},
|
||||
"author": "Formbricks <hola@formbricks.com>",
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.23.9",
|
||||
"@babel/preset-env": "^7.23.9",
|
||||
"@babel/core": "^7.23.7",
|
||||
"@babel/preset-env": "^7.23.8",
|
||||
"@babel/preset-typescript": "^7.23.3",
|
||||
"@formbricks/api": "workspace:*",
|
||||
"@formbricks/lib": "workspace:*",
|
||||
"@formbricks/surveys": "workspace:*",
|
||||
"@formbricks/tsconfig": "workspace:*",
|
||||
"@formbricks/types": "workspace:*",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@typescript-eslint/eslint-plugin": "^6.20.0",
|
||||
"@typescript-eslint/parser": "^6.20.0",
|
||||
"@types/jest": "^29.5.11",
|
||||
"@typescript-eslint/eslint-plugin": "^6.19.1",
|
||||
"@typescript-eslint/parser": "^6.19.1",
|
||||
"babel-jest": "^29.7.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"isomorphic-fetch": "^3.0.0",
|
||||
|
||||
@@ -248,22 +248,7 @@ export const authOptions: NextAuthOptions = {
|
||||
...account,
|
||||
userId: userProfile.id,
|
||||
});
|
||||
const product = await createProduct(team.id, { name: "My Product" });
|
||||
const updatedNotificationSettings = {
|
||||
...userProfile.notificationSettings,
|
||||
alert: {
|
||||
...userProfile.notificationSettings?.alert,
|
||||
},
|
||||
weeklySummary: {
|
||||
...userProfile.notificationSettings?.weeklySummary,
|
||||
[product.id]: true,
|
||||
},
|
||||
};
|
||||
|
||||
await updateUser(userProfile.id, {
|
||||
notificationSettings: updatedNotificationSettings,
|
||||
});
|
||||
|
||||
await createProduct(team.id, { name: "My Product" });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
} from "../constants";
|
||||
import { createInviteToken, createToken, createTokenForLinkSurvey } from "../jwt";
|
||||
import { getQuestionResponseMapping } from "../responses";
|
||||
import { getTeamByEnvironmentId } from "../team/service";
|
||||
import { withEmailTemplate } from "./email-template";
|
||||
|
||||
const nodemailer = require("nodemailer");
|
||||
@@ -162,52 +161,44 @@ export const sendResponseFinishedEmail = async (
|
||||
email: string,
|
||||
environmentId: string,
|
||||
survey: { id: string; name: string; questions: TSurveyQuestion[] },
|
||||
response: TResponse,
|
||||
responseCount: number
|
||||
response: TResponse
|
||||
) => {
|
||||
const personEmail = response.person?.attributes["email"];
|
||||
const team = await getTeamByEnvironmentId(environmentId);
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: personEmail
|
||||
? `${personEmail} just completed your ${survey.name} survey ✅`
|
||||
: `A response for ${survey.name} was completed ✅`,
|
||||
replyTo: personEmail?.toString() || MAIL_FROM,
|
||||
html: withEmailTemplate(`
|
||||
<h1>Hey 👋</h1>
|
||||
<p>Congrats, you received a new response to your survey!
|
||||
Someone just completed your survey <strong>${survey.name}</strong><br/></p>
|
||||
html: withEmailTemplate(`<h1>Hey 👋</h1>Someone just completed your survey <strong>${
|
||||
survey.name
|
||||
}</strong><br/>
|
||||
|
||||
<hr/>
|
||||
<hr/>
|
||||
|
||||
${getQuestionResponseMapping(survey, response)
|
||||
.map(
|
||||
(question) =>
|
||||
question.answer &&
|
||||
`<div style="margin-top:1em;">
|
||||
<p style="margin:0px;">${question.question}</p>
|
||||
<p style="font-weight: 500; margin:0px; white-space:pre-wrap">${question.answer}</p>
|
||||
</div>`
|
||||
)
|
||||
.join("")}
|
||||
${getQuestionResponseMapping(survey, response)
|
||||
.map(
|
||||
(question) =>
|
||||
question.answer &&
|
||||
`<div style="margin-top:1em;">
|
||||
<p style="margin:0px;">${question.question}</p>
|
||||
<p style="font-weight: 500; margin:0px; white-space:pre-wrap">${question.answer}</p>
|
||||
</div>`
|
||||
)
|
||||
.join("")}
|
||||
|
||||
<a class="button" href="${WEBAPP_URL}/environments/${environmentId}/surveys/${
|
||||
survey.id
|
||||
}/responses?utm_source=email_notification&utm_medium=email&utm_content=view_responses_CTA">${responseCount > 1 ? `View ${responseCount - 1} more ${responseCount === 2 ? "response" : "responses"}` : `View survey summary`}</a>
|
||||
<a class="button" href="${WEBAPP_URL}/environments/${environmentId}/surveys/${
|
||||
survey.id
|
||||
}/responses?utm_source=email_notification&utm_medium=email&utm_content=view_responses_CTA">View all responses</a>
|
||||
|
||||
<div class="tooltip">
|
||||
<p class='brandcolor'><strong>Start a conversation 💡</strong></p>
|
||||
${
|
||||
personEmail
|
||||
? `<p>Hit 'Reply' or reach out manually: ${personEmail}</p>`
|
||||
: "<p>If you set the email address as an attribute in in-app surveys, you can reply directly to the respondent.</p>"
|
||||
}
|
||||
</div>
|
||||
|
||||
<hr/>
|
||||
|
||||
<p><b>Don't want to get these emails?</b></p>
|
||||
<div style="margin-top:0.8em; background-color:#f1f5f9; border-radius:8px; padding:0.01em 1.6em; text-align:center; font-size:0.8em; line-height:1.2em;"><p><i>Turn off notifications for <a href="${WEBAPP_URL}/environments/${environmentId}/settings/notifications?type=alert&elementId=${survey.id}">this form</a>. <br/> Turn off notifications for <a href="${WEBAPP_URL}/environments/${environmentId}/settings/notifications?type=unsubscribedTeamIds&elementId=${team?.id}">all newly created forms</a>.</i></p></div>
|
||||
<div class="tooltip">
|
||||
<p class='brandcolor'><strong>Start a conversation 💡</strong></p>
|
||||
${
|
||||
personEmail
|
||||
? `<p>Hit 'Reply' or reach out manually: ${personEmail}</p>`
|
||||
: "<p>If you set the email address as an attribute in in-app surveys, you can reply directly to the respondent.</p>"
|
||||
}
|
||||
</div>
|
||||
`),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -121,7 +121,7 @@ export const deleteInvite = async (inviteId: string): Promise<TInvite> => {
|
||||
}
|
||||
};
|
||||
|
||||
export const getInvite = async (inviteId: string): Promise<InviteWithCreator | null> => {
|
||||
export const getInvite = async (inviteId: string): Promise<InviteWithCreator> => {
|
||||
const invite = await unstable_cache(
|
||||
async () => {
|
||||
validateInputs([inviteId, ZString]);
|
||||
@@ -140,18 +140,18 @@ export const getInvite = async (inviteId: string): Promise<InviteWithCreator | n
|
||||
},
|
||||
});
|
||||
|
||||
if (!invite) {
|
||||
throw new ResourceNotFoundError("Invite", inviteId);
|
||||
}
|
||||
return invite;
|
||||
},
|
||||
[`getInvite-${inviteId}`],
|
||||
{ tags: [inviteCache.tag.byId(inviteId)], revalidate: SERVICES_REVALIDATION_INTERVAL }
|
||||
)();
|
||||
|
||||
return invite
|
||||
? {
|
||||
...formatDateFields(invite, ZInvite),
|
||||
creator: invite.creator,
|
||||
}
|
||||
: null;
|
||||
return {
|
||||
...formatDateFields(invite, ZInvite),
|
||||
creator: invite.creator,
|
||||
};
|
||||
};
|
||||
|
||||
export const resendInvite = async (inviteId: string): Promise<TInvite> => {
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
"test": "jest -ci --coverage --no-cache"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/s3-presigned-post": "3.504.0",
|
||||
"@aws-sdk/client-s3": "3.504.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.504.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.499.0",
|
||||
"@aws-sdk/client-s3": "3.499.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.499.0",
|
||||
"@t3-oss/env-nextjs": "^0.8.0",
|
||||
"@formbricks/api": "*",
|
||||
"@formbricks/database": "*",
|
||||
@@ -27,10 +27,10 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"markdown-it": "^14.0.0",
|
||||
"mime-types": "^2.1.35",
|
||||
"nanoid": "^5.0.5",
|
||||
"nanoid": "^5.0.4",
|
||||
"next-auth": "^4.24.5",
|
||||
"nodemailer": "^6.9.9",
|
||||
"posthog-node": "^3.6.1",
|
||||
"nodemailer": "^6.9.8",
|
||||
"posthog-node": "^3.6.0",
|
||||
"server-only": "^0.0.1",
|
||||
"tailwind-merge": "^2.2.1"
|
||||
},
|
||||
|
||||
@@ -357,7 +357,7 @@ export const getResponse = async (responseId: string): Promise<TResponse | null>
|
||||
});
|
||||
|
||||
if (!responsePrisma) {
|
||||
return null;
|
||||
throw new ResourceNotFoundError("Response", responseId);
|
||||
}
|
||||
|
||||
const response: TResponse = {
|
||||
@@ -382,12 +382,10 @@ export const getResponse = async (responseId: string): Promise<TResponse | null>
|
||||
}
|
||||
)();
|
||||
|
||||
return response
|
||||
? ({
|
||||
...formatDateFields(response, ZResponse),
|
||||
notes: response.notes.map((note) => formatDateFields(note, ZResponseNote)),
|
||||
} as TResponse)
|
||||
: null;
|
||||
return {
|
||||
...formatDateFields(response, ZResponse),
|
||||
notes: response.notes.map((note) => formatDateFields(note, ZResponseNote)),
|
||||
} as TResponse;
|
||||
};
|
||||
|
||||
export const getResponses = async (
|
||||
|
||||
@@ -280,8 +280,7 @@ describe("Tests for getResponse service", () => {
|
||||
|
||||
it("Throws ResourceNotFoundError if no response is found", async () => {
|
||||
prismaMock.response.findUnique.mockResolvedValue(null);
|
||||
const response = await getResponse(mockResponse.id);
|
||||
expect(response).toBeNull();
|
||||
await expect(getResponse(mockResponse.id)).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
|
||||
it("Throws DatabaseError on PrismaClientKnownRequestError", async () => {
|
||||
|
||||
@@ -20,7 +20,6 @@ import { personCache } from "../person/cache";
|
||||
import { productCache } from "../product/cache";
|
||||
import { getProductByEnvironmentId } from "../product/service";
|
||||
import { responseCache } from "../response/cache";
|
||||
import { subscribeTeamMembersToSurveyResponses } from "../team/service";
|
||||
import { diffInDays, formatDateFields } from "../utils/datetime";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { surveyCache } from "./cache";
|
||||
@@ -32,7 +31,6 @@ export const selectSurvey = {
|
||||
name: true,
|
||||
type: true,
|
||||
environmentId: true,
|
||||
createdBy: true,
|
||||
status: true,
|
||||
welcomeCard: true,
|
||||
questions: true,
|
||||
@@ -412,7 +410,6 @@ export const updateSurvey = async (updatedSurvey: TSurvey): Promise<TSurvey> =>
|
||||
revalidateSurveyByAttributeClassId([...newFilters, ...removedFilters]);
|
||||
}
|
||||
|
||||
surveyData.updatedAt = new Date();
|
||||
data = {
|
||||
...surveyData,
|
||||
...data,
|
||||
@@ -492,30 +489,17 @@ export const createSurvey = async (environmentId: string, surveyBody: TSurveyInp
|
||||
const actionClasses = await getActionClasses(environmentId);
|
||||
revalidateSurveyByActionClassId(actionClasses, surveyBody.triggers);
|
||||
}
|
||||
|
||||
const createdBy = surveyBody.createdBy;
|
||||
delete surveyBody.createdBy;
|
||||
|
||||
const data: Omit<Prisma.SurveyCreateInput, "environment"> = {
|
||||
// TODO: Create with triggers & attributeFilters
|
||||
delete surveyBody.triggers;
|
||||
delete surveyBody.attributeFilters;
|
||||
const data: Omit<TSurveyInput, "triggers" | "attributeFilters"> = {
|
||||
...surveyBody,
|
||||
// TODO: Create with triggers & attributeFilters
|
||||
triggers: undefined,
|
||||
attributeFilters: undefined,
|
||||
};
|
||||
|
||||
if (surveyBody.type === "web" && data.thankYouCard) {
|
||||
data.thankYouCard.buttonLabel = "";
|
||||
data.thankYouCard.buttonLink = "";
|
||||
}
|
||||
|
||||
if (createdBy) {
|
||||
data.creator = {
|
||||
connect: {
|
||||
id: createdBy,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const survey = await prisma.survey.create({
|
||||
data: {
|
||||
...data,
|
||||
@@ -533,8 +517,6 @@ export const createSurvey = async (environmentId: string, surveyBody: TSurveyInp
|
||||
triggers: survey.triggers.map((trigger) => trigger.actionClass.name),
|
||||
};
|
||||
|
||||
await subscribeTeamMembersToSurveyResponses(environmentId, survey.id);
|
||||
|
||||
surveyCache.revalidate({
|
||||
id: survey.id,
|
||||
environmentId: survey.environmentId,
|
||||
@@ -543,7 +525,7 @@ export const createSurvey = async (environmentId: string, surveyBody: TSurveyInp
|
||||
return transformedSurvey;
|
||||
};
|
||||
|
||||
export const duplicateSurvey = async (environmentId: string, surveyId: string, userId: string) => {
|
||||
export const duplicateSurvey = async (environmentId: string, surveyId: string) => {
|
||||
validateInputs([environmentId, ZId], [surveyId, ZId]);
|
||||
const existingSurvey = await getSurvey(surveyId);
|
||||
|
||||
@@ -564,7 +546,6 @@ export const duplicateSurvey = async (environmentId: string, surveyId: string, u
|
||||
...existingSurvey,
|
||||
id: undefined, // id is auto-generated
|
||||
environmentId: undefined, // environmentId is set below
|
||||
createdBy: undefined,
|
||||
name: `${existingSurvey.name} (copy)`,
|
||||
status: "draft",
|
||||
questions: JSON.parse(JSON.stringify(existingSurvey.questions)),
|
||||
@@ -582,11 +563,6 @@ export const duplicateSurvey = async (environmentId: string, surveyId: string, u
|
||||
id: environmentId,
|
||||
},
|
||||
},
|
||||
creator: {
|
||||
connect: {
|
||||
id: userId,
|
||||
},
|
||||
},
|
||||
surveyClosedMessage: existingSurvey.surveyClosedMessage
|
||||
? JSON.parse(JSON.stringify(existingSurvey.surveyClosedMessage))
|
||||
: Prisma.JsonNull,
|
||||
|
||||
@@ -12,8 +12,6 @@ import {
|
||||
TSurveyQuestionType,
|
||||
TSurveyWelcomeCard,
|
||||
} from "@formbricks/types/surveys";
|
||||
import { TTeam } from "@formbricks/types/teams";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
|
||||
import { selectSurvey } from "../service";
|
||||
|
||||
@@ -59,38 +57,6 @@ export const mockDisplay = {
|
||||
status: null,
|
||||
};
|
||||
|
||||
// id: true,
|
||||
// name: true,
|
||||
// email: true,
|
||||
// emailVerified: true,
|
||||
// imageUrl: true,
|
||||
// createdAt: true,
|
||||
// updatedAt: true,
|
||||
// onboardingCompleted: true,
|
||||
// twoFactorEnabled: true,
|
||||
// identityProvider: true,
|
||||
// objective: true,
|
||||
// notificationSettings: true,
|
||||
|
||||
export const mockUser: TUser = {
|
||||
id: mockId,
|
||||
name: "mock User",
|
||||
email: "test@unit.com",
|
||||
emailVerified: currentDate,
|
||||
imageUrl: "https://www.google.com",
|
||||
createdAt: currentDate,
|
||||
updatedAt: currentDate,
|
||||
onboardingCompleted: true,
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "google",
|
||||
objective: "improve_user_retention",
|
||||
notificationSettings: {
|
||||
alert: {},
|
||||
weeklySummary: {},
|
||||
unsubscribedTeamIds: [],
|
||||
},
|
||||
};
|
||||
|
||||
export const mockPerson: TPerson = {
|
||||
id: mockId,
|
||||
userId: mockId,
|
||||
@@ -161,30 +127,6 @@ const baseSurveyProperties = {
|
||||
...commonMockProperties,
|
||||
};
|
||||
|
||||
export const mockTeamOutput: TTeam = {
|
||||
id: mockId,
|
||||
name: "mock Team",
|
||||
createdAt: currentDate,
|
||||
updatedAt: currentDate,
|
||||
billing: {
|
||||
stripeCustomerId: null,
|
||||
features: {
|
||||
inAppSurvey: {
|
||||
status: "inactive",
|
||||
unlimited: false,
|
||||
},
|
||||
linkSurvey: {
|
||||
status: "inactive",
|
||||
unlimited: false,
|
||||
},
|
||||
userTargeting: {
|
||||
status: "inactive",
|
||||
unlimited: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const mockSurveyOutput: SurveyMock = {
|
||||
type: "web",
|
||||
status: "inProgress",
|
||||
@@ -194,7 +136,6 @@ export const mockSurveyOutput: SurveyMock = {
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
displayPercentage: null,
|
||||
createdBy: null,
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
...baseSurveyProperties,
|
||||
@@ -218,7 +159,6 @@ export const updateSurveyInput: TSurvey = {
|
||||
styling: null,
|
||||
singleUse: null,
|
||||
displayPercentage: null,
|
||||
createdBy: null,
|
||||
pin: null,
|
||||
resultShareKey: null,
|
||||
...commonMockProperties,
|
||||
|
||||
@@ -24,11 +24,9 @@ import {
|
||||
mockProduct,
|
||||
mockSurveyOutput,
|
||||
mockSurveyWithAttributesOutput,
|
||||
mockTeamOutput,
|
||||
mockTransformedSurveyOutput,
|
||||
mockTransformedSurveyWithAttributesIdOutput,
|
||||
mockTransformedSurveyWithAttributesOutput,
|
||||
mockUser,
|
||||
updateSurveyInput,
|
||||
} from "./survey.mock";
|
||||
|
||||
@@ -237,27 +235,6 @@ describe("Tests for createSurvey", () => {
|
||||
describe("Happy Path", () => {
|
||||
it("Creates a survey successfully", async () => {
|
||||
prismaMock.survey.create.mockResolvedValueOnce(mockSurveyWithAttributesOutput);
|
||||
prismaMock.team.findFirst.mockResolvedValueOnce(mockTeamOutput);
|
||||
prismaMock.user.findMany.mockResolvedValueOnce([
|
||||
{
|
||||
...mockUser,
|
||||
twoFactorSecret: null,
|
||||
backupCodes: null,
|
||||
password: null,
|
||||
identityProviderAccountId: null,
|
||||
groupId: null,
|
||||
role: "engineer",
|
||||
},
|
||||
]);
|
||||
prismaMock.user.update.mockResolvedValueOnce({
|
||||
...mockUser,
|
||||
twoFactorSecret: null,
|
||||
backupCodes: null,
|
||||
password: null,
|
||||
identityProviderAccountId: null,
|
||||
groupId: null,
|
||||
role: "engineer",
|
||||
});
|
||||
const createdSurvey = await createSurvey(mockId, createSurveyInput);
|
||||
expect(createdSurvey).toEqual(mockTransformedSurveyWithAttributesIdOutput);
|
||||
});
|
||||
@@ -283,7 +260,7 @@ describe("Tests for duplicateSurvey", () => {
|
||||
it("Duplicates a survey successfully", async () => {
|
||||
prismaMock.survey.findUnique.mockResolvedValueOnce(mockSurveyWithAttributesOutput);
|
||||
prismaMock.survey.create.mockResolvedValueOnce(mockSurveyWithAttributesOutput);
|
||||
const createdSurvey = await duplicateSurvey(mockId, mockId, mockId);
|
||||
const createdSurvey = await duplicateSurvey(mockId, mockId);
|
||||
expect(createdSurvey).toEqual(mockSurveyWithAttributesOutput);
|
||||
});
|
||||
});
|
||||
@@ -293,13 +270,13 @@ describe("Tests for duplicateSurvey", () => {
|
||||
|
||||
it("Throws ResourceNotFoundError if the survey does not exist", async () => {
|
||||
prismaMock.survey.findUnique.mockRejectedValueOnce(new ResourceNotFoundError("Survey", mockId));
|
||||
await expect(duplicateSurvey(mockId, mockId, mockId)).rejects.toThrow(ResourceNotFoundError);
|
||||
await expect(duplicateSurvey(mockId, mockId)).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
|
||||
it("should throw an error if there is an unknown error", async () => {
|
||||
const mockErrorMessage = "Unknown error occurred";
|
||||
prismaMock.survey.create.mockRejectedValue(new Error(mockErrorMessage));
|
||||
await expect(duplicateSurvey(mockId, mockId, mockId)).rejects.toThrow(Error);
|
||||
await expect(duplicateSurvey(mockId, mockId)).rejects.toThrow(Error);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,12 +15,10 @@ import {
|
||||
ZTeam,
|
||||
ZTeamCreateInput,
|
||||
} from "@formbricks/types/teams";
|
||||
import { TUserNotificationSettings } from "@formbricks/types/user";
|
||||
|
||||
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
|
||||
import { environmentCache } from "../environment/cache";
|
||||
import { getProducts } from "../product/service";
|
||||
import { getUsersWithTeam, updateUser } from "../user/service";
|
||||
import { formatDateFields } from "../utils/datetime";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { teamCache } from "./cache";
|
||||
@@ -398,38 +396,3 @@ export const getTeamBillingInfo = async (teamId: string): Promise<TTeamBilling |
|
||||
tags: [teamCache.tag.byId(teamId)],
|
||||
}
|
||||
)();
|
||||
|
||||
export const subscribeTeamMembersToSurveyResponses = async (
|
||||
environmentId: string,
|
||||
surveyId: string
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const team = await getTeamByEnvironmentId(environmentId);
|
||||
if (!team) {
|
||||
throw new ResourceNotFoundError("Team", environmentId);
|
||||
}
|
||||
|
||||
const users = await getUsersWithTeam(team.id);
|
||||
await Promise.all(
|
||||
users.map((user) => {
|
||||
if (!user.notificationSettings?.unsubscribedTeamIds?.includes(team?.id as string)) {
|
||||
const defaultSettings = { alert: {}, weeklySummary: {} };
|
||||
const updatedNotificationSettings: TUserNotificationSettings = {
|
||||
...defaultSettings,
|
||||
...user.notificationSettings,
|
||||
};
|
||||
|
||||
updatedNotificationSettings.alert[surveyId] = true;
|
||||
|
||||
return updateUser(user.id, {
|
||||
notificationSettings: updatedNotificationSettings,
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"include": ["."],
|
||||
"exclude": ["dist", "build", "node_modules"],
|
||||
"compilerOptions": {
|
||||
"downlevelIteration": true,
|
||||
},
|
||||
"downlevelIteration": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ const responseSelection = {
|
||||
twoFactorEnabled: true,
|
||||
identityProvider: true,
|
||||
objective: true,
|
||||
notificationSettings: true,
|
||||
};
|
||||
|
||||
// function to retrive basic information about a user's user
|
||||
@@ -230,38 +229,3 @@ export const deleteUser = async (id: string): Promise<TUser> => {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const getUsersWithTeam = async (teamId: string): Promise<TUser[]> => {
|
||||
validateInputs([teamId, ZId]);
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
memberships: {
|
||||
some: {
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: responseSelection,
|
||||
});
|
||||
|
||||
return users;
|
||||
};
|
||||
|
||||
export const userIdRelatedToApiKey = async (apiKey: string) => {
|
||||
const userId = await prisma.apiKey.findUnique({
|
||||
where: { id: apiKey },
|
||||
select: {
|
||||
environment: {
|
||||
select: {
|
||||
people: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return userId;
|
||||
};
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import cuid2 from "@paralleldrive/cuid2";
|
||||
|
||||
import { decryptAES128, symmetricDecrypt, symmetricEncrypt } from "../../lib/crypto";
|
||||
import { env } from "../../lib/env.mjs";
|
||||
|
||||
// generate encrypted single use id for the survey
|
||||
export const generateSurveySingleUseId = (isEncrypted: boolean): string => {
|
||||
const cuid = cuid2.createId();
|
||||
if (!isEncrypted) {
|
||||
return cuid;
|
||||
}
|
||||
|
||||
const encryptedCuid = symmetricEncrypt(cuid, env.ENCRYPTION_KEY);
|
||||
return encryptedCuid;
|
||||
};
|
||||
|
||||
// validate the survey single use id
|
||||
export const validateSurveySingleUseId = (surveySingleUseId: string): string | undefined => {
|
||||
try {
|
||||
let decryptedCuid: string | null = null;
|
||||
|
||||
if (surveySingleUseId.length === 64) {
|
||||
if (!env.FORMBRICKS_ENCRYPTION_KEY) {
|
||||
throw new Error("FORMBRICKS_ENCRYPTION_KEY is not defined");
|
||||
}
|
||||
|
||||
decryptedCuid = decryptAES128(env.FORMBRICKS_ENCRYPTION_KEY!, surveySingleUseId);
|
||||
} else {
|
||||
decryptedCuid = symmetricDecrypt(surveySingleUseId, env.ENCRYPTION_KEY);
|
||||
}
|
||||
|
||||
if (cuid2.isCuid(decryptedCuid)) {
|
||||
return decryptedCuid;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
@@ -8,7 +8,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier": "^3.2.4",
|
||||
"prettier-plugin-tailwindcss": "^0.5.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@formbricks/surveys",
|
||||
"license": "MIT",
|
||||
"version": "1.5.1",
|
||||
"version": "1.5.0",
|
||||
"description": "Formbricks-surveys is a helper library to embed surveys into your application",
|
||||
"homepage": "https://formbricks.com",
|
||||
"repository": {
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
"clean": "rimraf node_modules dist turbo"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "20.11.16",
|
||||
"@types/react": "18.2.52",
|
||||
"@types/node": "20.11.6",
|
||||
"@types/react": "18.2.48",
|
||||
"@types/react-dom": "18.2.18",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
|
||||
@@ -416,7 +416,6 @@ export const ZSurvey = z.object({
|
||||
name: z.string(),
|
||||
type: ZSurveyType,
|
||||
environmentId: z.string(),
|
||||
createdBy: z.string().nullable(),
|
||||
status: ZSurveyStatus,
|
||||
attributeFilters: z.array(ZSurveyAttributeFilter),
|
||||
displayOption: ZSurveyDisplayOption,
|
||||
@@ -444,7 +443,6 @@ export const ZSurvey = z.object({
|
||||
export const ZSurveyInput = z.object({
|
||||
name: z.string(),
|
||||
type: ZSurveyType.optional(),
|
||||
createdBy: z.string().cuid().optional(),
|
||||
status: ZSurveyStatus.optional(),
|
||||
displayOption: ZSurveyDisplayOption.optional(),
|
||||
autoClose: z.number().optional(),
|
||||
@@ -464,13 +462,11 @@ export const ZSurveyInput = z.object({
|
||||
});
|
||||
|
||||
export type TSurvey = z.infer<typeof ZSurvey>;
|
||||
|
||||
export type TSurveyDates = {
|
||||
createdAt: TSurvey["createdAt"];
|
||||
updatedAt: TSurvey["updatedAt"];
|
||||
closeOnDate: TSurvey["closeOnDate"];
|
||||
};
|
||||
|
||||
export type TSurveyInput = z.infer<typeof ZSurveyInput>;
|
||||
|
||||
export const ZSurveyTSurveyQuestionType = z.union([
|
||||
|
||||
@@ -13,14 +13,6 @@ export const ZUserObjective = z.enum([
|
||||
|
||||
export type TUserObjective = z.infer<typeof ZUserObjective>;
|
||||
|
||||
export const ZUserNotificationSettings = z.object({
|
||||
alert: z.record(z.boolean()),
|
||||
weeklySummary: z.record(z.boolean()),
|
||||
unsubscribedTeamIds: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export type TUserNotificationSettings = z.infer<typeof ZUserNotificationSettings>;
|
||||
|
||||
export const ZUser = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().nullable(),
|
||||
@@ -33,7 +25,6 @@ export const ZUser = z.object({
|
||||
updatedAt: z.date(),
|
||||
onboardingCompleted: z.boolean(),
|
||||
objective: ZUserObjective.nullable(),
|
||||
notificationSettings: ZUserNotificationSettings,
|
||||
});
|
||||
|
||||
export type TUser = z.infer<typeof ZUser>;
|
||||
@@ -46,7 +37,6 @@ export const ZUserUpdateInput = z.object({
|
||||
role: ZRole.optional(),
|
||||
objective: ZUserObjective.nullish(),
|
||||
imageUrl: z.string().url().nullish(),
|
||||
notificationSettings: ZUserNotificationSettings.optional(),
|
||||
});
|
||||
|
||||
export type TUserUpdateInput = z.infer<typeof ZUserUpdateInput>;
|
||||
@@ -63,3 +53,10 @@ export const ZUserCreateInput = z.object({
|
||||
});
|
||||
|
||||
export type TUserCreateInput = z.infer<typeof ZUserCreateInput>;
|
||||
|
||||
export const ZUserNotificationSettings = z.object({
|
||||
alert: z.record(z.boolean()),
|
||||
weeklySummary: z.record(z.boolean()),
|
||||
});
|
||||
|
||||
export type TUserNotificationSettings = z.infer<typeof ZUserNotificationSettings>;
|
||||
|
||||
@@ -63,7 +63,7 @@ export const Button: React.ForwardRefExoticComponent<
|
||||
// different styles depending on size
|
||||
size === "sm" && "px-3 py-2 text-sm leading-4 font-medium rounded-md",
|
||||
size === "base" && "px-6 py-3 text-sm font-medium rounded-md",
|
||||
size === "lg" && "px-8 py-4 text-base font-medium rounded-md",
|
||||
size === "lg" && "px-4 py-2 text-base font-medium rounded-md",
|
||||
size === "icon" &&
|
||||
"w-10 h-10 justify-center group p-2 border rounded-lg border-transparent text-neutral-400 hover:border-slate-200 transition",
|
||||
// turn button into a floating action button (fab)
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon, PauseIcon, PencilIcon } from "lucide-react";
|
||||
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
import { ArchiveBoxIcon, CheckIcon, PauseIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../Tooltip";
|
||||
|
||||
interface SurveyStatusIndicatorProps {
|
||||
status: TSurvey["status"];
|
||||
status: string;
|
||||
tooltip?: boolean;
|
||||
}
|
||||
|
||||
@@ -33,9 +31,9 @@ export function SurveyStatusIndicator({ status, tooltip }: SurveyStatusIndicator
|
||||
<CheckIcon className="h-3 w-3 text-slate-600" />
|
||||
</div>
|
||||
)}
|
||||
{status === "draft" && (
|
||||
<div className=" rounded-full bg-slate-200 p-1">
|
||||
<CheckIcon className="h-3 w-3 text-slate-600" />
|
||||
{status === "archived" && (
|
||||
<div className=" rounded-full bg-slate-300 p-1">
|
||||
<ArchiveBoxIcon className="h-3 w-3 text-slate-600" />
|
||||
</div>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
@@ -63,6 +61,13 @@ export function SurveyStatusIndicator({ status, tooltip }: SurveyStatusIndicator
|
||||
<CheckIcon className="h-3 w-3 text-slate-600" />
|
||||
</div>
|
||||
</div>
|
||||
) : status === "archived" ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>Survey archived.</span>
|
||||
<div className=" rounded-full bg-slate-300 p-1">
|
||||
<ArchiveBoxIcon className="h-3 w-3 text-slate-600" />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
@@ -79,18 +84,18 @@ export function SurveyStatusIndicator({ status, tooltip }: SurveyStatusIndicator
|
||||
</span>
|
||||
)}
|
||||
{status === "paused" && (
|
||||
<div className="rounded-full bg-slate-300 p-1">
|
||||
<div className=" rounded-full bg-slate-300 p-1">
|
||||
<PauseIcon className="h-3 w-3 text-slate-600" />
|
||||
</div>
|
||||
)}
|
||||
{status === "completed" && (
|
||||
<div className="rounded-full bg-slate-200 p-1">
|
||||
<div className=" rounded-full bg-slate-200 p-1">
|
||||
<CheckIcon className="h-3 w-3 text-slate-600" />
|
||||
</div>
|
||||
)}
|
||||
{status === "draft" && (
|
||||
<div className="rounded-full bg-slate-300 p-1">
|
||||
<PencilIcon className="h-3 w-3 text-slate-600" />
|
||||
{status === "archived" && (
|
||||
<div className=" rounded-full bg-slate-300 p-1">
|
||||
<ArchiveBoxIcon className="h-3 w-3 text-slate-600" />
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { canUserAccessSurvey, verifyUserRoleAccess } from "@formbricks/lib/survey/auth";
|
||||
import { surveyCache } from "@formbricks/lib/survey/cache";
|
||||
import { deleteSurvey, duplicateSurvey, getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { generateSurveySingleUseId } from "@formbricks/lib/utils/singleUseSurveys";
|
||||
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
|
||||
import { prisma } from "../../database/src";
|
||||
|
||||
export async function duplicateSurveyAction(environmentId: string, surveyId: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const duplicatedSurvey = await duplicateSurvey(environmentId, surveyId, session.user.id);
|
||||
return duplicatedSurvey;
|
||||
}
|
||||
|
||||
export async function copyToOtherEnvironmentAction(
|
||||
environmentId: string,
|
||||
surveyId: string,
|
||||
targetEnvironmentId: string
|
||||
) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorizedToAccessSourceEnvironment = await hasUserEnvironmentAccess(
|
||||
session.user.id,
|
||||
environmentId
|
||||
);
|
||||
if (!isAuthorizedToAccessSourceEnvironment) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorizedToAccessTargetEnvironment = await hasUserEnvironmentAccess(
|
||||
session.user.id,
|
||||
targetEnvironmentId
|
||||
);
|
||||
if (!isAuthorizedToAccessTargetEnvironment) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const existingSurvey = await prisma.survey.findFirst({
|
||||
where: {
|
||||
id: surveyId,
|
||||
environmentId,
|
||||
},
|
||||
include: {
|
||||
triggers: {
|
||||
include: {
|
||||
actionClass: true,
|
||||
},
|
||||
},
|
||||
attributeFilters: {
|
||||
include: {
|
||||
attributeClass: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingSurvey) {
|
||||
throw new ResourceNotFoundError("Survey", surveyId);
|
||||
}
|
||||
|
||||
let targetEnvironmentTriggers: string[] = [];
|
||||
// map the local triggers to the target environment
|
||||
for (const trigger of existingSurvey.triggers) {
|
||||
const targetEnvironmentTrigger = await prisma.actionClass.findFirst({
|
||||
where: {
|
||||
name: trigger.actionClass.name,
|
||||
environment: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!targetEnvironmentTrigger) {
|
||||
// if the trigger does not exist in the target environment, create it
|
||||
const newTrigger = await prisma.actionClass.create({
|
||||
data: {
|
||||
name: trigger.actionClass.name,
|
||||
environment: {
|
||||
connect: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
description: trigger.actionClass.description,
|
||||
type: trigger.actionClass.type,
|
||||
noCodeConfig: trigger.actionClass.noCodeConfig
|
||||
? JSON.parse(JSON.stringify(trigger.actionClass.noCodeConfig))
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
targetEnvironmentTriggers.push(newTrigger.id);
|
||||
} else {
|
||||
targetEnvironmentTriggers.push(targetEnvironmentTrigger.id);
|
||||
}
|
||||
}
|
||||
|
||||
let targetEnvironmentAttributeFilters: string[] = [];
|
||||
// map the local attributeFilters to the target env
|
||||
for (const attributeFilter of existingSurvey.attributeFilters) {
|
||||
// check if attributeClass exists in target env.
|
||||
// if not, create it
|
||||
const targetEnvironmentAttributeClass = await prisma.attributeClass.findFirst({
|
||||
where: {
|
||||
name: attributeFilter.attributeClass.name,
|
||||
environment: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!targetEnvironmentAttributeClass) {
|
||||
const newAttributeClass = await prisma.attributeClass.create({
|
||||
data: {
|
||||
name: attributeFilter.attributeClass.name,
|
||||
description: attributeFilter.attributeClass.description,
|
||||
type: attributeFilter.attributeClass.type,
|
||||
environment: {
|
||||
connect: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
targetEnvironmentAttributeFilters.push(newAttributeClass.id);
|
||||
} else {
|
||||
targetEnvironmentAttributeFilters.push(targetEnvironmentAttributeClass.id);
|
||||
}
|
||||
}
|
||||
|
||||
// create new survey with the data of the existing survey
|
||||
const newSurvey = await prisma.survey.create({
|
||||
data: {
|
||||
...existingSurvey,
|
||||
id: undefined, // id is auto-generated
|
||||
environmentId: undefined, // environmentId is set below
|
||||
createdBy: undefined,
|
||||
name: `${existingSurvey.name} (copy)`,
|
||||
status: "draft",
|
||||
questions: JSON.parse(JSON.stringify(existingSurvey.questions)),
|
||||
thankYouCard: JSON.parse(JSON.stringify(existingSurvey.thankYouCard)),
|
||||
triggers: {
|
||||
create: targetEnvironmentTriggers.map((actionClassId) => ({
|
||||
actionClassId: actionClassId,
|
||||
})),
|
||||
},
|
||||
attributeFilters: {
|
||||
create: existingSurvey.attributeFilters.map((attributeFilter, idx) => ({
|
||||
attributeClassId: targetEnvironmentAttributeFilters[idx],
|
||||
condition: attributeFilter.condition,
|
||||
value: attributeFilter.value,
|
||||
})),
|
||||
},
|
||||
environment: {
|
||||
connect: {
|
||||
id: targetEnvironmentId,
|
||||
},
|
||||
},
|
||||
creator: {
|
||||
connect: {
|
||||
id: session.user.id,
|
||||
},
|
||||
},
|
||||
surveyClosedMessage: existingSurvey.surveyClosedMessage ?? Prisma.JsonNull,
|
||||
singleUse: existingSurvey.singleUse ?? Prisma.JsonNull,
|
||||
productOverwrites: existingSurvey.productOverwrites ?? Prisma.JsonNull,
|
||||
verifyEmail: existingSurvey.verifyEmail ?? Prisma.JsonNull,
|
||||
styling: existingSurvey.styling ?? Prisma.JsonNull,
|
||||
},
|
||||
});
|
||||
|
||||
surveyCache.revalidate({
|
||||
id: newSurvey.id,
|
||||
environmentId: targetEnvironmentId,
|
||||
});
|
||||
return newSurvey;
|
||||
}
|
||||
|
||||
export const deleteSurveyAction = async (surveyId: string) => {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const isAuthorized = await canUserAccessSurvey(session.user.id, surveyId);
|
||||
if (!isAuthorized) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const survey = await getSurvey(surveyId);
|
||||
|
||||
const { hasDeleteAccess } = await verifyUserRoleAccess(survey!.environmentId, session.user.id);
|
||||
if (!hasDeleteAccess) throw new AuthorizationError("Not authorized");
|
||||
|
||||
await deleteSurvey(surveyId);
|
||||
};
|
||||
|
||||
export async function generateSingleUseIdAction(surveyId: string, isEncrypted: boolean): Promise<string> {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
const hasUserSurveyAccess = await canUserAccessSurvey(session.user.id, surveyId);
|
||||
|
||||
if (!hasUserSurveyAccess) throw new AuthorizationError("Not authorized");
|
||||
|
||||
return generateSurveySingleUseId(isEncrypted);
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Code, Link2Icon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { timeSince } from "@formbricks/lib/time";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
|
||||
import { SurveyStatusIndicator } from "../../SurveyStatusIndicator";
|
||||
import { generateSingleUseIdAction } from "../actions";
|
||||
import SurveyDropDownMenu from "./SurveyDropdownMenu";
|
||||
|
||||
interface SurveyCardProps {
|
||||
survey: TSurvey;
|
||||
environment: TEnvironment;
|
||||
otherEnvironment: TEnvironment;
|
||||
isViewer: boolean;
|
||||
WEBAPP_URL: string;
|
||||
orientation: string;
|
||||
}
|
||||
export default function SurveyCard({
|
||||
survey,
|
||||
environment,
|
||||
otherEnvironment,
|
||||
isViewer,
|
||||
WEBAPP_URL,
|
||||
orientation,
|
||||
}: SurveyCardProps) {
|
||||
const isSurveyCreationDeletionDisabled = isViewer;
|
||||
|
||||
const surveyStatusLabel = useMemo(() => {
|
||||
if (survey.status === "inProgress") return "Active";
|
||||
else if (survey.status === "completed") return "Completed";
|
||||
else if (survey.status === "draft") return "Draft";
|
||||
else if (survey.status === "paused") return "Paused";
|
||||
}, [survey]);
|
||||
|
||||
const [singleUseId, setSingleUseId] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (survey.singleUse?.enabled) {
|
||||
generateSingleUseIdAction(survey.id, survey.singleUse?.isEncrypted ? true : false).then(setSingleUseId);
|
||||
} else {
|
||||
setSingleUseId(undefined);
|
||||
}
|
||||
}, [survey]);
|
||||
|
||||
const linkHref = useMemo(() => {
|
||||
return survey.status === "draft"
|
||||
? `/environments/${environment.id}/surveys/${survey.id}/edit`
|
||||
: `/environments/${environment.id}/surveys/${survey.id}/summary`;
|
||||
}, [survey.status, survey.id, environment.id]);
|
||||
|
||||
const SurveyTypeIndicator = ({ type }: { type: string }) => (
|
||||
<div className="flex items-center space-x-2 text-sm text-slate-600">
|
||||
{type === "web" ? (
|
||||
<>
|
||||
<Code className="h-4 w-4" />
|
||||
<span> In-app</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link2Icon className="h-4 w-4" />
|
||||
<span> Link</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderGridContent = () => {
|
||||
return (
|
||||
<Link
|
||||
href={linkHref}
|
||||
key={survey.id}
|
||||
className="relative col-span-2 flex h-44 flex-col justify-between rounded-xl border border-slate-200 bg-white p-4 shadow-sm transition-all ease-in-out hover:scale-105 ">
|
||||
<div className="flex justify-between">
|
||||
<SurveyTypeIndicator type={survey.type} />
|
||||
<SurveyDropDownMenu
|
||||
survey={survey}
|
||||
key={`surveys-${survey.id}`}
|
||||
environmentId={environment.id}
|
||||
environment={environment}
|
||||
otherEnvironment={otherEnvironment!}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
singleUseId={singleUseId}
|
||||
isSurveyCreationDeletionDisabled={isSurveyCreationDeletionDisabled}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-slate-900">{survey.name}</div>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-3 flex w-fit items-center gap-2 rounded-full py-1 pl-1 pr-2 text-xs text-slate-800",
|
||||
surveyStatusLabel === "Active" && "bg-emerald-50",
|
||||
surveyStatusLabel === "Completed" && "bg-slate-200",
|
||||
surveyStatusLabel === "Draft" && "bg-slate-100",
|
||||
surveyStatusLabel === "Paused" && "bg-slate-100"
|
||||
)}>
|
||||
<SurveyStatusIndicator status={survey.status} /> {surveyStatusLabel}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
const renderListContent = () => {
|
||||
return (
|
||||
<Link
|
||||
href={linkHref}
|
||||
key={survey.id}
|
||||
className="relative grid w-full grid-cols-8 place-items-center gap-3 rounded-xl border border-slate-200 bg-white p-4
|
||||
shadow-sm transition-all ease-in-out hover:scale-[101%]">
|
||||
<div className="col-span-2 flex items-center justify-self-start overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium text-slate-900">
|
||||
{survey.name}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 rounded-full py-1 pl-1 pr-2 text-sm text-slate-800",
|
||||
surveyStatusLabel === "Active" && "bg-emerald-50",
|
||||
surveyStatusLabel === "Completed" && "bg-slate-200",
|
||||
surveyStatusLabel === "Draft" && "bg-slate-100",
|
||||
surveyStatusLabel === "Paused" && "bg-slate-100"
|
||||
)}>
|
||||
<SurveyStatusIndicator status={survey.status} /> {surveyStatusLabel}{" "}
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<SurveyTypeIndicator type={survey.type} />
|
||||
</div>
|
||||
|
||||
<div className="col-span-4 grid w-full grid-cols-5 place-items-center">
|
||||
<div className="col-span-2 overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
|
||||
{timeSince(survey.createdAt.toString())}
|
||||
</div>
|
||||
<div className="col-span-2 overflow-hidden text-ellipsis whitespace-nowrap text-sm text-slate-600">
|
||||
{timeSince(survey.updatedAt.toString())}
|
||||
</div>
|
||||
<div className="place-self-end">
|
||||
<SurveyDropDownMenu
|
||||
survey={survey}
|
||||
key={`surveys-${survey.id}`}
|
||||
environmentId={environment.id}
|
||||
environment={environment}
|
||||
otherEnvironment={otherEnvironment!}
|
||||
webAppUrl={WEBAPP_URL}
|
||||
singleUseId={singleUseId}
|
||||
isSurveyCreationDeletionDisabled={isSurveyCreationDeletionDisabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
if (orientation === "grid") return renderGridContent();
|
||||
else return renderListContent();
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDownIcon, Equal, Grid2X2, Search, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
|
||||
import { Button } from "../../Button";
|
||||
import { Checkbox } from "../../Checkbox";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../DropdownMenu";
|
||||
import { TooltipRenderer } from "../../Tooltip";
|
||||
|
||||
interface SurveyFilterProps {
|
||||
surveys: TSurvey[];
|
||||
setFilteredSurveys: (surveys: TSurvey[]) => void;
|
||||
orientation: string;
|
||||
setOrientation: (orientation: string) => void;
|
||||
userId: string;
|
||||
}
|
||||
interface TFilterOption {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
interface TSortOption {
|
||||
label: string;
|
||||
sortFunction: (a: TSurvey, b: TSurvey) => number;
|
||||
}
|
||||
|
||||
interface FilterDropdownProps {
|
||||
title: string;
|
||||
id: string;
|
||||
options: TFilterOption[];
|
||||
selectedOptions: string[];
|
||||
setSelectedOptions: (options: string[]) => void;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
const statusOptions = [
|
||||
{ label: "In Progress", value: "inProgress" },
|
||||
{ label: "Paused", value: "paused" },
|
||||
{ label: "Completed", value: "completed" },
|
||||
{ label: "Draft", value: "draft" },
|
||||
];
|
||||
const typeOptions = [
|
||||
{ label: "Link", value: "link" },
|
||||
{ label: "In-app", value: "web" },
|
||||
];
|
||||
|
||||
const sortOptions = [
|
||||
{
|
||||
label: "Last Modified",
|
||||
sortFunction: (a: TSurvey, b: TSurvey) => {
|
||||
const dateA = new Date(a.updatedAt);
|
||||
const dateB = new Date(b.updatedAt);
|
||||
if (!isNaN(dateA.getTime()) && !isNaN(dateB.getTime())) {
|
||||
return dateB.getTime() - dateA.getTime();
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Created On",
|
||||
sortFunction: (a: TSurvey, b: TSurvey) => {
|
||||
const dateA = new Date(a.createdAt);
|
||||
const dateB = new Date(b.createdAt);
|
||||
if (!isNaN(dateA.getTime()) && !isNaN(dateB.getTime())) {
|
||||
return dateB.getTime() - dateA.getTime();
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Alphabetical",
|
||||
sortFunction: (a: TSurvey, b: TSurvey) => a.name.localeCompare(b.name),
|
||||
},
|
||||
// Add other sorting options as needed
|
||||
];
|
||||
|
||||
const getToolTipContent = (orientation: string) => {
|
||||
return <div>{orientation} View</div>;
|
||||
};
|
||||
|
||||
export default function SurveyFilters({
|
||||
surveys,
|
||||
setFilteredSurveys,
|
||||
orientation,
|
||||
setOrientation,
|
||||
userId,
|
||||
}: SurveyFilterProps) {
|
||||
const [createdByFilter, setCreatedByFilter] = useState<string[]>([]);
|
||||
const [statusFilters, setStatusFilters] = useState<string[]>([]);
|
||||
const [typeFilters, setTypeFilters] = useState<string[]>([]);
|
||||
const [sortBy, setSortBy] = useState(sortOptions[0]);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [dropdownOpenStates, setDropdownOpenStates] = useState(new Map());
|
||||
|
||||
const toggleDropdown = (id: string) => {
|
||||
setDropdownOpenStates(new Map(dropdownOpenStates).set(id, !dropdownOpenStates.get(id)));
|
||||
};
|
||||
|
||||
const creatorOptions = [
|
||||
{ label: "You", value: userId },
|
||||
{ label: "Others", value: "other" },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
let filtered = [...surveys];
|
||||
|
||||
// Filter by search term
|
||||
if (searchTerm) {
|
||||
filtered = filtered.filter((survey) => survey.name.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||
}
|
||||
|
||||
if (createdByFilter.length > 0) {
|
||||
filtered = filtered.filter((survey) => {
|
||||
if (survey.createdBy) {
|
||||
if (createdByFilter.length === 2) return true;
|
||||
if (createdByFilter.includes("other")) return survey.createdBy !== userId;
|
||||
else {
|
||||
return survey.createdBy === userId;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (statusFilters.length > 0) {
|
||||
filtered = filtered.filter((survey) => statusFilters.includes(survey.status));
|
||||
}
|
||||
if (typeFilters.length > 0) {
|
||||
filtered = filtered.filter((survey) => typeFilters.includes(survey.type));
|
||||
}
|
||||
if (sortBy && sortBy.sortFunction) {
|
||||
filtered.sort(sortBy.sortFunction);
|
||||
}
|
||||
|
||||
setFilteredSurveys(filtered);
|
||||
}, [createdByFilter, statusFilters, typeFilters, sortBy, searchTerm, surveys]);
|
||||
|
||||
const handleFilterChange = (
|
||||
value: string,
|
||||
selectedOptions: string[],
|
||||
setSelectedOptions: (options: string[]) => void
|
||||
) => {
|
||||
if (selectedOptions.includes(value)) {
|
||||
setSelectedOptions(selectedOptions.filter((option) => option !== value));
|
||||
} else {
|
||||
setSelectedOptions([...selectedOptions, value]);
|
||||
}
|
||||
};
|
||||
|
||||
const renderSortOption = (option: TSortOption) => (
|
||||
<DropdownMenuItem
|
||||
key={option.label}
|
||||
className="m-0 p-0"
|
||||
onClick={() => {
|
||||
setSortBy(option);
|
||||
}}>
|
||||
<div className="flex h-full w-full items-center space-x-2 px-2 py-1 hover:bg-slate-700">
|
||||
<span
|
||||
className={`h-4 w-4 rounded-full border ${sortBy === option ? "bg-brand-dark outline-brand-dark border-slate-900 outline" : "border-white"}`}></span>
|
||||
<p className="font-normal text-white">{option.label}</p>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
||||
const FilterDropdown = ({
|
||||
title,
|
||||
id,
|
||||
options,
|
||||
selectedOptions,
|
||||
setSelectedOptions,
|
||||
isOpen,
|
||||
}: FilterDropdownProps) => {
|
||||
const triggerClasses = `surveyFilterDropdown min-w-auto h-8 rounded-md border border-slate-700 sm:px-2 cursor-pointer outline-none
|
||||
${selectedOptions.length > 0 ? "bg-slate-900 text-white" : "hover:bg-slate-900"}`;
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={() => toggleDropdown(id)}>
|
||||
<DropdownMenuTrigger asChild className={triggerClasses}>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="text-sm">{title}</span>
|
||||
<ChevronDownIcon className="ml-2 h-4 w-4" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="bg-slate-900">
|
||||
{options.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.value}
|
||||
className="m-0 p-0"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleFilterChange(option.value, selectedOptions, setSelectedOptions);
|
||||
}}>
|
||||
<div className="flex h-full w-full items-center space-x-2 px-2 py-1 hover:bg-slate-700">
|
||||
<Checkbox
|
||||
checked={selectedOptions.includes(option.value)}
|
||||
className={`bg-white ${selectedOptions.includes(option.value) ? "bg-brand-dark border-none" : ""}`}
|
||||
/>
|
||||
<p className="font-normal text-white">{option.label}</p>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-between">
|
||||
<div className="flex space-x-2">
|
||||
<div className="flex h-8 items-center rounded-lg border border-slate-300 bg-white px-4">
|
||||
<Search className="h-4 w-4" />
|
||||
<input
|
||||
type="text"
|
||||
className="border-none bg-transparent placeholder:text-sm"
|
||||
placeholder="Search by survey name"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FilterDropdown
|
||||
title="Created By"
|
||||
id="creatorDropdown"
|
||||
options={creatorOptions}
|
||||
selectedOptions={createdByFilter}
|
||||
setSelectedOptions={setCreatedByFilter}
|
||||
isOpen={dropdownOpenStates.get("creatorDropdown")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FilterDropdown
|
||||
title="Status"
|
||||
id="statusDropdown"
|
||||
options={statusOptions}
|
||||
selectedOptions={statusFilters}
|
||||
setSelectedOptions={setStatusFilters}
|
||||
isOpen={dropdownOpenStates.get("statusDropdown")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FilterDropdown
|
||||
title="Type"
|
||||
id="typeDropdown"
|
||||
options={typeOptions}
|
||||
selectedOptions={typeFilters}
|
||||
setSelectedOptions={setTypeFilters}
|
||||
isOpen={dropdownOpenStates.get("typeDropdown")}
|
||||
/>
|
||||
</div>
|
||||
{(createdByFilter.length > 0 || statusFilters.length > 0 || typeFilters.length > 0) && (
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCreatedByFilter([]);
|
||||
setStatusFilters([]);
|
||||
setTypeFilters([]);
|
||||
}}
|
||||
className="h-8"
|
||||
EndIcon={X}
|
||||
endIconClassName="h-4 w-4">
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<TooltipRenderer
|
||||
shouldRender={true}
|
||||
tooltipContent={getToolTipContent("List")}
|
||||
className="bg-slate-900 text-white">
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg border p-1 ${orientation === "list" ? "bg-slate-900 text-white" : "bg-white"}`}
|
||||
onClick={() => setOrientation("list")}>
|
||||
<Equal className="h-5 w-5" />
|
||||
</div>
|
||||
</TooltipRenderer>
|
||||
|
||||
<TooltipRenderer
|
||||
shouldRender={true}
|
||||
tooltipContent={getToolTipContent("Grid")}
|
||||
className="bg-slate-900 text-white">
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg border p-1 ${orientation === "grid" ? "bg-slate-900 text-white" : "bg-white"}`}
|
||||
onClick={() => setOrientation("grid")}>
|
||||
<Grid2X2 className="h-5 w-5" />
|
||||
</div>
|
||||
</TooltipRenderer>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
className="surveyFilterDropdown h-full cursor-pointer border border-slate-700 outline-none hover:bg-slate-900">
|
||||
<div className="min-w-auto h-8 rounded-md border sm:flex sm:px-2">
|
||||
<div className="hidden w-full items-center justify-between hover:text-white sm:flex">
|
||||
<span className="text-sm ">Sort by: {sortBy.label}</span>
|
||||
<ChevronDownIcon className="ml-2 h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="bg-slate-900 ">
|
||||
{sortOptions.map(renderSortOption)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
|
||||
import { Button } from "../v2/Button";
|
||||
import SurveyCard from "./components/SurveyCard";
|
||||
import SurveyFilters from "./components/SurveyFilters";
|
||||
|
||||
interface SurveysListProps {
|
||||
environment: TEnvironment;
|
||||
surveys: TSurvey[];
|
||||
otherEnvironment: TEnvironment;
|
||||
isViewer: boolean;
|
||||
WEBAPP_URL: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export default function SurveysList({
|
||||
environment,
|
||||
surveys,
|
||||
otherEnvironment,
|
||||
isViewer,
|
||||
WEBAPP_URL,
|
||||
userId,
|
||||
}: SurveysListProps) {
|
||||
const [filteredSurveys, setFilteredSurveys] = useState<TSurvey[]>(surveys);
|
||||
const [orientation, setOrientation] = useState("grid");
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between">
|
||||
<h1 className="my-2 text-3xl font-bold text-slate-800">Surveys</h1>
|
||||
<Button
|
||||
href={`/environments/${environment.id}/surveys/templates`}
|
||||
variant="darkCTA"
|
||||
EndIcon={PlusIcon}>
|
||||
New survey
|
||||
</Button>
|
||||
</div>
|
||||
<SurveyFilters
|
||||
surveys={surveys}
|
||||
setFilteredSurveys={setFilteredSurveys}
|
||||
orientation={orientation}
|
||||
setOrientation={setOrientation}
|
||||
userId={userId}
|
||||
/>
|
||||
{filteredSurveys.length > 0 ? (
|
||||
<div>
|
||||
{orientation === "list" && (
|
||||
<div className="flex-col space-y-3">
|
||||
<div className="mt-6 grid w-full grid-cols-8 place-items-center gap-3 px-6 text-sm text-slate-800">
|
||||
<div className="col-span-4 place-self-start">Name</div>
|
||||
<div className="col-span-4 grid w-full grid-cols-5 place-items-center">
|
||||
<div className="col-span-2">Created at</div>
|
||||
<div className="col-span-2">Updated at</div>
|
||||
</div>
|
||||
</div>
|
||||
{filteredSurveys.map((survey) => {
|
||||
return (
|
||||
<SurveyCard
|
||||
survey={survey}
|
||||
environment={environment}
|
||||
otherEnvironment={otherEnvironment}
|
||||
isViewer={isViewer}
|
||||
WEBAPP_URL={WEBAPP_URL}
|
||||
orientation={orientation}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{orientation === "grid" && (
|
||||
<div className="grid grid-cols-4 place-content-stretch gap-4 lg:grid-cols-6 ">
|
||||
{filteredSurveys.map((survey) => {
|
||||
return (
|
||||
<SurveyCard
|
||||
survey={survey}
|
||||
environment={environment}
|
||||
otherEnvironment={otherEnvironment}
|
||||
isViewer={isViewer}
|
||||
WEBAPP_URL={WEBAPP_URL}
|
||||
orientation={orientation}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center">
|
||||
<span className="mb-4 h-24 w-24 rounded-full bg-slate-100 p-6 text-5xl">🕵️</span>
|
||||
|
||||
<div className="text-slate-600">No surveys found</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import * as React from "react";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
|
||||
@@ -32,25 +31,3 @@ const TooltipContent: React.ComponentType<TooltipPrimitive.TooltipContentProps>
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
|
||||
interface TooltipRendererProps {
|
||||
shouldRender: boolean;
|
||||
tooltipContent: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
export function TooltipRenderer(props: TooltipRendererProps) {
|
||||
const { children, shouldRender, tooltipContent, className } = props;
|
||||
if (shouldRender) {
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>{children}</TooltipTrigger>
|
||||
<TooltipContent className={className}>{tooltipContent}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -19,13 +19,13 @@
|
||||
"@formbricks/surveys": "workspace:*",
|
||||
"@formbricks/lib": "workspace:*",
|
||||
"@heroicons/react": "^2.1.1",
|
||||
"@lexical/code": "^0.13.1",
|
||||
"@lexical/link": "^0.13.1",
|
||||
"@lexical/list": "^0.13.1",
|
||||
"@lexical/markdown": "^0.13.1",
|
||||
"@lexical/react": "^0.13.1",
|
||||
"@lexical/rich-text": "^0.13.1",
|
||||
"@lexical/table": "^0.13.1",
|
||||
"@lexical/code": "^0.13.0",
|
||||
"@lexical/link": "^0.13.0",
|
||||
"@lexical/list": "^0.13.0",
|
||||
"@lexical/markdown": "^0.13.0",
|
||||
"@lexical/react": "^0.13.0",
|
||||
"@lexical/rich-text": "^0.13.0",
|
||||
"@lexical/table": "^0.13.0",
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
@@ -39,9 +39,9 @@
|
||||
"boring-avatars": "^1.10.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"cmdk": "^0.2.1",
|
||||
"lexical": "^0.13.1",
|
||||
"lucide-react": "^0.321.0",
|
||||
"cmdk": "^0.2.0",
|
||||
"lexical": "^0.13.0",
|
||||
"lucide-react": "^0.315.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-confetti": "^6.1.0",
|
||||
"react-day-picker": "^8.10.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"extends": "@formbricks/tsconfig/react-library.json",
|
||||
"include": [".", "../types/*.d.ts"],
|
||||
"include": ["."],
|
||||
"exclude": ["build", "node_modules"],
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2021.String"]
|
||||
|
||||
2910
pnpm-lock.yaml
generated
2910
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user