diff --git a/.env.example b/.env.example index 2f722a67d2..e67e91976f 100644 --- a/.env.example +++ b/.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 # ##################### diff --git a/apps/formbricks-com/app/docs/self-hosting/external-auth-providers/page.mdx b/apps/formbricks-com/app/docs/self-hosting/external-auth-providers/page.mdx index 92366fa462..7537a7480b 100644 --- a/apps/formbricks-com/app/docs/self-hosting/external-auth-providers/page.mdx +++ b/apps/formbricks-com/app/docs/self-hosting/external-auth-providers/page.mdx @@ -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 diff --git a/apps/formbricks-com/pages/api/oss-friends/index.ts b/apps/formbricks-com/pages/api/oss-friends/index.ts index 2805136d10..bccd11f304 100644 --- a/apps/formbricks-com/pages/api/oss-friends/index.ts +++ b/apps/formbricks-com/pages/api/oss-friends/index.ts @@ -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", diff --git a/apps/formbricks-com/pages/feature-chaser/index.tsx b/apps/formbricks-com/pages/feature-chaser/index.tsx index 59d0919499..bea06fd642 100644 --- a/apps/formbricks-com/pages/feature-chaser/index.tsx +++ b/apps/formbricks-com/pages/feature-chaser/index.tsx @@ -26,7 +26,7 @@ export default function FeatureChaserPage() {

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.

diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/billing/layout.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/billing/layout.tsx new file mode 100644 index 0000000000..8d20a6d4cc --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/billing/layout.tsx @@ -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} : }; +} diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/billing/page.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/billing/page.tsx index cc97c5a210..da026b34ca 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/billing/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/billing/page.tsx @@ -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 ( <>
- {!isPricingDisabled ? ( - - ) : ( - - )} +
); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/billing/unlimited/page.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/billing/unlimited/page.tsx index cdef01cb6e..d756971357 100644 --- a/apps/web/app/(app)/environments/[environmentId]/settings/billing/unlimited/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/settings/billing/unlimited/page.tsx @@ -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"); diff --git a/apps/web/app/(app)/environments/[environmentId]/settings/billing/unlimited99/page.tsx b/apps/web/app/(app)/environments/[environmentId]/settings/billing/unlimited99/page.tsx new file mode 100644 index 0000000000..1d761aca55 --- /dev/null +++ b/apps/web/app/(app)/environments/[environmentId]/settings/billing/unlimited99/page.tsx @@ -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"); + } +} diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.tsx index 99736233a6..1a99c6a336 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponsePage.tsx @@ -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(); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter.tsx index 57d45d4f0b..100dea4514 100755 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter.tsx @@ -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(null); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox.tsx index f6ac53b6af..72e58d245a 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionFilterComboBox.tsx @@ -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 diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox.tsx index ee34245f85..92b6e0816c 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/QuestionsComboBox.tsx @@ -5,6 +5,7 @@ import { CursorArrowRippleIcon, HashtagIcon, ListBulletIcon, + PhotoIcon, QuestionMarkCircleIcon, QueueListIcon, StarIcon, @@ -60,6 +61,8 @@ const SelectedCommandItem = ({ label, questionType, type }: Partial; case TSurveyQuestionType.Consent: return ; + case TSurveyQuestionType.PictureSelection: + return ; } } if (type === OptionsType.ATTRIBUTES) { diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/components/Modal.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/components/Modal.tsx index 7f66533c6e..11f5374014 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/components/Modal.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/components/Modal.tsx @@ -52,7 +52,7 @@ export default function Modal({ return { transform: `scale(${scaleValue})`, - "transform-origin": placementClass, + transformOrigin: placementClass, }; }; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/components/PreviewSurvey.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/components/PreviewSurvey.tsx index aba7be092f..06c2b98698 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/components/PreviewSurvey.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/components/PreviewSurvey.tsx @@ -241,8 +241,9 @@ export default function PreviewSurvey({
-

- {previewType === "modal" ? "Your web app" : "Preview"} +

+

{previewType === "modal" ? "Your web app" : "Preview"}

+
{isFullScreenPreview ? (
-

+
{previewType === "modal" ? ( diff --git a/apps/web/app/(auth)/auth/login/components/SigninForm.tsx b/apps/web/app/(auth)/auth/login/components/SigninForm.tsx index 235868497d..e9f585f0b4 100644 --- a/apps/web/app/(auth)/auth/login/components/SigninForm.tsx +++ b/apps/web/app/(auth)/auth/login/components/SigninForm.tsx @@ -241,7 +241,7 @@ export const SigninForm = ({ New to Formbricks?
Create an account diff --git a/apps/web/app/health/page.tsx b/apps/web/app/health/page.tsx index ef7458ae48..80935f6ae9 100644 --- a/apps/web/app/health/page.tsx +++ b/apps/web/app/health/page.tsx @@ -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 (
diff --git a/apps/web/app/lib/surveys/surveys.ts b/apps/web/app/lib/surveys/surveys.ts index 96643507d0..3f12edfb7e 100644 --- a/apps/web/app/lib/surveys/surveys.ts +++ b/apps/web/app/lib/surveys/surveys.ts @@ -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, + }; + } + } } }); } diff --git a/apps/web/app/s/[surveyId]/components/LinkSurvey.tsx b/apps/web/app/s/[surveyId]/components/LinkSurvey.tsx index 6d6136f414..0257c27572 100644 --- a/apps/web/app/s/[surveyId]/components/LinkSurvey.tsx +++ b/apps/web/app/s/[surveyId]/components/LinkSurvey.tsx @@ -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(); diff --git a/packages/ee/billing/handlers/checkoutSessionCompleted.ts b/packages/ee/billing/handlers/checkoutSessionCompleted.ts index dc3d8879e4..f40008215c 100644 --- a/packages/ee/billing/handlers/checkoutSessionCompleted.ts +++ b/packages/ee/billing/handlers/checkoutSessionCompleted.ts @@ -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); diff --git a/packages/ee/billing/handlers/subscriptionCreatedOrUpdated.ts b/packages/ee/billing/handlers/subscriptionCreatedOrUpdated.ts index 155932c410..348ad7b24f 100644 --- a/packages/ee/billing/handlers/subscriptionCreatedOrUpdated.ts +++ b/packages/ee/billing/handlers/subscriptionCreatedOrUpdated.ts @@ -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( diff --git a/packages/ee/billing/lib/constants.ts b/packages/ee/billing/lib/constants.ts index da145ae452..1dfcac0801 100644 --- a/packages/ee/billing/lib/constants.ts +++ b/packages/ee/billing/lib/constants.ts @@ -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", } diff --git a/packages/js/src/lib/widget.ts b/packages/js/src/lib/widget.ts index 054ab65ee4..dd4b47ab78 100644 --- a/packages/js/src/lib/widget.ts +++ b/packages/js/src/lib/widget.ts @@ -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 diff --git a/packages/lib/constants.ts b/packages/lib/constants.ts index 96810611d2..d93f019d14 100644 --- a/packages/lib/constants.ts +++ b/packages/lib/constants.ts @@ -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 = { diff --git a/packages/lib/env.ts b/packages/lib/env.ts index 00d1e7107a..04e3a3a0e5 100644 --- a/packages/lib/env.ts +++ b/packages/lib/env.ts @@ -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, diff --git a/packages/lib/responseQueue.ts b/packages/lib/responseQueue.ts index ed2b96bb03..f142027d7c 100644 --- a/packages/lib/responseQueue.ts +++ b/packages/lib/responseQueue.ts @@ -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 } diff --git a/packages/lib/storage/service.ts b/packages/lib/storage/service.ts index 97cebcdfb9..4bd1b19e33 100644 --- a/packages/lib/storage/service.ts +++ b/packages/lib/storage/service.ts @@ -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 => { }); 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, diff --git a/packages/surveys/src/components/general/Survey.tsx b/packages/surveys/src/components/general/Survey.tsx index ae752ace7d..e9b207b7bb 100644 --- a/packages/surveys/src/components/general/Survey.tsx +++ b/packages/surveys/src/components/general/Survey.tsx @@ -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([]); const [responseData, setResponseData] = useState({}); @@ -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 ( { - 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 (
@@ -66,7 +68,7 @@ export default function ThankYouCard({ - {buttonLabel && ( + {buttonLabel && isResponseSendingFinished && (
- )} - { - setText(recallToHeadline(e.target.value ?? "", localSurvey, false)); - updateQuestionDetails(headlineToRecall(e.target.value, recallQuestions, fallbacks)); + return ( +
+ +
+ {showImageUploader && type === "headline" && ( + { + 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 && ( - +
+
+
+ {renderedText} +
+ {getQuestionTextBasedOnType().includes("recall:") && ( + + )} + | 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 && ( + + )} +
+ {type === "headline" && ( + setShowImageUploader((prev) => !prev)} /> )}
- {type === "headline" && ( - setShowImageUploader((prev) => !prev)} - /> - )}
+ {showQuestionSelect && ( + + )}
- {showQuestionSelect && ( - - )} -
- ); -}; + ); + } +); +QuestionFormInput.displayName = "QuestionFormInput"; + export default QuestionFormInput; diff --git a/packages/ui/Survey/index.tsx b/packages/ui/Survey/index.tsx index c5b388ed50..9aca02cf78 100644 --- a/packages/ui/Survey/index.tsx +++ b/packages/ui/Survey/index.tsx @@ -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; - 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) => { const containerId = useMemo(() => createContainerId(), []); useEffect(() => { renderSurveyInline({ @@ -45,7 +16,7 @@ export const SurveyInline = (props: SurveyProps) => { return
; }; -export const SurveyModal = (props: SurveyModalProps) => { +export const SurveyModal = (props: SurveyModalProps & { brandColor: string }) => { useEffect(() => { renderSurveyModal(props); }, [props]); diff --git a/packages/ui/SurveysList/index.tsx b/packages/ui/SurveysList/index.tsx index a9188ce1dc..01b20fafa2 100644 --- a/packages/ui/SurveysList/index.tsx +++ b/packages/ui/SurveysList/index.tsx @@ -28,12 +28,16 @@ export default function SurveysList({ userId, }: SurveysListProps) { const [filteredSurveys, setFilteredSurveys] = useState(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 ( diff --git a/turbo.json b/turbo.json index 51cfba1ce9..ab63374c6a 100644 --- a/turbo.json +++ b/turbo.json @@ -119,6 +119,7 @@ "S3_SECRET_KEY", "S3_REGION", "S3_BUCKET_NAME", + "S3_ENDPOINT_URL", "SENTRY_DSN", "SHORT_URL_BASE", "SIGNUP_DISABLED",