Merge branch 'main' of https://github.com/Dhruwang/formbricks into KapadiaNaitik/main
12
README.md
@@ -117,12 +117,18 @@ We are very happy if you are interested in contributing to Formbricks 🤗
|
||||
|
||||
Here are a few options:
|
||||
|
||||
- Star this repo
|
||||
- Create issues every time you feel something is missing or goes wrong
|
||||
- Upvote issues with 👍 reaction so we know what's the demand for particular issue to prioritize it within roadmap
|
||||
- Star this repo.
|
||||
- Create issues every time you feel something is missing or goes wrong.
|
||||
- Upvote issues with 👍 reaction so we know what's the demand for a particular issue to prioritize it within the roadmap.
|
||||
|
||||
Please check out [our contribution guide](https://formbricks.com/docs/contributing/introduction) and our [list of open issues](https://github.com/formbricks/formbricks/issues) for more information.
|
||||
|
||||
## All Thanks To Our Contributors
|
||||
|
||||
<a href="https://github.com/formbricks/formbricks/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=formbricks/formbricks" />
|
||||
</a>
|
||||
|
||||
## 📆 Contact us
|
||||
|
||||
Let's have a chat about your survey needs and get you started.
|
||||
|
||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,80 @@
|
||||
import Image from "next/image";
|
||||
|
||||
import EnvVar from "./env-variable.webp";
|
||||
import ShareModal from "./share-modal.webp";
|
||||
import Settings from "./single-use-setting.webp";
|
||||
import Message from "./used-message.webp";
|
||||
import Metadata from "./metadata.webp";
|
||||
|
||||
export const meta = {
|
||||
title: "Single Use Links",
|
||||
description: "Make sure that each respondent only replies once with single use links.",
|
||||
};
|
||||
|
||||
#### Link Surveys
|
||||
|
||||
# Single Use Links
|
||||
|
||||
This guide will help you understand how to generate and use single-use links within our application.
|
||||
|
||||
## Purpose
|
||||
|
||||
- Single-use links (or one-time / disposable links) are URLs that grant access to a survey only once.
|
||||
|
||||
- The primary purpose of single-use links is to assure that no respondent submits a survey twice.
|
||||
|
||||
## Using Single-Use Links with Formbricks
|
||||
|
||||
Using single-use links with Formbricks is quite straight-forward:
|
||||
|
||||
1. In the survey settings, toggle "Single Use Link" on:
|
||||
|
||||
<Image
|
||||
src={Settings}
|
||||
alt="Single use survey settings"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
2. When you publish your survey, the following modal will open:
|
||||
<Image
|
||||
src={ShareModal}
|
||||
alt="Share modal with 7 single use links which can be regenerated"
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
Here, you can copy and generate as many single-use links as you need.
|
||||
|
||||
## URL Encryption
|
||||
|
||||
You can encrypt single use URLs to assure information to be protected. To enable it, you have to set the correct environment variable:
|
||||
|
||||
<Image
|
||||
src={EnvVar}
|
||||
alt="Set the right env var to be able to enable encryption."
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
## Check suId of a submission
|
||||
|
||||
You can find the suId of each submission in the submission meta data. To view it, simplte hover over the Avatar:
|
||||
|
||||
<Image
|
||||
src={Metadata}
|
||||
alt="View suId in the submission meta data."
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
||||
### 'Link used' message
|
||||
|
||||
You can customize the 'link used' messaging in the Survey Editor settings:
|
||||
|
||||
<Image
|
||||
src={Message}
|
||||
alt="Adjust the message shown to people if a link was already used."
|
||||
quality="100"
|
||||
className="max-w-full rounded-lg sm:max-w-3xl"
|
||||
/>
|
||||
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 42 KiB |
@@ -237,6 +237,7 @@ export const navigation: Array<NavGroup> = [
|
||||
links: [
|
||||
{ title: "Data Prefilling", href: "/docs/link-surveys/data-prefilling" },
|
||||
{ title: "User Identification", href: "/docs/link-surveys/user-identification" },
|
||||
{ title: "Single Use Links", href: "/docs/link-surveys/single-use-links" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -15,7 +15,7 @@ const navigation = {
|
||||
href: "https://twitter.com/formbricks",
|
||||
icon: (props: any) => (
|
||||
<svg fill="currentColor" viewBox="0 0 24 24" {...props}>
|
||||
<path d="M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0022 5.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.072 4.072 0 012.8 9.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 012 18.407a11.616 11.616 0 006.29 1.84" />
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
|
||||
3
apps/web/.gitignore
vendored
@@ -40,3 +40,6 @@ next-env.d.ts
|
||||
|
||||
# Google Sheets Token File
|
||||
token.json
|
||||
|
||||
# Local Uploads
|
||||
uploads/
|
||||
@@ -234,7 +234,7 @@ export default function Navigation({
|
||||
return (
|
||||
<>
|
||||
{product && (
|
||||
<nav className="top-0 z-10 w-full border-b border-slate-200 bg-white">
|
||||
<nav className="top-0 w-full border-b border-slate-200 bg-white">
|
||||
{environment?.type === "development" && (
|
||||
<div className="h-6 w-full bg-[#A33700] p-0.5 text-center text-sm text-white">
|
||||
You're in development mode. Use it to test surveys, actions and attributes.
|
||||
|
||||
@@ -61,6 +61,7 @@ export default function ResponseFeed({
|
||||
<SurveyStatusIndicator
|
||||
status={response.survey.status}
|
||||
environment={environment}
|
||||
type={response.survey.type}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,13 @@ import type { TProduct } from "@formbricks/types/v1/product";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
import { Button } from "@formbricks/ui";
|
||||
import { ArrowPathRoundedSquareIcon } from "@heroicons/react/24/outline";
|
||||
import { ComputerDesktopIcon, DevicePhoneMobileIcon } from "@heroicons/react/24/solid";
|
||||
import {
|
||||
ArrowsPointingInIcon,
|
||||
ArrowsPointingOutIcon,
|
||||
ComputerDesktopIcon,
|
||||
DevicePhoneMobileIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { Variants, motion } from "framer-motion";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
type TPreviewType = "modal" | "fullwidth" | "email";
|
||||
@@ -24,6 +30,65 @@ interface PreviewSurveyProps {
|
||||
|
||||
let surveyNameTemp;
|
||||
|
||||
const previewParentContainerVariant: Variants = {
|
||||
expanded: {
|
||||
position: "fixed",
|
||||
height: "100%",
|
||||
width: "100%",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.4)",
|
||||
backdropFilter: "blur(15px)",
|
||||
left: 0,
|
||||
top: 0,
|
||||
zIndex: 1040,
|
||||
transition: {
|
||||
ease: "easeIn",
|
||||
duration: 0.001,
|
||||
},
|
||||
},
|
||||
shrink: {
|
||||
display: "none",
|
||||
position: "fixed",
|
||||
backgroundColor: "rgba(0, 0, 0, 0.0)",
|
||||
backdropFilter: "blur(0px)",
|
||||
transition: {
|
||||
duration: 0,
|
||||
},
|
||||
zIndex: -1,
|
||||
},
|
||||
};
|
||||
|
||||
const previewScreenVariants: Variants = {
|
||||
expanded: {
|
||||
right: "5%",
|
||||
bottom: "2%",
|
||||
width: "90%",
|
||||
height: "90%",
|
||||
zIndex: 1050,
|
||||
boxShadow: "0px 4px 5px 4px rgba(169, 169, 169, 0.25)",
|
||||
transition: {
|
||||
ease: "easeInOut",
|
||||
duration: 0.3,
|
||||
},
|
||||
},
|
||||
expanded_with_fixed_positioning: {
|
||||
zIndex: 1050,
|
||||
position: "fixed",
|
||||
right: "5%",
|
||||
bottom: "5%",
|
||||
width: "90%",
|
||||
height: "90%",
|
||||
transition: {
|
||||
ease: "easeOut",
|
||||
duration: 0.2,
|
||||
},
|
||||
},
|
||||
shrink: {
|
||||
display: "relative",
|
||||
width: ["83.33%"],
|
||||
height: ["95%"],
|
||||
},
|
||||
};
|
||||
|
||||
export default function PreviewSurvey({
|
||||
setActiveQuestionId,
|
||||
activeQuestionId,
|
||||
@@ -33,8 +98,10 @@ export default function PreviewSurvey({
|
||||
environment,
|
||||
}: PreviewSurveyProps) {
|
||||
const [isModalOpen, setIsModalOpen] = useState(true);
|
||||
const [isFullScreenPreview, setIsFullScreenPreview] = useState(false);
|
||||
const [widgetSetupCompleted, setWidgetSetupCompleted] = useState(false);
|
||||
const [previewMode, setPreviewMode] = useState("desktop");
|
||||
const [previewPosition, setPreviewPosition] = useState("relative");
|
||||
const ContentRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const { productOverwrites } = survey || {};
|
||||
@@ -62,12 +129,12 @@ export default function PreviewSurvey({
|
||||
}
|
||||
}, [activeQuestionId, survey.type, survey, setActiveQuestionId]);
|
||||
|
||||
// this useEffect is fo refreshing the survey preview only if user is switching between templates on survey templates page and hence we are checking for survey.id === "someUniqeId1" which is a common Id for all templates
|
||||
useEffect(() => {
|
||||
if (survey.name !== surveyNameTemp) {
|
||||
if (survey.name !== surveyNameTemp && survey.id === "someUniqueId1") {
|
||||
resetQuestionProgress();
|
||||
surveyNameTemp = survey.name;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [survey]);
|
||||
|
||||
@@ -99,7 +166,22 @@ export default function PreviewSurvey({
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-items-center">
|
||||
<div className="relative flex h-[95%] max-h-[95%] w-5/6 items-center justify-center rounded-lg border border-slate-300 bg-slate-200">
|
||||
<motion.div
|
||||
variants={previewParentContainerVariant}
|
||||
className="fixed hidden h-[95%] w-5/6"
|
||||
animate={isFullScreenPreview ? "expanded" : "shrink"}
|
||||
/>
|
||||
<motion.div
|
||||
layout
|
||||
variants={previewScreenVariants}
|
||||
animate={
|
||||
isFullScreenPreview
|
||||
? previewPosition === "relative"
|
||||
? "expanded"
|
||||
: "expanded_with_fixed_positioning"
|
||||
: "shrink"
|
||||
}
|
||||
className="relative flex h-[95] max-h-[95%] w-5/6 items-center justify-center rounded-lg border border-slate-300 bg-slate-200">
|
||||
{previewMode === "mobile" && (
|
||||
<>
|
||||
<div className="absolute right-0 top-0 m-2">
|
||||
@@ -153,7 +235,26 @@ export default function PreviewSurvey({
|
||||
</div>
|
||||
<p className="ml-4 flex w-full justify-between font-mono text-sm text-slate-400">
|
||||
{previewType === "modal" ? "Your web app" : "Preview"}
|
||||
<ResetProgressButton resetQuestionProgress={resetQuestionProgress} />
|
||||
<div className="flex items-center">
|
||||
{isFullScreenPreview ? (
|
||||
<ArrowsPointingInIcon
|
||||
className="mr-2 h-4 w-4 cursor-pointer"
|
||||
onClick={() => {
|
||||
setIsFullScreenPreview(false);
|
||||
setPreviewPosition("relative");
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ArrowsPointingOutIcon
|
||||
className="mr-2 h-4 w-4 cursor-pointer"
|
||||
onClick={() => {
|
||||
setIsFullScreenPreview(true);
|
||||
setTimeout(() => setPreviewPosition("fixed"), 300);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ResetProgressButton resetQuestionProgress={resetQuestionProgress} />
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -173,7 +274,7 @@ export default function PreviewSurvey({
|
||||
/>
|
||||
</Modal>
|
||||
) : (
|
||||
<div className="flex flex-grow flex-col overflow-y-auto" ref={ContentRef}>
|
||||
<div className="flex flex-grow flex-col overflow-y-auto rounded-b-lg" ref={ContentRef}>
|
||||
<div className="flex w-full flex-grow flex-col items-center justify-center bg-white p-4 py-6">
|
||||
<div className="w-full max-w-md">
|
||||
<SurveyInline
|
||||
@@ -190,7 +291,8 @@ export default function PreviewSurvey({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* for toggling between mobile and desktop mode */}
|
||||
<div className="mt-2 flex rounded-full border-2 border-slate-300 p-1">
|
||||
<TabOption
|
||||
|
||||
@@ -81,7 +81,12 @@ export default async function SurveysList({ environmentId }: { environmentId: st
|
||||
<div className="flex items-center">
|
||||
{survey.status !== "draft" && (
|
||||
<>
|
||||
<SurveyStatusIndicator status={survey.status} tooltip environment={environment} />
|
||||
<SurveyStatusIndicator
|
||||
status={survey.status}
|
||||
tooltip
|
||||
environment={environment}
|
||||
type={survey.type}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{survey.status === "draft" && (
|
||||
|
||||
@@ -28,9 +28,9 @@ export const RatingResponse: React.FC<RatingResponseProps> = ({ scale, range, an
|
||||
const stars: any = [];
|
||||
for (let i = 0; i < range; i++) {
|
||||
if (i < parseInt(answer)) {
|
||||
stars.push(<StarIcon className="h-7 text-yellow-400" />);
|
||||
stars.push(<StarIcon key={i} className="h-7 text-yellow-400" />);
|
||||
} else {
|
||||
stars.push(<StarIcon className="h-7 text-gray-300" />);
|
||||
stars.push(<StarIcon key={i} className="h-7 text-gray-300" />);
|
||||
}
|
||||
}
|
||||
return <div className="flex">{stars}</div>;
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import { TProduct } from "@formbricks/types/v1/product";
|
||||
import ShareEmbedSurvey from "./ShareEmbedSurvey";
|
||||
import LinkSingleUseSurveyModal from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/LinkSingleUseSurveyModal";
|
||||
import { TProfile } from "@formbricks/types/v1/profile";
|
||||
|
||||
interface LinkSurveyShareButtonProps {
|
||||
@@ -16,7 +15,6 @@ interface LinkSurveyShareButtonProps {
|
||||
surveyBaseUrl: string;
|
||||
product: TProduct;
|
||||
profile: TProfile;
|
||||
singleUseIds?: string[];
|
||||
}
|
||||
|
||||
export default function LinkSurveyShareButton({
|
||||
@@ -25,7 +23,6 @@ export default function LinkSurveyShareButton({
|
||||
surveyBaseUrl,
|
||||
product,
|
||||
profile,
|
||||
singleUseIds,
|
||||
}: LinkSurveyShareButtonProps) {
|
||||
const [showLinkModal, setShowLinkModal] = useState(false);
|
||||
const isSingleUse = survey.singleUse?.enabled ?? false;
|
||||
@@ -38,15 +35,19 @@ export default function LinkSurveyShareButton({
|
||||
"border border-slate-300 bg-white px-2 hover:bg-slate-100 focus:bg-slate-100 lg:px-6",
|
||||
className
|
||||
)}
|
||||
onClick={() => setShowLinkModal(true)}>
|
||||
onClick={() => {
|
||||
setShowLinkModal(true);
|
||||
}}>
|
||||
<ShareIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
{showLinkModal && isSingleUse && singleUseIds ? (
|
||||
<LinkSingleUseSurveyModal
|
||||
{showLinkModal && isSingleUse ? (
|
||||
<ShareEmbedSurvey
|
||||
survey={survey}
|
||||
open={showLinkModal}
|
||||
setOpen={setShowLinkModal}
|
||||
singleUseIds={singleUseIds}
|
||||
product={product}
|
||||
surveyBaseUrl={surveyBaseUrl}
|
||||
profile={profile}
|
||||
/>
|
||||
) : (
|
||||
<ShareEmbedSurvey
|
||||
|
||||
@@ -1,35 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Dialog, DialogContent } from "@formbricks/ui";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
import { ArrowPathIcon, CheckIcon } from "@heroicons/react/24/outline";
|
||||
import { CheckCircleIcon, DocumentDuplicateIcon, EyeIcon } from "@heroicons/react/24/solid";
|
||||
import { useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { generateSingleUseIdAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/actions";
|
||||
import { truncateMiddle } from "@/lib/utils";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
import { Button } from "@formbricks/ui";
|
||||
import { ArrowPathIcon } from "@heroicons/react/24/outline";
|
||||
import { DocumentDuplicateIcon, EyeIcon } from "@heroicons/react/24/solid";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
interface LinkSingleUseSurveyModalProps {
|
||||
survey: TSurvey;
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
singleUseIds: string[];
|
||||
surveyBaseUrl: string;
|
||||
}
|
||||
|
||||
export default function LinkSingleUseSurveyModal({
|
||||
survey,
|
||||
open,
|
||||
setOpen,
|
||||
singleUseIds,
|
||||
}: LinkSingleUseSurveyModalProps) {
|
||||
const defaultSurveyUrl = `${window.location.protocol}//${window.location.host}/s/${survey.id}`;
|
||||
export default function LinkSingleUseSurveyModal({ survey, surveyBaseUrl }: LinkSingleUseSurveyModalProps) {
|
||||
const [singleUseIds, setSingleUseIds] = useState<string[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSingleUseIds();
|
||||
}, [survey.singleUse?.isEncrypted]);
|
||||
|
||||
const fetchSingleUseIds = async () => {
|
||||
const ids = await generateSingleUseIds(survey.singleUse?.isEncrypted ?? false);
|
||||
setSingleUseIds(ids);
|
||||
};
|
||||
|
||||
const generateSingleUseIds = async (isEncrypted: boolean) => {
|
||||
const promises = Array(7)
|
||||
.fill(null)
|
||||
.map(() => generateSingleUseIdAction(isEncrypted));
|
||||
const ids = await Promise.all(promises);
|
||||
return ids;
|
||||
};
|
||||
|
||||
const defaultSurveyUrl = `${surveyBaseUrl}/${survey.id}`;
|
||||
const [selectedSingleUseIds, setSelectedSingleIds] = useState<number[]>([]);
|
||||
|
||||
const linkTextRef = useRef<HTMLDivElement>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const handleLinkOnClick = (index: number) => {
|
||||
if (!singleUseIds) return;
|
||||
setSelectedSingleIds([...selectedSingleUseIds, index]);
|
||||
const surveyUrl = `${defaultSurveyUrl}?suId=${singleUseIds[index]}`;
|
||||
navigator.clipboard.writeText(surveyUrl);
|
||||
@@ -37,41 +49,54 @@ export default function LinkSingleUseSurveyModal({
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="bottom-0 max-w-sm bg-white p-4 sm:bottom-auto sm:max-w-xl sm:p-6">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-teal-100">
|
||||
<CheckIcon className="h-6 w-6 text-teal-600" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="mt-3 text-center sm:mt-5">
|
||||
<h3 className="text-lg font-semibold leading-6 text-gray-900">Your survey is ready!</h3>
|
||||
<div className="mt-4">
|
||||
<p className="text-sm text-gray-500">
|
||||
Here are 5 single use links to let people answer your survey:
|
||||
</p>
|
||||
<div ref={linkTextRef}>
|
||||
{singleUseIds.map((singleUseId, index) => {
|
||||
const isSelected = selectedSingleUseIds.includes(index);
|
||||
return (
|
||||
<div
|
||||
key={singleUseId}
|
||||
className={cn(
|
||||
"row relative mt-3 flex max-w-full cursor-pointer items-center justify-between overflow-auto rounded-lg border border-slate-300 bg-slate-50 px-8 py-4 text-left text-slate-800 transition-all duration-200 ease-in-out hover:border-slate-500",
|
||||
isSelected && "border-slate-200 text-slate-400 hover:border-slate-200"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isSelected) {
|
||||
handleLinkOnClick(index);
|
||||
}
|
||||
}}>
|
||||
<span>{truncateMiddle(`${defaultSurveyUrl}?suId=${singleUseId}`, 48)}</span>
|
||||
{isSelected ? (
|
||||
<CheckCircleIcon className="ml-4 h-4 w-4" />
|
||||
) : (
|
||||
<DocumentDuplicateIcon className="ml-4 h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<>
|
||||
{singleUseIds && (
|
||||
<div className="w-full">
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
title="Preview survey"
|
||||
aria-label="Preview survey"
|
||||
className="flex w-fit justify-center"
|
||||
href={`${defaultSurveyUrl}?suId=${singleUseIds[0]}&preview=true`}
|
||||
target="_blank"
|
||||
EndIcon={EyeIcon}>
|
||||
Preview Survey
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="my-4 border-t border-slate-300 pb-2 pt-4">
|
||||
<p className="mb-3 font-semibold text-slate-800">Single Use Links</p>
|
||||
<div ref={linkTextRef} className="min flex flex-col space-y-4">
|
||||
{singleUseIds &&
|
||||
singleUseIds.map((singleUseId, index) => {
|
||||
const isSelected = selectedSingleUseIds.includes(index);
|
||||
return (
|
||||
<div className="flex h-fit justify-center p-0">
|
||||
<div
|
||||
key={singleUseId}
|
||||
className={cn(
|
||||
"row relative flex w-full cursor-pointer items-center justify-between overflow-hidden rounded-lg border border-slate-300 bg-white px-6 py-2 text-left text-slate-800 transition-all duration-200 ease-in-out hover:border-slate-500",
|
||||
isSelected && "border-slate-200 text-slate-400 hover:border-slate-200"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isSelected) {
|
||||
handleLinkOnClick(index);
|
||||
}
|
||||
}}>
|
||||
<span>{truncateMiddle(`${defaultSurveyUrl}?suId=${singleUseId}`, 48)}</span>
|
||||
</div>
|
||||
<div className="ml-2 min-h-full">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="m-0 my-0 h-full w-full overflow-hidden whitespace-pre text-center"
|
||||
onClick={() => handleLinkOnClick(index)}>
|
||||
{isSelected ? "Copied" : " Copy "}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col justify-center gap-2 sm:flex-row sm:justify-end">
|
||||
@@ -81,7 +106,7 @@ export default function LinkSingleUseSurveyModal({
|
||||
aria-label="Generate new single-use survey link"
|
||||
className="flex justify-center"
|
||||
onClick={() => {
|
||||
router.refresh();
|
||||
fetchSingleUseIds();
|
||||
setSelectedSingleIds([]);
|
||||
toast.success("New survey links generated!");
|
||||
}}
|
||||
@@ -102,21 +127,11 @@ export default function LinkSingleUseSurveyModal({
|
||||
aria-label="Copy all survey links to clipboard"
|
||||
className="flex justify-center"
|
||||
EndIcon={DocumentDuplicateIcon}>
|
||||
Copy 5 URLs
|
||||
</Button>
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
title="Preview survey"
|
||||
aria-label="Preview survey"
|
||||
className="flex justify-center"
|
||||
href={`${defaultSurveyUrl}?suId=${singleUseIds[0]}&preview=true`}
|
||||
target="_blank"
|
||||
EndIcon={EyeIcon}>
|
||||
Preview
|
||||
Copy all URLs
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import LinkTab from "./shareEmbedTabs/LinkTab";
|
||||
import EmailTab from "./shareEmbedTabs/EmailTab";
|
||||
import WebpageTab from "./shareEmbedTabs/WebpageTab";
|
||||
import LinkSingleUseSurveyModal from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/LinkSingleUseSurveyModal";
|
||||
import { useMemo, useState } from "react";
|
||||
import { TProduct } from "@formbricks/types/v1/product";
|
||||
import { TSurvey } from "@formbricks/types/v1/surveys";
|
||||
@@ -19,7 +20,6 @@ interface ShareEmbedSurveyProps {
|
||||
product: TProduct;
|
||||
profile: TProfile;
|
||||
}
|
||||
|
||||
export default function ShareEmbedSurvey({
|
||||
survey,
|
||||
open,
|
||||
@@ -29,14 +29,24 @@ export default function ShareEmbedSurvey({
|
||||
profile,
|
||||
}: ShareEmbedSurveyProps) {
|
||||
const surveyUrl = useMemo(() => surveyBaseUrl + survey.id, [survey]);
|
||||
|
||||
const isSingleUseLinkSurvey = survey.singleUse?.enabled;
|
||||
const { email } = profile;
|
||||
const { brandColor } = product;
|
||||
|
||||
const tabs = [
|
||||
{ id: "link", label: `${isSingleUseLinkSurvey ? "Single Use Links" : "Share the Link"}`, icon: LinkIcon },
|
||||
{ id: "email", label: "Embed in an Email", icon: EnvelopeIcon },
|
||||
{ id: "webpage", label: "Embed in a Web Page", icon: CodeBracketIcon },
|
||||
];
|
||||
|
||||
const [activeId, setActiveId] = useState(tabs[0].id);
|
||||
|
||||
const componentMap = {
|
||||
link: <LinkTab surveyUrl={surveyUrl} survey={survey} brandColor={brandColor} />,
|
||||
link: isSingleUseLinkSurvey ? (
|
||||
<LinkSingleUseSurveyModal survey={survey} surveyBaseUrl={surveyBaseUrl} />
|
||||
) : (
|
||||
<LinkTab surveyUrl={surveyUrl} survey={survey} brandColor={brandColor} />
|
||||
),
|
||||
email: <EmailTab survey={survey} surveyUrl={surveyUrl} email={email} brandColor={brandColor} />,
|
||||
webpage: <WebpageTab surveyUrl={surveyUrl} />,
|
||||
};
|
||||
@@ -99,9 +109,3 @@ export default function ShareEmbedSurvey({
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: "link", label: "Share the Link", icon: LinkIcon },
|
||||
{ id: "email", label: "Embed in an Email", icon: EnvelopeIcon },
|
||||
{ id: "webpage", label: "Embed in a Web Page", icon: CodeBracketIcon },
|
||||
];
|
||||
|
||||
@@ -7,7 +7,6 @@ import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import ShareEmbedSurvey from "./ShareEmbedSurvey";
|
||||
import { TProduct } from "@formbricks/types/v1/product";
|
||||
import LinkSingleUseSurveyModal from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/LinkSingleUseSurveyModal";
|
||||
import { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
import { TProfile } from "@formbricks/types/v1/profile";
|
||||
|
||||
@@ -26,10 +25,7 @@ export default function SuccessMessage({
|
||||
surveyBaseUrl,
|
||||
product,
|
||||
profile,
|
||||
singleUseIds,
|
||||
}: SummaryMetadataProps) {
|
||||
const isSingleUse = survey.singleUse?.enabled ?? false;
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const [showLinkModal, setShowLinkModal] = useState(false);
|
||||
const [confetti, setConfetti] = useState(false);
|
||||
@@ -60,23 +56,14 @@ export default function SuccessMessage({
|
||||
|
||||
return (
|
||||
<>
|
||||
{showLinkModal && isSingleUse && singleUseIds ? (
|
||||
<LinkSingleUseSurveyModal
|
||||
survey={survey}
|
||||
open={showLinkModal}
|
||||
setOpen={setShowLinkModal}
|
||||
singleUseIds={singleUseIds}
|
||||
/>
|
||||
) : (
|
||||
<ShareEmbedSurvey
|
||||
survey={survey}
|
||||
open={showLinkModal}
|
||||
setOpen={setShowLinkModal}
|
||||
surveyBaseUrl={surveyBaseUrl}
|
||||
product={product}
|
||||
profile={profile}
|
||||
/>
|
||||
)}
|
||||
<ShareEmbedSurvey
|
||||
survey={survey}
|
||||
open={showLinkModal}
|
||||
setOpen={setShowLinkModal}
|
||||
surveyBaseUrl={surveyBaseUrl}
|
||||
product={product}
|
||||
profile={profile}
|
||||
/>
|
||||
{confetti && <Confetti />}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -23,7 +23,6 @@ interface SummaryPageProps {
|
||||
surveyId: string;
|
||||
responses: TResponse[];
|
||||
surveyBaseUrl: string;
|
||||
singleUseIds?: string[];
|
||||
product: TProduct;
|
||||
profile: TProfile;
|
||||
environmentTags: TTag[];
|
||||
@@ -35,7 +34,6 @@ const SummaryPage = ({
|
||||
surveyId,
|
||||
responses,
|
||||
surveyBaseUrl,
|
||||
singleUseIds,
|
||||
product,
|
||||
profile,
|
||||
environmentTags,
|
||||
@@ -61,7 +59,6 @@ const SummaryPage = ({
|
||||
survey={survey}
|
||||
surveyId={surveyId}
|
||||
surveyBaseUrl={surveyBaseUrl}
|
||||
singleUseIds={singleUseIds}
|
||||
product={product}
|
||||
profile={profile}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"use server";
|
||||
import { generateSurveySingleUseId } from "@/lib/singleUseSurveys";
|
||||
|
||||
export async function generateSingleUseIdAction(isEncrypted: boolean): Promise<string> {
|
||||
const singleUseId = generateSurveySingleUseId(isEncrypted);
|
||||
return singleUseId;
|
||||
}
|
||||
@@ -9,17 +9,8 @@ import { getEnvironment } from "@formbricks/lib/environment/service";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
|
||||
import { getTagsByEnvironmentId } from "@formbricks/lib/tag/service";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { generateSurveySingleUseId } from "@/lib/singleUseSurveys";
|
||||
import { getProfile } from "@formbricks/lib/profile/service";
|
||||
|
||||
const generateSingleUseIds = (isEncrypted: boolean) => {
|
||||
return Array(5)
|
||||
.fill(null)
|
||||
.map(() => {
|
||||
return generateSurveySingleUseId(isEncrypted);
|
||||
});
|
||||
};
|
||||
|
||||
export default async function Page({ params }) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
@@ -30,12 +21,6 @@ export default async function Page({ params }) {
|
||||
getAnalysisData(params.surveyId, params.environmentId),
|
||||
getEnvironment(params.environmentId),
|
||||
]);
|
||||
const isSingleUseSurvey = survey.singleUse?.enabled ?? false;
|
||||
|
||||
let singleUseIds: string[] | undefined = undefined;
|
||||
if (isSingleUseSurvey) {
|
||||
singleUseIds = generateSingleUseIds(survey.singleUse?.isEncrypted ?? false);
|
||||
}
|
||||
|
||||
if (!environment) {
|
||||
throw new Error("Environment not found");
|
||||
@@ -62,7 +47,6 @@ export default async function Page({ params }) {
|
||||
survey={survey}
|
||||
surveyId={params.surveyId}
|
||||
surveyBaseUrl={SURVEY_BASE_URL}
|
||||
singleUseIds={isSingleUseSurvey ? singleUseIds : undefined}
|
||||
product={product}
|
||||
profile={profile}
|
||||
environmentTags={tags}
|
||||
|
||||
@@ -33,7 +33,6 @@ interface SummaryHeaderProps {
|
||||
surveyBaseUrl: string;
|
||||
product: TProduct;
|
||||
profile: TProfile;
|
||||
singleUseIds?: string[];
|
||||
}
|
||||
const SummaryHeader = ({
|
||||
surveyId,
|
||||
@@ -42,7 +41,6 @@ const SummaryHeader = ({
|
||||
surveyBaseUrl,
|
||||
product,
|
||||
profile,
|
||||
singleUseIds,
|
||||
}: SummaryHeaderProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -61,7 +59,6 @@ const SummaryHeader = ({
|
||||
<LinkSurveyShareButton
|
||||
survey={survey}
|
||||
surveyBaseUrl={surveyBaseUrl}
|
||||
singleUseIds={singleUseIds}
|
||||
product={product}
|
||||
profile={profile}
|
||||
/>
|
||||
@@ -93,7 +90,6 @@ const SummaryHeader = ({
|
||||
surveyBaseUrl={surveyBaseUrl}
|
||||
product={product}
|
||||
profile={profile}
|
||||
singleUseIds={singleUseIds}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
@@ -105,7 +101,11 @@ const SummaryHeader = ({
|
||||
disabled={isStatusChangeDisabled}
|
||||
style={isStatusChangeDisabled ? { pointerEvents: "none", opacity: 0.5 } : {}}>
|
||||
<div className="flex items-center">
|
||||
<SurveyStatusIndicator status={survey.status} environment={environment} />
|
||||
<SurveyStatusIndicator
|
||||
status={survey.status}
|
||||
environment={environment}
|
||||
type={survey.type}
|
||||
/>
|
||||
<span className="ml-1 text-sm text-slate-700">
|
||||
{survey.status === "inProgress" && "In-progress"}
|
||||
{survey.status === "paused" && "Paused"}
|
||||
@@ -177,7 +177,6 @@ const SummaryHeader = ({
|
||||
surveyBaseUrl={surveyBaseUrl}
|
||||
product={product}
|
||||
profile={profile}
|
||||
singleUseIds={singleUseIds}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
95
apps/web/app/api/v1/client/storage/route.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { env } from "@/env.mjs";
|
||||
import { responses } from "@/lib/api/response";
|
||||
import { UPLOADS_DIR, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { putFileToLocalStorage, putFileToS3 } from "@formbricks/lib/storage/service";
|
||||
import { getSurvey } from "@formbricks/lib/survey/service";
|
||||
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
const accessType = "private"; // private files are only accessible by the user who has access to the environment
|
||||
const { fileName, contentType, fileBuffer, surveyId } = await req.json();
|
||||
|
||||
if (!surveyId) {
|
||||
return responses.badRequestResponse("surveyId ID is required");
|
||||
}
|
||||
|
||||
if (!fileName) {
|
||||
return responses.badRequestResponse("fileName is required");
|
||||
}
|
||||
|
||||
if (!contentType) {
|
||||
return responses.badRequestResponse("contentType is required");
|
||||
}
|
||||
|
||||
if (!fileBuffer) {
|
||||
return responses.badRequestResponse("no file provided, fileBuffer is required");
|
||||
}
|
||||
|
||||
const survey = await getSurvey(surveyId);
|
||||
|
||||
if (!survey) {
|
||||
return responses.notFoundResponse("Survey", surveyId);
|
||||
}
|
||||
|
||||
const { environmentId } = survey;
|
||||
|
||||
const team = await getTeamByEnvironmentId(environmentId);
|
||||
|
||||
if (!team) {
|
||||
return responses.notFoundResponse("TeamByEnvironmentId", environmentId);
|
||||
}
|
||||
|
||||
const { plan } = team;
|
||||
|
||||
const buffer = Buffer.from(fileBuffer);
|
||||
|
||||
const bufferBytes = buffer.byteLength;
|
||||
const bufferKB = bufferBytes / 1024;
|
||||
|
||||
if (plan === "free" && bufferKB > 10240) {
|
||||
return responses.badRequestResponse("Maximum file size for free plan is 10MB, please upgrade your plan");
|
||||
}
|
||||
|
||||
if (plan === "pro" && bufferKB > 1024 * 1024) {
|
||||
return responses.badRequestResponse("Maximum file size for pro plan is 1GB");
|
||||
}
|
||||
|
||||
const uploadPrivateFile = async () => {
|
||||
// if s3 is not configured, we'll upload to a local folder named uploads
|
||||
|
||||
if (!env.AWS_ACCESS_KEY || !env.AWS_SECRET_KEY || !env.S3_REGION || !env.S3_BUCKET_NAME) {
|
||||
try {
|
||||
await putFileToLocalStorage(fileName, fileBuffer, accessType, environmentId, UPLOADS_DIR);
|
||||
|
||||
return responses.successResponse({
|
||||
uploaded: true,
|
||||
url: `${WEBAPP_URL}/storage/${environmentId}/${accessType}/${fileName}`,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.name === "FileTooLargeError") {
|
||||
return responses.badRequestResponse(err.message);
|
||||
}
|
||||
|
||||
return responses.internalServerErrorResponse(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await putFileToS3(fileName, contentType, fileBuffer, accessType, environmentId);
|
||||
|
||||
return responses.successResponse({
|
||||
uploaded: true,
|
||||
url: `${WEBAPP_URL}/storage/${environmentId}/${accessType}/${fileName}`,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.name === "FileTooLargeError") {
|
||||
return responses.badRequestResponse(err.message);
|
||||
}
|
||||
|
||||
return responses.internalServerErrorResponse(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
return await uploadPrivateFile();
|
||||
}
|
||||
104
apps/web/app/api/v1/management/storage/route.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { responses } from "@/lib/api/response";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { env } from "@/env.mjs";
|
||||
import { putFileToLocalStorage, putFileToS3 } from "@formbricks/lib/storage/service";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { UPLOADS_DIR, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
|
||||
// api endpoint for uploading public files
|
||||
// uploaded files will be public, anyone can access the file
|
||||
// uploading public files requires authentication
|
||||
// use this to upload files for a specific resource, e.g. a user profile picture or a survey
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
const accessType = "public"; // public files are accessible by anyone
|
||||
const { fileName, contentType, environmentId, fileBuffer, allowedFileExtensions } = await req.json();
|
||||
|
||||
if (!fileName) {
|
||||
return responses.badRequestResponse("fileName is required");
|
||||
}
|
||||
|
||||
if (!contentType) {
|
||||
return responses.badRequestResponse("contentType is required");
|
||||
}
|
||||
|
||||
if (!fileBuffer) {
|
||||
return responses.badRequestResponse("no file provided, fileBuffer is required");
|
||||
}
|
||||
|
||||
if (!environmentId) {
|
||||
return responses.badRequestResponse("environmentId is required");
|
||||
}
|
||||
|
||||
if (allowedFileExtensions?.length) {
|
||||
const fileExtension = fileName.split(".").pop();
|
||||
if (!fileExtension || !allowedFileExtensions.includes(fileExtension)) {
|
||||
return responses.badRequestResponse(
|
||||
`File extension is not allowed, allowed extensions are: ${allowedFileExtensions.join(", ")}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// auth and upload private file
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session || !session.user) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
|
||||
|
||||
if (!isUserAuthorized) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
return await uploadPublicFile(fileName, fileBuffer, accessType, environmentId, contentType);
|
||||
}
|
||||
|
||||
const uploadPublicFile = async (
|
||||
fileName: string,
|
||||
fileBuffer: Buffer,
|
||||
accessType: "public" | "private",
|
||||
environmentId,
|
||||
contentType?: string
|
||||
) => {
|
||||
// if s3 is not configured, we'll upload to a local folder named uploads
|
||||
|
||||
if (!env.AWS_ACCESS_KEY || !env.AWS_SECRET_KEY || !env.S3_REGION || !env.S3_BUCKET_NAME) {
|
||||
try {
|
||||
await putFileToLocalStorage(fileName, fileBuffer, accessType, environmentId, UPLOADS_DIR, true);
|
||||
|
||||
return responses.successResponse({
|
||||
uploaded: true,
|
||||
url: `${WEBAPP_URL}/storage/${environmentId}/${accessType}/${fileName}`,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.name === "FileTooLargeError") {
|
||||
return responses.badRequestResponse(err.message);
|
||||
}
|
||||
|
||||
return responses.internalServerErrorResponse("Internal server error");
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (!contentType) {
|
||||
return responses.badRequestResponse("contentType is required");
|
||||
}
|
||||
|
||||
await putFileToS3(fileName, contentType, fileBuffer, accessType, environmentId, true);
|
||||
|
||||
return responses.successResponse({
|
||||
uploaded: true,
|
||||
url: `${WEBAPP_URL}/storage/${environmentId}/${accessType}/${fileName}`,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.name === "FileTooLargeError") {
|
||||
return responses.badRequestResponse(err.message);
|
||||
}
|
||||
|
||||
return responses.internalServerErrorResponse("Internal server error");
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { env } from "@/env.mjs";
|
||||
import { responses } from "@/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/lib/api/validator";
|
||||
import { UPLOADS_DIR } from "@formbricks/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { getFileFromLocalStorage, getFileFromS3 } from "@formbricks/lib/storage/service";
|
||||
import { ZStorageRetrievalParams } from "@formbricks/types/v1/storage";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound } from "next/navigation";
|
||||
import { NextRequest } from "next/server";
|
||||
import path from "path";
|
||||
|
||||
export async function GET(
|
||||
_: NextRequest,
|
||||
{ params }: { params: { environmentId: string; accessType: string; fileName: string } }
|
||||
) {
|
||||
const paramValidation = ZStorageRetrievalParams.safeParse(params);
|
||||
|
||||
if (!paramValidation.success) {
|
||||
return responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(paramValidation.error),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
const { environmentId, accessType, fileName } = params;
|
||||
|
||||
const getFile = async () => {
|
||||
if (!env.AWS_ACCESS_KEY || !env.AWS_SECRET_KEY || !env.S3_REGION || !env.S3_BUCKET_NAME) {
|
||||
try {
|
||||
const { fileBuffer, metaData } = await getFileFromLocalStorage(
|
||||
path.join(UPLOADS_DIR, environmentId, accessType, fileName)
|
||||
);
|
||||
|
||||
return new Response(fileBuffer, {
|
||||
headers: {
|
||||
"Content-Type": metaData.contentType,
|
||||
"Content-Disposition": "inline",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
notFound();
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { fileBuffer, metaData } = await getFileFromS3(fileName);
|
||||
|
||||
return new Response(fileBuffer, {
|
||||
headers: {
|
||||
"Content-Type": metaData.contentType,
|
||||
"Content-Disposition": "inline",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.name === "NoSuchKey") {
|
||||
return responses.notFoundResponse("File not found", fileName);
|
||||
} else {
|
||||
return responses.internalServerErrorResponse("Internal server error");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (accessType === "public") {
|
||||
return await getFile();
|
||||
}
|
||||
|
||||
// auth and download private file
|
||||
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session || !session.user) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
const isUserAuthorized = await hasUserEnvironmentAccess(session.user.id, environmentId);
|
||||
|
||||
if (!isUserAuthorized) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
return await getFile();
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export default function SurveyStatusDropdown({
|
||||
<>
|
||||
{survey.status === "draft" ? (
|
||||
<div className="flex items-center">
|
||||
<SurveyStatusIndicator status={survey.status} environment={environment} />
|
||||
<SurveyStatusIndicator status={survey.status} environment={environment} type={survey.type} />
|
||||
{survey.status === "draft" && <p className="text-sm italic text-slate-600">Draft</p>}
|
||||
</div>
|
||||
) : (
|
||||
@@ -69,7 +69,11 @@ export default function SurveyStatusDropdown({
|
||||
<SelectTrigger className="w-[170px] bg-white py-6 md:w-[200px]">
|
||||
<SelectValue>
|
||||
<div className="flex items-center">
|
||||
<SurveyStatusIndicator status={survey.status} environment={environment} />
|
||||
<SurveyStatusIndicator
|
||||
status={survey.status}
|
||||
environment={environment}
|
||||
type={survey.type}
|
||||
/>
|
||||
<span className="ml-2 text-sm text-slate-700">
|
||||
{survey.status === "inProgress" && "In-progress"}
|
||||
{survey.status === "paused" && "Paused"}
|
||||
|
||||
@@ -2,16 +2,32 @@
|
||||
|
||||
import { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui";
|
||||
import { ArchiveBoxIcon, CheckIcon, PauseIcon } from "@heroicons/react/24/solid";
|
||||
import { ArchiveBoxIcon, CheckIcon, PauseIcon, ExclamationTriangleIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
interface SurveyStatusIndicatorProps {
|
||||
status: string;
|
||||
tooltip?: boolean;
|
||||
environment: TEnvironment;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export default function SurveyStatusIndicator({ status, tooltip, environment }: SurveyStatusIndicatorProps) {
|
||||
if (!environment.widgetSetupCompleted) return null;
|
||||
export default function SurveyStatusIndicator({
|
||||
status,
|
||||
tooltip,
|
||||
environment,
|
||||
type,
|
||||
}: SurveyStatusIndicatorProps) {
|
||||
if (!environment.widgetSetupCompleted) {
|
||||
if (type === "web") {
|
||||
return (
|
||||
<div>
|
||||
<ExclamationTriangleIcon className="h-4 w-4 text-amber-500" />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
|
||||
@@ -52,6 +52,10 @@ export const env = createEnv({
|
||||
GOOGLE_SHEETS_CLIENT_ID: z.string().optional(),
|
||||
GOOGLE_SHEETS_CLIENT_SECRET: z.string().optional(),
|
||||
GOOGLE_SHEETS_REDIRECT_URL: z.string().optional(),
|
||||
AWS_ACCESS_KEY: z.string().optional(),
|
||||
AWS_SECRET_KEY: z.string().optional(),
|
||||
S3_REGION: z.string().optional(),
|
||||
S3_BUCKET_NAME: z.string().optional(),
|
||||
},
|
||||
|
||||
/*
|
||||
@@ -108,6 +112,10 @@ export const env = createEnv({
|
||||
GOOGLE_SHEETS_CLIENT_ID: process.env.GOOGLE_SHEETS_CLIENT_ID,
|
||||
GOOGLE_SHEETS_CLIENT_SECRET: process.env.GOOGLE_SHEETS_CLIENT_SECRET,
|
||||
GOOGLE_SHEETS_REDIRECT_URL: process.env.GOOGLE_SHEETS_REDIRECT_URL,
|
||||
AWS_ACCESS_KEY: process.env.AWS_ACCESS_KEY,
|
||||
AWS_SECRET_KEY: process.env.AWS_SECRET_KEY,
|
||||
S3_REGION: process.env.S3_REGION,
|
||||
S3_BUCKET_NAME: process.env.S3_BUCKET_NAME,
|
||||
NEXT_PUBLIC_FORMBRICKS_API_HOST: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST,
|
||||
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID,
|
||||
NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID: process.env.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID,
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.418.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.418.0",
|
||||
"@formbricks/api": "workspace:*",
|
||||
"@formbricks/database": "workspace:*",
|
||||
"@formbricks/ee": "workspace:*",
|
||||
@@ -30,10 +32,12 @@
|
||||
"@t3-oss/env-nextjs": "^0.7.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"encoding": "^0.1.13",
|
||||
"eslint-config-next": "^13.5.4",
|
||||
"eslint-config-next": "^13.5.3",
|
||||
"framer-motion": "10.16.4",
|
||||
"googleapis": "^126.0.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
"mime": "^3.0.0",
|
||||
"lucide-react": "^0.284.0",
|
||||
"next": "13.5.4",
|
||||
"next-auth": "^4.23.2",
|
||||
|
||||
|
Before Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 57 KiB |
@@ -1,6 +1,6 @@
|
||||
Forget all previous instructions. You are the coding assistant "Formbricks Assistant".
|
||||
|
||||
- You help programmers find a solution to their questions and coding challenges. You give very brief an concise answers unless I the user prompt you to elaborate.
|
||||
- You help programmers find a solution to their questions and coding challenges. You give very brief and concise answers unless I the user prompt you to elaborate.
|
||||
- Formbricks is the open-source go-to solution for in-product micro-surveys that is supercharging our users product experience!
|
||||
- Formbricks uses Typescript, Next.Js, Next-auth, Prisma, TailwindCss, Radix UI
|
||||
- When you are asked to generate documentation please have a playful but succinct writing style and return everything in escaped markdown.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import "server-only";
|
||||
import path from "path";
|
||||
import { env } from "@/env.mjs";
|
||||
|
||||
export const RESPONSES_LIMIT_FREE = 100;
|
||||
@@ -54,3 +55,6 @@ export const MAIL_FROM = env.MAIL_FROM;
|
||||
export const NEXTAUTH_SECRET = env.NEXTAUTH_SECRET;
|
||||
export const NEXTAUTH_URL = env.NEXTAUTH_URL;
|
||||
export const PEOPLE_PER_PAGE = 50;
|
||||
|
||||
// Storage constants
|
||||
export const UPLOADS_DIR = path.resolve("./uploads");
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/v1/errors";
|
||||
import { TPerson } from "@formbricks/types/v1/people";
|
||||
import { TTag } from "@formbricks/types/v1/tags";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { cache } from "react";
|
||||
import { getPerson, transformPrismaPerson } from "../person/service";
|
||||
@@ -19,6 +18,7 @@ import { captureTelemetry } from "../telemetry";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
import { ZId } from "@formbricks/types/v1/environment";
|
||||
import { revalidateTag } from "next/cache";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
const responseSelection = {
|
||||
id: true,
|
||||
|
||||
157
packages/lib/storage/service.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { PutObjectCommand, S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { access, mkdir, writeFile, readFile } from "fs/promises";
|
||||
import mime from "mime";
|
||||
import { env } from "@/env.mjs";
|
||||
|
||||
// global variables
|
||||
|
||||
const AWS_BUCKET_NAME = env.S3_BUCKET_NAME!;
|
||||
const AWS_REGION = env.S3_REGION!;
|
||||
const AWS_ACCESS_KEY = env.AWS_ACCESS_KEY!;
|
||||
const AWS_SECRET_KEY = env.AWS_SECRET_KEY!;
|
||||
|
||||
// S3Client Singleton
|
||||
|
||||
const s3Client = new S3Client({
|
||||
credentials: {
|
||||
accessKeyId: AWS_ACCESS_KEY,
|
||||
secretAccessKey: AWS_SECRET_KEY!,
|
||||
},
|
||||
region: AWS_REGION!,
|
||||
});
|
||||
|
||||
const ensureDirectoryExists = async (dirPath: string) => {
|
||||
try {
|
||||
await access(dirPath);
|
||||
} catch (error: any) {
|
||||
if (error.code === "ENOENT") {
|
||||
await mkdir(dirPath, { recursive: true });
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
type TGetFileResponse = {
|
||||
fileBuffer: Buffer;
|
||||
metaData: {
|
||||
contentType: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const getFileFromS3 = async (fileKey: string): Promise<TGetFileResponse> => {
|
||||
const getObjectCommand = new GetObjectCommand({
|
||||
Bucket: AWS_BUCKET_NAME,
|
||||
Key: fileKey,
|
||||
});
|
||||
|
||||
try {
|
||||
const data = await s3Client.send(getObjectCommand);
|
||||
const byteArray = await data.Body?.transformToByteArray();
|
||||
const buffer = Buffer.from(byteArray as Uint8Array);
|
||||
|
||||
return {
|
||||
fileBuffer: buffer,
|
||||
metaData: {
|
||||
contentType: data.ContentType ?? "",
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
export const getFileFromLocalStorage = async (filePath: string): Promise<TGetFileResponse> => {
|
||||
try {
|
||||
const file = await readFile(filePath);
|
||||
let contentType = "";
|
||||
|
||||
try {
|
||||
contentType = mime.getType(filePath) ?? "";
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
return {
|
||||
fileBuffer: file,
|
||||
metaData: {
|
||||
contentType: contentType ?? "",
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
export const putFileToS3 = async (
|
||||
fileName: string,
|
||||
contentType: string,
|
||||
fileBuffer: Buffer,
|
||||
accessType: string,
|
||||
environmentId: string,
|
||||
isPublic: boolean = false
|
||||
) => {
|
||||
try {
|
||||
const buffer = Buffer.from(fileBuffer);
|
||||
|
||||
if (isPublic) {
|
||||
//check the size of buffer and if it is greater than 10MB, return error
|
||||
|
||||
const bufferBytes = buffer.byteLength;
|
||||
const bufferKB = bufferBytes / 1024;
|
||||
|
||||
if (bufferKB > 10240) {
|
||||
const err = new Error("File size is greater than 10MB");
|
||||
err.name = "FileTooLargeError";
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const putObjectCommand = new PutObjectCommand({
|
||||
Bucket: AWS_BUCKET_NAME,
|
||||
Key: `${environmentId}/${accessType}/${fileName}`,
|
||||
Body: buffer,
|
||||
ContentType: contentType,
|
||||
});
|
||||
|
||||
await s3Client.send(putObjectCommand);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
export const putFileToLocalStorage = async (
|
||||
fileName: string,
|
||||
fileBuffer: Buffer,
|
||||
accessType: string,
|
||||
environmentId: string,
|
||||
rootDir: string,
|
||||
isPublic: boolean = false
|
||||
) => {
|
||||
try {
|
||||
await ensureDirectoryExists(`${rootDir}/${environmentId}/${accessType}`);
|
||||
|
||||
const uploadPath = `${rootDir}/${environmentId}/${accessType}/${fileName}`;
|
||||
|
||||
const buffer = Buffer.from(fileBuffer);
|
||||
|
||||
if (isPublic) {
|
||||
//check the size of buffer and if it is greater than 10MB, return error
|
||||
|
||||
const bufferBytes = buffer.byteLength;
|
||||
const bufferKB = bufferBytes / 1024;
|
||||
|
||||
if (bufferKB > 10240) {
|
||||
const err = new Error("File size is greater than 10MB");
|
||||
err.name = "FileTooLargeError";
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
await writeFile(uploadPath, buffer);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
@@ -3,11 +3,13 @@ import { cn } from "../../../lib/cn";
|
||||
interface BackButtonProps {
|
||||
onClick: () => void;
|
||||
backButtonLabel?: string;
|
||||
tabIndex?: number;
|
||||
}
|
||||
|
||||
export function BackButton({ onClick, backButtonLabel }: BackButtonProps) {
|
||||
export function BackButton({ onClick, backButtonLabel, tabIndex = 2 }: BackButtonProps) {
|
||||
return (
|
||||
<button
|
||||
tabIndex={tabIndex}
|
||||
type={"button"}
|
||||
className={cn(
|
||||
"flex items-center rounded-md border border-transparent px-3 py-3 text-base font-medium leading-4 shadow-sm hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2"
|
||||
|
||||
@@ -33,9 +33,10 @@ export default function CTAQuestion({
|
||||
{!isFirstQuestion && (
|
||||
<BackButton backButtonLabel={question.backButtonLabel} onClick={() => onBack()} />
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<div className="flex w-full justify-end">
|
||||
{!question.required && (
|
||||
<button
|
||||
tabIndex={0}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSubmit({ [question.id]: "dismissed" });
|
||||
@@ -48,6 +49,7 @@ export default function CTAQuestion({
|
||||
question={question}
|
||||
isLastQuestion={isLastQuestion}
|
||||
brandColor={brandColor}
|
||||
focus={true}
|
||||
onClick={() => {
|
||||
if (question.buttonExternal && question.buttonUrl) {
|
||||
window?.open(question.buttonUrl, "_blank")?.focus();
|
||||
|
||||
@@ -36,7 +36,14 @@ export default function ConsentQuestion({
|
||||
e.preventDefault();
|
||||
onSubmit({ [question.id]: value });
|
||||
}}>
|
||||
<label className="relative z-10 mt-4 flex w-full cursor-pointer items-center rounded-md border border-gray-200 bg-slate-50 p-4 text-sm text-slate-800 focus:outline-none">
|
||||
<label
|
||||
tabIndex={1}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key == "Enter") {
|
||||
onChange({ [question.id]: "accepted" });
|
||||
}
|
||||
}}
|
||||
className="relative z-10 mt-4 flex w-full cursor-pointer items-center rounded-md border border-gray-200 p-4 text-sm text-slate-800 hover:bg-slate-50 focus:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={question.id}
|
||||
@@ -62,10 +69,11 @@ export default function ConsentQuestion({
|
||||
|
||||
<div className="mt-4 flex w-full justify-between">
|
||||
{!isFirstQuestion && (
|
||||
<BackButton backButtonLabel={question.backButtonLabel} onClick={() => onBack()} />
|
||||
<BackButton tabIndex={3} backButtonLabel={question.backButtonLabel} onClick={() => onBack()} />
|
||||
)}
|
||||
<div />
|
||||
<SubmitButton
|
||||
tabIndex={2}
|
||||
brandColor={brandColor}
|
||||
question={question}
|
||||
isLastQuestion={isLastQuestion}
|
||||
|
||||
@@ -3,6 +3,7 @@ export default function FormbricksSignature() {
|
||||
<a
|
||||
href="https://formbricks.com?utm_source=survey_branding"
|
||||
target="_blank"
|
||||
tabIndex={-1}
|
||||
className="mb-5 mt-2 flex justify-center">
|
||||
<p className="text-xs text-slate-400">
|
||||
Powered by{" "}
|
||||
|
||||
@@ -86,18 +86,29 @@ export default function MultipleChoiceSingleQuestion({
|
||||
<fieldset>
|
||||
<legend className="sr-only">Options</legend>
|
||||
<div className="relative max-h-[42vh] space-y-2 overflow-y-auto rounded-md bg-white py-0.5 pr-2">
|
||||
{questionChoices.map((choice) => (
|
||||
{questionChoices.map((choice, idx) => (
|
||||
<label
|
||||
key={choice.id}
|
||||
tabIndex={idx + 1}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key == "Enter") {
|
||||
if (Array.isArray(value) && value.includes(choice.label)) {
|
||||
removeItem(choice.label);
|
||||
} else {
|
||||
addItem(choice.label);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
value === choice.label ? "z-10 border-slate-400 bg-slate-50" : "border-gray-200",
|
||||
"relative flex cursor-pointer flex-col rounded-md border p-4 text-slate-800 hover:bg-slate-50 focus:outline-none"
|
||||
"relative flex cursor-pointer flex-col rounded-md border p-4 text-slate-800 focus-within:border-slate-400 hover:bg-slate-50 focus:bg-slate-50 focus:outline-none "
|
||||
)}>
|
||||
<span className="flex items-center text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={choice.id}
|
||||
name={question.id}
|
||||
tabIndex={-1}
|
||||
value={choice.label}
|
||||
className="h-4 w-4 border border-slate-300 focus:ring-0 focus:ring-offset-0"
|
||||
aria-labelledby={`${choice.id}-label`}
|
||||
@@ -110,9 +121,7 @@ export default function MultipleChoiceSingleQuestion({
|
||||
}}
|
||||
checked={Array.isArray(value) && value.includes(choice.label)}
|
||||
style={{ borderColor: brandColor, color: brandColor }}
|
||||
required={
|
||||
question.required && Array.isArray(value) && value.length ? false : question.required
|
||||
}
|
||||
required={question.required && idx === 0}
|
||||
/>
|
||||
<span id={`${choice.id}-label`} className="ml-3 font-medium">
|
||||
{choice.label}
|
||||
@@ -122,13 +131,20 @@ export default function MultipleChoiceSingleQuestion({
|
||||
))}
|
||||
{otherOption && (
|
||||
<label
|
||||
tabIndex={questionChoices.length + 1}
|
||||
className={cn(
|
||||
value === otherOption.label ? "z-10 border-slate-400 bg-slate-50" : "border-gray-200",
|
||||
"relative flex cursor-pointer flex-col rounded-md border p-4 text-slate-800 hover:bg-slate-50 focus:outline-none"
|
||||
)}>
|
||||
"relative flex cursor-pointer flex-col rounded-md border p-4 text-slate-800 focus-within:border-slate-400 focus-within:bg-slate-50 hover:bg-slate-50 focus:outline-none"
|
||||
)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key == "Enter") {
|
||||
setOtherSelected(!otherSelected);
|
||||
}
|
||||
}}>
|
||||
<span className="flex items-center text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
tabIndex={-1}
|
||||
id={otherOption.id}
|
||||
name={question.id}
|
||||
value={otherOption.label}
|
||||
@@ -155,12 +171,20 @@ export default function MultipleChoiceSingleQuestion({
|
||||
ref={otherSpecify}
|
||||
id={`${otherOption.id}-label`}
|
||||
name={question.id}
|
||||
tabIndex={questionChoices.length + 1}
|
||||
value={otherValue}
|
||||
onChange={(e) => {
|
||||
setOtherValue(e.currentTarget.value);
|
||||
removeItem(otherValue);
|
||||
addItem(e.currentTarget.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key == "Enter") {
|
||||
setTimeout(() => {
|
||||
onSubmit({ [question.id]: value });
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
placeholder="Please specify"
|
||||
className="mt-3 flex h-10 w-full rounded-md border border-slate-300 bg-transparent bg-white px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-500 dark:text-slate-300"
|
||||
required={question.required}
|
||||
@@ -173,9 +197,16 @@ export default function MultipleChoiceSingleQuestion({
|
||||
</fieldset>
|
||||
</div>
|
||||
<div className="mt-4 flex w-full justify-between">
|
||||
{!isFirstQuestion && <BackButton backButtonLabel={question.backButtonLabel} onClick={onBack} />}
|
||||
{!isFirstQuestion && (
|
||||
<BackButton
|
||||
tabIndex={questionChoices.length + 3}
|
||||
backButtonLabel={question.backButtonLabel}
|
||||
onClick={onBack}
|
||||
/>
|
||||
)}
|
||||
<div></div>
|
||||
<SubmitButton
|
||||
tabIndex={questionChoices.length + 2}
|
||||
question={question}
|
||||
isLastQuestion={isLastQuestion}
|
||||
brandColor={brandColor}
|
||||
|
||||
@@ -55,7 +55,6 @@ export default function MultipleChoiceSingleQuestion({
|
||||
otherSpecify.current?.focus();
|
||||
}
|
||||
}, [otherSelected]);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
@@ -68,16 +67,28 @@ export default function MultipleChoiceSingleQuestion({
|
||||
<div className="mt-4">
|
||||
<fieldset>
|
||||
<legend className="sr-only">Options</legend>
|
||||
<div className="relative max-h-[42vh] space-y-2 overflow-y-auto rounded-md bg-white py-0.5 pr-2">
|
||||
<div
|
||||
className="relative max-h-[42vh] space-y-2 overflow-y-auto rounded-md bg-white py-0.5 pr-2"
|
||||
role="radiogroup">
|
||||
{questionChoices.map((choice, idx) => (
|
||||
<label
|
||||
key={choice.id}
|
||||
tabIndex={idx + 1}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key == "Enter") {
|
||||
onChange({ [question.id]: choice.label });
|
||||
setTimeout(() => {
|
||||
onSubmit({ [question.id]: choice.label });
|
||||
}, 350);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
value === choice.label ? "z-10 border-slate-400 bg-slate-50" : "border-gray-200",
|
||||
"relative flex cursor-pointer flex-col rounded-md border p-4 text-slate-800 hover:bg-slate-50 focus:outline-none"
|
||||
"relative flex cursor-pointer flex-col rounded-md border p-4 text-slate-800 focus-within:border-slate-400 focus-within:bg-slate-50 hover:bg-slate-50 focus:outline-none "
|
||||
)}>
|
||||
<span className="flex items-center text-sm">
|
||||
<input
|
||||
tabIndex={-1}
|
||||
type="radio"
|
||||
id={choice.id}
|
||||
name={question.id}
|
||||
@@ -100,14 +111,22 @@ export default function MultipleChoiceSingleQuestion({
|
||||
))}
|
||||
{otherOption && (
|
||||
<label
|
||||
tabIndex={questionChoices.length + 1}
|
||||
className={cn(
|
||||
value === otherOption.label ? "z-10 border-slate-400 bg-slate-50" : "border-gray-200",
|
||||
"relative flex cursor-pointer flex-col rounded-md border p-4 text-slate-800 hover:bg-slate-50 focus:outline-none"
|
||||
)}>
|
||||
"relative flex cursor-pointer flex-col rounded-md border p-4 text-slate-800 focus-within:border-slate-400 focus-within:bg-slate-50 hover:bg-slate-50 focus:outline-none"
|
||||
)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key == "Enter") {
|
||||
setOtherSelected(!otherSelected);
|
||||
if (!otherSelected) onChange({ [question.id]: "" });
|
||||
}
|
||||
}}>
|
||||
<span className="flex items-center text-sm">
|
||||
<input
|
||||
type="radio"
|
||||
id={otherOption.id}
|
||||
tabIndex={-1}
|
||||
name={question.id}
|
||||
value={otherOption.label}
|
||||
className="h-4 w-4 border border-slate-300 focus:ring-0 focus:ring-offset-0"
|
||||
@@ -126,12 +145,20 @@ export default function MultipleChoiceSingleQuestion({
|
||||
{otherSelected && (
|
||||
<input
|
||||
ref={otherSpecify}
|
||||
tabIndex={questionChoices.length + 1}
|
||||
id={`${otherOption.id}-label`}
|
||||
name={question.id}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
onChange({ [question.id]: e.currentTarget.value });
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key == "Enter") {
|
||||
setTimeout(() => {
|
||||
onSubmit({ [question.id]: value });
|
||||
}, 100);
|
||||
}
|
||||
}}
|
||||
placeholder="Please specify"
|
||||
className="mt-3 flex h-10 w-full rounded-md border border-slate-300 bg-transparent bg-white px-3 py-2 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-500 dark:text-slate-300"
|
||||
required={question.required}
|
||||
@@ -144,9 +171,16 @@ export default function MultipleChoiceSingleQuestion({
|
||||
</fieldset>
|
||||
</div>
|
||||
<div className="mt-4 flex w-full justify-between">
|
||||
{!isFirstQuestion && <BackButton backButtonLabel={question.backButtonLabel} onClick={onBack} />}
|
||||
{!isFirstQuestion && (
|
||||
<BackButton
|
||||
backButtonLabel={question.backButtonLabel}
|
||||
tabIndex={questionChoices.length + 3}
|
||||
onClick={onBack}
|
||||
/>
|
||||
)}
|
||||
<div></div>
|
||||
<SubmitButton
|
||||
tabIndex={questionChoices.length + 2}
|
||||
question={question}
|
||||
isLastQuestion={isLastQuestion}
|
||||
brandColor={brandColor}
|
||||
|
||||
@@ -39,12 +39,18 @@ export default function NPSQuestion({
|
||||
<fieldset>
|
||||
<legend className="sr-only">Options</legend>
|
||||
<div className="flex">
|
||||
{Array.from({ length: 11 }, (_, i) => i).map((number) => (
|
||||
{Array.from({ length: 11 }, (_, i) => i).map((number, idx) => (
|
||||
<label
|
||||
key={number}
|
||||
tabIndex={idx + 1}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key == "Enter") {
|
||||
onSubmit({ [question.id]: number });
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
value === number ? "z-10 border-slate-400 bg-slate-50" : "",
|
||||
"relative h-10 flex-1 cursor-pointer border bg-white text-center text-sm leading-10 text-slate-800 first:rounded-l-md last:rounded-r-md hover:bg-gray-100 focus:outline-none"
|
||||
"relative h-10 flex-1 cursor-pointer border bg-white text-center text-sm leading-10 text-slate-800 first:rounded-l-md last:rounded-r-md hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
|
||||
)}>
|
||||
<input
|
||||
type="radio"
|
||||
@@ -76,6 +82,7 @@ export default function NPSQuestion({
|
||||
<div className="mt-4 flex w-full justify-between">
|
||||
{!isFirstQuestion && (
|
||||
<BackButton
|
||||
tabIndex={isLastQuestion ? 12 : 13}
|
||||
backButtonLabel={question.backButtonLabel}
|
||||
onClick={() => {
|
||||
onBack();
|
||||
@@ -85,6 +92,7 @@ export default function NPSQuestion({
|
||||
<div></div>
|
||||
{!question.required && (
|
||||
<SubmitButton
|
||||
tabIndex={12}
|
||||
question={question}
|
||||
isLastQuestion={isLastQuestion}
|
||||
brandColor={brandColor}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { BackButton } from "./BackButton";
|
||||
import Headline from "./Headline";
|
||||
import Subheader from "./Subheader";
|
||||
import SubmitButton from "./SubmitButton";
|
||||
import { useCallback } from "react";
|
||||
|
||||
interface OpenTextQuestionProps {
|
||||
question: TSurveyOpenTextQuestion;
|
||||
@@ -33,6 +34,11 @@ export default function OpenTextQuestion({
|
||||
// setIsValid(isValidInput);
|
||||
onChange({ [question.id]: inputValue });
|
||||
};
|
||||
const openTextRef = useCallback((currentElement: HTMLInputElement | HTMLTextAreaElement | null) => {
|
||||
if (currentElement && autoFocus) {
|
||||
currentElement.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<form
|
||||
@@ -48,6 +54,8 @@ export default function OpenTextQuestion({
|
||||
<div className="mt-4">
|
||||
{question.longAnswer === false ? (
|
||||
<input
|
||||
ref={openTextRef}
|
||||
tabIndex={1}
|
||||
name={question.id}
|
||||
id={question.id}
|
||||
placeholder={question.placeholder}
|
||||
@@ -56,6 +64,9 @@ export default function OpenTextQuestion({
|
||||
type={question.inputType}
|
||||
onInput={(e) => handleInputChange(e.currentTarget.value)}
|
||||
autoFocus={autoFocus}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key == "Enter") onSubmit({ [question.id]: value });
|
||||
}}
|
||||
pattern={question.inputType === "phone" ? "[+][0-9 ]+" : ".*"}
|
||||
title={question.inputType === "phone" ? "Enter a valid phone number" : undefined}
|
||||
className={`block w-full rounded-md border
|
||||
@@ -64,8 +75,10 @@ export default function OpenTextQuestion({
|
||||
/>
|
||||
) : (
|
||||
<textarea
|
||||
ref={openTextRef}
|
||||
rows={3}
|
||||
name={question.id}
|
||||
tabIndex={1}
|
||||
id={question.id}
|
||||
placeholder={question.placeholder}
|
||||
required={question.required}
|
||||
|
||||
@@ -81,24 +81,38 @@ export default function RatingQuestion({
|
||||
key={number}
|
||||
onMouseOver={() => setHoveredNumber(number)}
|
||||
onMouseLeave={() => setHoveredNumber(0)}
|
||||
className="max-w-10 relative flex max-h-10 flex-1 cursor-pointer justify-center bg-white text-center text-sm leading-10">
|
||||
className="max-w-10 relative max-h-10 flex-1 cursor-pointer bg-white text-center text-sm leading-10">
|
||||
{question.scale === "number" ? (
|
||||
<label
|
||||
tabIndex={i + 1}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key == "Enter") {
|
||||
handleSelect(number);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
value === number ? "z-10 border-slate-400 bg-slate-50" : "",
|
||||
a.length === number ? "rounded-r-md" : "",
|
||||
number === 1 ? "rounded-l-md" : "",
|
||||
"block h-full w-full border text-slate-800 hover:bg-gray-100 focus:outline-none"
|
||||
"block h-full w-full border text-slate-800 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
|
||||
)}>
|
||||
<HiddenRadioInput number={number} />
|
||||
{number}
|
||||
</label>
|
||||
) : question.scale === "star" ? (
|
||||
<label
|
||||
tabIndex={i + 1}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key == "Enter") {
|
||||
handleSelect(number);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
number <= hoveredNumber ? "text-yellow-500" : "",
|
||||
"flex h-full w-full justify-center"
|
||||
)}>
|
||||
"flex h-full w-full justify-center focus:text-yellow-500 focus:outline-none"
|
||||
)}
|
||||
onFocus={() => setHoveredNumber(number)}
|
||||
onBlur={() => setHoveredNumber(0)}>
|
||||
<HiddenRadioInput number={number} />
|
||||
{typeof value === "number" && value >= number ? (
|
||||
<span className="text-yellow-300">
|
||||
@@ -132,10 +146,15 @@ export default function RatingQuestion({
|
||||
</label>
|
||||
) : (
|
||||
<label
|
||||
className={cn(
|
||||
"flex items-center justify-center text-slate-800",
|
||||
question.range === 10 ? "h-6 w-6" : "h-full w-full"
|
||||
)}>
|
||||
tabIndex={i + 1}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key == "Enter") {
|
||||
handleSelect(number);
|
||||
}
|
||||
}}
|
||||
className="flex h-full w-full justify-center text-slate-800 focus:outline-none"
|
||||
onFocus={() => setHoveredNumber(number)}
|
||||
onBlur={() => setHoveredNumber(0)}>
|
||||
<HiddenRadioInput number={number} />
|
||||
<RatingSmiley
|
||||
active={value === number || hoveredNumber === number}
|
||||
@@ -157,6 +176,7 @@ export default function RatingQuestion({
|
||||
<div className="mt-4 flex w-full justify-between">
|
||||
{!isFirstQuestion && (
|
||||
<BackButton
|
||||
tabIndex={!question.required || value ? question.range + 2 : question.range + 1}
|
||||
backButtonLabel={question.backButtonLabel}
|
||||
onClick={() => {
|
||||
onBack();
|
||||
@@ -166,6 +186,7 @@ export default function RatingQuestion({
|
||||
<div></div>
|
||||
{(!question.required || value) && (
|
||||
<SubmitButton
|
||||
tabIndex={question.range + 1}
|
||||
question={question}
|
||||
isLastQuestion={isLastQuestion}
|
||||
brandColor={brandColor}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useCallback } from "preact/hooks";
|
||||
import { cn } from "../../../lib/cn";
|
||||
import { isLight } from "../lib/utils";
|
||||
import { TSurveyQuestion } from "../../../types/v1/surveys";
|
||||
@@ -7,13 +8,34 @@ interface SubmitButtonProps {
|
||||
isLastQuestion: boolean;
|
||||
brandColor: string;
|
||||
onClick: () => void;
|
||||
focus?: boolean;
|
||||
tabIndex?: number;
|
||||
type?: "submit" | "button";
|
||||
}
|
||||
|
||||
function SubmitButton({ question, isLastQuestion, brandColor, onClick, type = "submit" }: SubmitButtonProps) {
|
||||
function SubmitButton({
|
||||
question,
|
||||
isLastQuestion,
|
||||
brandColor,
|
||||
onClick,
|
||||
tabIndex = 1,
|
||||
focus = false,
|
||||
type = "submit",
|
||||
}: SubmitButtonProps) {
|
||||
const buttonRef = useCallback((currentButton: HTMLButtonElement | null) => {
|
||||
if (currentButton && focus) {
|
||||
setTimeout(() => {
|
||||
currentButton.focus();
|
||||
}, 200);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type={type}
|
||||
tabIndex={tabIndex}
|
||||
autoFocus={focus}
|
||||
className={cn(
|
||||
"flex items-center rounded-md border border-transparent px-3 py-3 text-base font-medium leading-4 shadow-sm hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-slate-500 focus:ring-offset-2",
|
||||
isLight(brandColor) ? "text-black" : "text-white"
|
||||
|
||||
9
packages/types/v1/storage.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZAccessType = z.enum(["public", "private"]);
|
||||
|
||||
export const ZStorageRetrievalParams = z.object({
|
||||
fileName: z.string(),
|
||||
environmentId: z.string().cuid(),
|
||||
accessType: ZAccessType,
|
||||
});
|
||||
1318
pnpm-lock.yaml
generated
@@ -88,7 +88,11 @@
|
||||
"STRIPE_WEBHOOK_SECRET",
|
||||
"TELEMETRY_DISABLED",
|
||||
"VERCEL_URL",
|
||||
"WEBAPP_URL"
|
||||
"WEBAPP_URL",
|
||||
"AWS_ACCESS_KEY",
|
||||
"AWS_SECRET_KEY",
|
||||
"S3_REGION",
|
||||
"S3_BUCKET_NAME"
|
||||
]
|
||||
},
|
||||
"post-install": {
|
||||
|
||||