);
}
diff --git a/app/(app)/create/SignedInDraftHydration.tsx b/app/(app)/create/SignedInDraftHydration.tsx
index c7e6c74..b53364f 100644
--- a/app/(app)/create/SignedInDraftHydration.tsx
+++ b/app/(app)/create/SignedInDraftHydration.tsx
@@ -11,6 +11,7 @@ import {
import { useCreateFlow } from "./context/CreateFlowContext";
import { fetchDraftFromServer } from "../../../lib/create/api";
import messages from "../../../messages/en/index";
+import Alert from "../../components/modals/Alert";
import {
isValidStep,
parseCreateFlowScreenFromPathname,
@@ -119,12 +120,19 @@ export function SignedInDraftHydration({
if (!loadingHydration) return null;
return (
-
- {messages.create.draftHydration.loadingSavedProgress}
+
);
}
diff --git a/app/(app)/create/review-template/[slug]/page.tsx b/app/(app)/create/review-template/[slug]/page.tsx
index ba38dff..f0106eb 100644
--- a/app/(app)/create/review-template/[slug]/page.tsx
+++ b/app/(app)/create/review-template/[slug]/page.tsx
@@ -86,19 +86,28 @@ export default function ReviewTemplatePage({ params }: PageProps) {
if (error || !template) {
return (
-
+ <>
-
+
+
+
+ >
);
}
diff --git a/app/(app)/profile/ProfilePageClient.tsx b/app/(app)/profile/ProfilePageClient.tsx
index 97c068e..aeb6446 100644
--- a/app/(app)/profile/ProfilePageClient.tsx
+++ b/app/(app)/profile/ProfilePageClient.tsx
@@ -62,6 +62,7 @@ export default function ProfilePageClient() {
const [emailChangeModalError, setEmailChangeModalError] = useState<
string | null
>(null);
+ const [emailChangeRequestSent, setEmailChangeRequestSent] = useState(false);
const [profileSuccessMessage, setProfileSuccessMessage] = useState<
string | null
>(null);
@@ -131,6 +132,7 @@ export default function ProfilePageClient() {
setActionError(null);
setProfileSuccessMessage(null);
setEmailChangeModalError(null);
+ setEmailChangeRequestSent(false);
setEmailChangeInput(user.email);
setEmailChangeOpen(true);
}, [user]);
@@ -138,8 +140,25 @@ export default function ProfilePageClient() {
const handleCloseEmailChange = useCallback(() => {
if (emailChangeBusy) return;
setEmailChangeOpen(false);
+ setEmailChangeRequestSent(false);
}, [emailChangeBusy]);
+ const handleDismissProfileSuccess = useCallback(() => {
+ setProfileSuccessMessage(null);
+ }, []);
+
+ const handleDismissActionError = useCallback(() => {
+ setActionError(null);
+ }, []);
+
+ const handleDismissRulesError = useCallback(() => {
+ setRulesError(false);
+ }, []);
+
+ const handleDismissEmailChangeModalError = useCallback(() => {
+ setEmailChangeModalError(null);
+ }, []);
+
const handleSubmitEmailChange = useCallback(async () => {
const trimmed = emailChangeInput.trim();
if (!trimmed || emailChangeBusy) return;
@@ -157,8 +176,7 @@ export default function ProfilePageClient() {
setEmailChangeModalError(res.error);
}
} else {
- setEmailChangeOpen(false);
- setProfileSuccessMessage(t("emailChangeRequestSent"));
+ setEmailChangeRequestSent(true);
}
}, [emailChangeBusy, emailChangeInput, t]);
@@ -318,7 +336,12 @@ export default function ProfilePageClient() {
emailChangeValue={emailChangeInput}
onEmailChangeValueChange={(value) => setEmailChangeInput(value)}
emailChangeBusy={emailChangeBusy}
+ emailChangeRequestSent={emailChangeRequestSent}
emailChangeModalError={emailChangeModalError}
+ onDismissProfileSuccess={handleDismissProfileSuccess}
+ onDismissActionError={handleDismissActionError}
+ onDismissRulesError={handleDismissRulesError}
+ onDismissEmailChangeModalError={handleDismissEmailChangeModalError}
onOpenEmailChange={handleOpenEmailChange}
onCloseEmailChange={handleCloseEmailChange}
onSubmitEmailChange={handleSubmitEmailChange}
diff --git a/app/(app)/profile/_components/ProfilePage.view.tsx b/app/(app)/profile/_components/ProfilePage.view.tsx
index 9f6790a..e93877a 100644
--- a/app/(app)/profile/_components/ProfilePage.view.tsx
+++ b/app/(app)/profile/_components/ProfilePage.view.tsx
@@ -6,7 +6,9 @@ import RuleCard from "../../../components/cards/RuleCard";
import TextInput from "../../../components/controls/TextInput";
import List from "../../../components/layout/List";
import type { ListItem, ListSize } from "../../../components/layout/List";
+import Icon from "../../../components/asset/Icon";
import Dialog from "../../../components/modals/Dialog";
+import Alert from "../../../components/modals/Alert";
import HeaderLockup from "../../../components/type/HeaderLockup";
import { useTranslation } from "../../../contexts/MessagesContext";
import type { CreateFlowState } from "../../create/types";
@@ -49,7 +51,9 @@ export type ProfilePageViewProps = {
emailChangeValue: string;
onEmailChangeValueChange: (value: string) => void;
emailChangeBusy: boolean;
+ emailChangeRequestSent: boolean;
emailChangeModalError: string | null;
+ onDismissEmailChangeModalError: () => void;
onOpenEmailChange: () => void;
onCloseEmailChange: () => void;
onSubmitEmailChange: () => void;
@@ -65,6 +69,9 @@ export type ProfilePageViewProps = {
onOpenDeleteAccount: () => void;
onCloseDeleteAccount: () => void;
onConfirmDeleteAccount: () => void;
+ onDismissProfileSuccess: () => void;
+ onDismissActionError: () => void;
+ onDismissRulesError: () => void;
};
/**
@@ -171,7 +178,9 @@ export function ProfilePageView({
emailChangeValue,
onEmailChangeValueChange,
emailChangeBusy,
+ emailChangeRequestSent,
emailChangeModalError,
+ onDismissEmailChangeModalError,
onOpenEmailChange,
onCloseEmailChange,
onSubmitEmailChange,
@@ -187,8 +196,12 @@ export function ProfilePageView({
onOpenDeleteAccount,
onCloseDeleteAccount,
onConfirmDeleteAccount,
+ onDismissProfileSuccess,
+ onDismissActionError,
+ onDismissRulesError,
}: ProfilePageViewProps) {
const t = useTranslation("pages.profile");
+ const tLogin = useTranslation("pages.login");
const titleId = useId();
const welcomeTitle = t("welcomeTitle").replace(/\{\{name\}\}/g, userEmail);
const welcomeBody =
@@ -277,30 +290,6 @@ export function ProfilePageView({
)}
- {profileSuccessMessage ? (
-
- {profileSuccessMessage}
-
- ) : null}
-
- {actionError ? (
-
- {actionError}
-
- ) : null}
-
- {rulesError ? (
-
- {t("actionError")}
-
- ) : null}
-
+ emailChangeRequestSent ? (
-
- >
+ ) : (
+ <>
+
+
+ >
+ )
}
>
- {emailChangeModalError ? (
-
- {emailChangeModalError}
-
- ) : null}
- onEmailChangeValueChange(e.target.value)}
- disabled={emailChangeBusy}
- error={Boolean(emailChangeModalError)}
- autoComplete="email"
- />
+ {emailChangeRequestSent ? (
+
+ ) : (
+ onEmailChangeValueChange(e.target.value)}
+ disabled={emailChangeBusy}
+ error={Boolean(emailChangeModalError)}
+ autoComplete="email"
+ />
+ )}
+
+ {(profileSuccessMessage || actionError || rulesError) && (
+
+
+ {profileSuccessMessage ? (
+
+ ) : null}
+ {actionError ? (
+
+ ) : null}
+ {rulesError ? (
+
+ ) : null}
+
+
+ )}
+
+ {emailChangeOpen && emailChangeModalError ? (
+
+ ) : null}
>
);
}
diff --git a/app/components/modals/Alert/Alert.container.tsx b/app/components/modals/Alert/Alert.container.tsx
index cdd678d..ce01a55 100644
--- a/app/components/modals/Alert/Alert.container.tsx
+++ b/app/components/modals/Alert/Alert.container.tsx
@@ -1,52 +1,117 @@
"use client";
+/**
+ * Figma: "Modal / Alert" (6351-14646)
+ * https://www.figma.com/design/agv0VBLiBlcnSAaiAORgPR/Community-Rule-System?node-id=6351-14646
+ */
+
import { memo } from "react";
import { AlertView } from "./Alert.view";
import type { AlertProps } from "./Alert.types";
+function layoutFor(
+ type: NonNullable,
+ size: NonNullable,
+): {
+ containerClasses: string;
+ titleClasses: string;
+ descriptionClasses: string;
+} {
+ if (type === "toast") {
+ const padH =
+ size === "s"
+ ? "px-[var(--space-500)]"
+ : "px-[var(--space-1200)]";
+ const containerClasses = `flex gap-[var(--space-300)] items-center ${padH} pb-[var(--space-500)] pt-[var(--space-400)] rounded-tl-[var(--radius-200,8px)] rounded-tr-[var(--radius-200,8px)] border-solid`;
+ if (size === "s") {
+ return {
+ containerClasses,
+ titleClasses:
+ "font-inter text-[14px] leading-[18px] font-medium tracking-[0%]",
+ descriptionClasses:
+ "font-inter text-[14px] leading-[20px] font-normal tracking-[0%] mt-[var(--spacing-scale-004)]",
+ };
+ }
+ return {
+ containerClasses,
+ titleClasses:
+ "font-inter text-[18px] leading-[24px] font-medium tracking-[0%]",
+ descriptionClasses:
+ "font-inter text-[18px] leading-[1.3] font-normal tracking-[0%] mt-[var(--spacing-scale-004)]",
+ };
+ }
+
+ if (size === "s") {
+ return {
+ containerClasses:
+ "flex gap-[var(--space-300)] items-center p-[var(--space-300)] rounded-[var(--radius-200,8px)] border-solid",
+ titleClasses:
+ "font-inter text-[14px] leading-[18px] font-medium tracking-[0%]",
+ descriptionClasses:
+ "font-inter text-[14px] leading-[20px] font-normal tracking-[0%] mt-[var(--spacing-scale-004)]",
+ };
+ }
+ return {
+ containerClasses:
+ "flex gap-[var(--space-300)] items-center px-[var(--space-600)] py-[var(--space-400)] rounded-[var(--radius-200,8px)] border-solid",
+ titleClasses:
+ "font-inter text-[16px] leading-[20px] font-medium tracking-[0%]",
+ descriptionClasses:
+ "font-inter text-[16px] leading-[24px] font-normal tracking-[0%] mt-[var(--spacing-scale-004)]",
+ };
+}
+
const AlertContainer = memo(
({
title,
description,
status: statusProp = "default",
type: typeProp = "toast",
+ size: sizeProp = "m",
hasLeadingIcon = true,
hasBodyText = true,
+ hasTrailingIcon: hasTrailingIconProp,
onClose,
className = "",
}) => {
const status = statusProp;
const type = typeProp;
- // Determine background and border colors based on status and type
+ const size = sizeProp;
+
const getStatusStyles = () => {
switch (status) {
case "positive":
return {
- background: "bg-[var(--color-kiwi-kiwi0)]",
+ background:
+ "bg-[var(--color-surface-invert-positive-secondary,var(--color-kiwi-kiwi0))]",
borderColor:
type === "toast"
? "var(--color-border-invert-positive-primary)"
: undefined,
titleColor: "text-[var(--color-content-invert-primary)]",
- descriptionColor: "text-[var(--color-content-invert-secondary)]",
+ descriptionColor:
+ "text-[var(--color-content-invert-secondary)]",
iconColor: "var(--color-kiwi-kiwi500)",
closeButtonIconColor: "var(--color-content-invert-primary)",
};
case "warning":
return {
- background: "bg-[var(--color-yellow-yellow0)]",
+ background:
+ "bg-[var(--color-surface-invert-warning-secondary,var(--color-yellow-yellow0))]",
borderColor:
type === "toast"
? "var(--color-border-invert-warning-primary)"
: undefined,
titleColor: "text-[var(--color-content-invert-primary)]",
- descriptionColor: "text-[var(--color-content-invert-secondary)]",
+ descriptionColor:
+ "text-[var(--color-content-invert-secondary)]",
iconColor: "var(--color-yellow-yellow500)",
closeButtonIconColor: "var(--color-content-invert-primary)",
};
case "danger":
return {
- background: "bg-[var(--color-red-red0)]",
+ background:
+ "bg-[var(--color-surface-invert-negative-secondary,var(--color-red-red0))]",
borderColor:
type === "toast"
? "var(--color-border-invert-negative-primary)"
@@ -67,18 +132,14 @@ const AlertContainer = memo(
titleColor: "text-[var(--color-content-default-primary)]",
descriptionColor: "text-[var(--color-content-default-primary)]",
iconColor: "var(--color-content-default-brand-primary)",
- closeButtonIconColor: "var(--color-content-default-brand-primary)",
+ closeButtonIconColor:
+ "var(--color-content-default-brand-primary)",
};
}
};
const statusStyles = getStatusStyles();
-
- const containerClasses = `flex gap-[var(--space-300)] items-center ${
- type === "toast"
- ? `pb-[var(--space-500)] pt-[var(--space-400)] px-[var(--space-1200)] rounded-tl-[var(--radius-200,8px)] rounded-tr-[var(--radius-200,8px)]`
- : `px-[var(--spacing-scale-024)] py-[var(--spacing-scale-016)] rounded-[var(--radius-200,8px)]`
- } ${statusStyles.background} border-solid`;
+ const layout = layoutFor(type, size);
const containerStyle =
type === "toast" && statusStyles.borderColor
@@ -88,15 +149,14 @@ const AlertContainer = memo(
}
: undefined;
- const titleClasses =
- type === "banner"
- ? `font-inter text-[16px] leading-[20px] font-medium tracking-[0%] ${statusStyles.titleColor} relative shrink-0 w-full`
- : `font-inter text-[18px] leading-[24px] font-medium tracking-[0%] ${statusStyles.titleColor} relative shrink-0 w-full`;
+ const containerClasses = `${layout.containerClasses} ${statusStyles.background}`;
- const descriptionClasses =
- type === "banner"
- ? `font-inter text-[16px] leading-[24px] font-normal tracking-[0%] ${statusStyles.descriptionColor} relative shrink-0 w-full mt-[var(--spacing-scale-004)]`
- : `font-inter text-[18px] leading-[23.4px] font-normal tracking-[0%] ${statusStyles.descriptionColor} relative shrink-0 w-full mt-[var(--spacing-scale-004)]`;
+ const titleClasses = `${layout.titleClasses} ${statusStyles.titleColor} relative shrink-0 w-full`;
+ const descriptionClasses = `${layout.descriptionClasses} ${statusStyles.descriptionColor} relative shrink-0 w-full`;
+
+ const hasTrailingIcon =
+ hasTrailingIconProp ?? Boolean(onClose);
+ const showClose = hasTrailingIcon && Boolean(onClose);
return (
(
type={type}
hasLeadingIcon={hasLeadingIcon}
hasBodyText={hasBodyText}
+ hasTrailingIcon={showClose}
className={className}
containerClasses={containerClasses}
containerStyle={containerStyle}
diff --git a/app/components/modals/Alert/Alert.types.ts b/app/components/modals/Alert/Alert.types.ts
index e01a8d2..3278b1b 100644
--- a/app/components/modals/Alert/Alert.types.ts
+++ b/app/components/modals/Alert/Alert.types.ts
@@ -1,7 +1,11 @@
+import type { AlertSizeValue } from "../../../../lib/propNormalization";
+
export type AlertStatusValue = "default" | "positive" | "warning" | "danger";
export type AlertTypeValue = "toast" | "banner";
+export type { AlertSizeValue };
+
export interface AlertProps {
title: string;
description?: string;
@@ -13,6 +17,11 @@ export interface AlertProps {
* Alert type.
*/
type?: AlertTypeValue;
+ /**
+ * Density / typography scale (Figma Modal Alert S | M).
+ * @default "m"
+ */
+ size?: AlertSizeValue;
/**
* Whether to show the leading icon (Figma prop).
* @default true
@@ -23,6 +32,11 @@ export interface AlertProps {
* @default true
*/
hasBodyText?: boolean;
+ /**
+ * Trailing dismiss control (Figma `hasTrailingIcon`).
+ * When omitted, defaults to `true` when `onClose` is provided, else `false`.
+ */
+ hasTrailingIcon?: boolean;
onClose?: () => void;
className?: string;
}
@@ -34,6 +48,7 @@ export interface AlertViewProps {
type: "toast" | "banner";
hasLeadingIcon: boolean;
hasBodyText: boolean;
+ hasTrailingIcon: boolean;
className: string;
containerClasses: string;
containerStyle?: React.CSSProperties;
diff --git a/app/components/modals/Alert/Alert.view.tsx b/app/components/modals/Alert/Alert.view.tsx
index 4482230..08a67b6 100644
--- a/app/components/modals/Alert/Alert.view.tsx
+++ b/app/components/modals/Alert/Alert.view.tsx
@@ -8,6 +8,7 @@ export function AlertView({
type: _type,
hasLeadingIcon,
hasBodyText,
+ hasTrailingIcon,
className,
containerClasses,
containerStyle,
@@ -54,40 +55,42 @@ export function AlertView({
{description}
)}
-
+
+
+
+
+
+
+
+
+ ) : null}
);
}
diff --git a/app/components/modals/Login/LoginForm.tsx b/app/components/modals/Login/LoginForm.tsx
index 2276c84..02abeb4 100644
--- a/app/components/modals/Login/LoginForm.tsx
+++ b/app/components/modals/Login/LoginForm.tsx
@@ -7,6 +7,7 @@ import { useTranslation } from "../../../contexts/MessagesContext";
import Button from "../../buttons/Button";
import TextInput from "../../controls/TextInput";
import ContentLockup from "../../type/ContentLockup";
+import Alert from "../Alert";
import { requestMagicLink } from "../../../../lib/create/api";
import { safeInternalPath } from "../../../../lib/safeInternalPath";
import { setTransferPendingFlag } from "../../../(app)/create/utils/anonymousDraftStorage";
@@ -55,7 +56,6 @@ export default function LoginForm({
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
- const formAlertId = useId();
const emailErrorId = useId();
const [email, setEmail] = useState("");
@@ -166,26 +166,40 @@ export default function LoginForm({
/>