RuleTemplate seed and create flow

This commit is contained in:
adilallo
2026-04-10 22:17:52 -06:00
parent cee81eda16
commit ec5afd1464
47 changed files with 1706 additions and 265 deletions
+112 -4
View File
@@ -18,6 +18,10 @@ import Button from "../components/buttons/Button";
import { buildPublishPayload } from "../../lib/create/buildPublishPayload";
import { fetchAuthSession, publishRule } from "../../lib/create/api";
import { writeLastPublishedRule } from "../../lib/create/lastPublishedRule";
import {
fetchTemplateBySlug,
type RuleTemplateDto,
} from "../../lib/create/fetchTemplates";
import messages from "../../messages/en/index";
import { useAuthModal } from "../contexts/AuthModalContext";
import { PostLoginDraftTransfer } from "./PostLoginDraftTransfer";
@@ -89,6 +93,18 @@ function CreateFlowLayoutContent({
string | null
>(null);
const [isPublishing, setIsPublishing] = useState(false);
const [templateReviewApplyError, setTemplateReviewApplyError] = useState<
string | null
>(null);
const [isApplyingTemplate, setIsApplyingTemplate] = useState(false);
const templateReviewMatch = pathname?.match(
/^\/create\/review-template\/([^/]+)$/,
);
const templateReviewSlug = templateReviewMatch?.[1]
? decodeURIComponent(templateReviewMatch[1])
: null;
const isTemplateReviewRoute = Boolean(templateReviewSlug);
const handleFinalize = useCallback(async () => {
setPublishBannerMessage(null);
@@ -134,6 +150,39 @@ function CreateFlowLayoutContent({
);
}, [state, router, openLogin]);
const handleUseTemplateWithoutChanges = useCallback(async () => {
if (!templateReviewSlug) return;
setTemplateReviewApplyError(null);
setIsApplyingTemplate(true);
const result = await fetchTemplateBySlug(templateReviewSlug);
setIsApplyingTemplate(false);
if (result === null) {
setTemplateReviewApplyError(messages.create.templateReview.errors.notFound);
return;
}
if ("error" in result) {
setTemplateReviewApplyError(result.error);
return;
}
const template: RuleTemplateDto = result;
const doc = template.body;
if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
setTemplateReviewApplyError(messages.create.templateReview.errors.applyFailed);
return;
}
const summaryRaw =
typeof template.description === "string"
? template.description.trim()
: "";
writeLastPublishedRule({
id: `template:${template.slug}`,
title: template.title,
summary: summaryRaw.length > 0 ? summaryRaw : null,
document: doc as Record<string, unknown>,
});
router.push("/create/completed");
}, [router, templateReviewSlug]);
const runAuthenticatedExit = useCreateFlowExit({
state,
currentStep,
@@ -149,9 +198,15 @@ function CreateFlowLayoutContent({
if (sessionUser === null) {
if (saveDraft) return;
const returnToTemplateReview =
templateReviewSlug != null
? `/create/review-template/${encodeURIComponent(templateReviewSlug)}?syncDraft=1`
: null;
openLogin({
variant: "saveProgress",
nextPath: `${pathname ?? "/create/informational"}?syncDraft=1`,
nextPath:
returnToTemplateReview ??
`${pathname ?? "/create/informational"}?syncDraft=1`,
backdropVariant: "blurredYellow",
});
return;
@@ -169,7 +224,9 @@ function CreateFlowLayoutContent({
Boolean(sessionUser) && stepIdx >= SAVE_EXIT_FROM_STEP_INDEX;
const hasErrorOverlays =
Boolean(draftSaveBannerMessage) || Boolean(publishBannerMessage);
Boolean(draftSaveBannerMessage) ||
Boolean(publishBannerMessage) ||
Boolean(templateReviewApplyError);
return (
<div className="relative flex h-screen min-h-0 flex-col overflow-hidden bg-black">
@@ -202,6 +259,18 @@ function CreateFlowLayoutContent({
/>
</div>
) : null}
{templateReviewApplyError ? (
<div className="pointer-events-auto mx-auto w-full max-w-[960px]">
<Alert
type="banner"
status="danger"
title={messages.create.templateReview.errors.applyFailed}
description={templateReviewApplyError}
onClose={() => setTemplateReviewApplyError(null)}
className="w-full"
/>
</div>
) : null}
</div>
) : null}
<Suspense fallback={null}>
@@ -243,8 +312,41 @@ function CreateFlowLayoutContent({
{!isCompletedStep && (
<CreateFlowFooter
className="shrink-0"
progressBar={!isTemplateReviewRoute}
secondButton={
nextStep ? (
isTemplateReviewRoute ? (
<div className="flex flex-shrink-0 items-center gap-3 md:gap-4">
<Button
buttonType="ghost"
palette="default"
size="xsmall"
disabled={isApplyingTemplate}
className="md:!text-[14px] md:!leading-[16px] !text-[12px] !leading-[14px] !px-[var(--spacing-measures-spacing-200,8px)] md:!px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!py-[var(--spacing-measures-spacing-250,10px)] !text-white"
onClick={() => void handleUseTemplateWithoutChanges()}
>
{messages.create.templateReview.footer.useWithoutChanges}
</Button>
<Button
buttonType="filled"
palette="default"
size="xsmall"
disabled={isApplyingTemplate}
title={
messages.create.templateReview.footer.customizeAriaHint
}
className="md:!text-[14px] md:!leading-[16px] !text-[12px] !leading-[14px] !px-[var(--spacing-measures-spacing-200,8px)] md:!px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!py-[var(--spacing-measures-spacing-250,10px)]"
onClick={() => {
if (!templateReviewSlug) return;
// Preserve template slug for a future customize / prefill ticket (informational does not read it yet).
router.push(
`/create/informational?template=${encodeURIComponent(templateReviewSlug)}`,
);
}}
>
{messages.create.templateReview.footer.customize}
</Button>
</div>
) : nextStep ? (
<Button
buttonType="filled"
palette="default"
@@ -269,7 +371,13 @@ function CreateFlowLayoutContent({
</Button>
) : null
}
onBackClick={previousStep ? goToPreviousStep : undefined}
onBackClick={
isTemplateReviewRoute
? () => router.push("/")
: previousStep
? goToPreviousStep
: undefined
}
/>
)}
</div>
+130
View File
@@ -0,0 +1,130 @@
"use client";
import { use, useEffect, useState } from "react";
import HeaderLockup from "../../../components/type/HeaderLockup";
import { TemplateReviewCard } from "../../../components/cards/TemplateReviewCard";
import { useTranslation } from "../../../contexts/MessagesContext";
import { useMediaQuery } from "../../../hooks/useMediaQuery";
import {
fetchTemplateBySlug,
type RuleTemplateDto,
} from "../../../../lib/create/fetchTemplates";
import messages from "../../../../messages/en/index";
import Alert from "../../../components/modals/Alert";
interface PageProps {
params: Promise<{ slug: string }>;
}
/**
* Template review: same responsive grid and RuleCard chrome as final-review;
* copy from Figma 22142-898702 (intro + dynamic card from API).
*/
export default function ReviewTemplatePage({ params }: PageProps) {
const { slug: rawSlug } = use(params);
const slug = decodeURIComponent(rawSlug);
const t = useTranslation("create.templateReview");
const [template, setTemplate] = useState<RuleTemplateDto | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [isMounted, setIsMounted] = useState(false);
const isMdOrLarger = useMediaQuery("(min-width: 640px)");
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- match final-review: defer breakpoint until mount
setIsMounted(true);
}, []);
useEffect(() => {
let cancelled = false;
void (async () => {
if (!cancelled) {
setLoading(true);
setError(null);
}
const result = await fetchTemplateBySlug(slug);
if (cancelled) return;
if (result === null) {
setError(messages.create.templateReview.errors.notFound);
setTemplate(null);
} else if ("error" in result) {
setError(result.error);
setTemplate(null);
} else {
setTemplate(result);
setError(null);
}
setLoading(false);
})();
return () => {
cancelled = true;
};
}, [slug]);
const showDesktopLayout = !isMounted || isMdOrLarger;
if (loading) {
return (
<div className="flex w-full max-w-[1280px] shrink-0 items-center justify-center px-5 py-16 md:px-12">
<p className="text-[var(--color-content-default-secondary,#a3a3a3)]">
{t("loading")}
</p>
</div>
);
}
if (error || !template) {
return (
<div className="flex w-full max-w-[640px] shrink-0 flex-col gap-4 px-5 py-8 md:px-12">
<Alert
type="banner"
status="danger"
title={t("errors.loadFailed")}
description={error ?? t("errors.notFound")}
className="w-full"
/>
</div>
);
}
if (showDesktopLayout) {
return (
<div className="w-full max-w-[1280px] shrink-0 px-5 md:px-12">
<div className="flex w-full flex-col gap-4 min-w-0 sm:grid sm:grid-cols-2 sm:gap-[var(--measures-spacing-1200,48px)]">
<div className="min-w-0 flex flex-col justify-center">
<HeaderLockup
title={t("intro.title")}
description={t("intro.description")}
justification="left"
size="L"
/>
</div>
<div className="min-w-0 w-full flex flex-col items-stretch">
<TemplateReviewCard
template={template}
ruleCardClassName="rounded-[24px] !max-w-full !w-full min-w-0"
/>
</div>
</div>
</div>
);
}
return (
<div className="w-full flex flex-col items-center px-5 min-w-0">
<div className="flex flex-col gap-4 w-full max-w-[639px]">
<HeaderLockup
title={t("intro.title")}
description={t("intro.description")}
justification="left"
size="M"
/>
<TemplateReviewCard
template={template}
ruleCardClassName="w-full rounded-[12px] p-4"
/>
</div>
</div>
);
}