316 lines
12 KiB
TypeScript
316 lines
12 KiB
TypeScript
import clsx from "clsx";
|
|
import { Fragment, useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import ButtonPrimary from "~/components/ui/buttons/ButtonPrimary";
|
|
import ButtonSecondary from "~/components/ui/buttons/ButtonSecondary";
|
|
import CustomCarousel from "~/components/ui/images/CustomCarousel";
|
|
import InputSelect from "~/components/ui/input/InputSelect";
|
|
import InputText from "~/components/ui/input/InputText";
|
|
import Modal from "~/components/ui/modals/Modal";
|
|
import Tabs from "~/components/ui/tabs/Tabs";
|
|
import { renderSanitizedMarkdown } from "~/utils/html-sanitizer";
|
|
import { OnboardingBlockDto, OnboardingStepBlockDto } from "./OnboardingBlockUtils";
|
|
import OnboardingGridUtils from "./grid/OnboardingGridUtils";
|
|
|
|
export default function OnboardingVariantModal({
|
|
block,
|
|
currentStepIdx,
|
|
setStep,
|
|
open,
|
|
onClose,
|
|
onDismiss,
|
|
onComplete,
|
|
onLinkClick,
|
|
onUpdateCurrentStepState,
|
|
}: {
|
|
block: OnboardingBlockDto;
|
|
currentStepIdx: number;
|
|
open: boolean;
|
|
setStep: (idx: number) => void;
|
|
onClose: () => void;
|
|
onDismiss: () => void;
|
|
onComplete: () => void;
|
|
onLinkClick: (item: { text: string; href: string }) => void;
|
|
onUpdateCurrentStepState: (state: { [key: string]: string }) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
function dismiss() {
|
|
onDismiss();
|
|
}
|
|
function onNext() {
|
|
const nextStep = block.steps[currentStepIdx + 1];
|
|
if (nextStep) {
|
|
setStep(currentStepIdx + 1);
|
|
} else {
|
|
onComplete();
|
|
}
|
|
}
|
|
return (
|
|
<Modal
|
|
open={open}
|
|
setOpen={() => {
|
|
if (block.canBeDismissed) {
|
|
onClose();
|
|
}
|
|
}}
|
|
padding="none"
|
|
>
|
|
<div>
|
|
<div className="border-border flex items-center justify-between space-x-2 border-b px-2 py-2">
|
|
<div className="truncate text-lg font-bold">{t(block.title)}</div>
|
|
<div className="hidden items-center space-x-1 sm:flex">
|
|
{block.steps.map((step, idx) => {
|
|
return (
|
|
<button
|
|
key={idx}
|
|
type="button"
|
|
onClick={() => setStep(idx)}
|
|
aria-label={t(step.title)}
|
|
aria-current={idx === currentStepIdx ? "step" : undefined}
|
|
className={clsx(
|
|
"flex min-h-10 min-w-10 items-center justify-center focus:outline-hidden focus-visible:ring-2 focus-visible:ring-teal-500 focus-visible:ring-offset-1",
|
|
step.completedAt
|
|
? "text-teal-500 hover:text-teal-600"
|
|
: idx === currentStepIdx
|
|
? "text-teal-600 hover:text-teal-700"
|
|
: "hover:text-muted-foreground text-gray-300"
|
|
)}
|
|
>
|
|
<svg
|
|
aria-hidden="true"
|
|
className={clsx("h-4 w-4")}
|
|
fill="currentColor"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
x="0px"
|
|
y="0px"
|
|
width="50"
|
|
height="50"
|
|
viewBox="0 0 50 50"
|
|
>
|
|
{" "}
|
|
<path d="M 41 42 L 9 42 C 5.132813 42 2 38.867188 2 35 L 2 15 C 2 11.132813 5.132813 8 9 8 L 41 8 C 44.867188 8 48 11.132813 48 15 L 48 35 C 48 38.867188 44.867188 42 41 42 Z"></path>{" "}
|
|
</svg>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<Tabs
|
|
className="sm:hidden"
|
|
onSelected={(e) => setStep(e)}
|
|
tabs={block.steps.map((step) => {
|
|
return { name: step.title };
|
|
})}
|
|
/>
|
|
</div>
|
|
<div
|
|
className={clsx(
|
|
"bg-secondary",
|
|
block.height === "xs" && "h-20",
|
|
block.height === "sm" && "h-40",
|
|
block.height === "md" && "h-60",
|
|
block.height === "lg" && "h-80",
|
|
block.height === "xl" && "h-96",
|
|
block.height === "auto" && "h-auto",
|
|
block.height !== "auto" && "overflow-y-auto"
|
|
)}
|
|
>
|
|
<div className="relative flex h-full">
|
|
{block.steps.length > 0 && (
|
|
<div className="divide-border bg-secondary/90 hidden w-1/2 divide-y-2 overflow-y-scroll sm:block">
|
|
{block.steps.map((step, idx) => {
|
|
return (
|
|
<Fragment key={idx}>
|
|
<button
|
|
type="button"
|
|
aria-current={idx === currentStepIdx ? "step" : undefined}
|
|
className={clsx(
|
|
"w-full truncate px-3 py-3 text-left text-base font-medium focus:outline-hidden focus-visible:ring-2 focus-visible:ring-teal-500 focus-visible:ring-inset",
|
|
idx === currentStepIdx ? "text-foreground bg-background" : "border-border text-muted-foreground border-r-2"
|
|
)}
|
|
onClick={() => setStep(idx)}
|
|
>
|
|
<div className="flex items-center space-x-2">
|
|
{step.icon && (
|
|
<>
|
|
{step.icon.startsWith("<svg") ? (
|
|
<div dangerouslySetInnerHTML={{ __html: step.icon.replace("<svg", `<svg class='${"h-5 w-5"}'`) ?? "" }} />
|
|
) : step.icon.startsWith("http") ? (
|
|
<img className="h-5 w-5" src={step.icon} alt={step.title} />
|
|
) : (
|
|
<div className="w-5">{step.icon}</div>
|
|
)}
|
|
</>
|
|
)}
|
|
<div>{t(step.title)}</div>
|
|
</div>
|
|
</button>
|
|
</Fragment>
|
|
);
|
|
})}
|
|
<div className="border-border h-full border-r-2 p-2"></div>
|
|
</div>
|
|
)}
|
|
{block.steps.length > 0 && (
|
|
<div className={clsx("bg-background w-full overflow-y-auto px-4 pb-4")}>
|
|
{block.steps.length > currentStepIdx && (
|
|
<OnboardingStep step={block.steps[currentStepIdx]} onLinkClick={onLinkClick} onUpdate={onUpdateCurrentStepState} />
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="border-border bg-secondary flex items-center justify-between space-x-2 border-t px-2 py-2">
|
|
<div className="flex items-center space-x-2 truncate px-2 py-2">
|
|
<ButtonSecondary type="button" disabled={currentStepIdx === 0} onClick={() => setStep(currentStepIdx - 1)} className="sm:hidden">
|
|
{t("shared.back")}
|
|
</ButtonSecondary>
|
|
{block.canBeDismissed && (
|
|
<button
|
|
type="button"
|
|
className="text-muted-foreground rounded-md px-2 py-2 text-xs hover:underline focus:underline focus:outline-hidden"
|
|
onClick={dismiss}
|
|
>
|
|
{t("shared.dontShowThisAgain")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center justify-end space-x-2 truncate px-2 py-2">
|
|
<ButtonSecondary type="button" disabled={currentStepIdx === 0} onClick={() => setStep(currentStepIdx - 1)} className="hidden sm:block">
|
|
{t("shared.back")}
|
|
</ButtonSecondary>
|
|
<ButtonSecondary
|
|
disabled={block.steps.length === currentStepIdx + 1}
|
|
type="button"
|
|
onClick={onNext}
|
|
className={clsx(block.steps.length === currentStepIdx + 1 && "hidden sm:block")}
|
|
>
|
|
{t("shared.next")}
|
|
</ButtonSecondary>
|
|
<ButtonPrimary
|
|
disabled={block.steps.length !== currentStepIdx + 1}
|
|
type="button"
|
|
onClick={onComplete}
|
|
className={clsx(block.steps.length !== currentStepIdx + 1 && "hidden sm:block")}
|
|
>
|
|
{t("shared.complete")}
|
|
</ButtonPrimary>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
function OnboardingStep({
|
|
step,
|
|
onLinkClick,
|
|
onUpdate,
|
|
}: {
|
|
step: OnboardingStepBlockDto;
|
|
onLinkClick: (link: { text: string; href: string }) => void;
|
|
onUpdate: (state: { [key: string]: string }) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [state, setState] = useState<{ [key: string]: string }>({});
|
|
useEffect(() => {
|
|
if (onUpdate) {
|
|
onUpdate(state);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [state]);
|
|
return (
|
|
<div className="space-y-2">
|
|
{step.description && (
|
|
<div className="prose">
|
|
<div
|
|
dangerouslySetInnerHTML={{
|
|
__html: renderSanitizedMarkdown(step.description),
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
{step.links && step.links.length > 0 && (
|
|
<div className="-mx-0.5 flex flex-wrap items-center">
|
|
{step.links.map((link, idx) => {
|
|
return (
|
|
<div key={idx} className="p-0.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => onLinkClick(link)}
|
|
className={clsx(
|
|
"rounded-md px-2 py-1 text-sm",
|
|
link.isPrimary ? "bg-theme-700 text-white" : "border-border text-foreground bg-secondary/90 border"
|
|
)}
|
|
>
|
|
{t(link.text)}
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
{step.gallery && step.gallery.length > 0 && (
|
|
<div className="flex flex-col justify-center space-y-2">
|
|
<CustomCarousel items={step.gallery} />
|
|
{/* {step.gallery.map((galleryItem, idx) => {
|
|
return (
|
|
<Fragment key={idx}>
|
|
{galleryItem.type === "image" && (
|
|
<img
|
|
alt={galleryItem.title && t(galleryItem.title)}
|
|
className="rounded-md border border-dashed border-border object-cover shadow-md"
|
|
src={galleryItem.src}
|
|
/>
|
|
)}
|
|
{galleryItem.type === "video" && (
|
|
<iframe
|
|
src={galleryItem?.src}
|
|
title={galleryItem?.title ?? ""}
|
|
frameBorder="0"
|
|
loading="lazy"
|
|
className="rounded-md border border-dashed border-border object-cover shadow-md"
|
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
></iframe>
|
|
)}
|
|
</Fragment>
|
|
);
|
|
})} */}
|
|
</div>
|
|
)}
|
|
{step.input && step.input.length > 0 && (
|
|
<div className={clsx("truncate p-1", OnboardingGridUtils.getClasses(step.inputGrid ?? { columns: "2", gap: "sm" }))}>
|
|
{step.input.map((input, idx) => {
|
|
return (
|
|
<div key={idx}>
|
|
{input.type === "text" && (
|
|
<InputText
|
|
name={input.name}
|
|
title={t(input.title)}
|
|
required={input.isRequired}
|
|
value={state?.[input.name] ?? ""}
|
|
setValue={(e) => setState({ ...state, [input.name]: e.toString() })}
|
|
/>
|
|
)}
|
|
{input.type === "select" && (
|
|
<InputSelect
|
|
name={input.name}
|
|
title={t(input.title)}
|
|
required={input.isRequired}
|
|
options={
|
|
input.options?.map((f) => {
|
|
return { value: f.value, name: t(f.name) };
|
|
}) ?? []
|
|
}
|
|
value={state?.[input.name] ?? ""}
|
|
setValue={(e) => setState({ ...state, [input.name]: e?.toString() ?? "" })}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|