mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-26 19:00:22 -06:00
Merge branch 'main' of github.com:formbricks/formbricks into shubham/for-1119-tweak-set-default-for-in-app-surveys-to-limit-to-50
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import JsLogo from "@/images/jslogo.png";
|
||||
import WebhookLogo from "@/images/webhook.png";
|
||||
import ZapierLogo from "@/images/zapier-small.png";
|
||||
import { Card } from "@formbricks/ui";
|
||||
import Image from "next/image";
|
||||
import JsLogo from "@/images/jslogo.png";
|
||||
import ZapierLogo from "@/images/zapier-small.png";
|
||||
|
||||
export default function IntegrationsPage() {
|
||||
export default function IntegrationsPage({ params }) {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="my-2 text-3xl font-bold text-slate-800">Integrations</h1>
|
||||
@@ -11,17 +12,34 @@ export default function IntegrationsPage() {
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
<Card
|
||||
docsHref="https://formbricks.com/docs/getting-started/nextjs-app"
|
||||
docsText="Docs"
|
||||
docsNewTab={true}
|
||||
label="Javascript Widget"
|
||||
description="Integrate Formbricks into your Webapp"
|
||||
icon={<Image src={JsLogo} alt="Javascript Logo" />}
|
||||
/>
|
||||
<Card
|
||||
docsHref="https://formbricks.com/docs/integrations/zapier"
|
||||
docsText="Docs"
|
||||
docsNewTab={true}
|
||||
connectHref="https://zapier.com/apps/formbricks/integrations"
|
||||
connectText="Connect"
|
||||
connectNewTab={true}
|
||||
label="Zapier"
|
||||
description="Integrate Formbricks with 5000+ apps via Zapier"
|
||||
icon={<Image src={ZapierLogo} alt="Zapier Logo" />}
|
||||
/>
|
||||
<Card
|
||||
connectHref={`/environments/${params.environmentId}/integrations/webhooks`}
|
||||
connectText="Manage Webhooks"
|
||||
connectNewTab={false}
|
||||
docsHref="https://formbricks.com/docs/webhook-api/overview"
|
||||
docsText="Docs"
|
||||
docsNewTab={true}
|
||||
label="Webhooks"
|
||||
description="Trigger Webhooks based on actions in your surveys"
|
||||
icon={<Image src={WebhookLogo} alt="Webhook Logo" />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import SurveyCheckboxGroup from "@/app/(app)/environments/[environmentId]/integrations/webhooks/SurveyCheckboxGroup";
|
||||
import TriggerCheckboxGroup from "@/app/(app)/environments/[environmentId]/integrations/webhooks/TriggerCheckboxGroup";
|
||||
import { triggers } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/HardcodedTriggers";
|
||||
import { testEndpoint } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/testEndpoint";
|
||||
import Modal from "@/components/shared/Modal";
|
||||
import { createWebhook } from "@formbricks/lib/services/webhook";
|
||||
import { TPipelineTrigger } from "@formbricks/types/v1/pipelines";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
import { TWebhookInput } from "@formbricks/types/v1/webhooks";
|
||||
import { Button, Input, Label } from "@formbricks/ui";
|
||||
import clsx from "clsx";
|
||||
import { Webhook } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
interface AddWebhookModalProps {
|
||||
environmentId: string;
|
||||
open: boolean;
|
||||
surveys: TSurvey[];
|
||||
setOpen: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export default function AddWebhookModal({ environmentId, surveys, open, setOpen }: AddWebhookModalProps) {
|
||||
const router = useRouter();
|
||||
const {
|
||||
handleSubmit,
|
||||
reset,
|
||||
register,
|
||||
formState: { isSubmitting },
|
||||
} = useForm();
|
||||
|
||||
const [testEndpointInput, setTestEndpointInput] = useState("");
|
||||
const [hittingEndpoint, setHittingEndpoint] = useState<boolean>(false);
|
||||
const [endpointAccessible, setEndpointAccessible] = useState<boolean>();
|
||||
const [selectedTriggers, setSelectedTriggers] = useState<TPipelineTrigger[]>([]);
|
||||
const [selectedSurveys, setSelectedSurveys] = useState<string[]>([]);
|
||||
const [selectedAllSurveys, setSelectedAllSurveys] = useState(false);
|
||||
const [creatingWebhook, setCreatingWebhook] = useState(false);
|
||||
|
||||
const handleTestEndpoint = async (sendSuccessToast: boolean) => {
|
||||
try {
|
||||
setHittingEndpoint(true);
|
||||
await testEndpoint(testEndpointInput);
|
||||
setHittingEndpoint(false);
|
||||
if (sendSuccessToast) toast.success("Yay! We are able to ping the webhook!");
|
||||
setEndpointAccessible(true);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setHittingEndpoint(false);
|
||||
toast.error("Oh no! We are unable to ping the webhook!");
|
||||
setEndpointAccessible(false);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAllSurveys = () => {
|
||||
setSelectedAllSurveys(!selectedAllSurveys);
|
||||
setSelectedSurveys([]);
|
||||
};
|
||||
|
||||
const handleSelectedSurveyChange = (surveyId: string) => {
|
||||
setSelectedSurveys((prevSelectedSurveys: string[]) =>
|
||||
prevSelectedSurveys.includes(surveyId)
|
||||
? prevSelectedSurveys.filter((id) => id !== surveyId)
|
||||
: [...prevSelectedSurveys, surveyId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (selectedValue: TPipelineTrigger) => {
|
||||
setSelectedTriggers((prevValues) =>
|
||||
prevValues.includes(selectedValue)
|
||||
? prevValues.filter((value) => value !== selectedValue)
|
||||
: [...prevValues, selectedValue]
|
||||
);
|
||||
};
|
||||
|
||||
const submitWebhook = async (data: TWebhookInput): Promise<void> => {
|
||||
if (!isSubmitting) {
|
||||
try {
|
||||
setCreatingWebhook(true);
|
||||
if (!testEndpointInput || testEndpointInput === "") {
|
||||
throw new Error("Please enter a URL");
|
||||
}
|
||||
if (selectedTriggers.length === 0) {
|
||||
throw new Error("Please select at least one trigger");
|
||||
}
|
||||
|
||||
if (!selectedAllSurveys && selectedSurveys.length === 0) {
|
||||
throw new Error("Please select at least one survey");
|
||||
}
|
||||
|
||||
const endpointHitSuccessfully = await handleTestEndpoint(false);
|
||||
if (!endpointHitSuccessfully) return;
|
||||
|
||||
const updatedData: TWebhookInput = {
|
||||
name: data.name,
|
||||
url: testEndpointInput,
|
||||
triggers: selectedTriggers,
|
||||
surveyIds: selectedSurveys,
|
||||
};
|
||||
|
||||
await createWebhook(environmentId, updatedData);
|
||||
router.refresh();
|
||||
setOpenWithStates(false);
|
||||
toast.success("Webhook added successfully.");
|
||||
} catch (e) {
|
||||
toast.error(e.message);
|
||||
} finally {
|
||||
setCreatingWebhook(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const setOpenWithStates = (isOpen: boolean) => {
|
||||
setOpen(isOpen);
|
||||
reset();
|
||||
setTestEndpointInput("");
|
||||
setEndpointAccessible(undefined);
|
||||
setSelectedSurveys([]);
|
||||
setSelectedTriggers([]);
|
||||
setSelectedAllSurveys(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} setOpen={setOpenWithStates} noPadding closeOnOutsideClick={false}>
|
||||
<div className="flex h-full flex-col rounded-lg">
|
||||
<div className="rounded-t-lg bg-slate-100">
|
||||
<div className="flex w-full items-center justify-between p-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="mr-1.5 h-6 w-6 text-slate-500">
|
||||
<Webhook />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl font-medium text-slate-700">Add Webhook</div>
|
||||
<div className="text-sm text-slate-500">Send survey response data to a custom endpoint</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(submitWebhook)}>
|
||||
<div className="flex justify-between rounded-lg p-6">
|
||||
<div className="w-full space-y-4">
|
||||
<div className="col-span-1">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<div className="mt-1 flex">
|
||||
<Input
|
||||
type="text"
|
||||
id="name"
|
||||
{...register("name")}
|
||||
placeholder="Optional: Label your webhook for easy identification"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-1">
|
||||
<Label htmlFor="URL">URL</Label>
|
||||
<div className="mt-1 flex">
|
||||
<Input
|
||||
type="url"
|
||||
id="URL"
|
||||
value={testEndpointInput}
|
||||
onChange={(e) => {
|
||||
setTestEndpointInput(e.target.value);
|
||||
}}
|
||||
className={clsx(
|
||||
endpointAccessible === true
|
||||
? "border-green-500 bg-green-50"
|
||||
: endpointAccessible === false
|
||||
? "border-red-200 bg-red-50"
|
||||
: endpointAccessible === undefined
|
||||
? "border-slate-200 bg-white"
|
||||
: null
|
||||
)}
|
||||
placeholder="Paste the URL you want the event to trigger on"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
loading={hittingEndpoint}
|
||||
className="ml-2 whitespace-nowrap"
|
||||
onClick={() => {
|
||||
handleTestEndpoint(true);
|
||||
}}>
|
||||
Test Endpoint
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="Triggers">Triggers</Label>
|
||||
<TriggerCheckboxGroup
|
||||
triggers={triggers}
|
||||
selectedTriggers={selectedTriggers}
|
||||
onCheckboxChange={handleCheckboxChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="Surveys">Surveys</Label>
|
||||
<SurveyCheckboxGroup
|
||||
surveys={surveys}
|
||||
selectedSurveys={selectedSurveys}
|
||||
selectedAllSurveys={selectedAllSurveys}
|
||||
onSelectAllSurveys={handleSelectAllSurveys}
|
||||
onSelectedSurveyChange={handleSelectedSurveyChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end border-t border-slate-200 p-6">
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="minimal"
|
||||
onClick={() => {
|
||||
setOpenWithStates(false);
|
||||
}}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="darkCTA" type="submit" loading={creatingWebhook}>
|
||||
Add Webhook
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { TPipelineTrigger } from "@formbricks/types/v1/pipelines";
|
||||
|
||||
export const triggers = [
|
||||
{ title: "Response Created", value: "responseCreated" as TPipelineTrigger },
|
||||
{ title: "Response Updated", value: "responseUpdated" as TPipelineTrigger },
|
||||
{ title: "Response Finished", value: "responseFinished" as TPipelineTrigger },
|
||||
];
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from "react";
|
||||
import { Checkbox } from "@formbricks/ui";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
|
||||
interface SurveyCheckboxGroupProps {
|
||||
surveys: TSurvey[];
|
||||
selectedSurveys: string[];
|
||||
selectedAllSurveys: boolean;
|
||||
onSelectAllSurveys: () => void;
|
||||
onSelectedSurveyChange: (surveyId: string) => void;
|
||||
}
|
||||
|
||||
export const SurveyCheckboxGroup: React.FC<SurveyCheckboxGroupProps> = ({
|
||||
surveys,
|
||||
selectedSurveys,
|
||||
selectedAllSurveys,
|
||||
onSelectAllSurveys,
|
||||
onSelectedSurveyChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="mt-1 rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||
<div className="my-1 flex items-center space-x-2">
|
||||
<Checkbox
|
||||
type="button"
|
||||
id="allSurveys"
|
||||
className="bg-white"
|
||||
value=""
|
||||
checked={selectedAllSurveys}
|
||||
onCheckedChange={onSelectAllSurveys}
|
||||
/>
|
||||
<label
|
||||
htmlFor="allSurveys"
|
||||
className={`flex cursor-pointer items-center ${selectedAllSurveys ? "font-semibold" : ""}`}>
|
||||
All current and new surveys
|
||||
</label>
|
||||
</div>
|
||||
{surveys.map((survey) => (
|
||||
<div key={survey.id} className="my-1 flex items-center space-x-2">
|
||||
<Checkbox
|
||||
type="button"
|
||||
id={survey.id}
|
||||
value={survey.id}
|
||||
className="bg-white"
|
||||
checked={selectedSurveys.includes(survey.id) && !selectedAllSurveys}
|
||||
disabled={selectedAllSurveys}
|
||||
onCheckedChange={() => onSelectedSurveyChange(survey.id)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={survey.id}
|
||||
className={`flex cursor-pointer items-center ${
|
||||
selectedAllSurveys ? "cursor-not-allowed opacity-50" : ""
|
||||
}`}>
|
||||
{survey.name}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SurveyCheckboxGroup;
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from "react";
|
||||
import { Checkbox } from "@formbricks/ui";
|
||||
import { TPipelineTrigger } from "@formbricks/types/v1/pipelines";
|
||||
|
||||
interface TriggerCheckboxGroupProps {
|
||||
triggers: { title: string; value: TPipelineTrigger }[];
|
||||
selectedTriggers: TPipelineTrigger[];
|
||||
onCheckboxChange: (selectedValue: TPipelineTrigger) => void;
|
||||
}
|
||||
|
||||
export const TriggerCheckboxGroup: React.FC<TriggerCheckboxGroupProps> = ({
|
||||
triggers,
|
||||
selectedTriggers,
|
||||
onCheckboxChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="mt-1 rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-50 p-3 text-left text-sm text-slate-900">
|
||||
{triggers.map((trigger) => (
|
||||
<div key={trigger.value} className="my-1 flex items-center space-x-2">
|
||||
<label htmlFor={trigger.value} className="flex cursor-pointer items-center">
|
||||
<Checkbox
|
||||
type="button"
|
||||
id={trigger.value}
|
||||
value={trigger.value}
|
||||
className="bg-white"
|
||||
checked={selectedTriggers.includes(trigger.value)}
|
||||
onCheckedChange={() => {
|
||||
onCheckboxChange(trigger.value);
|
||||
}}
|
||||
/>
|
||||
<span className="ml-2">{trigger.title}</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TriggerCheckboxGroup;
|
||||
@@ -0,0 +1,47 @@
|
||||
import ModalWithTabs from "@/components/shared/ModalWithTabs";
|
||||
import { TWebhook } from "@formbricks/types/v1/webhooks";
|
||||
import WebhookOverviewTab from "@/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookOverviewTab";
|
||||
import WebhookSettingsTab from "@/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookSettingsTab";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
import { Webhook } from "lucide-react";
|
||||
|
||||
interface WebhookModalProps {
|
||||
environmentId: string;
|
||||
open: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
webhook: TWebhook;
|
||||
surveys: TSurvey[];
|
||||
}
|
||||
|
||||
export default function WebhookModal({ environmentId, open, setOpen, webhook, surveys }: WebhookModalProps) {
|
||||
const tabs = [
|
||||
{
|
||||
title: "Overview",
|
||||
children: <WebhookOverviewTab webhook={webhook} surveys={surveys} />,
|
||||
},
|
||||
{
|
||||
title: "Settings",
|
||||
children: (
|
||||
<WebhookSettingsTab
|
||||
environmentId={environmentId}
|
||||
webhook={webhook}
|
||||
surveys={surveys}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalWithTabs
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
tabs={tabs}
|
||||
icon={<Webhook />}
|
||||
label={webhook.name ? webhook.name : webhook.url}
|
||||
description={""}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Label } from "@formbricks/ui";
|
||||
import { convertDateTimeStringShort } from "@formbricks/lib/time";
|
||||
import { TWebhook } from "@formbricks/types/v1/webhooks";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
|
||||
interface ActivityTabProps {
|
||||
webhook: TWebhook;
|
||||
surveys: TSurvey[];
|
||||
}
|
||||
|
||||
const getSurveyNamesForWebhook = (webhook: TWebhook, allSurveys: TSurvey[]): string[] => {
|
||||
if (webhook.surveyIds.length === 0) {
|
||||
return allSurveys.map((survey) => survey.name);
|
||||
} else {
|
||||
return webhook.surveyIds.map((surveyId) => {
|
||||
const survey = allSurveys.find((survey) => survey.id === surveyId);
|
||||
return survey ? survey.name : "";
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const convertTriggerIdToName = (triggerId: string): string => {
|
||||
switch (triggerId) {
|
||||
case "responseCreated":
|
||||
return "Response Created";
|
||||
case "responseUpdated":
|
||||
return "Response Updated";
|
||||
case "responseFinished":
|
||||
return "Response Finished";
|
||||
default:
|
||||
return triggerId;
|
||||
}
|
||||
};
|
||||
|
||||
export default function WebhookOverviewTab({ webhook, surveys }: ActivityTabProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 pb-2">
|
||||
<div className="col-span-2 space-y-4 pr-6">
|
||||
<div>
|
||||
<Label className="text-slate-500">Name</Label>
|
||||
<p className="text-sm text-slate-900">{webhook.name ? webhook.name : "-"}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-slate-500">URL</Label>
|
||||
<p className="text-sm text-slate-900">{webhook.url}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-slate-500">Surveys</Label>
|
||||
|
||||
{getSurveyNamesForWebhook(webhook, surveys).map((surveyName, index) => (
|
||||
<p key={index} className="text-sm text-slate-900">
|
||||
{surveyName}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-slate-500">Triggers</Label>
|
||||
{webhook.triggers.map((triggerId) => (
|
||||
<p key={triggerId} className="text-sm text-slate-900">
|
||||
{convertTriggerIdToName(triggerId)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 space-y-3 rounded-lg border border-slate-100 bg-slate-50 p-2">
|
||||
<div>
|
||||
<Label className="text-xs font-normal text-slate-500">Created on</Label>
|
||||
<p className=" text-xs text-slate-700">
|
||||
{convertDateTimeStringShort(webhook.createdAt?.toString())}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className=" text-xs font-normal text-slate-500">Last updated</Label>
|
||||
<p className=" text-xs text-slate-700">
|
||||
{convertDateTimeStringShort(webhook.updatedAt?.toString())}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { timeSinceConditionally } from "@formbricks/lib/time";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
import { TWebhook } from "@formbricks/types/v1/webhooks";
|
||||
|
||||
const renderSelectedSurveysText = (webhook: TWebhook, allSurveys: TSurvey[]) => {
|
||||
if (webhook.surveyIds.length === 0) {
|
||||
const allSurveyNames = allSurveys.map((survey) => survey.name);
|
||||
return <p className="text-slate-400">{allSurveyNames.join(", ")}</p>;
|
||||
} else {
|
||||
const selectedSurveyNames = webhook.surveyIds.map((surveyId) => {
|
||||
const survey = allSurveys.find((survey) => survey.id === surveyId);
|
||||
return survey ? survey.name : "";
|
||||
});
|
||||
return <p className="text-slate-400">{selectedSurveyNames.join(", ")}</p>;
|
||||
}
|
||||
};
|
||||
|
||||
const renderSelectedTriggersText = (webhook: TWebhook) => {
|
||||
if (webhook.triggers.length === 0) {
|
||||
return <p className="text-slate-400">No Triggers</p>;
|
||||
} else {
|
||||
let cleanedTriggers = webhook.triggers.map((trigger) => {
|
||||
if (trigger === "responseCreated") {
|
||||
return "Response Created";
|
||||
} else if (trigger === "responseUpdated") {
|
||||
return "Response Updated";
|
||||
} else if (trigger === "responseFinished") {
|
||||
return "Response Finished";
|
||||
} else {
|
||||
return trigger;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<p className="text-slate-400">
|
||||
{cleanedTriggers
|
||||
.sort((a, b) => {
|
||||
const triggerOrder = {
|
||||
"Response Created": 1,
|
||||
"Response Updated": 2,
|
||||
"Response Finished": 3,
|
||||
};
|
||||
|
||||
return triggerOrder[a] - triggerOrder[b];
|
||||
})
|
||||
.join(", ")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default function WebhookRowData({ webhook, surveys }: { webhook: TWebhook; surveys: TSurvey[] }) {
|
||||
return (
|
||||
<div className="mt-2 grid h-16 grid-cols-12 content-center rounded-lg hover:bg-slate-100">
|
||||
<div className="col-span-4 flex items-center pl-6 text-sm">
|
||||
<div className="flex items-center">
|
||||
<div className="text-left">
|
||||
{webhook.name ? (
|
||||
<div className="text-left">
|
||||
<div className="font-medium text-slate-900">{webhook.name}</div>
|
||||
<div className="text-xs text-slate-400">{webhook.url}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="font-medium text-slate-900">{webhook.url}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-4 my-auto text-center text-sm text-slate-800">
|
||||
{renderSelectedSurveysText(webhook, surveys)}
|
||||
</div>
|
||||
<div className="col-span-2 my-auto text-center text-sm text-slate-800">
|
||||
{renderSelectedTriggersText(webhook)}
|
||||
</div>
|
||||
<div className="col-span-2 my-auto whitespace-nowrap text-center text-sm text-slate-500">
|
||||
{timeSinceConditionally(webhook.createdAt.toString())}
|
||||
</div>
|
||||
<div className="text-center"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
"use client";
|
||||
|
||||
import DeleteDialog from "@/components/shared/DeleteDialog";
|
||||
import { Button, Input, Label } from "@formbricks/ui";
|
||||
import { TrashIcon } from "@heroicons/react/24/outline";
|
||||
import clsx from "clsx";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { TWebhook, TWebhookInput } from "@formbricks/types/v1/webhooks";
|
||||
import { deleteWebhook, updateWebhook } from "@formbricks/lib/services/webhook";
|
||||
import { TPipelineTrigger } from "@formbricks/types/v1/pipelines";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
import { testEndpoint } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/testEndpoint";
|
||||
import { triggers } from "@/app/(app)/environments/[environmentId]/integrations/webhooks/HardcodedTriggers";
|
||||
import TriggerCheckboxGroup from "@/app/(app)/environments/[environmentId]/integrations/webhooks/TriggerCheckboxGroup";
|
||||
import SurveyCheckboxGroup from "@/app/(app)/environments/[environmentId]/integrations/webhooks/SurveyCheckboxGroup";
|
||||
|
||||
interface ActionSettingsTabProps {
|
||||
environmentId: string;
|
||||
webhook: TWebhook;
|
||||
surveys: TSurvey[];
|
||||
setOpen: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export default function WebhookSettingsTab({
|
||||
environmentId,
|
||||
webhook,
|
||||
surveys,
|
||||
setOpen,
|
||||
}: ActionSettingsTabProps) {
|
||||
const router = useRouter();
|
||||
const { register, handleSubmit } = useForm({
|
||||
defaultValues: {
|
||||
name: webhook.name,
|
||||
url: webhook.url,
|
||||
triggers: webhook.triggers,
|
||||
surveyIds: webhook.surveyIds,
|
||||
},
|
||||
});
|
||||
|
||||
const [openDeleteDialog, setOpenDeleteDialog] = useState(false);
|
||||
const [isUpdatingWebhook, setIsUpdatingWebhook] = useState(false);
|
||||
const [selectedTriggers, setSelectedTriggers] = useState<TPipelineTrigger[]>(webhook.triggers);
|
||||
const [selectedSurveys, setSelectedSurveys] = useState<string[]>(webhook.surveyIds);
|
||||
const [testEndpointInput, setTestEndpointInput] = useState(webhook.url);
|
||||
const [endpointAccessible, setEndpointAccessible] = useState<boolean>();
|
||||
const [hittingEndpoint, setHittingEndpoint] = useState<boolean>(false);
|
||||
const [selectedAllSurveys, setSelectedAllSurveys] = useState(webhook.surveyIds.length === 0);
|
||||
|
||||
const handleTestEndpoint = async (sendSuccessToast: boolean) => {
|
||||
try {
|
||||
setHittingEndpoint(true);
|
||||
await testEndpoint(testEndpointInput);
|
||||
setHittingEndpoint(false);
|
||||
if (sendSuccessToast) toast.success("Yay! We are able to ping the webhook!");
|
||||
setEndpointAccessible(true);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setHittingEndpoint(false);
|
||||
toast.error("Oh no! We are unable to ping the webhook!");
|
||||
setEndpointAccessible(false);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAllSurveys = () => {
|
||||
setSelectedAllSurveys(!selectedAllSurveys);
|
||||
setSelectedSurveys([]);
|
||||
};
|
||||
|
||||
const handleSelectedSurveyChange = (surveyId) => {
|
||||
setSelectedSurveys((prevSelectedSurveys) => {
|
||||
if (prevSelectedSurveys.includes(surveyId)) {
|
||||
return prevSelectedSurveys.filter((id) => id !== surveyId);
|
||||
} else {
|
||||
return [...prevSelectedSurveys, surveyId];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (selectedValue) => {
|
||||
setSelectedTriggers((prevValues) => {
|
||||
if (prevValues.includes(selectedValue)) {
|
||||
return prevValues.filter((value) => value !== selectedValue);
|
||||
} else {
|
||||
return [...prevValues, selectedValue];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
if (selectedTriggers.length === 0) {
|
||||
toast.error("Please select at least one trigger");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedAllSurveys && selectedSurveys.length === 0) {
|
||||
toast.error("Please select at least one survey");
|
||||
return;
|
||||
}
|
||||
const endpointHitSuccessfully = await handleTestEndpoint(false);
|
||||
if (!endpointHitSuccessfully) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedData: TWebhookInput = {
|
||||
name: data.name,
|
||||
url: data.url as string,
|
||||
triggers: selectedTriggers,
|
||||
surveyIds: selectedSurveys,
|
||||
};
|
||||
setIsUpdatingWebhook(true);
|
||||
await updateWebhook(environmentId, webhook.id, updatedData);
|
||||
toast.success("Webhook updated successfully.");
|
||||
router.refresh();
|
||||
setIsUpdatingWebhook(false);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<form className="space-y-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="col-span-1">
|
||||
<Label htmlFor="Name">Name</Label>
|
||||
<div className="mt-1 flex">
|
||||
<Input
|
||||
type="text"
|
||||
id="name"
|
||||
{...register("name")}
|
||||
defaultValue={webhook.name ?? ""}
|
||||
placeholder="Optional: Label your webhook for easy identification"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-1">
|
||||
<Label htmlFor="URL">URL</Label>
|
||||
<div className="mt-1 flex">
|
||||
<Input
|
||||
{...register("url", {
|
||||
value: testEndpointInput,
|
||||
})}
|
||||
type="text"
|
||||
value={testEndpointInput}
|
||||
onChange={(e) => {
|
||||
setTestEndpointInput(e.target.value);
|
||||
}}
|
||||
className={clsx(
|
||||
endpointAccessible === true
|
||||
? "border-green-500 bg-green-50"
|
||||
: endpointAccessible === false
|
||||
? "border-red-200 bg-red-50"
|
||||
: endpointAccessible === undefined
|
||||
? "border-slate-200 bg-white"
|
||||
: null
|
||||
)}
|
||||
placeholder="Paste the URL you want the event to trigger on"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
loading={hittingEndpoint}
|
||||
className="ml-2 whitespace-nowrap"
|
||||
onClick={() => {
|
||||
handleTestEndpoint(true);
|
||||
}}>
|
||||
Test Endpoint
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="Triggers">Triggers</Label>
|
||||
<TriggerCheckboxGroup
|
||||
triggers={triggers}
|
||||
selectedTriggers={selectedTriggers}
|
||||
onCheckboxChange={handleCheckboxChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="Surveys">Surveys</Label>
|
||||
<SurveyCheckboxGroup
|
||||
surveys={surveys}
|
||||
selectedSurveys={selectedSurveys}
|
||||
selectedAllSurveys={selectedAllSurveys}
|
||||
onSelectAllSurveys={handleSelectAllSurveys}
|
||||
onSelectedSurveyChange={handleSelectedSurveyChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between border-t border-slate-200 py-6">
|
||||
<div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="warn"
|
||||
onClick={() => setOpenDeleteDialog(true)}
|
||||
StartIcon={TrashIcon}
|
||||
className="mr-3">
|
||||
Delete
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
href="https://formbricks.com/docs/webhook-api/overview"
|
||||
target="_blank">
|
||||
Read Docs
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Button type="submit" variant="darkCTA" loading={isUpdatingWebhook}>
|
||||
Save changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<DeleteDialog
|
||||
open={openDeleteDialog}
|
||||
setOpen={setOpenDeleteDialog}
|
||||
deleteWhat={"Webhook"}
|
||||
text="Are you sure you want to delete this Webhook? This will stop sending you any further notifications."
|
||||
onDelete={async () => {
|
||||
setOpen(false);
|
||||
try {
|
||||
await deleteWebhook(webhook.id);
|
||||
router.refresh();
|
||||
toast.success("Webhook deleted successfully");
|
||||
} catch (error) {
|
||||
toast.error("Something went wrong. Please try again.");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@formbricks/ui";
|
||||
import { useState } from "react";
|
||||
import { TWebhook } from "@formbricks/types/v1/webhooks";
|
||||
import AddWebhookModal from "@/app/(app)/environments/[environmentId]/integrations/webhooks/AddWebhookModal";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
import WebhookModal from "@/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookDetailModal";
|
||||
import { Webhook } from "lucide-react";
|
||||
import EmptySpaceFiller from "@/components/shared/EmptySpaceFiller";
|
||||
|
||||
export default function WebhookTable({
|
||||
environmentId,
|
||||
webhooks,
|
||||
surveys,
|
||||
children: [TableHeading, webhookRows],
|
||||
}: {
|
||||
environmentId: string;
|
||||
webhooks: TWebhook[];
|
||||
surveys: TSurvey[];
|
||||
children: [JSX.Element, JSX.Element[]];
|
||||
}) {
|
||||
const [isWebhookDetailModalOpen, setWebhookDetailModalOpen] = useState(false);
|
||||
const [isAddWebhookModalOpen, setAddWebhookModalOpen] = useState(false);
|
||||
|
||||
const [activeWebhook, setActiveWebhook] = useState<TWebhook>({
|
||||
environmentId,
|
||||
id: "",
|
||||
name: "",
|
||||
url: "",
|
||||
triggers: [],
|
||||
surveyIds: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const handleOpenWebhookDetailModalClick = (e, webhook: TWebhook) => {
|
||||
e.preventDefault();
|
||||
setActiveWebhook(webhook);
|
||||
setWebhookDetailModalOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6 text-right">
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
onClick={() => {
|
||||
setAddWebhookModalOpen(true);
|
||||
}}>
|
||||
<Webhook className="mr-2 h-5 w-5 text-white" />
|
||||
Add Webhook
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{webhooks.length === 0 ? (
|
||||
<EmptySpaceFiller
|
||||
type="table"
|
||||
environmentId={environmentId}
|
||||
noWidgetRequired={true}
|
||||
emptyMessage="Your webhooks will appear here as soon as you add them. ⏲️"
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg border border-slate-200">
|
||||
{TableHeading}
|
||||
<div className="grid-cols-7">
|
||||
{webhooks.map((webhook, index) => (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
handleOpenWebhookDetailModalClick(e, webhook);
|
||||
}}
|
||||
className="w-full"
|
||||
key={webhook.id}>
|
||||
{webhookRows[index]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<WebhookModal
|
||||
environmentId={environmentId}
|
||||
open={isWebhookDetailModalOpen}
|
||||
setOpen={setWebhookDetailModalOpen}
|
||||
webhook={activeWebhook}
|
||||
surveys={surveys}
|
||||
/>
|
||||
<AddWebhookModal
|
||||
environmentId={environmentId}
|
||||
surveys={surveys}
|
||||
open={isAddWebhookModalOpen}
|
||||
setOpen={setAddWebhookModalOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export default function WebhookTableHeading() {
|
||||
return (
|
||||
<>
|
||||
<div className="grid h-12 grid-cols-12 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||
<span className="sr-only">Edit</span>
|
||||
<div className="col-span-4 pl-6 ">Webhook</div>
|
||||
<div className="col-span-4 text-center">Surveys</div>
|
||||
<div className="col-span-2 text-center ">Triggers</div>
|
||||
<div className="col-span-2 text-center">Updated</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import GoBackButton from "@/components/shared/GoBackButton";
|
||||
import { Button } from "@formbricks/ui";
|
||||
import { Webhook } from "lucide-react";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<>
|
||||
<GoBackButton />
|
||||
<div className="mb-6 text-right">
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-gray-200">
|
||||
<Webhook className="mr-2 h-5 w-5 text-white" />
|
||||
Loading
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-slate-200">
|
||||
<div className="grid h-12 grid-cols-12 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||
<span className="sr-only">Edit</span>
|
||||
<div className="col-span-4 pl-6 ">Webhook</div>
|
||||
<div className="col-span-4 text-center">Surveys</div>
|
||||
<div className="col-span-2 text-center ">Triggers</div>
|
||||
<div className="col-span-2 text-center">Updated</div>
|
||||
</div>
|
||||
<div className="grid-cols-7">
|
||||
{[...Array(3)].map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="mt-2 grid h-16 grid-cols-12 content-center rounded-lg hover:bg-slate-100">
|
||||
<div className="col-span-4 flex items-center pl-6 text-sm">
|
||||
<div className="text-left">
|
||||
<div className="font-medium text-slate-900">
|
||||
<div className="mt-0 h-4 w-48 animate-pulse rounded-full bg-gray-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-4 my-auto flex items-center justify-center text-center text-sm text-slate-500">
|
||||
<div className="font-medium text-slate-500">
|
||||
<div className="mt-0 h-4 w-36 animate-pulse rounded-full bg-gray-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto flex items-center justify-center text-center text-sm text-slate-500">
|
||||
<div className="font-medium text-slate-500">
|
||||
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-gray-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto flex items-center justify-center whitespace-nowrap text-center text-sm text-slate-500">
|
||||
<div className="h-4 w-16 animate-pulse rounded-full bg-gray-200"></div>
|
||||
</div>
|
||||
<div className="text-center"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import WebhookRowData from "@/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookRowData";
|
||||
import WebhookTable from "@/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookTable";
|
||||
import WebhookTableHeading from "@/app/(app)/environments/[environmentId]/integrations/webhooks/WebhookTableHeading";
|
||||
import GoBackButton from "@/components/shared/GoBackButton";
|
||||
import { getSurveys } from "@formbricks/lib/services/survey";
|
||||
import { getWebhooks } from "@formbricks/lib/services/webhook";
|
||||
|
||||
export default async function CustomWebhookPage({ params }) {
|
||||
const webhooks = (await getWebhooks(params.environmentId)).sort((a, b) => {
|
||||
if (a.createdAt > b.createdAt) return -1;
|
||||
if (a.createdAt < b.createdAt) return 1;
|
||||
return 0;
|
||||
});
|
||||
const surveys = await getSurveys(params.environmentId);
|
||||
return (
|
||||
<>
|
||||
<GoBackButton />
|
||||
<WebhookTable environmentId={params.environmentId} webhooks={webhooks} surveys={surveys}>
|
||||
<WebhookTableHeading />
|
||||
{webhooks.map((webhook) => (
|
||||
<WebhookRowData key={webhook.id} webhook={webhook} surveys={surveys} />
|
||||
))}
|
||||
</WebhookTable>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use server";
|
||||
import "server-only";
|
||||
|
||||
export const testEndpoint = async (url: string) => {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
formbricks: "test endpoint",
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
const statusCode = response.status;
|
||||
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
return true;
|
||||
} else {
|
||||
const errorMessage = await response.text();
|
||||
throw new Error(`Request failed with status code ${statusCode}: ${errorMessage}`);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Error while fetching the URL: ${error.message}`);
|
||||
}
|
||||
};
|
||||
@@ -54,7 +54,7 @@ export default function DeleteTeam({ environmentId }) {
|
||||
{!isDeleteDisabled && (
|
||||
<div>
|
||||
<p className="text-sm text-slate-900">
|
||||
This action cannot be undone. If it's gone, it's gone.
|
||||
This action cannot be undone. If it's gone, it's gone.
|
||||
</p>
|
||||
<Button
|
||||
disabled={isDeleteDisabled}
|
||||
|
||||
@@ -1,81 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import DeleteDialog from "@/components/shared/DeleteDialog";
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import AvatarPlaceholder from "@/images/avatar-placeholder.png";
|
||||
import { formbricksLogout } from "@/lib/formbricks";
|
||||
import { useProfileMutation } from "@/lib/profile/mutateProfile";
|
||||
import { useProfile } from "@/lib/profile/profile";
|
||||
import { deleteProfile } from "@/lib/users/users";
|
||||
import { Button, ErrorComponent, Input, Label, ProfileAvatar } from "@formbricks/ui";
|
||||
import { Button, Input, ProfileAvatar } from "@formbricks/ui";
|
||||
import { Session } from "next-auth";
|
||||
import { signOut } from "next-auth/react";
|
||||
import Image from "next/image";
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from "react";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
import { Dispatch, SetStateAction, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
export function EditName() {
|
||||
const { register, handleSubmit, control, setValue } = useForm();
|
||||
const { profile, isLoadingProfile, isErrorProfile } = useProfile();
|
||||
|
||||
const { triggerProfileMutate, isMutatingProfile } = useProfileMutation();
|
||||
|
||||
const profileName = useWatch({
|
||||
control,
|
||||
name: "name",
|
||||
});
|
||||
const isProfileNameInputEmpty = !profileName?.trim();
|
||||
const currentProfileName = profileName?.trim().toLowerCase() ?? "";
|
||||
const previousProfileName = profile?.name?.trim().toLowerCase() ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
setValue("name", profile?.name ?? "");
|
||||
}, [profile?.name]);
|
||||
|
||||
if (isLoadingProfile) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
if (isErrorProfile) {
|
||||
return <ErrorComponent />;
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="w-full max-w-sm items-center"
|
||||
onSubmit={handleSubmit((data) => {
|
||||
triggerProfileMutate(data)
|
||||
.then(() => {
|
||||
toast.success("Your name was updated successfully.");
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(`Error: ${error.message}`);
|
||||
});
|
||||
})}>
|
||||
<Label htmlFor="fullname">Full Name</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="fullname"
|
||||
defaultValue={profile.name}
|
||||
{...register("name")}
|
||||
className={isProfileNameInputEmpty ? "border-red-300 focus:border-red-300" : ""}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input type="email" id="fullname" defaultValue={profile.email} disabled />
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="darkCTA"
|
||||
className="mt-4"
|
||||
loading={isMutatingProfile}
|
||||
disabled={isProfileNameInputEmpty || currentProfileName === previousProfileName}>
|
||||
Update
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
import { profileDeleteAction } from "./actions";
|
||||
import { TProfile } from "@formbricks/types/v1/profile";
|
||||
|
||||
export function EditAvatar({ session }) {
|
||||
return (
|
||||
@@ -103,9 +38,10 @@ interface DeleteAccountModalProps {
|
||||
open: boolean;
|
||||
setOpen: Dispatch<SetStateAction<boolean>>;
|
||||
session: Session;
|
||||
profile: TProfile;
|
||||
}
|
||||
|
||||
function DeleteAccountModal({ setOpen, open, session }: DeleteAccountModalProps) {
|
||||
function DeleteAccountModal({ setOpen, open, session, profile }: DeleteAccountModalProps) {
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
|
||||
@@ -116,7 +52,7 @@ function DeleteAccountModal({ setOpen, open, session }: DeleteAccountModalProps)
|
||||
const deleteAccount = async () => {
|
||||
try {
|
||||
setDeleting(true);
|
||||
await deleteProfile();
|
||||
await profileDeleteAction(profile.id);
|
||||
await signOut();
|
||||
await formbricksLogout();
|
||||
} catch (error) {
|
||||
@@ -169,7 +105,7 @@ function DeleteAccountModal({ setOpen, open, session }: DeleteAccountModalProps)
|
||||
);
|
||||
}
|
||||
|
||||
export function DeleteAccount({ session }: { session: Session | null }) {
|
||||
export function DeleteAccount({ session, profile }: { session: Session | null; profile: TProfile }) {
|
||||
const [isModalOpen, setModalOpen] = useState(false);
|
||||
|
||||
if (!session) {
|
||||
@@ -178,7 +114,7 @@ export function DeleteAccount({ session }: { session: Session | null }) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DeleteAccountModal open={isModalOpen} setOpen={setModalOpen} session={session} />
|
||||
<DeleteAccountModal open={isModalOpen} setOpen={setModalOpen} session={session} profile={profile} />
|
||||
<p className="text-sm text-slate-700">
|
||||
Delete your account with all personal data. <strong>This cannot be undone!</strong>
|
||||
</p>
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import AvatarPlaceholder from "@/images/avatar-placeholder.png";
|
||||
import { Button, ProfileAvatar } from "@formbricks/ui";
|
||||
import Image from "next/image";
|
||||
import { Session } from "next-auth";
|
||||
|
||||
export function EditAvatar({ session }:{session: Session | null}) {
|
||||
return (
|
||||
<div>
|
||||
{session?.user?.image ? (
|
||||
<Image
|
||||
src={AvatarPlaceholder}
|
||||
width="100"
|
||||
height="100"
|
||||
className="h-24 w-24 rounded-full"
|
||||
alt="Avatar placeholder"
|
||||
/>
|
||||
) : (
|
||||
<ProfileAvatar userId={session!.user.id} />
|
||||
)}
|
||||
|
||||
<Button className="mt-4" variant="darkCTA" disabled={true}>
|
||||
Upload Image
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Input, Label } from "@formbricks/ui";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { profileEditAction } from "./actions";
|
||||
import { TProfile } from "@formbricks/types/v1/profile";
|
||||
|
||||
export function EditName({ profile }: { profile: TProfile }) {
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<{name:string}>()
|
||||
|
||||
return (
|
||||
<>
|
||||
<form
|
||||
className="w-full max-w-sm items-center"
|
||||
onSubmit={handleSubmit(async(data) => {
|
||||
try {
|
||||
await profileEditAction(profile.id, data);
|
||||
toast.success("Your name was updated successfully.");
|
||||
} catch (error) {
|
||||
toast.error(`Error: ${error.message}`);
|
||||
}
|
||||
})}>
|
||||
<Label htmlFor="fullname">Full Name</Label>
|
||||
<Input
|
||||
type="text"
|
||||
id="fullname"
|
||||
defaultValue={profile.name ? profile.name : ""}
|
||||
{...register("name")}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input type="email" id="fullname" defaultValue={profile.email} disabled />
|
||||
</div>
|
||||
<Button type="submit" variant="darkCTA" className="mt-4" loading={isSubmitting}>
|
||||
Update
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
"use server";
|
||||
|
||||
import { updateProfile, deleteProfile } from "@formbricks/lib/services/profile";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
export async function profileEditAction(userId: string, data: Prisma.UserUpdateInput) {
|
||||
return await updateProfile(userId, data);
|
||||
}
|
||||
|
||||
export async function profileDeleteAction(userId: string) {
|
||||
return await deleteProfile(userId);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
function LoadingCard({ title, description, skeletonLines }) {
|
||||
return (
|
||||
<div className="my-4 rounded-lg border border-slate-200">
|
||||
<div className="grid content-center rounded-lg bg-slate-100 px-6 py-5 text-left text-slate-900">
|
||||
<h3 className="text-lg font-medium leading-6">{title}</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">{description}</p>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<div className="rounded-lg px-6 py-5 hover:bg-slate-100">
|
||||
{skeletonLines.map((line, index) => (
|
||||
<div key={index} className="mt-4">
|
||||
<div className={`animate-pulse rounded-full bg-gray-200 ${line.classes}`}></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Loading() {
|
||||
const cards = [
|
||||
{
|
||||
title: "Personal Information",
|
||||
description: "Update your personal information",
|
||||
skeletonLines: [
|
||||
{ classes: "h-4 w-28" },
|
||||
{ classes: "h-6 w-64" },
|
||||
{ classes: "h-4 w-28" },
|
||||
{ classes: "h-6 w-64" },
|
||||
{ classes: "h-8 w-24" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Avatar",
|
||||
description: "Assist your team in identifying you on Formbricks.",
|
||||
skeletonLines: [{ classes: "h-10 w-10" }, { classes: "h-8 w-24" }],
|
||||
},
|
||||
{
|
||||
title: "Delete account",
|
||||
description: "Delete your account with all of your personal information and data.",
|
||||
skeletonLines: [{ classes: "h-4 w-60" }, { classes: "h-8 w-24" }],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="my-4 text-2xl font-medium leading-6 text-slate-800">Profile</h2>
|
||||
{cards.map((card, index) => (
|
||||
<LoadingCard key={index} {...card} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,37 @@
|
||||
export const revalidate = REVALIDATION_INTERVAL;
|
||||
|
||||
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import SettingsCard from "../SettingsCard";
|
||||
import SettingsTitle from "../SettingsTitle";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { EditName, EditAvatar, DeleteAccount } from "./editProfile";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { DeleteAccount } from "./DeleteAccount";
|
||||
import { EditName } from "./EditName";
|
||||
import { EditAvatar } from "./EditAvatar";
|
||||
import { getProfile } from "@formbricks/lib/services/profile";
|
||||
|
||||
export default async function ProfileSettingsPage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
const profile = session ? await getProfile(session.user.id) : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SettingsTitle title="Profile" />
|
||||
<SettingsCard title="Personal Information" description="Update your personal information.">
|
||||
<EditName />
|
||||
</SettingsCard>
|
||||
<SettingsCard title="Avatar" description="Assist your team in identifying you on Formbricks.">
|
||||
<EditAvatar session={session} />
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title="Delete account"
|
||||
description="Delete your account with all of your personal information and data.">
|
||||
<DeleteAccount session={session} />
|
||||
</SettingsCard>
|
||||
</div>
|
||||
<>
|
||||
{profile && (
|
||||
<div>
|
||||
<SettingsTitle title="Profile" />
|
||||
<SettingsCard title="Personal Information" description="Update your personal information.">
|
||||
<EditName profile={profile} />
|
||||
</SettingsCard>
|
||||
<SettingsCard title="Avatar" description="Assist your team in identifying you on Formbricks.">
|
||||
<EditAvatar session={session} />
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title="Delete account"
|
||||
description="Delete your account with all of your personal information and data.">
|
||||
<DeleteAccount session={session} profile={profile} />
|
||||
</SettingsCard>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,10 +30,10 @@ const EmptySpaceFiller: React.FC<EmptySpaceFillerProps> = ({
|
||||
<div className="w-full space-y-4 rounded-b-lg bg-white p-4">
|
||||
<div className="h-16 w-full rounded-lg bg-slate-100"></div>
|
||||
|
||||
<div className=" flex h-16 w-full items-center justify-center rounded-lg bg-slate-50 text-slate-700 transition-all duration-300 ease-in-out hover:bg-slate-100 ">
|
||||
<div className="flex flex-col h-16 w-full items-center justify-center rounded-lg bg-slate-50 text-slate-700 transition-all duration-300 ease-in-out hover:bg-slate-100 ">
|
||||
{!environment.widgetSetupCompleted && !noWidgetRequired && (
|
||||
<Link
|
||||
className="flex h-full w-full items-center justify-center"
|
||||
className="flex w-full items-center justify-center"
|
||||
href={`/environments/${environmentId}/settings/setup`}>
|
||||
<span className="decoration-brand-dark underline transition-all duration-300 ease-in-out">
|
||||
Install Formbricks Widget. <strong>Go to Setup Checklist 👉</strong>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { BackIcon } from "@formbricks/ui";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
|
||||
BIN
apps/web/images/webhook.png
Normal file
BIN
apps/web/images/webhook.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -1,14 +1,20 @@
|
||||
import useSWR from "swr";
|
||||
import { fetcher } from "@formbricks/lib/fetcher";
|
||||
import { createDisplay, markDisplayResponded } from "@formbricks/lib/client/display";
|
||||
import { createResponse, updateResponse } from "@formbricks/lib/client/response";
|
||||
import { QuestionType, type Logic, type Question } from "@formbricks/types/questions";
|
||||
import { TResponseInput } from "@formbricks/types/v1/responses";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import type { Survey } from "@formbricks/types/surveys";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useGetOrCreatePerson } from "../people/people";
|
||||
import { fetcher } from "@formbricks/lib/fetcher";
|
||||
import { Response } from "@formbricks/types/js";
|
||||
import { QuestionType, type Logic, type Question } from "@formbricks/types/questions";
|
||||
import type { Survey } from "@formbricks/types/surveys";
|
||||
import { TResponseInput } from "@formbricks/types/v1/responses";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import { useGetOrCreatePerson } from "../people/people";
|
||||
|
||||
interface StoredResponse {
|
||||
id: string | null;
|
||||
data: { [x: string]: any };
|
||||
history: string[];
|
||||
}
|
||||
|
||||
export const useLinkSurvey = (surveyId: string) => {
|
||||
const { data, error, mutate, isLoading } = useSWR(`/api/v1/client/surveys/${surveyId}`, fetcher);
|
||||
@@ -29,6 +35,7 @@ export const useLinkSurveyUtils = (survey: Survey) => {
|
||||
const [loadingElement, setLoadingElement] = useState(false);
|
||||
const [responseId, setResponseId] = useState<string | null>(null);
|
||||
const [displayId, setDisplayId] = useState<string | null>(null);
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const [initiateCountdown, setinitiateCountdown] = useState<boolean>(false);
|
||||
const [storedResponseValue, setStoredResponseValue] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
@@ -44,19 +51,25 @@ export const useLinkSurveyUtils = (survey: Survey) => {
|
||||
const personId = person?.data.person.id ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
const storedResponses = getStoredResponses(survey.id);
|
||||
const storedResponse = getStoredResponse(survey.id);
|
||||
const questionKeys = survey.questions.map((question) => question.id);
|
||||
if (storedResponses) {
|
||||
const storedResponsesKeys = Object.keys(storedResponses);
|
||||
if (storedResponse) {
|
||||
if (storedResponse.id) {
|
||||
setResponseId(storedResponse.id);
|
||||
}
|
||||
if (storedResponse.history) {
|
||||
setHistory(storedResponse.history);
|
||||
}
|
||||
const storedResponsesKeys = Object.keys(storedResponse.data);
|
||||
// reduce to find the last answered question index
|
||||
const lastAnsweredQuestionIndex = questionKeys.reduce((acc, key, index) => {
|
||||
const lastStoredQuestionIndex = questionKeys.reduce((acc, key, index) => {
|
||||
if (storedResponsesKeys.includes(key)) {
|
||||
return index;
|
||||
}
|
||||
return acc;
|
||||
}, 0);
|
||||
if (lastAnsweredQuestionIndex > 0 && survey.questions.length > lastAnsweredQuestionIndex + 1) {
|
||||
const nextQuestion = survey.questions[lastAnsweredQuestionIndex + 1];
|
||||
if (lastStoredQuestionIndex > 0 && survey.questions.length > lastStoredQuestionIndex + 1) {
|
||||
const nextQuestion = survey.questions[lastStoredQuestionIndex];
|
||||
setCurrentQuestion(nextQuestion);
|
||||
setProgress(calculateProgress(nextQuestion, survey));
|
||||
setStoredResponseValue(getStoredResponseValue(survey.id, nextQuestion.id));
|
||||
@@ -67,8 +80,8 @@ export const useLinkSurveyUtils = (survey: Survey) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoadingPerson) {
|
||||
const storedResponses = getStoredResponses(survey.id);
|
||||
if (survey && !storedResponses) {
|
||||
const storedResponse = getStoredResponse(survey.id);
|
||||
if (survey && !storedResponse) {
|
||||
setCurrentQuestion(survey.questions[0]);
|
||||
|
||||
if (isPreview) return;
|
||||
@@ -95,10 +108,23 @@ export const useLinkSurveyUtils = (survey: Survey) => {
|
||||
return elementIdx / survey.questions.length;
|
||||
}, []);
|
||||
|
||||
const getNextQuestionId = (): string => {
|
||||
const getNextQuestionId = (answer: any): string => {
|
||||
const activeQuestionId: string = currentQuestion?.id || "";
|
||||
const currentQuestionIndex = survey.questions.findIndex((q) => q.id === currentQuestion?.id);
|
||||
if (currentQuestionIndex === -1) throw new Error("Question not found");
|
||||
|
||||
const answerValue = answer[activeQuestionId];
|
||||
|
||||
if (currentQuestion?.logic && currentQuestion?.logic.length > 0) {
|
||||
for (let logic of currentQuestion.logic) {
|
||||
if (!logic.destination) continue;
|
||||
|
||||
if (evaluateCondition(logic, answerValue)) {
|
||||
return logic.destination;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (lastQuestion) return "end";
|
||||
return survey.questions[currentQuestionIndex + 1].id;
|
||||
};
|
||||
@@ -111,21 +137,7 @@ export const useLinkSurveyUtils = (survey: Survey) => {
|
||||
|
||||
const submitResponse = async (data: { [x: string]: any }) => {
|
||||
setLoadingElement(true);
|
||||
const activeQuestionId: string = currentQuestion?.id || "";
|
||||
const nextQuestionId = getNextQuestionId();
|
||||
const responseValue = data[activeQuestionId];
|
||||
|
||||
if (currentQuestion?.logic && currentQuestion?.logic.length > 0) {
|
||||
for (let logic of currentQuestion.logic) {
|
||||
if (!logic.destination) continue;
|
||||
|
||||
if (evaluateCondition(logic, responseValue)) {
|
||||
return logic.destination;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const finished = nextQuestionId === "end";
|
||||
// build response
|
||||
const responseRequest: TResponseInput = {
|
||||
surveyId: survey.id,
|
||||
@@ -137,6 +149,9 @@ export const useLinkSurveyUtils = (survey: Survey) => {
|
||||
},
|
||||
};
|
||||
|
||||
const nextQuestionId = getNextQuestionId(data);
|
||||
responseRequest.finished = nextQuestionId === "end";
|
||||
|
||||
if (!responseId && !isPreview) {
|
||||
const response = await createResponse(
|
||||
responseRequest,
|
||||
@@ -146,33 +161,19 @@ export const useLinkSurveyUtils = (survey: Survey) => {
|
||||
markDisplayResponded(displayId, `${window.location.protocol}//${window.location.host}`);
|
||||
}
|
||||
setResponseId(response.id);
|
||||
storeResponse(survey.id, response.data);
|
||||
storeResponse(survey.id, response.data, response.id, history);
|
||||
} else if (responseId && !isPreview) {
|
||||
await updateResponse(
|
||||
responseRequest,
|
||||
responseId,
|
||||
`${window.location.protocol}//${window.location.host}`
|
||||
);
|
||||
storeResponse(survey.id, data);
|
||||
storeResponse(survey.id, data, responseId, history);
|
||||
}
|
||||
|
||||
setLoadingElement(false);
|
||||
|
||||
if (!finished && nextQuestionId !== "end") {
|
||||
const question = survey.questions.find((q) => q.id === nextQuestionId);
|
||||
|
||||
if (!question) throw new Error("Question not found");
|
||||
|
||||
setStoredResponseValue(getStoredResponseValue(survey.id, nextQuestionId));
|
||||
setCurrentQuestion(question);
|
||||
} else {
|
||||
setProgress(1);
|
||||
setFinished(true);
|
||||
clearStoredResponses(survey.id);
|
||||
if (survey.redirectUrl && Object.values(data)[0] !== "dismissed") {
|
||||
handleRedirect(survey.redirectUrl);
|
||||
}
|
||||
}
|
||||
goToNextQuestion(data);
|
||||
};
|
||||
|
||||
const handleRedirect = (url) => {
|
||||
@@ -212,10 +213,11 @@ export const useLinkSurveyUtils = (survey: Survey) => {
|
||||
}, [handlePrefilling]);
|
||||
|
||||
const getPreviousQuestionId = (): string => {
|
||||
const currentQuestionIndex = survey.questions.findIndex((q) => q.id === currentQuestion?.id);
|
||||
if (currentQuestionIndex === -1) throw new Error("Question not found");
|
||||
|
||||
return survey.questions[currentQuestionIndex - 1].id;
|
||||
const newHistory = [...history];
|
||||
const prevQuestionId = newHistory.pop();
|
||||
if (!prevQuestionId) throw new Error("Question not found");
|
||||
setHistory(newHistory);
|
||||
return prevQuestionId;
|
||||
};
|
||||
|
||||
const goToPreviousQuestion = (answer: Response["data"]) => {
|
||||
@@ -236,14 +238,25 @@ export const useLinkSurveyUtils = (survey: Survey) => {
|
||||
|
||||
const goToNextQuestion = (answer: Response["data"]) => {
|
||||
setLoadingElement(true);
|
||||
const nextQuestionId = getNextQuestionId();
|
||||
const nextQuestionId = getNextQuestionId(answer);
|
||||
|
||||
if (nextQuestionId === "end") {
|
||||
submitResponse(answer);
|
||||
setProgress(1);
|
||||
setFinished(true);
|
||||
clearStoredResponses(survey.id);
|
||||
if (survey.redirectUrl && Object.values(answer)[0] !== "dismissed") {
|
||||
handleRedirect(survey.redirectUrl);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
storeResponse(survey.id, answer);
|
||||
const newHistory = [...history];
|
||||
if (currentQuestion) {
|
||||
newHistory.push(currentQuestion.id);
|
||||
}
|
||||
setHistory(newHistory);
|
||||
|
||||
storeResponse(survey.id, answer, null, newHistory);
|
||||
|
||||
const nextQuestion = survey.questions.find((q) => q.id === nextQuestionId);
|
||||
|
||||
@@ -271,38 +284,50 @@ export const useLinkSurveyUtils = (survey: Survey) => {
|
||||
};
|
||||
};
|
||||
|
||||
const storeResponse = (surveyId: string, answer: Response["data"]) => {
|
||||
const storedResponses = localStorage.getItem(`formbricks-${surveyId}-responses`);
|
||||
const storeResponse = (
|
||||
surveyId: string,
|
||||
responseData: Response["data"],
|
||||
responseId: string | null = null,
|
||||
history: string[] = []
|
||||
) => {
|
||||
const storedResponses = localStorage.getItem(`formbricks-${surveyId}-response`);
|
||||
if (storedResponses) {
|
||||
const parsedResponses = JSON.parse(storedResponses);
|
||||
localStorage.setItem(
|
||||
`formbricks-${surveyId}-responses`,
|
||||
JSON.stringify({ ...parsedResponses, ...answer })
|
||||
);
|
||||
const existingResponse = JSON.parse(storedResponses);
|
||||
if (responseId) {
|
||||
existingResponse.id = responseId;
|
||||
}
|
||||
existingResponse.data = { ...existingResponse.data, ...responseData };
|
||||
existingResponse.history = history;
|
||||
localStorage.setItem(`formbricks-${surveyId}-response`, JSON.stringify(existingResponse));
|
||||
} else {
|
||||
localStorage.setItem(`formbricks-${surveyId}-responses`, JSON.stringify(answer));
|
||||
const response = {
|
||||
id: responseId,
|
||||
data: responseData,
|
||||
history,
|
||||
};
|
||||
localStorage.setItem(`formbricks-${surveyId}-response`, JSON.stringify(response));
|
||||
}
|
||||
};
|
||||
|
||||
const getStoredResponses = (surveyId: string): Record<string, string> | null => {
|
||||
const storedResponses = localStorage.getItem(`formbricks-${surveyId}-responses`);
|
||||
const getStoredResponse = (surveyId: string): StoredResponse | null => {
|
||||
const storedResponses = localStorage.getItem(`formbricks-${surveyId}-response`);
|
||||
if (storedResponses) {
|
||||
const parsedResponses = JSON.parse(storedResponses);
|
||||
return parsedResponses;
|
||||
return JSON.parse(storedResponses);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getStoredResponseValue = (surveyId: string, questionId: string): string | null => {
|
||||
const storedResponses = getStoredResponses(surveyId);
|
||||
if (storedResponses) {
|
||||
return storedResponses[questionId];
|
||||
const storedResponse = getStoredResponse(surveyId);
|
||||
|
||||
if (storedResponse && typeof storedResponse.data === "object") {
|
||||
return storedResponse.data[questionId];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const clearStoredResponses = (surveyId: string) => {
|
||||
localStorage.removeItem(`formbricks-${surveyId}-responses`);
|
||||
localStorage.removeItem(`formbricks-${surveyId}-response`);
|
||||
};
|
||||
|
||||
const checkValidity = (question: Question, answer: any): boolean => {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Webhook" ADD COLUMN "name" TEXT;
|
||||
@@ -30,6 +30,7 @@ enum PipelineTriggers {
|
||||
|
||||
model Webhook {
|
||||
id String @id @default(cuid())
|
||||
name String?
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
url String
|
||||
|
||||
@@ -162,18 +162,31 @@ export default function SurveyView({ config, survey, close, errorHandler }: Surv
|
||||
}
|
||||
}
|
||||
|
||||
function getNextQuestionId() {
|
||||
function getNextQuestionId(data: TResponseData): string {
|
||||
const questions = survey.questions;
|
||||
const currentQuestionIndex = questions.findIndex((q) => q.id === activeQuestionId);
|
||||
const currentQuestion = questions[currentQuestionIndex];
|
||||
const responseValue = data[activeQuestionId];
|
||||
|
||||
if (currentQuestionIndex === -1) throw new Error("Question not found");
|
||||
|
||||
if (currentQuestion?.logic && currentQuestion?.logic.length > 0) {
|
||||
for (let logic of currentQuestion.logic) {
|
||||
if (!logic.destination) continue;
|
||||
|
||||
if (evaluateCondition(logic, responseValue)) {
|
||||
return logic.destination;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return questions[currentQuestionIndex + 1]?.id || "end";
|
||||
}
|
||||
|
||||
function goToNextQuestion(answer: TResponseData): string {
|
||||
setLoadingElement(true);
|
||||
const questions = survey.questions;
|
||||
const nextQuestionId = getNextQuestionId();
|
||||
const nextQuestionId = getNextQuestionId(answer);
|
||||
|
||||
if (nextQuestionId === "end") {
|
||||
submitResponse(answer);
|
||||
@@ -212,21 +225,7 @@ export default function SurveyView({ config, survey, close, errorHandler }: Surv
|
||||
|
||||
const submitResponse = async (data: TResponseData) => {
|
||||
setLoadingElement(true);
|
||||
const questions = survey.questions;
|
||||
const nextQuestionId = getNextQuestionId();
|
||||
const currentQuestion = questions[activeQuestionId];
|
||||
const responseValue = data[activeQuestionId];
|
||||
|
||||
if (currentQuestion?.logic && currentQuestion?.logic.length > 0) {
|
||||
for (let logic of currentQuestion.logic) {
|
||||
if (!logic.destination) continue;
|
||||
|
||||
if (evaluateCondition(logic, responseValue)) {
|
||||
return logic.destination;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nextQuestionId = getNextQuestionId(data);
|
||||
const finished = nextQuestionId === "end";
|
||||
// build response
|
||||
const responseRequest: TResponseInput = {
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import type { TResponseData } from "../../../types/v1/responses";
|
||||
|
||||
export const storeResponse = (surveyId: string, answer: TResponseData) => {
|
||||
const storedResponse = localStorage.getItem(`formbricks-${surveyId}-responses`);
|
||||
const storedResponse = localStorage.getItem(`formbricks-${surveyId}-response`);
|
||||
if (storedResponse) {
|
||||
const parsedAnswers = JSON.parse(storedResponse);
|
||||
localStorage.setItem(`formbricks-${surveyId}-responses`, JSON.stringify({ ...parsedAnswers, ...answer }));
|
||||
localStorage.setItem(`formbricks-${surveyId}-response`, JSON.stringify({ ...parsedAnswers, ...answer }));
|
||||
} else {
|
||||
localStorage.setItem(`formbricks-${surveyId}-responses`, JSON.stringify(answer));
|
||||
localStorage.setItem(`formbricks-${surveyId}-response`, JSON.stringify(answer));
|
||||
}
|
||||
};
|
||||
|
||||
export const getStoredResponse = (surveyId: string, questionId: string): string | null => {
|
||||
const storedResponse = localStorage.getItem(`formbricks-${surveyId}-responses`);
|
||||
const storedResponse = localStorage.getItem(`formbricks-${surveyId}-response`);
|
||||
if (storedResponse) {
|
||||
const parsedAnswers = JSON.parse(storedResponse);
|
||||
return parsedAnswers[questionId] || null;
|
||||
@@ -20,5 +20,5 @@ export const getStoredResponse = (surveyId: string, questionId: string): string
|
||||
};
|
||||
|
||||
export const clearStoredResponse = (surveyId: string) => {
|
||||
localStorage.removeItem(`formbricks-${surveyId}-responses`);
|
||||
localStorage.removeItem(`formbricks-${surveyId}-response`);
|
||||
};
|
||||
|
||||
141
packages/lib/services/profile.ts
Normal file
141
packages/lib/services/profile.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/errors";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { TProfile } from "@formbricks/types/v1/profile";
|
||||
import { deleteTeam } from "./team";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { cache } from "react";
|
||||
|
||||
const responseSelection = {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
};
|
||||
|
||||
interface Membership {
|
||||
role: MembershipRole;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
// function to retrive basic information about a user's profile
|
||||
export const getProfile = cache(async (userId: string): Promise<TProfile | null> => {
|
||||
try {
|
||||
const profile = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: responseSelection,
|
||||
});
|
||||
|
||||
if (!profile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return profile;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError("Database operation failed");
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
const updateUserMembership = async (teamId: string, userId: string, role: MembershipRole) => {
|
||||
await prisma.membership.update({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
userId,
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
role,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const getAdminMemberships = (memberships: Membership[]) =>
|
||||
memberships.filter((membership) => membership.role === MembershipRole.admin);
|
||||
|
||||
// function to update a user's profile
|
||||
export const updateProfile = async (personId: string, data: Prisma.UserUpdateInput): Promise<TProfile> => {
|
||||
try {
|
||||
const updatedProfile = await prisma.user.update({
|
||||
where: {
|
||||
id: personId,
|
||||
},
|
||||
data: data,
|
||||
});
|
||||
|
||||
return updatedProfile;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2016") {
|
||||
throw new ResourceNotFoundError("Profile", personId);
|
||||
} else {
|
||||
throw error; // Re-throw any other errors
|
||||
}
|
||||
}
|
||||
};
|
||||
const deleteUser = async (userId: string) => {
|
||||
await prisma.user.delete({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// function to delete a user's profile including teams
|
||||
export const deleteProfile = async (personId: string): Promise<void> => {
|
||||
try {
|
||||
const currentUserMemberships = await prisma.membership.findMany({
|
||||
where: {
|
||||
userId: personId,
|
||||
},
|
||||
include: {
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
memberships: {
|
||||
select: {
|
||||
userId: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const currentUserMembership of currentUserMemberships) {
|
||||
const teamMemberships = currentUserMembership.team.memberships;
|
||||
const role = currentUserMembership.role;
|
||||
const teamId = currentUserMembership.teamId;
|
||||
|
||||
const teamAdminMemberships = getAdminMemberships(teamMemberships);
|
||||
const teamHasAtLeastOneAdmin = teamAdminMemberships.length > 0;
|
||||
const teamHasOnlyOneMember = teamMemberships.length === 1;
|
||||
const currentUserIsTeamOwner = role === MembershipRole.owner;
|
||||
|
||||
if (teamHasOnlyOneMember) {
|
||||
await deleteTeam(teamId);
|
||||
} else if (currentUserIsTeamOwner && teamHasAtLeastOneAdmin) {
|
||||
const firstAdmin = teamAdminMemberships[0];
|
||||
await updateUserMembership(teamId, firstAdmin.userId, MembershipRole.owner);
|
||||
} else if (currentUserIsTeamOwner) {
|
||||
await deleteTeam(teamId);
|
||||
}
|
||||
}
|
||||
|
||||
await deleteUser(personId);
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError("Database operation failed");
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -1,9 +1,9 @@
|
||||
import { cache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { DatabaseError } from "@formbricks/errors";
|
||||
import { TTeam } from "@formbricks/types/v1/teams";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache } from "react";
|
||||
import {
|
||||
ChurnResponses,
|
||||
ChurnSurvey,
|
||||
@@ -58,6 +58,22 @@ export const getTeamByEnvironmentId = cache(async (environmentId: string): Promi
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteTeam = async (teamId: string) => {
|
||||
try {
|
||||
await prisma.team.delete({
|
||||
where: {
|
||||
id: teamId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError("Database operation failed");
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const createDemoProduct = cache(async (teamId: string) => {
|
||||
const productWithEnvironment = Prisma.validator<Prisma.ProductArgs>()({
|
||||
include: {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
"use server";
|
||||
import "server-only";
|
||||
|
||||
import { TWebhook, TWebhookInput } from "@formbricks/types/v1/webhooks";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { Prisma } from "@prisma/client";
|
||||
@@ -34,6 +37,7 @@ export const createWebhook = async (
|
||||
}
|
||||
return await prisma.webhook.create({
|
||||
data: {
|
||||
name: webhookInput.name,
|
||||
url: webhookInput.url,
|
||||
triggers: webhookInput.triggers,
|
||||
surveyIds: webhookInput.surveyIds || [],
|
||||
@@ -52,6 +56,31 @@ export const createWebhook = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const updateWebhook = async (
|
||||
environmentId: string,
|
||||
webhookId: string,
|
||||
webhookInput: Partial<TWebhookInput>
|
||||
): Promise<TWebhook> => {
|
||||
try {
|
||||
const result = await prisma.webhook.update({
|
||||
where: {
|
||||
id: webhookId,
|
||||
},
|
||||
data: {
|
||||
name: webhookInput.name,
|
||||
url: webhookInput.url,
|
||||
triggers: webhookInput.triggers,
|
||||
surveyIds: webhookInput.surveyIds || [],
|
||||
},
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw new DatabaseError(
|
||||
`Database error when updating webhook with ID ${webhookId} for environment ${environmentId}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteWebhook = async (id: string): Promise<TWebhook> => {
|
||||
try {
|
||||
return await prisma.webhook.delete({
|
||||
|
||||
11
packages/types/v1/profile.ts
Normal file
11
packages/types/v1/profile.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import z from "zod";
|
||||
|
||||
export const ZProfile = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().nullish(),
|
||||
email: z.string(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
});
|
||||
|
||||
export type TProfile = z.infer<typeof ZProfile>;
|
||||
@@ -3,17 +3,20 @@ import { ZPipelineTrigger } from "./pipelines";
|
||||
|
||||
export const ZWebhook = z.object({
|
||||
id: z.string().cuid2(),
|
||||
name: z.string().nullable(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
url: z.string().url(),
|
||||
environmentId: z.string().cuid2(),
|
||||
triggers: z.array(ZPipelineTrigger),
|
||||
surveyIds: z.array(z.string().cuid2()),
|
||||
});
|
||||
|
||||
export type TWebhook = z.infer<typeof ZWebhook>;
|
||||
|
||||
export const ZWebhookInput = z.object({
|
||||
url: z.string().url(),
|
||||
name: z.string().nullable(),
|
||||
triggers: z.array(ZPipelineTrigger),
|
||||
surveyIds: z.array(z.string().cuid2()).optional(),
|
||||
});
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Button } from "./Button";
|
||||
|
||||
interface CardProps {
|
||||
connectText?: string;
|
||||
connectHref?: string;
|
||||
connectNewTab?: boolean;
|
||||
docsText?: string;
|
||||
docsHref?: string;
|
||||
docsNewTab?: boolean;
|
||||
label: string;
|
||||
description: string;
|
||||
icon?: React.ReactNode;
|
||||
@@ -10,20 +14,30 @@ interface CardProps {
|
||||
|
||||
export type { CardProps };
|
||||
|
||||
export const Card: React.FC<CardProps> = ({ connectHref, docsHref, label, description, icon }) => (
|
||||
export const Card: React.FC<CardProps> = ({
|
||||
connectText,
|
||||
connectHref,
|
||||
connectNewTab,
|
||||
docsText,
|
||||
docsHref,
|
||||
docsNewTab,
|
||||
label,
|
||||
description,
|
||||
icon,
|
||||
}) => (
|
||||
<div className="rounded-lg bg-white p-8 text-left shadow-sm ">
|
||||
{icon && <div className="mb-6 h-8 w-8">{icon}</div>}
|
||||
<h3 className="text-lg font-bold text-slate-800">{label}</h3>
|
||||
<p className="text-xs text-slate-500">{description}</p>
|
||||
<div className="mt-4 flex space-x-2">
|
||||
{connectHref && (
|
||||
<Button href={connectHref} target="_blank" size="sm" variant="darkCTA">
|
||||
Connect
|
||||
<Button href={connectHref} target={connectNewTab ? "_blank" : "_self"} size="sm" variant="darkCTA">
|
||||
{connectText}
|
||||
</Button>
|
||||
)}
|
||||
{docsHref && (
|
||||
<Button href={docsHref} target="_blank" size="sm" variant="secondary">
|
||||
Docs
|
||||
<Button href={docsHref} target={docsNewTab ? "_blank" : "_self"} size="sm" variant="secondary">
|
||||
{docsText}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user