mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-05 21:32:02 -06:00
Move Look & Feel Settings to React Server Components (#672)
* feat: migrate look and feel to serverside component with loading screen * fix: use existing product type instead of creating a custom type * fix: make improvements as Matti suggested * change attributes order in updateProduct function * run pnpm format --------- Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
committed by
GitHub
parent
09436c78fc
commit
c6686209be
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { TProduct, TProductUpdateInput } from "@formbricks/types/v1/product";
|
||||
import { Button, ColorPicker, Label } from "@formbricks/ui";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { updateProductAction } from "./actions";
|
||||
|
||||
interface EditBrandColorProps {
|
||||
product: TProduct;
|
||||
}
|
||||
|
||||
export function EditBrandColor({ product }: EditBrandColorProps) {
|
||||
const [color, setColor] = useState(product.brandColor);
|
||||
const [updatingColor, setUpdatingColor] = useState(false);
|
||||
|
||||
const handleUpdateBrandColor = async () => {
|
||||
try {
|
||||
setUpdatingColor(true);
|
||||
let inputProduct: Partial<TProductUpdateInput> = {
|
||||
brandColor: color,
|
||||
};
|
||||
await updateProductAction(product.id, inputProduct);
|
||||
toast.success("Brand color updated successfully.");
|
||||
} catch (error) {
|
||||
toast.error(`Error: ${error.message}`);
|
||||
} finally {
|
||||
setUpdatingColor(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-sm items-center">
|
||||
<Label htmlFor="brandcolor">Color (HEX)</Label>
|
||||
<ColorPicker color={color} onChange={setColor} />
|
||||
<Button variant="darkCTA" className="mt-4" loading={updatingColor} onClick={handleUpdateBrandColor}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { Button, ColorPicker, Label, Switch } from "@formbricks/ui";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { DEFAULT_BRAND_COLOR } from "@formbricks/lib/constants";
|
||||
import { TProduct, TProductUpdateInput } from "@formbricks/types/v1/product";
|
||||
import { updateProductAction } from "./actions";
|
||||
|
||||
interface EditHighlightBorderProps {
|
||||
product: TProduct;
|
||||
}
|
||||
|
||||
export const EditHighlightBorder = ({ product }: EditHighlightBorderProps) => {
|
||||
const [showHighlightBorder, setShowHighlightBorder] = useState(product.highlightBorderColor ? true : false);
|
||||
const [color, setColor] = useState<string | null>(product.highlightBorderColor || DEFAULT_BRAND_COLOR);
|
||||
const [updatingBorder, setUpdatingBorder] = useState(false);
|
||||
|
||||
const handleUpdateHighlightBorder = async () => {
|
||||
try {
|
||||
setUpdatingBorder(true);
|
||||
let inputProduct: Partial<TProductUpdateInput> = {
|
||||
highlightBorderColor: color,
|
||||
};
|
||||
await updateProductAction(product.id, inputProduct);
|
||||
toast.success("Border color updated successfully.");
|
||||
} catch (error) {
|
||||
toast.error(`Error: ${error.message}`);
|
||||
} finally {
|
||||
setUpdatingBorder(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwitch = (checked: boolean) => {
|
||||
if (checked) {
|
||||
if (!color) {
|
||||
setColor(DEFAULT_BRAND_COLOR);
|
||||
setShowHighlightBorder(true);
|
||||
} else {
|
||||
setShowHighlightBorder(true);
|
||||
}
|
||||
} else {
|
||||
setShowHighlightBorder(false);
|
||||
setColor(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full w-full">
|
||||
<div className="flex w-1/2 flex-col px-6 py-5">
|
||||
<div className="mb-6 flex items-center space-x-2">
|
||||
<Switch id="highlightBorder" checked={showHighlightBorder} onCheckedChange={handleSwitch} />
|
||||
<h2 className="text-sm font-medium text-slate-800">Show highlight border</h2>
|
||||
</div>
|
||||
|
||||
{showHighlightBorder && color ? (
|
||||
<>
|
||||
<Label htmlFor="brandcolor">Color (HEX)</Label>
|
||||
<ColorPicker color={color} onChange={setColor} />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
className="mt-4 flex max-w-[80px] items-center justify-center"
|
||||
loading={updatingBorder}
|
||||
onClick={handleUpdateHighlightBorder}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex w-1/2 flex-col items-center justify-center gap-4 bg-slate-200 px-6 py-5">
|
||||
<h3 className="text-slate-500">Preview</h3>
|
||||
<div
|
||||
className={cn("flex flex-col gap-4 rounded-lg border-2 bg-white p-5")}
|
||||
{...(showHighlightBorder &&
|
||||
color && {
|
||||
style: {
|
||||
borderColor: color,
|
||||
},
|
||||
})}>
|
||||
<h3 className="text-sm font-semibold text-slate-800">How easy was it for you to do this?</h3>
|
||||
<div className="flex rounded-2xl border border-slate-400">
|
||||
{[1, 2, 3, 4, 5].map((num) => (
|
||||
<div key={num} className="border-r border-slate-400 px-6 py-5 last:border-r-0">
|
||||
<span className="text-sm font-medium">{num}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { Button, Label, RadioGroup, RadioGroupItem } from "@formbricks/ui";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { getPlacementStyle } from "@/lib/preview";
|
||||
import { PlacementType } from "@formbricks/types/js";
|
||||
import { TProduct, TProductUpdateInput } from "@formbricks/types/v1/product";
|
||||
import { updateProductAction } from "./actions";
|
||||
|
||||
const placements = [
|
||||
{ name: "Bottom Right", value: "bottomRight", disabled: false },
|
||||
{ name: "Top Right", value: "topRight", disabled: false },
|
||||
{ name: "Top Left", value: "topLeft", disabled: false },
|
||||
{ name: "Bottom Left", value: "bottomLeft", disabled: false },
|
||||
{ name: "Centered Modal", value: "center", disabled: false },
|
||||
];
|
||||
|
||||
interface EditPlacementProps {
|
||||
product: TProduct;
|
||||
}
|
||||
|
||||
export function EditPlacement({ product }: EditPlacementProps) {
|
||||
const [currentPlacement, setCurrentPlacement] = useState<PlacementType>(product.placement);
|
||||
const [overlay, setOverlay] = useState(product.darkOverlay ? "darkOverlay" : "lightOverlay");
|
||||
const [clickOutside, setClickOutside] = useState(product.clickOutsideClose ? "allow" : "disallow");
|
||||
const [updatingPlacement, setUpdatingPlacement] = useState(false);
|
||||
|
||||
const handleUpdatePlacement = async () => {
|
||||
try {
|
||||
setUpdatingPlacement(true);
|
||||
let inputProduct: Partial<TProductUpdateInput> = {
|
||||
placement: currentPlacement,
|
||||
darkOverlay: overlay === "darkOverlay",
|
||||
clickOutsideClose: clickOutside === "allow",
|
||||
};
|
||||
await updateProductAction(product.id, inputProduct);
|
||||
toast.success("Placement updated successfully.");
|
||||
} catch (error) {
|
||||
toast.error(`Error: ${error.message}`);
|
||||
} finally {
|
||||
setUpdatingPlacement(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full items-center">
|
||||
<div className="flex">
|
||||
<RadioGroup onValueChange={(e) => setCurrentPlacement(e as PlacementType)} value={currentPlacement}>
|
||||
{placements.map((placement) => (
|
||||
<div key={placement.value} className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id={placement.value} value={placement.value} disabled={placement.disabled} />
|
||||
<Label
|
||||
htmlFor={placement.value}
|
||||
className={cn(placement.disabled ? "cursor-not-allowed text-slate-500" : "text-slate-900")}>
|
||||
{placement.name}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
<div className="relative ml-8 h-40 w-full rounded bg-slate-200">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute h-16 w-16 rounded bg-slate-700",
|
||||
getPlacementStyle(currentPlacement)
|
||||
)}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentPlacement === "center" && (
|
||||
<>
|
||||
<div className="mt-6 space-y-2">
|
||||
<Label className="font-semibold">Centered modal overlay color</Label>
|
||||
<RadioGroup onValueChange={(e) => setOverlay(e)} value={overlay} className="flex space-x-4">
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="lightOverlay" value="lightOverlay" />
|
||||
<Label htmlFor="lightOverlay" className="text-slate-900">
|
||||
Light Overlay
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="darkOverlay" value="darkOverlay" />
|
||||
<Label htmlFor="darkOverlay" className="text-slate-900">
|
||||
Dark Overlay
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<div className="mt-6 space-y-2">
|
||||
<Label className="font-semibold">Allow users to exit by clicking outside the study</Label>
|
||||
<RadioGroup
|
||||
onValueChange={(e) => setClickOutside(e)}
|
||||
value={clickOutside}
|
||||
className="flex space-x-4">
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="disallow" value="disallow" />
|
||||
<Label htmlFor="disallow" className="text-slate-900">
|
||||
Don't Allow
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="allow" value="allow" />
|
||||
<Label htmlFor="allow" className="text-slate-900">
|
||||
Allow
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Button variant="darkCTA" className="mt-4" loading={updatingPlacement} onClick={handleUpdatePlacement}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { updateProductAction } from "./actions";
|
||||
import { TProduct, TProductUpdateInput } from "@formbricks/types/v1/product";
|
||||
import { Label, Switch } from "@formbricks/ui";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
interface EditSignatureProps {
|
||||
product: TProduct;
|
||||
}
|
||||
|
||||
export function EditFormbricksSignature({ product }: EditSignatureProps) {
|
||||
const [formbricksSignature, setFormbricksSignature] = useState(product.formbricksSignature);
|
||||
const [updatingSignature, setUpdatingSignature] = useState(false);
|
||||
|
||||
const toggleSignature = async () => {
|
||||
try {
|
||||
setUpdatingSignature(true);
|
||||
const newSignatureState = !formbricksSignature;
|
||||
setFormbricksSignature(newSignatureState);
|
||||
let inputProduct: Partial<TProductUpdateInput> = {
|
||||
formbricksSignature: newSignatureState,
|
||||
};
|
||||
await updateProductAction(product.id, inputProduct);
|
||||
toast.success(
|
||||
newSignatureState ? "Formbricks signature will be shown." : "Formbricks signature will now be hidden."
|
||||
);
|
||||
} catch (error) {
|
||||
toast.error(`Error: ${error.message}`);
|
||||
} finally {
|
||||
setUpdatingSignature(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full items-center">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="signature"
|
||||
checked={formbricksSignature}
|
||||
onCheckedChange={toggleSignature}
|
||||
disabled={updatingSignature}
|
||||
/>
|
||||
<Label htmlFor="signature">Show 'Powered by Formbricks' Signature</Label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
"use server";
|
||||
|
||||
import { updateProduct } from "@formbricks/lib/services/product";
|
||||
import { TProductUpdateInput } from "@formbricks/types/v1/product";
|
||||
|
||||
export async function updateProductAction(productId: string, inputProduct: Partial<TProductUpdateInput>) {
|
||||
return await updateProduct(productId, inputProduct);
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import LoadingSpinner from "@/components/shared/LoadingSpinner";
|
||||
import { useEnvironment } from "@/lib/environments/environments";
|
||||
import { useProductMutation } from "@/lib/products/mutateProducts";
|
||||
import { useProduct } from "@/lib/products/products";
|
||||
import {
|
||||
Button,
|
||||
ColorPicker,
|
||||
ErrorComponent,
|
||||
Label,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
Switch,
|
||||
} from "@formbricks/ui";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { getPlacementStyle } from "@/lib/preview";
|
||||
import { PlacementType } from "@formbricks/types/js";
|
||||
import { DEFAULT_BRAND_COLOR } from "@formbricks/lib/constants";
|
||||
|
||||
export function EditBrandColor({ environmentId }) {
|
||||
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
|
||||
const { triggerProductMutate, isMutatingProduct } = useProductMutation(environmentId);
|
||||
|
||||
const [color, setColor] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (product) setColor(product.brandColor);
|
||||
}, [product]);
|
||||
|
||||
if (isLoadingProduct) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
if (isErrorProduct) {
|
||||
return <div>Error</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-sm items-center">
|
||||
<Label htmlFor="brandcolor">Color (HEX)</Label>
|
||||
<ColorPicker color={color} onChange={setColor} />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="darkCTA"
|
||||
className="mt-4"
|
||||
loading={isMutatingProduct}
|
||||
onClick={() => {
|
||||
triggerProductMutate({ brandColor: color })
|
||||
.then(() => {
|
||||
toast.success("Brand color updated successfully.");
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(`Error: ${error.message}`);
|
||||
});
|
||||
}}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditPlacement({ environmentId }) {
|
||||
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
|
||||
const { triggerProductMutate, isMutatingProduct } = useProductMutation(environmentId);
|
||||
|
||||
const [currentPlacement, setCurrentPlacement] = useState<PlacementType>("bottomRight");
|
||||
const [overlay, setOverlay] = useState("");
|
||||
const [clickOutside, setClickOutside] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (product) {
|
||||
setCurrentPlacement(product.placement);
|
||||
setOverlay(product.darkOverlay ? "darkOverlay" : "lightOverlay");
|
||||
setClickOutside(product.clickOutsideClose ? "allow" : "disallow");
|
||||
}
|
||||
}, [product]);
|
||||
|
||||
if (isLoadingProduct) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
if (isErrorProduct) {
|
||||
return <ErrorComponent />;
|
||||
}
|
||||
|
||||
const placements = [
|
||||
{ name: "Bottom Right", value: "bottomRight", disabled: false },
|
||||
{ name: "Top Right", value: "topRight", disabled: false },
|
||||
{ name: "Top Left", value: "topLeft", disabled: false },
|
||||
{ name: "Bottom Left", value: "bottomLeft", disabled: false },
|
||||
{ name: "Centered Modal", value: "center", disabled: false },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="w-full items-center">
|
||||
<div className="flex">
|
||||
<RadioGroup onValueChange={(e) => setCurrentPlacement(e as PlacementType)} value={currentPlacement}>
|
||||
{placements.map((placement) => (
|
||||
<div key={placement.value} className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id={placement.value} value={placement.value} disabled={placement.disabled} />
|
||||
<Label
|
||||
htmlFor={placement.value}
|
||||
className={cn(placement.disabled ? "cursor-not-allowed text-slate-500" : "text-slate-900")}>
|
||||
{placement.name}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
<div className="relative ml-8 h-40 w-full rounded bg-slate-200">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute h-16 w-16 rounded bg-slate-700",
|
||||
getPlacementStyle(currentPlacement)
|
||||
)}></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentPlacement === "center" && (
|
||||
<>
|
||||
<div className="mt-6 space-y-2">
|
||||
<Label className="font-semibold">Centered modal overlay color</Label>
|
||||
<RadioGroup onValueChange={(e) => setOverlay(e)} value={overlay} className="flex space-x-4">
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="lightOverlay" value="lightOverlay" />
|
||||
<Label htmlFor="lightOverlay" className="text-slate-900">
|
||||
Light Overlay
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="darkOverlay" value="darkOverlay" />
|
||||
<Label htmlFor="darkOverlay" className="text-slate-900">
|
||||
Dark Overlay
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<div className="mt-6 space-y-2">
|
||||
<Label className="font-semibold">Allow users to exit by clicking outside the study</Label>
|
||||
<RadioGroup
|
||||
onValueChange={(e) => setClickOutside(e)}
|
||||
value={clickOutside}
|
||||
className="flex space-x-4">
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="disallow" value="disallow" />
|
||||
<Label htmlFor="disallow" className="text-slate-900">
|
||||
Don't Allow
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||
<RadioGroupItem id="allow" value="allow" />
|
||||
<Label htmlFor="allow" className="text-slate-900">
|
||||
Allow
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="darkCTA"
|
||||
className="mt-4"
|
||||
loading={isMutatingProduct}
|
||||
onClick={() => {
|
||||
triggerProductMutate({
|
||||
placement: currentPlacement,
|
||||
darkOverlay: overlay === "darkOverlay",
|
||||
clickOutsideClose: clickOutside === "allow",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Placement updated successfully.");
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(`Error: ${error.message}`);
|
||||
});
|
||||
}}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const EditHighlightBorder: React.FC<{ environmentId: string }> = ({ environmentId }) => {
|
||||
const { product, isLoadingProduct, isErrorProduct, mutateProduct } = useProduct(environmentId);
|
||||
const { triggerProductMutate, isMutatingProduct } = useProductMutation(environmentId);
|
||||
|
||||
const [showHighlightBorder, setShowHighlightBorder] = useState(false);
|
||||
const [color, setColor] = useState<string | null>(DEFAULT_BRAND_COLOR);
|
||||
|
||||
// Sync product state with local state
|
||||
// not a good pattern, we should find a better way to do this
|
||||
useEffect(() => {
|
||||
if (product) {
|
||||
setShowHighlightBorder(product.highlightBorderColor ? true : false);
|
||||
setColor(product.highlightBorderColor);
|
||||
}
|
||||
}, [product]);
|
||||
|
||||
const handleSave = () => {
|
||||
triggerProductMutate(
|
||||
{ highlightBorderColor: color },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success("Settings updated successfully.");
|
||||
// refetch product to update data
|
||||
mutateProduct();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Something went wrong!");
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleSwitch = (checked: boolean) => {
|
||||
if (checked) {
|
||||
if (!color) {
|
||||
setColor(DEFAULT_BRAND_COLOR);
|
||||
setShowHighlightBorder(true);
|
||||
} else {
|
||||
setShowHighlightBorder(true);
|
||||
}
|
||||
} else {
|
||||
setShowHighlightBorder(false);
|
||||
setColor(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingProduct) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (isErrorProduct) {
|
||||
return <div>Error</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full w-full">
|
||||
<div className="flex w-1/2 flex-col px-6 py-5">
|
||||
<div className="mb-6 flex items-center space-x-2">
|
||||
<Switch id="highlightBorder" checked={showHighlightBorder} onCheckedChange={handleSwitch} />
|
||||
<h2 className="text-sm font-medium text-slate-800">Show highlight border</h2>
|
||||
</div>
|
||||
|
||||
{showHighlightBorder && color ? (
|
||||
<>
|
||||
<Label htmlFor="brandcolor">Color (HEX)</Label>
|
||||
<ColorPicker color={color} onChange={setColor} />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="darkCTA"
|
||||
className="mt-4 flex max-w-[80px] items-center justify-center"
|
||||
loading={isMutatingProduct}
|
||||
onClick={() => {
|
||||
handleSave();
|
||||
}}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex w-1/2 flex-col items-center justify-center gap-4 bg-slate-200 px-6 py-5">
|
||||
<h3 className="text-slate-500">Preview</h3>
|
||||
<div
|
||||
className={cn("flex flex-col gap-4 rounded-lg border-2 bg-white p-5")}
|
||||
{...(showHighlightBorder &&
|
||||
color && {
|
||||
style: {
|
||||
borderColor: color,
|
||||
},
|
||||
})}>
|
||||
<h3 className="text-sm font-semibold text-slate-800">How easy was it for you to do this?</h3>
|
||||
<div className="flex rounded-2xl border border-slate-400">
|
||||
{[1, 2, 3, 4, 5].map((num) => (
|
||||
<div className="border-r border-slate-400 px-6 py-5 last:border-r-0">
|
||||
<span className="text-sm font-medium">{num}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function EditFormbricksSignature({ environmentId }) {
|
||||
const { isLoadingEnvironment, isErrorEnvironment } = useEnvironment(environmentId);
|
||||
const { product, isLoadingProduct, isErrorProduct } = useProduct(environmentId);
|
||||
const { triggerProductMutate, isMutatingProduct } = useProductMutation(environmentId);
|
||||
|
||||
const [formbricksSignature, setFormbricksSignature] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (product) {
|
||||
setFormbricksSignature(product.formbricksSignature);
|
||||
}
|
||||
}, [product]);
|
||||
|
||||
const toggleSignature = () => {
|
||||
const newSignatureState = !formbricksSignature;
|
||||
setFormbricksSignature(newSignatureState);
|
||||
triggerProductMutate({ formbricksSignature: newSignatureState })
|
||||
.then(() => {
|
||||
toast.success(newSignatureState ? "Formbricks signature shown." : "Formbricks signature hidden.");
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(`Error: ${error.message}`);
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoadingEnvironment || isLoadingProduct) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
|
||||
if (isErrorEnvironment || isErrorProduct) {
|
||||
return <ErrorComponent />;
|
||||
}
|
||||
|
||||
if (formbricksSignature !== null) {
|
||||
return (
|
||||
<div className="w-full items-center">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="signature"
|
||||
checked={formbricksSignature}
|
||||
onCheckedChange={toggleSignature}
|
||||
disabled={isMutatingProduct}
|
||||
/>
|
||||
<Label htmlFor="signature">Show 'Powered by Formbricks' Signature</Label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import SettingsCard from "@/app/(app)/environments/[environmentId]/settings/SettingsCard";
|
||||
import SettingsTitle from "@/app/(app)/environments/[environmentId]/settings/SettingsTitle";
|
||||
import { cn } from "@formbricks/lib/cn";
|
||||
import { Button, Label, RadioGroup, RadioGroupItem, Switch } from "@formbricks/ui";
|
||||
|
||||
const placements = [
|
||||
{ name: "Bottom Right", value: "bottomRight", disabled: false },
|
||||
{ name: "Top Right", value: "topRight", disabled: false },
|
||||
{ name: "Top Left", value: "topLeft", disabled: false },
|
||||
{ name: "Bottom Left", value: "bottomLeft", disabled: false },
|
||||
{ name: "Centered Modal", value: "center", disabled: false },
|
||||
];
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div>
|
||||
<SettingsTitle title="Look & Feel" />
|
||||
<SettingsCard title="Brand Color" description="Match the surveys with your user interface.">
|
||||
<div className="w-full max-w-sm items-center">
|
||||
<Label htmlFor="brandcolor">Color (HEX)</Label>
|
||||
<div className="my-2">
|
||||
<div className="flex w-full items-center justify-between space-x-1 rounded-md border border-slate-300 px-2 text-sm text-slate-400">
|
||||
<div className="ml-2 mr-2 h-10 w-32 border-0 bg-transparent text-slate-500 outline-none focus:border-none"></div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
className="pointer-events-none mt-4 animate-pulse cursor-not-allowed select-none bg-gray-200">
|
||||
Loading
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title="In-app Survey Placement"
|
||||
description="Change where surveys will be shown in your web app.">
|
||||
<div className="w-full items-center">
|
||||
<div className="flex cursor-not-allowed select-none">
|
||||
<RadioGroup>
|
||||
{placements.map((placement) => (
|
||||
<div key={placement.value} className="flex items-center space-x-2 whitespace-nowrap ">
|
||||
<RadioGroupItem
|
||||
className="cursor-not-allowed select-none"
|
||||
id={placement.value}
|
||||
value={placement.value}
|
||||
disabled={placement.disabled}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={placement.value}
|
||||
className={cn(
|
||||
placement.disabled ? "cursor-not-allowed text-slate-500" : "text-slate-900"
|
||||
)}>
|
||||
{placement.name}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
<div className="relative ml-8 h-40 w-full rounded bg-slate-200">
|
||||
<div className={cn("absolute bottom-3 h-16 w-16 rounded bg-slate-700 sm:right-3")}></div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="darkCTA"
|
||||
className="pointer-events-none mt-4 animate-pulse cursor-not-allowed select-none bg-gray-200">
|
||||
Loading
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
noPadding
|
||||
title="Highlight Border"
|
||||
description="Make sure your users notice the survey you display">
|
||||
<div className="flex min-h-full w-full">
|
||||
<div className="flex w-1/2 flex-col px-6 py-5">
|
||||
<div className="pointer-events-none mb-6 flex cursor-not-allowed select-none items-center space-x-2">
|
||||
<Switch id="highlightBorder" checked={false} />
|
||||
<h2 className="text-sm font-medium text-slate-800">Show highlight border</h2>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="darkCTA"
|
||||
className="pointer-events-none mt-4 flex max-w-[100px] animate-pulse cursor-not-allowed select-none items-center justify-center">
|
||||
Loading
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex w-1/2 flex-col items-center justify-center gap-4 bg-slate-200 px-6 py-5">
|
||||
<h3 className="text-slate-500">Preview</h3>
|
||||
<div className={cn("flex flex-col gap-4 rounded-lg border-2 bg-white p-5")}>
|
||||
<h3 className="text-sm font-semibold text-slate-800">How easy was it for you to do this?</h3>
|
||||
<div className="flex rounded-2xl border border-slate-400">
|
||||
{[1, 2, 3, 4, 5].map((num) => (
|
||||
<div key={num} className="border-r border-slate-400 px-6 py-5 last:border-r-0">
|
||||
<span className="text-sm font-medium">{num}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title="Formbricks Signature"
|
||||
description="We love your support but understand if you toggle it off.">
|
||||
<div className="w-full items-center">
|
||||
<div className="pointer-events-none flex cursor-not-allowed select-none items-center space-x-2">
|
||||
<Switch id="signature" checked={false} />
|
||||
<Label htmlFor="signature">Show 'Powered by Formbricks' Signature</Label>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +1,37 @@
|
||||
export const revalidate = REVALIDATION_INTERVAL;
|
||||
|
||||
import { getProductByEnvironmentId } from "@formbricks/lib/services/product";
|
||||
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
|
||||
import SettingsCard from "../SettingsCard";
|
||||
import SettingsTitle from "../SettingsTitle";
|
||||
import {
|
||||
EditBrandColor,
|
||||
EditPlacement,
|
||||
EditFormbricksSignature,
|
||||
EditHighlightBorder,
|
||||
} from "./editLookAndFeel";
|
||||
import { EditFormbricksSignature } from "./EditSignature";
|
||||
import { EditBrandColor } from "./EditBrandColor";
|
||||
import { EditPlacement } from "./EditPlacement";
|
||||
import { EditHighlightBorder } from "./EditHighlightBorder";
|
||||
|
||||
export default function ProfileSettingsPage({ params }: { params: { environmentId: string } }) {
|
||||
export default async function ProfileSettingsPage({ params }: { params: { environmentId: string } }) {
|
||||
const product = await getProductByEnvironmentId(params.environmentId);
|
||||
return (
|
||||
<div>
|
||||
<SettingsTitle title="Look & Feel" />
|
||||
<SettingsCard title="Brand Color" description="Match the surveys with your user interface.">
|
||||
<EditBrandColor environmentId={params.environmentId} />
|
||||
<EditBrandColor product={product} />
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title="In-app Survey Placement"
|
||||
description="Change where surveys will be shown in your web app.">
|
||||
<EditPlacement environmentId={params.environmentId} />
|
||||
<EditPlacement product={product} />
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
noPadding
|
||||
title="Highlight Border"
|
||||
description="Make sure your users notice the survey you display">
|
||||
<EditHighlightBorder environmentId={params.environmentId} />
|
||||
<EditHighlightBorder product={product} />
|
||||
</SettingsCard>
|
||||
<SettingsCard
|
||||
title="Formbricks Signature"
|
||||
description="We love your support but understand if you toggle it off.">
|
||||
<EditFormbricksSignature environmentId={params.environmentId} />
|
||||
<EditFormbricksSignature product={product} />
|
||||
</SettingsCard>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -56,11 +56,10 @@ export default function Modal({
|
||||
ref={modalRef}
|
||||
style={highlightBorderColorStyle}
|
||||
className={cn(
|
||||
"pointer-events-auto absolute max-h-[90%] w-full h-fit max-w-sm overflow-y-auto rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5 transition-all duration-500 ease-in-out",
|
||||
"pointer-events-auto absolute h-fit max-h-[90%] w-full max-w-sm overflow-y-auto rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5 transition-all duration-500 ease-in-out",
|
||||
previewMode === "desktop" ? getPlacementStyle(placement) : "max-w-full ",
|
||||
slidingAnimationClass
|
||||
)}>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
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 type { TProduct, TProductUpdateInput } from "@formbricks/types/v1/product";
|
||||
import { cache } from "react";
|
||||
|
||||
const selectProduct = {
|
||||
@@ -57,3 +56,35 @@ export const getProductByEnvironmentId = cache(async (environmentId: string): Pr
|
||||
throw new ValidationError("Data validation of product failed");
|
||||
}
|
||||
});
|
||||
|
||||
export const updateProduct = async (
|
||||
productId: string,
|
||||
inputProduct: Partial<TProductUpdateInput>
|
||||
): Promise<TProduct> => {
|
||||
let updatedProduct;
|
||||
try {
|
||||
updatedProduct = await prisma.product.update({
|
||||
where: {
|
||||
id: productId,
|
||||
},
|
||||
data: {
|
||||
...inputProduct,
|
||||
},
|
||||
select: selectProduct,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError("Database operation failed");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
const product = ZProduct.parse(updatedProduct);
|
||||
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");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -19,3 +19,11 @@ export const ZProduct = z.object({
|
||||
});
|
||||
|
||||
export type TProduct = z.infer<typeof ZProduct>;
|
||||
|
||||
export const ZProductUpdateInput = ZProduct.omit({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
});
|
||||
|
||||
export type TProductUpdateInput = z.infer<typeof ZProductUpdateInput>;
|
||||
|
||||
Reference in New Issue
Block a user