mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-18 19:41:17 -05:00
Move Templates Page to server components and new services (#488)
* [ADD] types * [ADD] methods * [Rearrange] with-server-component * [FIX]server-methods,[ADD]preview-survey-server * [FIX] zod-types-normalized * pnpm format * [FIX] build-error-due-to-cascade * [RM] PreviewSurveyServer * [TMP] * [CHANGE] file-name-hack --------- Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
@@ -1,14 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import FormbricksSignature from "@/components/preview/FormbricksSignature";
|
||||
import Modal from "@/components/preview/Modal";
|
||||
import Progress from "@/components/preview/Progress";
|
||||
import QuestionConditional from "@/components/preview/QuestionConditional";
|
||||
import ThankYouCard from "@/components/preview/ThankYouCard";
|
||||
import { useEnvironment } from "@/lib/environments/environments";
|
||||
import { useProduct } from "@/lib/products/products";
|
||||
import type { Logic, Question } from "@formbricks/types/questions";
|
||||
import { Survey } from "@formbricks/types/surveys";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { TProduct } from "@formbricks/types/v1/product";
|
||||
import type { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
interface PreviewSurveyProps {
|
||||
setActiveQuestionId: (id: string | null) => void;
|
||||
activeQuestionId?: string | null;
|
||||
@@ -19,6 +20,8 @@ interface PreviewSurveyProps {
|
||||
thankYouCard: Survey["thankYouCard"];
|
||||
autoClose: Survey["autoClose"];
|
||||
previewType?: "modal" | "fullwidth" | "email";
|
||||
product: TProduct;
|
||||
environment: TEnvironment;
|
||||
}
|
||||
|
||||
export default function PreviewSurvey({
|
||||
@@ -26,15 +29,13 @@ export default function PreviewSurvey({
|
||||
activeQuestionId,
|
||||
questions,
|
||||
brandColor,
|
||||
environmentId,
|
||||
surveyType,
|
||||
thankYouCard,
|
||||
autoClose,
|
||||
previewType,
|
||||
product,
|
||||
environment,
|
||||
}: PreviewSurveyProps) {
|
||||
const { environment } = useEnvironment(environmentId);
|
||||
const { product } = useProduct(environmentId);
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = useState(true);
|
||||
const [progress, setProgress] = useState(0); // [0, 1]
|
||||
const [widgetSetupCompleted, setWidgetSetupCompleted] = useState(false);
|
||||
|
||||
@@ -32,12 +32,17 @@ import toast from "react-hot-toast";
|
||||
import TemplateList from "./templates/TemplateList";
|
||||
import { useEffect } from "react";
|
||||
import { changeEnvironment } from "@/lib/environments/changeEnvironments";
|
||||
import { TProduct } from "@formbricks/types/v1/product";
|
||||
|
||||
export default function SurveysList({ environmentId }) {
|
||||
interface SurveyListProps {
|
||||
environmentId: string;
|
||||
product: TProduct;
|
||||
}
|
||||
|
||||
export default function SurveysList({ environmentId, product }: SurveyListProps) {
|
||||
const router = useRouter();
|
||||
const { surveys, mutateSurveys, isLoadingSurveys, isErrorSurveys } = useSurveys(environmentId);
|
||||
const { environment, isErrorEnvironment, isLoadingEnvironment } = useEnvironment(environmentId);
|
||||
|
||||
const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [isCreateSurveyLoading, setIsCreateSurveyLoading] = useState(false);
|
||||
|
||||
@@ -133,6 +138,8 @@ export default function SurveysList({ environmentId }) {
|
||||
onTemplateClick={(template) => {
|
||||
newSurveyFromTemplate(template);
|
||||
}}
|
||||
environment={environment}
|
||||
product={product}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,7 @@ import SettingsView from "./SettingsView";
|
||||
import QuestionsAudienceTabs from "./QuestionsAudienceTabs";
|
||||
import QuestionsView from "./QuestionsView";
|
||||
import SurveyMenuBar from "./SurveyMenuBar";
|
||||
import { useEnvironment } from "@/lib/environments/environments";
|
||||
|
||||
interface SurveyEditorProps {
|
||||
environmentId: string;
|
||||
@@ -24,6 +25,7 @@ export default function SurveyEditor({ environmentId, surveyId }: SurveyEditorPr
|
||||
|
||||
const { survey, isLoadingSurvey, isErrorSurvey } = useSurvey(environmentId, surveyId);
|
||||
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
|
||||
const { environment, isLoadingEnvironment, isErrorEnvironment } = useEnvironment(environmentId);
|
||||
|
||||
useEffect(() => {
|
||||
if (survey) {
|
||||
@@ -37,11 +39,11 @@ export default function SurveyEditor({ environmentId, surveyId }: SurveyEditorPr
|
||||
}
|
||||
}, [survey]);
|
||||
|
||||
if (isLoadingSurvey || isLoadingProduct || !localSurvey) {
|
||||
if (isLoadingSurvey || isLoadingProduct || isLoadingEnvironment || !localSurvey) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (isErrorSurvey || isErrorProduct) {
|
||||
if (isErrorSurvey || isErrorProduct || isErrorEnvironment) {
|
||||
return <ErrorComponent />;
|
||||
}
|
||||
|
||||
@@ -81,6 +83,8 @@ export default function SurveyEditor({ environmentId, surveyId }: SurveyEditorPr
|
||||
questions={localSurvey.questions}
|
||||
brandColor={product.brandColor}
|
||||
environmentId={environmentId}
|
||||
product={product}
|
||||
environment={environment}
|
||||
surveyType={localSurvey.type}
|
||||
thankYouCard={localSurvey.thankYouCard}
|
||||
previewType={localSurvey.type === "web" ? "modal" : "fullwidth"}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import ContentWrapper from "@/components/shared/ContentWrapper";
|
||||
import WidgetStatusIndicator from "@/components/shared/WidgetStatusIndicator";
|
||||
import SurveysList from "./SurveyList";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
|
||||
|
||||
export default async function SurveysPage({ params }) {
|
||||
const environmentId = params.environmentId;
|
||||
const product = await getProductByEnvironmentId(environmentId);
|
||||
|
||||
return (
|
||||
<ContentWrapper className="flex h-full flex-col justify-between">
|
||||
<SurveysList environmentId={params.environmentId} />
|
||||
<SurveysList environmentId={params.environmentId} product={product} />
|
||||
<WidgetStatusIndicator environmentId={params.environmentId} type="mini" />
|
||||
</ContentWrapper>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Template } from "@formbricks/types/templates";
|
||||
import { useEffect } from "react";
|
||||
import { replacePresetPlaceholders } from "@/lib/templates";
|
||||
import { templates } from "./templates";
|
||||
import PreviewSurvey from "../PreviewSurvey";
|
||||
import TemplateList from "./TemplateList";
|
||||
import type { TProduct } from "@formbricks/types/v1/product";
|
||||
import type { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
|
||||
type TemplateContainerWithPreviewProps = {
|
||||
environmentId: string;
|
||||
product: TProduct;
|
||||
environment: TEnvironment;
|
||||
};
|
||||
|
||||
export default function TemplateContainerWithPreview({
|
||||
environmentId,
|
||||
product,
|
||||
environment,
|
||||
}: TemplateContainerWithPreviewProps) {
|
||||
const [activeTemplate, setActiveTemplate] = useState<Template | null>(null);
|
||||
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (product && templates?.length) {
|
||||
const newTemplate = replacePresetPlaceholders(templates[0], product);
|
||||
setActiveTemplate(newTemplate);
|
||||
setActiveQuestionId(newTemplate.preset.questions[0].id);
|
||||
}
|
||||
}, [product]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col ">
|
||||
<div className="relative z-0 flex flex-1 overflow-hidden">
|
||||
<div className="flex-1 flex-col overflow-auto bg-slate-50">
|
||||
<h1 className="ml-6 mt-6 text-2xl font-bold text-slate-800">Create a new survey</h1>
|
||||
<TemplateList
|
||||
environmentId={environmentId}
|
||||
environment={environment}
|
||||
product={product}
|
||||
onTemplateClick={(template) => {
|
||||
setActiveQuestionId(template.preset.questions[0].id);
|
||||
setActiveTemplate(template);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-100 bg-slate-50 md:flex md:flex-col">
|
||||
{activeTemplate && (
|
||||
<div className="my-6 flex h-full w-full flex-col items-center justify-center">
|
||||
<p className="pb-2 text-center text-sm font-normal text-slate-400">Preview</p>
|
||||
<PreviewSurvey
|
||||
activeQuestionId={activeQuestionId}
|
||||
questions={activeTemplate.preset.questions}
|
||||
brandColor={product.brandColor}
|
||||
product={product}
|
||||
environment={environment}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
environmentId={environmentId}
|
||||
surveyType={environment?.widgetSetupCompleted ? "web" : "link"}
|
||||
thankYouCard={{ enabled: true }}
|
||||
autoClose={null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import { useEnvironment } from "@/lib/environments/environments";
|
||||
import { useProduct } from "@/lib/products/products";
|
||||
import { useProfile } from "@/lib/profile";
|
||||
import { createSurvey } from "@/lib/surveys/surveys";
|
||||
import { replacePresetPlaceholders } from "@/lib/templates";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
@@ -16,24 +12,26 @@ import { useEffect, useState } from "react";
|
||||
import { customSurvey, templates } from "./templates";
|
||||
import { SplitIcon } from "lucide-react";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui";
|
||||
import type { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
import type { TProduct } from "@formbricks/types/v1/product";
|
||||
import { useProfile } from "@/lib/profile";
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
|
||||
type TemplateList = {
|
||||
environmentId: string;
|
||||
onTemplateClick: (template: Template) => void;
|
||||
environment: TEnvironment;
|
||||
product: TProduct;
|
||||
};
|
||||
|
||||
const ALL_CATEGORY_NAME = "All";
|
||||
const RECOMMENDED_CATEGORY_NAME = "For you";
|
||||
|
||||
export default function TemplateList({ environmentId, onTemplateClick }: TemplateList) {
|
||||
export default function TemplateList({ environmentId, onTemplateClick, product, environment }: TemplateList) {
|
||||
const router = useRouter();
|
||||
|
||||
const [activeTemplate, setActiveTemplate] = useState<Template | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
|
||||
const { profile, isLoadingProfile, isErrorProfile } = useProfile();
|
||||
const { environment } = useEnvironment(environmentId);
|
||||
const [selectedFilter, setSelectedFilter] = useState(RECOMMENDED_CATEGORY_NAME);
|
||||
const { profile, isLoadingProfile, isErrorProfile } = useProfile();
|
||||
|
||||
const [categories, setCategories] = useState<Array<string>>([]);
|
||||
|
||||
@@ -65,8 +63,8 @@ export default function TemplateList({ environmentId, onTemplateClick }: Templat
|
||||
router.push(`/environments/${environmentId}/surveys/${survey.id}/edit`);
|
||||
};
|
||||
|
||||
if (isLoadingProduct || isLoadingProfile) return <LoadingSpinner />;
|
||||
if (isErrorProduct || isErrorProfile) return <ErrorComponent />;
|
||||
if (isLoadingProfile) return <LoadingSpinner />;
|
||||
if (isErrorProfile) return <ErrorComponent />;
|
||||
|
||||
return (
|
||||
<main className="relative z-0 flex-1 overflow-y-auto px-6 pb-6 pt-3 focus:outline-none">
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
|
||||
export default function LoadingPage() {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
@@ -1,67 +1,13 @@
|
||||
"use client";
|
||||
import TemplateContainerWithPreview from "./TemplateContainer";
|
||||
import { getEnvironment } from "@formbricks/lib/services/environment";
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
|
||||
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import { useEnvironment } from "@/lib/environments/environments";
|
||||
import { useProduct } from "@/lib/products/products";
|
||||
import { replacePresetPlaceholders } from "@/lib/templates";
|
||||
import type { Template } from "@formbricks/types/templates";
|
||||
import { ErrorComponent } from "@formbricks/ui";
|
||||
import { useEffect, useState } from "react";
|
||||
import PreviewSurvey from "../PreviewSurvey";
|
||||
import TemplateList from "./TemplateList";
|
||||
import { templates } from "./templates";
|
||||
|
||||
export default function SurveyTemplatesPage({ params }) {
|
||||
export default async function SurveyTemplatesPage({ params }) {
|
||||
const environmentId = params.environmentId;
|
||||
const [activeTemplate, setActiveTemplate] = useState<Template | null>(null);
|
||||
const { environment, isLoadingEnvironment, isErrorEnvironment } = useEnvironment(environmentId);
|
||||
|
||||
const [activeQuestionId, setActiveQuestionId] = useState<string | null>(null);
|
||||
|
||||
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
|
||||
|
||||
useEffect(() => {
|
||||
if (product && templates?.length) {
|
||||
const newTemplate = replacePresetPlaceholders(templates[0], product);
|
||||
setActiveTemplate(newTemplate);
|
||||
setActiveQuestionId(newTemplate.preset.questions[0].id);
|
||||
}
|
||||
}, [product]);
|
||||
|
||||
if (isLoadingProduct || isLoadingEnvironment) return <LoadingSpinner />;
|
||||
if (isErrorProduct || isErrorEnvironment) return <ErrorComponent />;
|
||||
const environment = await getEnvironment(environmentId);
|
||||
const product = await getProductByEnvironmentId(environmentId);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col ">
|
||||
<div className="relative z-0 flex flex-1 overflow-hidden">
|
||||
<div className="flex-1 flex-col overflow-auto bg-slate-50">
|
||||
<h1 className="ml-6 mt-6 text-2xl font-bold text-slate-800">Create a new survey</h1>
|
||||
<TemplateList
|
||||
environmentId={environmentId}
|
||||
onTemplateClick={(template) => {
|
||||
setActiveQuestionId(template.preset.questions[0].id);
|
||||
setActiveTemplate(template);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<aside className="group hidden flex-1 flex-shrink-0 items-center justify-center overflow-hidden border-l border-slate-100 bg-slate-50 md:flex md:flex-col">
|
||||
{activeTemplate && (
|
||||
<div className="my-6 flex h-full w-full flex-col items-center justify-center">
|
||||
<p className="pb-2 text-center text-sm font-normal text-slate-400">Preview</p>
|
||||
<PreviewSurvey
|
||||
activeQuestionId={activeQuestionId}
|
||||
questions={activeTemplate.preset.questions}
|
||||
brandColor={product.brandColor}
|
||||
setActiveQuestionId={setActiveQuestionId}
|
||||
environmentId={environmentId}
|
||||
surveyType={environment?.widgetSetupCompleted ? "web" : "link"}
|
||||
thankYouCard={{ enabled: true }}
|
||||
autoClose={null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
<TemplateContainerWithPreview environmentId={environmentId} environment={environment} product={product} />
|
||||
);
|
||||
}
|
||||
|
||||
38
packages/lib/services/environment.ts
Normal file
38
packages/lib/services/environment.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import "server-only";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { z } from "zod";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/errors";
|
||||
import { ZEnvironment } from "@formbricks/types/v1/environment";
|
||||
import type { TEnvironment } from "@formbricks/types/v1/environment";
|
||||
import { cache } from "react";
|
||||
export const getEnvironment = cache(async (environmentId: string): Promise<TEnvironment | null> => {
|
||||
let environmentPrisma;
|
||||
try {
|
||||
environmentPrisma = await prisma.environment.findUnique({
|
||||
where: {
|
||||
id: environmentId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!environmentPrisma) {
|
||||
throw new ResourceNotFoundError("Environment", environmentId);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError("Database operation failed");
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const environment = ZEnvironment.parse(environmentPrisma);
|
||||
return environment;
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
console.error(JSON.stringify(error.errors, null, 2));
|
||||
}
|
||||
throw new ValidationError("Data validation of environment failed");
|
||||
}
|
||||
});
|
||||
43
packages/lib/services/product.ts
Normal file
43
packages/lib/services/product.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import "server-only";
|
||||
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { z } from "zod";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/errors";
|
||||
import { ZProduct } from "@formbricks/types/v1/product";
|
||||
import type { TProduct } from "@formbricks/types/v1/product";
|
||||
import { cache } from "react";
|
||||
|
||||
export const getProductByEnvironmentId = cache(async (environmentId: string): Promise<TProduct> => {
|
||||
let productPrisma;
|
||||
try {
|
||||
productPrisma = await prisma.product.findFirst({
|
||||
where: {
|
||||
environments: {
|
||||
some: {
|
||||
id: environmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!productPrisma) {
|
||||
throw new ResourceNotFoundError("Product for Environment", environmentId);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError("Database operation failed");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
const product = ZProduct.parse(productPrisma);
|
||||
return product;
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
console.error(JSON.stringify(error.errors, null, 2));
|
||||
}
|
||||
throw new ValidationError("Data validation of product failed");
|
||||
}
|
||||
});
|
||||
12
packages/types/v1/environment.ts
Normal file
12
packages/types/v1/environment.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZEnvironment: any = z.object({
|
||||
id: z.string().cuid2(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
type: z.enum(["development", "production"]),
|
||||
productId: z.string(),
|
||||
widgetSetupCompleted: z.boolean(),
|
||||
});
|
||||
|
||||
export type TEnvironment = z.infer<typeof ZEnvironment>;
|
||||
17
packages/types/v1/product.ts
Normal file
17
packages/types/v1/product.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZProduct = z.object({
|
||||
id: z.string().cuid2(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
name: z.string(),
|
||||
teamId: z.string(),
|
||||
brandColor: z.string().regex(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/),
|
||||
recontactDays: z.number().int(),
|
||||
formbricksSignature: z.boolean(),
|
||||
placement: z.enum(["bottomLeft", "bottomRight", "topLeft", "topRight", "center"]),
|
||||
clickOutsideClose: z.boolean(),
|
||||
darkOverlay: z.boolean(),
|
||||
});
|
||||
|
||||
export type TProduct = z.infer<typeof ZProduct>;
|
||||
Reference in New Issue
Block a user