mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 10:19:51 -06:00
Merge branch 'main' into feat/form-styling
This commit is contained in:
13
.env.example
13
.env.example
@@ -56,6 +56,19 @@ SMTP_PASSWORD=smtpPassword
|
||||
|
||||
# Uncomment the variables you would like to use and customize the values.
|
||||
|
||||
##############
|
||||
# S3 STORAGE #
|
||||
##############
|
||||
|
||||
# S3 Storage is required for the file uplaod in serverless environments like Vercel
|
||||
S3_ACCESS_KEY=
|
||||
S3_SECRET_KEY=
|
||||
S3_REGION=
|
||||
S3_BUCKET_NAME=
|
||||
# Configure a third party S3 compatible storage service endpoint like StorJ leave empty if you use Amazon S3
|
||||
# e.g., https://gateway.storjshare.io
|
||||
S3_ENDPOINT_URL=
|
||||
|
||||
#####################
|
||||
# Disable Features #
|
||||
#####################
|
||||
|
||||
@@ -122,6 +122,11 @@ These variables can be provided at the runtime i.e. in your docker-compose file.
|
||||
| NEXTAUTH_SECRET | Secret for NextAuth, used for session signing and encryption. | required | (Generated by the user) |
|
||||
| ENCRYPTION_KEY | Secret for used by Formbricks for data encryption | required | (Generated by the user) |
|
||||
| NEXTAUTH_URL | Location of the auth server. By default, this is the Formbricks docker instance itself. | required | `http://localhost:3000` |
|
||||
| S3_ACCESS_KEY | Access key for S3. | optional (required if S3 is enabled) | |
|
||||
| S3_SECRET_KEY | Secret key for S3. | optional (required if S3 is enabled) | |
|
||||
| S3_REGION | Region for S3. | optional (required if S3 is enabled) | |
|
||||
| S3_BUCKET | Bucket name for S3. | optional (required if S3 is enabled) | |
|
||||
| S3_ENDPOINT | Endpoint for S3. | optional (required if S3 is enabled) | |
|
||||
| PRIVACY_URL | URL for privacy policy. | optional | |
|
||||
| TERMS_URL | URL for terms of service. | optional | |
|
||||
| IMPRINT_URL | URL for imprint. | optional | |
|
||||
@@ -155,7 +160,7 @@ These variables can be provided at the runtime i.e. in your docker-compose file.
|
||||
| OIDC_CLIENT_ID | Client ID for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | |
|
||||
| OIDC_CLIENT_SECRET | Secret for Custom OpenID Connect Provider | optional (required if OIDC auth is enabled) | |
|
||||
| OIDC_ISSUER | Issuer URL for Custom OpenID Connect Provider (should have `.well-known` configured at this) | optional (required if OIDC auth is enabled) | |
|
||||
| OIDC_SIGNING_ALGORITHM | Signing Algorithm for Custom OpenID Connect Provider | optional | `RS256` |
|
||||
| OIDC_SIGNING_ALGORITHM | Signing Algorithm for Custom OpenID Connect Provider | optional | `RS256` |
|
||||
|
||||
## Build-time Variables
|
||||
|
||||
|
||||
@@ -200,9 +200,10 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
|
||||
href: "https://spark-framework.net",
|
||||
},
|
||||
{
|
||||
"name": "Tiledesk",
|
||||
"description": "The innovative open-source framework for developing LLM-enabled chatbots, Tiledesk empowers developers to create advanced, conversational AI agents.",
|
||||
"href": "https://tiledesk.com"
|
||||
name: "Tiledesk",
|
||||
description:
|
||||
"The innovative open-source framework for developing LLM-enabled chatbots, Tiledesk empowers developers to create advanced, conversational AI agents.",
|
||||
href: "https://tiledesk.com",
|
||||
},
|
||||
{
|
||||
name: "Tolgee",
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function FeatureChaserPage() {
|
||||
</h3>
|
||||
<p className="text-slate-600 dark:text-slate-400">
|
||||
Once you've embedded the Formbricks Widget in your application, you can start following user
|
||||
actions. Simply use our No-Code Action wizard to keep track of different actions users perfrom -
|
||||
actions. Simply use our No-Code Action wizard to keep track of different actions users perform -
|
||||
100% GPDR compliant.
|
||||
</p>
|
||||
<UseCaseCTA href="/docs/best-practices/feature-chaser" />
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Metadata } from "next";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
|
||||
import { ErrorComponent } from "@formbricks/ui/ErrorComponent";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Billing",
|
||||
};
|
||||
|
||||
export default async function BillingLayout({ children, params }) {
|
||||
if (!IS_FORMBRICKS_CLOUD) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const session = await getServerSession(authOptions);
|
||||
const team = await getTeamByEnvironmentId(params.environmentId);
|
||||
|
||||
if (!session) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
if (!team) {
|
||||
throw new Error("Team not found");
|
||||
}
|
||||
|
||||
const currentUserMembership = await getMembershipByUserIdTeamId(session?.user.id, team.id);
|
||||
const { isAdmin, isOwner } = getAccessFlags(currentUserMembership?.role);
|
||||
const isPricingDisabled = !isOwner && !isAdmin;
|
||||
|
||||
return <>{!isPricingDisabled ? <>{children}</> : <ErrorComponent />}</>;
|
||||
}
|
||||
@@ -1,37 +1,15 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import {
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
PRICING_APPSURVEYS_FREE_RESPONSES,
|
||||
PRICING_USERTARGETING_FREE_MTU,
|
||||
} from "@formbricks/lib/constants";
|
||||
import { getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service";
|
||||
import { getAccessFlags } from "@formbricks/lib/membership/utils";
|
||||
import { PRICING_APPSURVEYS_FREE_RESPONSES, PRICING_USERTARGETING_FREE_MTU } from "@formbricks/lib/constants";
|
||||
import {
|
||||
getMonthlyActiveTeamPeopleCount,
|
||||
getMonthlyTeamResponseCount,
|
||||
getTeamByEnvironmentId,
|
||||
} from "@formbricks/lib/team/service";
|
||||
import { ErrorComponent } from "@formbricks/ui/ErrorComponent";
|
||||
|
||||
import SettingsTitle from "../components/SettingsTitle";
|
||||
import PricingTable from "./components/PricingTable";
|
||||
|
||||
export default async function BillingPage({ params }) {
|
||||
if (!IS_FORMBRICKS_CLOUD) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
const team = await getTeamByEnvironmentId(params.environmentId);
|
||||
|
||||
if (!session) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
throw new Error("Team not found");
|
||||
}
|
||||
@@ -40,26 +18,19 @@ export default async function BillingPage({ params }) {
|
||||
getMonthlyActiveTeamPeopleCount(team.id),
|
||||
getMonthlyTeamResponseCount(team.id),
|
||||
]);
|
||||
const currentUserMembership = await getMembershipByUserIdTeamId(session?.user.id, team.id);
|
||||
const { isAdmin, isOwner } = getAccessFlags(currentUserMembership?.role);
|
||||
const isPricingDisabled = !isOwner && !isAdmin;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<SettingsTitle title="Billing & Plan" />
|
||||
{!isPricingDisabled ? (
|
||||
<PricingTable
|
||||
team={team}
|
||||
environmentId={params.environmentId}
|
||||
peopleCount={peopleCount}
|
||||
responseCount={responseCount}
|
||||
userTargetingFreeMtu={PRICING_USERTARGETING_FREE_MTU}
|
||||
inAppSurveyFreeResponses={PRICING_APPSURVEYS_FREE_RESPONSES}
|
||||
/>
|
||||
) : (
|
||||
<ErrorComponent />
|
||||
)}
|
||||
<PricingTable
|
||||
team={team}
|
||||
environmentId={params.environmentId}
|
||||
peopleCount={peopleCount}
|
||||
responseCount={responseCount}
|
||||
userTargetingFreeMtu={PRICING_USERTARGETING_FREE_MTU}
|
||||
inAppSurveyFreeResponses={PRICING_APPSURVEYS_FREE_RESPONSES}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,34 +1,20 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { StripePriceLookupKeys } from "@formbricks/ee/billing/lib/constants";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
|
||||
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
|
||||
|
||||
import { upgradePlanAction } from "../actions";
|
||||
|
||||
export default async function UnlimitedPage({ params }) {
|
||||
if (!IS_FORMBRICKS_CLOUD) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
const team = await getTeamByEnvironmentId(params.environmentId);
|
||||
|
||||
if (!session) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
throw new Error("Team not found");
|
||||
}
|
||||
|
||||
const { status, newPlan, url } = await upgradePlanAction(team.id, params.environmentId, [
|
||||
StripePriceLookupKeys.inAppSurveyUnlimited,
|
||||
StripePriceLookupKeys.linkSurveyUnlimited,
|
||||
StripePriceLookupKeys.userTargetingUnlimited,
|
||||
StripePriceLookupKeys.inAppSurveyUnlimitedPlan90,
|
||||
StripePriceLookupKeys.linkSurveyUnlimitedPlan19,
|
||||
StripePriceLookupKeys.userTargetingUnlimitedPlan90,
|
||||
]);
|
||||
if (status != 200) {
|
||||
throw new Error("Something went wrong");
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { StripePriceLookupKeys } from "@formbricks/ee/billing/lib/constants";
|
||||
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
|
||||
|
||||
import { upgradePlanAction } from "../actions";
|
||||
|
||||
export default async function UnlimitedPage({ params }) {
|
||||
const team = await getTeamByEnvironmentId(params.environmentId);
|
||||
if (!team) {
|
||||
throw new Error("Team not found");
|
||||
}
|
||||
|
||||
const { status, newPlan, url } = await upgradePlanAction(team.id, params.environmentId, [
|
||||
StripePriceLookupKeys.inAppSurveyUnlimitedPlan33,
|
||||
StripePriceLookupKeys.linkSurveyUnlimitedPlan33,
|
||||
StripePriceLookupKeys.userTargetingUnlimitedPlan33,
|
||||
]);
|
||||
if (status != 200) {
|
||||
throw new Error("Something went wrong");
|
||||
}
|
||||
if (newPlan && url) {
|
||||
redirect(url);
|
||||
} else if (!newPlan) {
|
||||
redirect(`/billing-confirmation?environmentId=${params.environmentId}`);
|
||||
} else {
|
||||
throw new Error("Something went wrong");
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,10 @@ const ResponsePage = ({
|
||||
|
||||
const { selectedFilter, dateRange, resetState } = useResponseFilter();
|
||||
|
||||
const filters = useMemo(() => getFormattedFilters(selectedFilter, dateRange), [selectedFilter, dateRange]);
|
||||
const filters = useMemo(
|
||||
() => getFormattedFilters(survey, selectedFilter, dateRange),
|
||||
[survey, selectedFilter, dateRange]
|
||||
);
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
|
||||
@@ -86,7 +86,10 @@ const CustomFilter = ({ environmentTags, attributes, survey }: CustomFilterProps
|
||||
setSelectedOptions({ questionFilterOptions, questionOptions });
|
||||
}, [survey, setSelectedOptions, environmentTags, attributes]);
|
||||
|
||||
const filters = useMemo(() => getFormattedFilters(selectedFilter, dateRange), [selectedFilter, dateRange]);
|
||||
const filters = useMemo(
|
||||
() => getFormattedFilters(survey, selectedFilter, dateRange),
|
||||
[survey, selectedFilter, dateRange]
|
||||
);
|
||||
|
||||
const datePickerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
||||
@@ -44,7 +44,9 @@ const QuestionFilterComboBox = ({
|
||||
|
||||
// multiple when question type is multi selection
|
||||
const isMultiple =
|
||||
type === TSurveyQuestionType.MultipleChoiceMulti || type === TSurveyQuestionType.MultipleChoiceSingle;
|
||||
type === TSurveyQuestionType.MultipleChoiceMulti ||
|
||||
type === TSurveyQuestionType.MultipleChoiceSingle ||
|
||||
type === TSurveyQuestionType.PictureSelection;
|
||||
|
||||
// when question type is multi selection so we remove the option from the options which has been already selected
|
||||
const options = isMultiple
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
CursorArrowRippleIcon,
|
||||
HashtagIcon,
|
||||
ListBulletIcon,
|
||||
PhotoIcon,
|
||||
QuestionMarkCircleIcon,
|
||||
QueueListIcon,
|
||||
StarIcon,
|
||||
@@ -60,6 +61,8 @@ const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOpti
|
||||
return <NetPromoterScoreIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionType.Consent:
|
||||
return <CheckIcon width={18} height={18} className="text-white" />;
|
||||
case TSurveyQuestionType.PictureSelection:
|
||||
return <PhotoIcon width={18} className="text-white" />;
|
||||
}
|
||||
}
|
||||
if (type === OptionsType.ATTRIBUTES) {
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function Modal({
|
||||
|
||||
return {
|
||||
transform: `scale(${scaleValue})`,
|
||||
"transform-origin": placementClass,
|
||||
transformOrigin: placementClass,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -241,8 +241,9 @@ export default function PreviewSurvey({
|
||||
<div className="h-3 w-3 rounded-full bg-amber-500"></div>
|
||||
<div className="h-3 w-3 rounded-full bg-emerald-500"></div>
|
||||
</div>
|
||||
<p className="ml-4 flex w-full justify-between font-mono text-sm text-slate-400">
|
||||
{previewType === "modal" ? "Your web app" : "Preview"}
|
||||
<div className="ml-4 flex w-full justify-between font-mono text-sm text-slate-400">
|
||||
<p>{previewType === "modal" ? "Your web app" : "Preview"}</p>
|
||||
|
||||
<div className="flex items-center">
|
||||
{isFullScreenPreview ? (
|
||||
<ArrowsPointingInIcon
|
||||
@@ -265,7 +266,7 @@ export default function PreviewSurvey({
|
||||
)}
|
||||
<ResetProgressButton resetQuestionProgress={resetQuestionProgress} />
|
||||
</div>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{previewType === "modal" ? (
|
||||
|
||||
@@ -241,7 +241,7 @@ export const SigninForm = ({
|
||||
<span className="leading-5 text-slate-500">New to Formbricks?</span>
|
||||
<br />
|
||||
<Link
|
||||
href={callbackUrl ? `/auth/signup?inviteToken=${inviteToken}` : "/auth/signup"}
|
||||
href={inviteToken ? `/auth/signup?inviteToken=${inviteToken}` : "/auth/signup"}
|
||||
className="font-semibold text-slate-600 underline hover:text-slate-700">
|
||||
Create an account
|
||||
</Link>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { CheckBadgeIcon } from "@heroicons/react/24/outline";
|
||||
import { Metadata } from "next";
|
||||
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { IS_S3_CONFIGURED } from "@formbricks/lib/constants";
|
||||
import { testS3BucketAccess } from "@formbricks/lib/storage/service";
|
||||
|
||||
export const dynamic = "force-dynamic"; // no caching
|
||||
|
||||
@@ -24,8 +26,21 @@ const checkDatabaseConnection = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const checkS3Connection = async () => {
|
||||
if (!IS_S3_CONFIGURED) {
|
||||
// dont try connecting if not in use
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await testS3BucketAccess();
|
||||
} catch (e) {
|
||||
throw new Error("S3 Bucket cannot be accessed");
|
||||
}
|
||||
};
|
||||
|
||||
export default async function HealthPage() {
|
||||
await checkDatabaseConnection();
|
||||
await checkS3Connection();
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex h-full max-w-xl flex-col items-center justify-center text-center">
|
||||
|
||||
@@ -23,6 +23,7 @@ const conditionOptions = {
|
||||
rating: ["Is equal to", "Is less than", "Is more than", "Submitted", "Skipped"],
|
||||
cta: ["is"],
|
||||
tags: ["is"],
|
||||
pictureSelection: ["Includes all", "Includes either"],
|
||||
userAttributes: ["Equals", "Not equals"],
|
||||
consent: ["is"],
|
||||
};
|
||||
@@ -72,6 +73,13 @@ export const generateQuestionAndFilterOptions = (
|
||||
filterComboBoxOptions: q?.choices ? q?.choices?.map((c) => c?.label) : [""],
|
||||
id: q.id,
|
||||
});
|
||||
} else if (q.type === TSurveyQuestionType.PictureSelection) {
|
||||
questionFilterOptions.push({
|
||||
type: q.type,
|
||||
filterOptions: conditionOptions[q.type],
|
||||
filterComboBoxOptions: q?.choices ? q?.choices?.map((_, idx) => `Picture ${idx + 1}`) : [""],
|
||||
id: q.id,
|
||||
});
|
||||
} else {
|
||||
questionFilterOptions.push({
|
||||
type: q.type,
|
||||
@@ -123,6 +131,7 @@ export const generateQuestionAndFilterOptions = (
|
||||
|
||||
// get the formatted filter expression to fetch filtered responses
|
||||
export const getFormattedFilters = (
|
||||
survey: TSurvey,
|
||||
selectedFilter: SelectedFilterValue,
|
||||
dateRange: DateRange
|
||||
): TResponseFilterCriteria => {
|
||||
@@ -249,6 +258,34 @@ export const getFormattedFilters = (
|
||||
};
|
||||
}
|
||||
}
|
||||
case TSurveyQuestionType.PictureSelection: {
|
||||
const questionId = questionType.id ?? "";
|
||||
const question = survey.questions.find((q) => q.id === questionId);
|
||||
|
||||
if (
|
||||
question?.type !== TSurveyQuestionType.PictureSelection ||
|
||||
!Array.isArray(filterType.filterComboBoxValue)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedOptions = filterType.filterComboBoxValue.map((option) => {
|
||||
const index = parseInt(option.split(" ")[1]);
|
||||
return question?.choices[index - 1].id;
|
||||
});
|
||||
|
||||
if (filterType.filterValue === "Includes all") {
|
||||
filters.data[questionId] = {
|
||||
op: "includesAll",
|
||||
value: selectedOptions,
|
||||
};
|
||||
} else if (filterType.filterValue === "Includes either") {
|
||||
filters.data[questionId] = {
|
||||
op: "includesOne",
|
||||
value: selectedOptions,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import ContentWrapper from "@formbricks/ui/ContentWrapper";
|
||||
import { SurveyInline } from "@formbricks/ui/Survey";
|
||||
|
||||
let setIsError = (_: boolean) => {};
|
||||
let setIsResponseSendingFinished = (_: boolean) => {};
|
||||
|
||||
interface LinkSurveyProps {
|
||||
survey: TSurvey;
|
||||
@@ -72,6 +73,10 @@ export default function LinkSurvey({
|
||||
onResponseSendingFailed: () => {
|
||||
setIsError(true);
|
||||
},
|
||||
onResponseSendingFinished: () => {
|
||||
// when response of current question is processed successfully
|
||||
setIsResponseSendingFinished(true);
|
||||
},
|
||||
setSurveyState: setSurveyState,
|
||||
},
|
||||
surveyState
|
||||
@@ -157,6 +162,9 @@ export default function LinkSurvey({
|
||||
getSetIsError={(f: (value: boolean) => void) => {
|
||||
setIsError = f;
|
||||
}}
|
||||
getSetIsResponseSendingFinished={(f: (value: boolean) => void) => {
|
||||
setIsResponseSendingFinished = f;
|
||||
}}
|
||||
onRetry={() => {
|
||||
setIsError(false);
|
||||
responseQueue.processQueue();
|
||||
|
||||
@@ -36,7 +36,10 @@ export const handleCheckoutSessionCompleted = async (event: Stripe.Event) => {
|
||||
switch (product.name) {
|
||||
case StripeProductNames.inAppSurvey:
|
||||
updatedFeatures.inAppSurvey.status = "active";
|
||||
if (item.price.lookup_key === StripePriceLookupKeys.inAppSurveyUnlimited) {
|
||||
const isInAppSurveyUnlimited =
|
||||
item.price.lookup_key === StripePriceLookupKeys.inAppSurveyUnlimitedPlan90 ||
|
||||
item.price.lookup_key === StripePriceLookupKeys.inAppSurveyUnlimitedPlan33;
|
||||
if (isInAppSurveyUnlimited) {
|
||||
updatedFeatures.inAppSurvey.unlimited = true;
|
||||
} else {
|
||||
const countForTeam = await getMonthlyTeamResponseCount(team.id);
|
||||
@@ -50,14 +53,20 @@ export const handleCheckoutSessionCompleted = async (event: Stripe.Event) => {
|
||||
|
||||
case StripeProductNames.linkSurvey:
|
||||
updatedFeatures.linkSurvey.status = "active";
|
||||
if (item.price.lookup_key === StripePriceLookupKeys.linkSurveyUnlimited) {
|
||||
const isLinkSurveyUnlimited =
|
||||
item.price.lookup_key === StripePriceLookupKeys.linkSurveyUnlimitedPlan19 ||
|
||||
item.price.lookup_key === StripePriceLookupKeys.linkSurveyUnlimitedPlan33;
|
||||
if (isLinkSurveyUnlimited) {
|
||||
updatedFeatures.linkSurvey.unlimited = true;
|
||||
}
|
||||
break;
|
||||
|
||||
case StripeProductNames.userTargeting:
|
||||
updatedFeatures.userTargeting.status = "active";
|
||||
if (item.price.lookup_key === StripePriceLookupKeys.userTargetingUnlimited) {
|
||||
const isUserTargetingUnlimited =
|
||||
item.price.lookup_key === StripePriceLookupKeys.userTargetingUnlimitedPlan90 ||
|
||||
item.price.lookup_key === StripePriceLookupKeys.userTargetingUnlimitedPlan33;
|
||||
if (isUserTargetingUnlimited) {
|
||||
updatedFeatures.userTargeting.unlimited = true;
|
||||
} else {
|
||||
const countForTeam = await getMonthlyActiveTeamPeopleCount(team.id);
|
||||
|
||||
@@ -68,7 +68,9 @@ export const handleSubscriptionUpdatedOrCreated = async (event: Stripe.Event) =>
|
||||
|
||||
switch (product.name) {
|
||||
case StripeProductNames.inAppSurvey:
|
||||
const isInAppSurveyUnlimited = item.price.lookup_key === StripePriceLookupKeys.inAppSurveyUnlimited;
|
||||
const isInAppSurveyUnlimited =
|
||||
item.price.lookup_key === StripePriceLookupKeys.inAppSurveyUnlimitedPlan90 ||
|
||||
item.price.lookup_key === StripePriceLookupKeys.inAppSurveyUnlimitedPlan33;
|
||||
|
||||
// If the current subscription is scheduled to cancel at the end of the period
|
||||
if (stripeSubscriptionObject.cancel_at_period_end) {
|
||||
@@ -105,7 +107,9 @@ export const handleSubscriptionUpdatedOrCreated = async (event: Stripe.Event) =>
|
||||
break;
|
||||
|
||||
case StripeProductNames.linkSurvey:
|
||||
const isLinkSurveyUnlimited = item.price.lookup_key === StripePriceLookupKeys.linkSurveyUnlimited;
|
||||
const isLinkSurveyUnlimited =
|
||||
item.price.lookup_key === StripePriceLookupKeys.linkSurveyUnlimitedPlan19 ||
|
||||
item.price.lookup_key === StripePriceLookupKeys.linkSurveyUnlimitedPlan33;
|
||||
|
||||
if (stripeSubscriptionObject.cancel_at_period_end) {
|
||||
const isLinkSurveyScheduled = await isProductScheduled(
|
||||
@@ -130,7 +134,8 @@ export const handleSubscriptionUpdatedOrCreated = async (event: Stripe.Event) =>
|
||||
|
||||
case StripeProductNames.userTargeting:
|
||||
const isUserTargetingUnlimited =
|
||||
item.price.lookup_key === StripePriceLookupKeys.userTargetingUnlimited;
|
||||
item.price.lookup_key === StripePriceLookupKeys.userTargetingUnlimitedPlan90 ||
|
||||
item.price.lookup_key === StripePriceLookupKeys.userTargetingUnlimitedPlan33;
|
||||
|
||||
if (stripeSubscriptionObject.cancel_at_period_end) {
|
||||
const isUserTargetingScheduled = await isProductScheduled(
|
||||
|
||||
@@ -13,7 +13,11 @@ export enum StripePriceLookupKeys {
|
||||
inAppSurvey = "inAppSurvey",
|
||||
linkSurvey = "linkSurvey",
|
||||
userTargeting = "userTargeting",
|
||||
inAppSurveyUnlimited = "survey-unlimited-03112023",
|
||||
linkSurveyUnlimited = "linkSurvey-unlimited-03112023",
|
||||
userTargetingUnlimited = "userTargeting-unlimited-03112023",
|
||||
inAppSurveyUnlimitedPlan90 = "survey-unlimited-03112023",
|
||||
linkSurveyUnlimitedPlan19 = "linkSurvey-unlimited-03112023",
|
||||
userTargetingUnlimitedPlan90 = "userTargeting-unlimited-03112023",
|
||||
|
||||
inAppSurveyUnlimitedPlan33 = "survey-unlimited-33-27022024",
|
||||
linkSurveyUnlimitedPlan33 = "linkSurvey-unlimited-33-27022024",
|
||||
userTargetingUnlimitedPlan33 = "userTargeting-unlimited-33-27022024",
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ const logger = Logger.getInstance();
|
||||
const errorHandler = ErrorHandler.getInstance();
|
||||
let surveyRunning = false;
|
||||
let setIsError = (_: boolean) => {};
|
||||
let setIsResponseSendingFinished = (_: boolean) => {};
|
||||
|
||||
export const renderWidget = async (survey: TSurvey) => {
|
||||
if (surveyRunning) {
|
||||
@@ -40,6 +41,9 @@ export const renderWidget = async (survey: TSurvey) => {
|
||||
onResponseSendingFailed: () => {
|
||||
setIsError(true);
|
||||
},
|
||||
onResponseSendingFinished: () => {
|
||||
setIsResponseSendingFinished(true);
|
||||
},
|
||||
},
|
||||
surveyState
|
||||
);
|
||||
@@ -51,7 +55,6 @@ export const renderWidget = async (survey: TSurvey) => {
|
||||
const darkOverlay = productOverwrites.darkOverlay ?? product.darkOverlay;
|
||||
const placement = productOverwrites.placement ?? product.placement;
|
||||
const isBrandingEnabled = product.inAppSurveyBranding;
|
||||
|
||||
const formbricksSurveys = await loadFormbricksSurveysExternally();
|
||||
|
||||
setTimeout(() => {
|
||||
@@ -66,6 +69,9 @@ export const renderWidget = async (survey: TSurvey) => {
|
||||
getSetIsError: (f: (value: boolean) => void) => {
|
||||
setIsError = f;
|
||||
},
|
||||
getSetIsResponseSendingFinished: (f: (value: boolean) => void) => {
|
||||
setIsResponseSendingFinished = f;
|
||||
},
|
||||
onDisplay: async () => {
|
||||
const { userId } = config.get();
|
||||
// if config does not have a person, we store the displays in local storage
|
||||
|
||||
@@ -74,7 +74,6 @@ export const SMTP_PASSWORD = env.SMTP_PASSWORD;
|
||||
export const MAIL_FROM = env.MAIL_FROM;
|
||||
|
||||
export const NEXTAUTH_SECRET = env.NEXTAUTH_SECRET;
|
||||
export const NEXTAUTH_URL = env.NEXTAUTH_URL;
|
||||
export const ITEMS_PER_PAGE = 50;
|
||||
export const RESPONSES_PER_PAGE = 10;
|
||||
export const TEXT_RESPONSES_PER_PAGE = 5;
|
||||
@@ -87,6 +86,7 @@ export const ONBOARDING_DISABLED = env.ONBOARDING_DISABLED;
|
||||
export const S3_ACCESS_KEY = env.S3_ACCESS_KEY;
|
||||
export const S3_SECRET_KEY = env.S3_SECRET_KEY;
|
||||
export const S3_REGION = env.S3_REGION;
|
||||
export const S3_ENDPOINT_URL = env.S3_ENDPOINT_URL;
|
||||
export const S3_BUCKET_NAME = env.S3_BUCKET_NAME;
|
||||
export const UPLOADS_DIR = "./uploads";
|
||||
export const MAX_SIZES = {
|
||||
|
||||
@@ -61,6 +61,7 @@ export const env = createEnv({
|
||||
S3_BUCKET_NAME: z.string().optional(),
|
||||
S3_REGION: z.string().optional(),
|
||||
S3_SECRET_KEY: z.string().optional(),
|
||||
S3_ENDPOINT_URL: z.string().optional(),
|
||||
SHORT_URL_BASE: z.string().url().optional().or(z.string().length(0)),
|
||||
SIGNUP_DISABLED: z.enum(["1", "0"]).optional(),
|
||||
SMTP_HOST: z.string().min(1).optional(),
|
||||
@@ -156,6 +157,7 @@ export const env = createEnv({
|
||||
S3_BUCKET_NAME: process.env.S3_BUCKET_NAME,
|
||||
S3_REGION: process.env.S3_REGION,
|
||||
S3_SECRET_KEY: process.env.S3_SECRET_KEY,
|
||||
S3_ENDPOINT_URL: process.env.S3_ENDPOINT_URL,
|
||||
SHORT_URL_BASE: process.env.SHORT_URL_BASE,
|
||||
SIGNUP_DISABLED: process.env.SIGNUP_DISABLED,
|
||||
SMTP_HOST: process.env.SMTP_HOST,
|
||||
|
||||
@@ -9,6 +9,7 @@ interface QueueConfig {
|
||||
environmentId: string;
|
||||
retryAttempts: number;
|
||||
onResponseSendingFailed?: (responseUpdate: TResponseUpdate) => void;
|
||||
onResponseSendingFinished?: () => void;
|
||||
setSurveyState?: (state: SurveyState) => void;
|
||||
}
|
||||
|
||||
@@ -68,6 +69,9 @@ export class ResponseQueue {
|
||||
}
|
||||
this.isRequestInProgress = false;
|
||||
} else {
|
||||
if (responseUpdate.finished && this.config.onResponseSendingFinished) {
|
||||
this.config.onResponseSendingFinished();
|
||||
}
|
||||
this.isRequestInProgress = false;
|
||||
this.processQueue(); // process the next item in the queue if any
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
DeleteObjectCommand,
|
||||
DeleteObjectsCommand,
|
||||
GetObjectCommand,
|
||||
HeadBucketCommand,
|
||||
ListObjectsCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
@@ -20,8 +21,11 @@ import { TAccessType } from "@formbricks/types/storage";
|
||||
import {
|
||||
IS_S3_CONFIGURED,
|
||||
MAX_SIZES,
|
||||
S3_ACCESS_KEY,
|
||||
S3_BUCKET_NAME,
|
||||
S3_ENDPOINT_URL,
|
||||
S3_REGION,
|
||||
S3_SECRET_KEY,
|
||||
UPLOADS_DIR,
|
||||
WEBAPP_URL,
|
||||
} from "../constants";
|
||||
@@ -30,14 +34,39 @@ import { env } from "../env";
|
||||
import { storageCache } from "./cache";
|
||||
|
||||
// S3Client Singleton
|
||||
let s3ClientInstance: S3Client | null = null;
|
||||
|
||||
export const s3Client = new S3Client({
|
||||
credentials: {
|
||||
accessKeyId: env.S3_ACCESS_KEY!,
|
||||
secretAccessKey: env.S3_SECRET_KEY!,
|
||||
},
|
||||
region: S3_REGION!,
|
||||
});
|
||||
export const getS3Client = () => {
|
||||
if (!s3ClientInstance) {
|
||||
s3ClientInstance = new S3Client({
|
||||
credentials: {
|
||||
accessKeyId: S3_ACCESS_KEY!,
|
||||
secretAccessKey: S3_SECRET_KEY!,
|
||||
},
|
||||
region: S3_REGION,
|
||||
endpoint: S3_ENDPOINT_URL,
|
||||
});
|
||||
}
|
||||
return s3ClientInstance;
|
||||
};
|
||||
|
||||
export const testS3BucketAccess = async () => {
|
||||
const s3Client = getS3Client();
|
||||
|
||||
try {
|
||||
// Attempt to retrieve metadata about the bucket
|
||||
const headBucketCommand = new HeadBucketCommand({
|
||||
Bucket: S3_BUCKET_NAME,
|
||||
});
|
||||
|
||||
await s3Client.send(headBucketCommand);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to access S3 bucket:", error);
|
||||
throw new Error(`S3 Bucket Access Test Failed: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const ensureDirectoryExists = async (dirPath: string) => {
|
||||
try {
|
||||
@@ -85,6 +114,7 @@ const getS3SignedUrl = async (fileKey: string): Promise<string> => {
|
||||
});
|
||||
|
||||
try {
|
||||
const s3Client = getS3Client();
|
||||
return await getSignedUrl(s3Client, getObjectCommand, { expiresIn });
|
||||
} catch (err) {
|
||||
throw err;
|
||||
@@ -239,6 +269,7 @@ export const getS3UploadSignedUrl = async (
|
||||
const postConditions: PresignedPostOptions["Conditions"] = [["content-length-range", 0, maxSize]];
|
||||
|
||||
try {
|
||||
const s3Client = getS3Client();
|
||||
const { fields, url } = await createPresignedPost(s3Client, {
|
||||
Expires: 10 * 60, // 10 minutes
|
||||
Bucket: env.S3_BUCKET_NAME!,
|
||||
@@ -309,6 +340,7 @@ export const putFile = async (
|
||||
};
|
||||
|
||||
const command = new PutObjectCommand(input);
|
||||
const s3Client = getS3Client();
|
||||
await s3Client.send(command);
|
||||
return { success: true, message: "File uploaded" };
|
||||
}
|
||||
@@ -358,6 +390,7 @@ export const deleteS3File = async (fileKey: string) => {
|
||||
});
|
||||
|
||||
try {
|
||||
const s3Client = getS3Client();
|
||||
await s3Client.send(deleteObjectCommand);
|
||||
} catch (err) {
|
||||
throw err;
|
||||
@@ -367,6 +400,7 @@ export const deleteS3File = async (fileKey: string) => {
|
||||
export const deleteS3FilesByEnvironmentId = async (environmentId: string) => {
|
||||
try {
|
||||
// List all objects in the bucket with the prefix of environmentId
|
||||
const s3Client = getS3Client();
|
||||
const listObjectsOutput = await s3Client.send(
|
||||
new ListObjectsCommand({
|
||||
Bucket: S3_BUCKET_NAME,
|
||||
|
||||
@@ -29,6 +29,7 @@ export function Survey({
|
||||
isRedirectDisabled = false,
|
||||
prefillResponseData,
|
||||
getSetIsError,
|
||||
getSetIsResponseSendingFinished,
|
||||
onFileUpload,
|
||||
responseCount,
|
||||
}: SurveyBaseProps) {
|
||||
@@ -36,6 +37,9 @@ export function Survey({
|
||||
activeQuestionId || (survey.welcomeCard.enabled ? "start" : survey?.questions[0]?.id)
|
||||
);
|
||||
const [showError, setShowError] = useState(false);
|
||||
// flag state to store whether response processing has been completed or not
|
||||
const [isResponseSendingFinished, setIsResponseSendingFinished] = useState(false);
|
||||
|
||||
const [loadingElement, setLoadingElement] = useState(false);
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const [responseData, setResponseData] = useState<TResponseData>({});
|
||||
@@ -85,7 +89,15 @@ export function Survey({
|
||||
setShowError(value);
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [getSetIsError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (getSetIsResponseSendingFinished) {
|
||||
getSetIsResponseSendingFinished((value: boolean) => {
|
||||
setIsResponseSendingFinished(value);
|
||||
});
|
||||
}
|
||||
}, [getSetIsResponseSendingFinished]);
|
||||
|
||||
let currIdxTemp = currentQuestionIndex;
|
||||
let currQuesTemp = currentQuestion;
|
||||
@@ -216,6 +228,7 @@ export function Survey({
|
||||
} else if (questionId === "end" && survey.thankYouCard.enabled) {
|
||||
return (
|
||||
<ThankYouCard
|
||||
isResponseSendingFinished={isResponseSendingFinished}
|
||||
headline={
|
||||
typeof survey.thankYouCard.headline === "string"
|
||||
? replaceRecallInfo(survey.thankYouCard.headline)
|
||||
|
||||
@@ -15,6 +15,7 @@ export function SurveyModal({
|
||||
darkOverlay,
|
||||
highlightBorderColor,
|
||||
onDisplay,
|
||||
getSetIsResponseSendingFinished,
|
||||
onActiveQuestionChange,
|
||||
onResponse,
|
||||
onClose,
|
||||
@@ -49,6 +50,7 @@ export function SurveyModal({
|
||||
isBrandingEnabled={isBrandingEnabled}
|
||||
activeQuestionId={activeQuestionId}
|
||||
onDisplay={onDisplay}
|
||||
getSetIsResponseSendingFinished={getSetIsResponseSendingFinished}
|
||||
onActiveQuestionChange={onActiveQuestionChange}
|
||||
onResponse={onResponse}
|
||||
onClose={close}
|
||||
|
||||
@@ -13,6 +13,7 @@ interface ThankYouCardProps {
|
||||
buttonLabel?: string;
|
||||
buttonLink?: string;
|
||||
imageUrl?: string;
|
||||
isResponseSendingFinished: boolean;
|
||||
}
|
||||
|
||||
export default function ThankYouCard({
|
||||
@@ -23,9 +24,10 @@ export default function ThankYouCard({
|
||||
buttonLabel,
|
||||
buttonLink,
|
||||
imageUrl,
|
||||
isResponseSendingFinished,
|
||||
}: ThankYouCardProps) {
|
||||
useEffect(() => {
|
||||
if (!buttonLink) return;
|
||||
if (!buttonLink || !isResponseSendingFinished) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Enter") {
|
||||
window.top?.location.replace(buttonLink);
|
||||
@@ -35,7 +37,7 @@ export default function ThankYouCard({
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [buttonLink]);
|
||||
}, [buttonLink, isResponseSendingFinished]);
|
||||
|
||||
return (
|
||||
<div className="text-center">
|
||||
@@ -66,7 +68,7 @@ export default function ThankYouCard({
|
||||
<Headline alignTextCenter={true} headline={headline} questionId="thankYouCard" />
|
||||
<Subheader subheader={subheader} questionId="thankYouCard" />
|
||||
<RedirectCountDown redirectUrl={redirectUrl} isRedirectDisabled={isRedirectDisabled} />
|
||||
{buttonLabel && (
|
||||
{buttonLabel && isResponseSendingFinished && (
|
||||
<div className="mt-6 flex w-full flex-col items-center justify-center space-y-4">
|
||||
<Button
|
||||
buttonLabel={buttonLabel}
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface SurveyBaseProps {
|
||||
isBrandingEnabled: boolean;
|
||||
activeQuestionId?: string;
|
||||
getSetIsError?: (getSetError: (value: boolean) => void) => void;
|
||||
getSetIsResponseSendingFinished?: (getSetIsResponseSendingFinished: (value: boolean) => void) => void;
|
||||
onDisplay?: () => void;
|
||||
onResponse?: (response: TResponseUpdate) => void;
|
||||
onFinished?: () => void;
|
||||
|
||||
@@ -477,7 +477,7 @@ export const ZSurveyInput = z
|
||||
welcomeCard: ZSurveyWelcomeCard.optional(),
|
||||
questions: ZSurveyQuestions.optional(),
|
||||
thankYouCard: ZSurveyThankYouCard.optional(),
|
||||
hiddenFields: ZSurveyHiddenFields,
|
||||
hiddenFields: ZSurveyHiddenFields.optional(),
|
||||
delay: z.number().optional(),
|
||||
autoComplete: z.number().optional(),
|
||||
closeOnDate: z.date().optional(),
|
||||
|
||||
@@ -11,13 +11,13 @@ export interface InputProps
|
||||
isInvalid?: boolean;
|
||||
}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, ...props }, ref) => {
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, isInvalid, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
className={cn(
|
||||
"focus:border-brand-dark flex h-10 w-full rounded-md border border-slate-300 bg-transparent 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",
|
||||
className,
|
||||
props.isInvalid && "border border-red-600 focus:border-red-600"
|
||||
isInvalid && "border border-red-600 focus:border-red-600"
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { PencilIcon } from "@heroicons/react/24/solid";
|
||||
import { ImagePlusIcon } from "lucide-react";
|
||||
import { RefObject, useEffect, useMemo, useRef, useState } from "react";
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
extractId,
|
||||
@@ -32,310 +33,316 @@ interface QuestionFormInputProps {
|
||||
environmentId: string;
|
||||
type: string;
|
||||
isInvalid?: boolean;
|
||||
ref?: RefObject<HTMLInputElement>;
|
||||
}
|
||||
|
||||
const QuestionFormInput = ({
|
||||
localSurvey,
|
||||
questionId,
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
updateSurvey,
|
||||
isInvalid,
|
||||
environmentId,
|
||||
type,
|
||||
}: QuestionFormInputProps) => {
|
||||
const isThankYouCard = questionId === "end";
|
||||
const question = useMemo(() => {
|
||||
return isThankYouCard
|
||||
? localSurvey.thankYouCard
|
||||
: localSurvey.questions.find((question) => question.id === questionId)!;
|
||||
}, [isThankYouCard, localSurvey, questionId]);
|
||||
const QuestionFormInput = React.forwardRef(
|
||||
(
|
||||
{
|
||||
localSurvey,
|
||||
questionId,
|
||||
questionIdx,
|
||||
updateQuestion,
|
||||
updateSurvey,
|
||||
isInvalid,
|
||||
environmentId,
|
||||
type,
|
||||
}: QuestionFormInputProps,
|
||||
ref
|
||||
) => {
|
||||
const isThankYouCard = questionId === "end";
|
||||
const question = useMemo(() => {
|
||||
return isThankYouCard
|
||||
? localSurvey.thankYouCard
|
||||
: localSurvey.questions.find((question) => question.id === questionId)!;
|
||||
}, [isThankYouCard, localSurvey, questionId]);
|
||||
|
||||
const getQuestionTextBasedOnType = (): string => {
|
||||
return question[type as keyof typeof question] || "";
|
||||
};
|
||||
const getQuestionTextBasedOnType = (): string => {
|
||||
return question[type as keyof typeof question] || "";
|
||||
};
|
||||
|
||||
const [text, setText] = useState(getQuestionTextBasedOnType() ?? "");
|
||||
const [renderedText, setRenderedText] = useState<JSX.Element[]>();
|
||||
const [text, setText] = useState(getQuestionTextBasedOnType() ?? "");
|
||||
const [renderedText, setRenderedText] = useState<JSX.Element[]>();
|
||||
|
||||
const highlightContainerRef = useRef<HTMLInputElement>(null);
|
||||
const fallbackInputRef = useRef<HTMLInputElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [showImageUploader, setShowImageUploader] = useState<boolean>(
|
||||
questionId === "end"
|
||||
? localSurvey.thankYouCard.imageUrl
|
||||
? true
|
||||
: false
|
||||
: !!(question as TSurveyQuestion).imageUrl
|
||||
);
|
||||
const [showQuestionSelect, setShowQuestionSelect] = useState(false);
|
||||
const [showFallbackInput, setShowFallbackInput] = useState(false);
|
||||
const [recallQuestions, setRecallQuestions] = useState<TSurveyQuestion[]>(
|
||||
text.includes("#recall:") ? getRecallQuestions(text, localSurvey) : []
|
||||
);
|
||||
const filteredRecallQuestions = Array.from(new Set(recallQuestions.map((q) => q.id))).map((id) => {
|
||||
return recallQuestions.find((q) => q.id === id);
|
||||
});
|
||||
const [fallbacks, setFallbacks] = useState<{ [type: string]: string }>(
|
||||
text.includes("/fallback:") ? getFallbackValues(text) : {}
|
||||
);
|
||||
|
||||
// Hook to synchronize the horizontal scroll position of highlightContainerRef and inputRef.
|
||||
useSyncScroll(highlightContainerRef, inputRef, text);
|
||||
|
||||
useEffect(() => {
|
||||
// Generates an array of headlines from recallQuestions, replacing nested recall questions with '___' .
|
||||
const recallQuestionHeadlines = recallQuestions.flatMap((recallQuestion) => {
|
||||
if (!recallQuestion.headline.includes("#recall:")) {
|
||||
return [recallQuestion.headline];
|
||||
}
|
||||
const recallQuestionText = (recallQuestion[type as keyof typeof recallQuestion] as string) || "";
|
||||
const recallInfo = extractRecallInfo(recallQuestionText);
|
||||
|
||||
if (recallInfo) {
|
||||
const recallQuestionId = extractId(recallInfo);
|
||||
const recallQuestion = localSurvey.questions.find((question) => question.id === recallQuestionId);
|
||||
|
||||
if (recallQuestion) {
|
||||
return [recallQuestionText.replace(recallInfo, `___`)];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
const highlightContainerRef = useRef<HTMLInputElement>(null);
|
||||
const fallbackInputRef = useRef<HTMLInputElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [showImageUploader, setShowImageUploader] = useState<boolean>(
|
||||
questionId === "end"
|
||||
? localSurvey.thankYouCard.imageUrl
|
||||
? true
|
||||
: false
|
||||
: !!(question as TSurveyQuestion).imageUrl
|
||||
);
|
||||
const [showQuestionSelect, setShowQuestionSelect] = useState(false);
|
||||
const [showFallbackInput, setShowFallbackInput] = useState(false);
|
||||
const [recallQuestions, setRecallQuestions] = useState<TSurveyQuestion[]>(
|
||||
text.includes("#recall:") ? getRecallQuestions(text, localSurvey) : []
|
||||
);
|
||||
const filteredRecallQuestions = Array.from(new Set(recallQuestions.map((q) => q.id))).map((id) => {
|
||||
return recallQuestions.find((q) => q.id === id);
|
||||
});
|
||||
const [fallbacks, setFallbacks] = useState<{ [type: string]: string }>(
|
||||
text.includes("/fallback:") ? getFallbackValues(text) : {}
|
||||
);
|
||||
|
||||
// Constructs an array of JSX elements representing segmented parts of text, interspersed with special formatted spans for recall headlines.
|
||||
const processInput = (): JSX.Element[] => {
|
||||
const parts: JSX.Element[] = [];
|
||||
let remainingText: string = text ?? "";
|
||||
remainingText = recallToHeadline(remainingText, localSurvey, false);
|
||||
filterRecallQuestions(remainingText);
|
||||
recallQuestionHeadlines.forEach((headline) => {
|
||||
const index = remainingText.indexOf("@" + headline);
|
||||
if (index !== -1) {
|
||||
if (index > 0) {
|
||||
// Hook to synchronize the horizontal scroll position of highlightContainerRef and inputRef.
|
||||
useSyncScroll(highlightContainerRef, inputRef, text);
|
||||
|
||||
useEffect(() => {
|
||||
// Generates an array of headlines from recallQuestions, replacing nested recall questions with '___' .
|
||||
const recallQuestionHeadlines = recallQuestions.flatMap((recallQuestion) => {
|
||||
if (!recallQuestion.headline.includes("#recall:")) {
|
||||
return [recallQuestion.headline];
|
||||
}
|
||||
const recallQuestionText = (recallQuestion[type as keyof typeof recallQuestion] as string) || "";
|
||||
const recallInfo = extractRecallInfo(recallQuestionText);
|
||||
|
||||
if (recallInfo) {
|
||||
const recallQuestionId = extractId(recallInfo);
|
||||
const recallQuestion = localSurvey.questions.find((question) => question.id === recallQuestionId);
|
||||
|
||||
if (recallQuestion) {
|
||||
return [recallQuestionText.replace(recallInfo, `___`)];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
// Constructs an array of JSX elements representing segmented parts of text, interspersed with special formatted spans for recall headlines.
|
||||
const processInput = (): JSX.Element[] => {
|
||||
const parts: JSX.Element[] = [];
|
||||
let remainingText: string = text ?? "";
|
||||
remainingText = recallToHeadline(remainingText, localSurvey, false);
|
||||
filterRecallQuestions(remainingText);
|
||||
recallQuestionHeadlines.forEach((headline) => {
|
||||
const index = remainingText.indexOf("@" + headline);
|
||||
if (index !== -1) {
|
||||
if (index > 0) {
|
||||
parts.push(
|
||||
<span key={parts.length} className="whitespace-pre">
|
||||
{remainingText.substring(0, index)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
parts.push(
|
||||
<span key={parts.length} className="whitespace-pre">
|
||||
{remainingText.substring(0, index)}
|
||||
<span
|
||||
className="z-30 flex cursor-pointer items-center justify-center whitespace-pre rounded-md bg-slate-100 text-sm text-transparent"
|
||||
key={parts.length}>
|
||||
{"@" + headline}
|
||||
</span>
|
||||
);
|
||||
remainingText = remainingText.substring(index + headline.length + 1);
|
||||
}
|
||||
});
|
||||
if (remainingText.length) {
|
||||
parts.push(
|
||||
<span
|
||||
className="z-30 flex cursor-pointer items-center justify-center whitespace-pre rounded-md bg-slate-100 text-sm text-transparent"
|
||||
key={parts.length}>
|
||||
{"@" + headline}
|
||||
<span className="whitespace-pre" key={parts.length}>
|
||||
{remainingText}
|
||||
</span>
|
||||
);
|
||||
remainingText = remainingText.substring(index + headline.length + 1);
|
||||
}
|
||||
return parts;
|
||||
};
|
||||
setRenderedText(processInput());
|
||||
}, [text]);
|
||||
|
||||
useEffect(() => {
|
||||
if (fallbackInputRef.current) {
|
||||
fallbackInputRef.current.focus();
|
||||
}
|
||||
}, [showFallbackInput]);
|
||||
|
||||
const checkForRecallSymbol = () => {
|
||||
const pattern = /(^|\s)@(\s|$)/;
|
||||
if (pattern.test(text)) {
|
||||
setShowQuestionSelect(true);
|
||||
} else {
|
||||
setShowQuestionSelect(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Adds a new recall question to the recallQuestions array, updates fallbacks, modifies the text with recall details.
|
||||
const addRecallQuestion = (recallQuestion: TSurveyQuestion) => {
|
||||
let recallQuestionTemp = { ...recallQuestion };
|
||||
recallQuestionTemp = replaceRecallInfoWithUnderline(recallQuestionTemp);
|
||||
setRecallQuestions((prevQuestions) => {
|
||||
const updatedQuestions = [...prevQuestions, recallQuestionTemp];
|
||||
return updatedQuestions;
|
||||
});
|
||||
if (!Object.keys(fallbacks).includes(recallQuestion.id)) {
|
||||
setFallbacks((prevFallbacks) => ({
|
||||
...prevFallbacks,
|
||||
[recallQuestion.id]: "",
|
||||
}));
|
||||
}
|
||||
setShowQuestionSelect(false);
|
||||
const modifiedHeadlineWithId = getQuestionTextBasedOnType().replace(
|
||||
"@",
|
||||
`#recall:${recallQuestion.id}/fallback:# `
|
||||
);
|
||||
updateQuestionDetails(modifiedHeadlineWithId);
|
||||
|
||||
const modifiedHeadlineWithName = recallToHeadline(modifiedHeadlineWithId, localSurvey, false);
|
||||
setText(modifiedHeadlineWithName);
|
||||
setShowFallbackInput(true);
|
||||
};
|
||||
|
||||
// Filters and updates the list of recall questions based on their presence in the given text, also managing related text and fallback states.
|
||||
const filterRecallQuestions = (text: string) => {
|
||||
let includedQuestions: TSurveyQuestion[] = [];
|
||||
recallQuestions.forEach((recallQuestion) => {
|
||||
if (text.includes(`@${recallQuestion.headline}`)) {
|
||||
includedQuestions.push(recallQuestion);
|
||||
} else {
|
||||
const questionToRemove = recallQuestion.headline.slice(0, -1);
|
||||
const newText = text.replace(`@${questionToRemove}`, "");
|
||||
setText(newText);
|
||||
updateQuestionDetails(newText);
|
||||
let updatedFallback = { ...fallbacks };
|
||||
delete updatedFallback[recallQuestion.id];
|
||||
setFallbacks(updatedFallback);
|
||||
}
|
||||
});
|
||||
if (remainingText.length) {
|
||||
parts.push(
|
||||
<span className="whitespace-pre" key={parts.length}>
|
||||
{remainingText}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return parts;
|
||||
setRecallQuestions(includedQuestions);
|
||||
};
|
||||
setRenderedText(processInput());
|
||||
}, [text]);
|
||||
|
||||
useEffect(() => {
|
||||
if (fallbackInputRef.current) {
|
||||
fallbackInputRef.current.focus();
|
||||
}
|
||||
}, [showFallbackInput]);
|
||||
const addFallback = () => {
|
||||
let headlineWithFallback = getQuestionTextBasedOnType();
|
||||
filteredRecallQuestions.forEach((recallQuestion) => {
|
||||
if (recallQuestion) {
|
||||
const recallInfo = findRecallInfoById(getQuestionTextBasedOnType(), recallQuestion!.id);
|
||||
if (recallInfo) {
|
||||
let fallBackValue = fallbacks[recallQuestion.id].trim();
|
||||
fallBackValue = fallBackValue.replace(/ /g, "nbsp");
|
||||
let updatedFallback = { ...fallbacks };
|
||||
updatedFallback[recallQuestion.id] = fallBackValue;
|
||||
setFallbacks(updatedFallback);
|
||||
headlineWithFallback = headlineWithFallback.replace(
|
||||
recallInfo,
|
||||
`#recall:${recallQuestion?.id}/fallback:${fallBackValue}#`
|
||||
);
|
||||
updateQuestionDetails(headlineWithFallback);
|
||||
}
|
||||
}
|
||||
});
|
||||
setShowFallbackInput(false);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
const checkForRecallSymbol = () => {
|
||||
const pattern = /(^|\s)@(\s|$)/;
|
||||
if (pattern.test(text)) {
|
||||
setShowQuestionSelect(true);
|
||||
} else {
|
||||
setShowQuestionSelect(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
checkForRecallSymbol();
|
||||
}, [text]);
|
||||
|
||||
// Adds a new recall question to the recallQuestions array, updates fallbacks, modifies the text with recall details.
|
||||
const addRecallQuestion = (recallQuestion: TSurveyQuestion) => {
|
||||
let recallQuestionTemp = { ...recallQuestion };
|
||||
recallQuestionTemp = replaceRecallInfoWithUnderline(recallQuestionTemp);
|
||||
setRecallQuestions((prevQuestions) => {
|
||||
const updatedQuestions = [...prevQuestions, recallQuestionTemp];
|
||||
return updatedQuestions;
|
||||
});
|
||||
if (!Object.keys(fallbacks).includes(recallQuestion.id)) {
|
||||
setFallbacks((prevFallbacks) => ({
|
||||
...prevFallbacks,
|
||||
[recallQuestion.id]: "",
|
||||
}));
|
||||
}
|
||||
setShowQuestionSelect(false);
|
||||
const modifiedHeadlineWithId = getQuestionTextBasedOnType().replace(
|
||||
"@",
|
||||
`#recall:${recallQuestion.id}/fallback:# `
|
||||
);
|
||||
updateQuestionDetails(modifiedHeadlineWithId);
|
||||
|
||||
const modifiedHeadlineWithName = recallToHeadline(modifiedHeadlineWithId, localSurvey, false);
|
||||
setText(modifiedHeadlineWithName);
|
||||
setShowFallbackInput(true);
|
||||
};
|
||||
|
||||
// Filters and updates the list of recall questions based on their presence in the given text, also managing related text and fallback states.
|
||||
const filterRecallQuestions = (text: string) => {
|
||||
let includedQuestions: TSurveyQuestion[] = [];
|
||||
recallQuestions.forEach((recallQuestion) => {
|
||||
if (text.includes(`@${recallQuestion.headline}`)) {
|
||||
includedQuestions.push(recallQuestion);
|
||||
// updation of questions and Thank You Card is done in a different manner, so for question we use updateQuestion and for ThankYouCard we use updateSurvey
|
||||
const updateQuestionDetails = (updatedText: string) => {
|
||||
if (isThankYouCard) {
|
||||
if (updateSurvey) {
|
||||
updateSurvey({ [type]: updatedText });
|
||||
}
|
||||
} else {
|
||||
const questionToRemove = recallQuestion.headline.slice(0, -1);
|
||||
const newText = text.replace(`@${questionToRemove}`, "");
|
||||
setText(newText);
|
||||
updateQuestionDetails(newText);
|
||||
let updatedFallback = { ...fallbacks };
|
||||
delete updatedFallback[recallQuestion.id];
|
||||
setFallbacks(updatedFallback);
|
||||
}
|
||||
});
|
||||
setRecallQuestions(includedQuestions);
|
||||
};
|
||||
|
||||
const addFallback = () => {
|
||||
let headlineWithFallback = getQuestionTextBasedOnType();
|
||||
filteredRecallQuestions.forEach((recallQuestion) => {
|
||||
if (recallQuestion) {
|
||||
const recallInfo = findRecallInfoById(getQuestionTextBasedOnType(), recallQuestion!.id);
|
||||
if (recallInfo) {
|
||||
let fallBackValue = fallbacks[recallQuestion.id].trim();
|
||||
fallBackValue = fallBackValue.replace(/ /g, "nbsp");
|
||||
let updatedFallback = { ...fallbacks };
|
||||
updatedFallback[recallQuestion.id] = fallBackValue;
|
||||
setFallbacks(updatedFallback);
|
||||
headlineWithFallback = headlineWithFallback.replace(
|
||||
recallInfo,
|
||||
`#recall:${recallQuestion?.id}/fallback:${fallBackValue}#`
|
||||
);
|
||||
updateQuestionDetails(headlineWithFallback);
|
||||
if (updateQuestion) {
|
||||
updateQuestion(questionIdx, {
|
||||
[type]: updatedText,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
setShowFallbackInput(false);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkForRecallSymbol();
|
||||
}, [text]);
|
||||
|
||||
// updation of questions and Thank You Card is done in a different manner, so for question we use updateQuestion and for ThankYouCard we use updateSurvey
|
||||
const updateQuestionDetails = (updatedText: string) => {
|
||||
if (isThankYouCard) {
|
||||
if (updateSurvey) {
|
||||
updateSurvey({ [type]: updatedText });
|
||||
}
|
||||
} else {
|
||||
if (updateQuestion) {
|
||||
updateQuestion(questionIdx, {
|
||||
[type]: updatedText,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-3 w-full">
|
||||
<Label htmlFor="headline">{type === "headline" ? "Question" : "Description"}</Label>
|
||||
<div className="mt-2 flex flex-col gap-6 overflow-hidden">
|
||||
{showImageUploader && type === "headline" && (
|
||||
<FileInput
|
||||
id="question-image"
|
||||
allowedFileExtensions={["png", "jpeg", "jpg"]}
|
||||
environmentId={environmentId}
|
||||
onFileUpload={(url: string[] | undefined) => {
|
||||
if (isThankYouCard && updateSurvey && url) {
|
||||
updateSurvey({ imageUrl: url[0] });
|
||||
} else if (updateQuestion && url) {
|
||||
updateQuestion(questionIdx, { imageUrl: url[0] });
|
||||
}
|
||||
}}
|
||||
fileUrl={
|
||||
isThankYouCard ? localSurvey.thankYouCard.imageUrl : (question as TSurveyQuestion).imageUrl
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="group relative w-full">
|
||||
<div className="h-10 w-full"></div>
|
||||
<div
|
||||
id="wrapper"
|
||||
ref={highlightContainerRef}
|
||||
className="no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll whitespace-nowrap px-3 py-2 text-center text-sm text-transparent ">
|
||||
{renderedText}
|
||||
</div>
|
||||
{getQuestionTextBasedOnType().includes("recall:") && (
|
||||
<button
|
||||
className="fixed right-14 hidden items-center rounded-b-lg bg-slate-100 px-2.5 py-1 text-xs hover:bg-slate-200 group-hover:flex"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setShowFallbackInput(true);
|
||||
}}>
|
||||
Edit Recall
|
||||
<PencilIcon className="ml-2 h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
<Input
|
||||
className="absolute top-0 text-black caret-black"
|
||||
placeholder={
|
||||
type === "headline"
|
||||
? "Your question here. Recall information with @"
|
||||
: "Your description here. Recall information with @"
|
||||
}
|
||||
autoFocus
|
||||
ref={inputRef}
|
||||
id={type}
|
||||
name={type}
|
||||
aria-label={type === "headline" ? "Question" : "Description"}
|
||||
autoComplete={showQuestionSelect ? "off" : "on"}
|
||||
value={recallToHeadline(text ?? "", localSurvey, false)}
|
||||
onChange={(e) => {
|
||||
setText(recallToHeadline(e.target.value ?? "", localSurvey, false));
|
||||
updateQuestionDetails(headlineToRecall(e.target.value, recallQuestions, fallbacks));
|
||||
return (
|
||||
<div className="mt-3 w-full">
|
||||
<Label htmlFor="headline">{type === "headline" ? "Question" : "Description"}</Label>
|
||||
<div className="mt-2 flex flex-col gap-6 overflow-hidden">
|
||||
{showImageUploader && type === "headline" && (
|
||||
<FileInput
|
||||
id="question-image"
|
||||
allowedFileExtensions={["png", "jpeg", "jpg"]}
|
||||
environmentId={environmentId}
|
||||
onFileUpload={(url: string[] | undefined) => {
|
||||
if (isThankYouCard && updateSurvey && url) {
|
||||
updateSurvey({ imageUrl: url[0] });
|
||||
} else if (updateQuestion && url) {
|
||||
updateQuestion(questionIdx, { imageUrl: url[0] });
|
||||
}
|
||||
}}
|
||||
isInvalid={isInvalid && text.trim() === ""}
|
||||
fileUrl={
|
||||
isThankYouCard ? localSurvey.thankYouCard.imageUrl : (question as TSurveyQuestion).imageUrl
|
||||
}
|
||||
/>
|
||||
{!showQuestionSelect && showFallbackInput && recallQuestions.length > 0 && (
|
||||
<FallbackInput
|
||||
filteredRecallQuestions={filteredRecallQuestions}
|
||||
fallbacks={fallbacks}
|
||||
setFallbacks={setFallbacks}
|
||||
fallbackInputRef={fallbackInputRef}
|
||||
addFallback={addFallback}
|
||||
)}
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="group relative w-full">
|
||||
<div className="h-10 w-full"></div>
|
||||
<div
|
||||
id="wrapper"
|
||||
ref={highlightContainerRef}
|
||||
className="no-scrollbar absolute top-0 z-0 mt-0.5 flex h-10 w-full overflow-scroll whitespace-nowrap px-3 py-2 text-center text-sm text-transparent ">
|
||||
{renderedText}
|
||||
</div>
|
||||
{getQuestionTextBasedOnType().includes("recall:") && (
|
||||
<button
|
||||
className="fixed right-14 hidden items-center rounded-b-lg bg-slate-100 px-2.5 py-1 text-xs hover:bg-slate-200 group-hover:flex"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setShowFallbackInput(true);
|
||||
}}>
|
||||
Edit Recall
|
||||
<PencilIcon className="ml-2 h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
<Input
|
||||
className="absolute top-0 text-black caret-black"
|
||||
placeholder={
|
||||
type === "headline"
|
||||
? "Your question here. Recall information with @"
|
||||
: "Your description here. Recall information with @"
|
||||
}
|
||||
autoFocus
|
||||
ref={ref as RefObject<HTMLInputElement> | undefined}
|
||||
id={type}
|
||||
name={type}
|
||||
aria-label={type === "headline" ? "Question" : "Description"}
|
||||
autoComplete={showQuestionSelect ? "off" : "on"}
|
||||
value={recallToHeadline(text ?? "", localSurvey, false)}
|
||||
onChange={(e) => {
|
||||
setText(recallToHeadline(e.target.value ?? "", localSurvey, false));
|
||||
updateQuestionDetails(headlineToRecall(e.target.value, recallQuestions, fallbacks));
|
||||
}}
|
||||
isInvalid={isInvalid && text.trim() === ""}
|
||||
/>
|
||||
{!showQuestionSelect && showFallbackInput && recallQuestions.length > 0 && (
|
||||
<FallbackInput
|
||||
filteredRecallQuestions={filteredRecallQuestions}
|
||||
fallbacks={fallbacks}
|
||||
setFallbacks={setFallbacks}
|
||||
fallbackInputRef={fallbackInputRef}
|
||||
addFallback={addFallback}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{type === "headline" && (
|
||||
<ImagePlusIcon
|
||||
aria-label="Toggle image uploader"
|
||||
className="ml-2 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
|
||||
onClick={() => setShowImageUploader((prev) => !prev)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{type === "headline" && (
|
||||
<ImagePlusIcon
|
||||
aria-label="Toggle image uploader"
|
||||
className="ml-2 h-4 w-4 cursor-pointer text-slate-400 hover:text-slate-500"
|
||||
onClick={() => setShowImageUploader((prev) => !prev)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{showQuestionSelect && (
|
||||
<RecallQuestionSelect
|
||||
localSurvey={localSurvey}
|
||||
questionId={questionId}
|
||||
addRecallQuestion={addRecallQuestion}
|
||||
setShowQuestionSelect={setShowQuestionSelect}
|
||||
showQuestionSelect={showQuestionSelect}
|
||||
inputRef={inputRef}
|
||||
recallQuestions={recallQuestions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{showQuestionSelect && (
|
||||
<RecallQuestionSelect
|
||||
localSurvey={localSurvey}
|
||||
questionId={questionId}
|
||||
addRecallQuestion={addRecallQuestion}
|
||||
setShowQuestionSelect={setShowQuestionSelect}
|
||||
showQuestionSelect={showQuestionSelect}
|
||||
inputRef={inputRef}
|
||||
recallQuestions={recallQuestions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
);
|
||||
}
|
||||
);
|
||||
QuestionFormInput.displayName = "QuestionFormInput";
|
||||
|
||||
export default QuestionFormInput;
|
||||
|
||||
@@ -1,40 +1,11 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
|
||||
import { renderSurveyInline, renderSurveyModal } from "@formbricks/surveys";
|
||||
import { TResponseData, TResponseUpdate } from "@formbricks/types/responses";
|
||||
import { TUploadFileConfig } from "@formbricks/types/storage";
|
||||
import { TSurvey } from "@formbricks/types/surveys";
|
||||
import { SurveyInlineProps, SurveyModalProps } from "@formbricks/types/formbricksSurveys";
|
||||
|
||||
const createContainerId = () => `formbricks-survey-container`;
|
||||
|
||||
interface SurveyProps {
|
||||
survey: TSurvey;
|
||||
brandColor: string;
|
||||
isBrandingEnabled: boolean;
|
||||
activeQuestionId?: string;
|
||||
getSetIsError?: (getSetError: (value: boolean) => void) => void;
|
||||
onRetry?: () => void;
|
||||
onDisplay?: () => void;
|
||||
onResponse?: (response: TResponseUpdate) => void;
|
||||
onFinished?: () => void;
|
||||
onActiveQuestionChange?: (questionId: string) => void;
|
||||
onClose?: () => void;
|
||||
onFileUpload: (file: File, config?: TUploadFileConfig) => Promise<string>;
|
||||
autoFocus?: boolean;
|
||||
prefillResponseData?: TResponseData;
|
||||
isRedirectDisabled?: boolean;
|
||||
responseCount?: number;
|
||||
supportEmail?: string | null;
|
||||
}
|
||||
|
||||
interface SurveyModalProps extends SurveyProps {
|
||||
placement: "topRight" | "bottomRight" | "bottomLeft" | "topLeft" | "center";
|
||||
clickOutside: boolean;
|
||||
darkOverlay: boolean;
|
||||
highlightBorderColor: string | null;
|
||||
}
|
||||
|
||||
export const SurveyInline = (props: SurveyProps) => {
|
||||
export const SurveyInline = (props: Omit<SurveyInlineProps & { brandColor: string }, "containerId">) => {
|
||||
const containerId = useMemo(() => createContainerId(), []);
|
||||
useEffect(() => {
|
||||
renderSurveyInline({
|
||||
@@ -45,7 +16,7 @@ export const SurveyInline = (props: SurveyProps) => {
|
||||
return <div id={containerId} className="h-full w-full" />;
|
||||
};
|
||||
|
||||
export const SurveyModal = (props: SurveyModalProps) => {
|
||||
export const SurveyModal = (props: SurveyModalProps & { brandColor: string }) => {
|
||||
useEffect(() => {
|
||||
renderSurveyModal(props);
|
||||
}, [props]);
|
||||
|
||||
@@ -28,12 +28,16 @@ export default function SurveysList({
|
||||
userId,
|
||||
}: SurveysListProps) {
|
||||
const [filteredSurveys, setFilteredSurveys] = useState<TSurvey[]>(surveys);
|
||||
// Initialize orientation state from localStorage or default to 'grid'
|
||||
const [orientation, setOrientation] = useState(() => localStorage.getItem("surveyOrientation") || "grid");
|
||||
// Initialize orientation state with a function that checks if window is defined
|
||||
const [orientation, setOrientation] = useState(() =>
|
||||
typeof window !== "undefined" ? localStorage.getItem("surveyOrientation") || "grid" : "grid"
|
||||
);
|
||||
|
||||
// Save orientation to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem("surveyOrientation", orientation);
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("surveyOrientation", orientation);
|
||||
}
|
||||
}, [orientation]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -119,6 +119,7 @@
|
||||
"S3_SECRET_KEY",
|
||||
"S3_REGION",
|
||||
"S3_BUCKET_NAME",
|
||||
"S3_ENDPOINT_URL",
|
||||
"SENTRY_DSN",
|
||||
"SHORT_URL_BASE",
|
||||
"SIGNUP_DISABLED",
|
||||
|
||||
Reference in New Issue
Block a user