Component cleanup
This commit is contained in:
@@ -1,33 +0,0 @@
|
||||
import { memo } from "react";
|
||||
|
||||
export type AvatarContainerSizeValue = "small" | "medium" | "large" | "xlarge";
|
||||
|
||||
interface AvatarContainerProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
children?: React.ReactNode;
|
||||
size?: AvatarContainerSizeValue;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const AvatarContainer = memo<AvatarContainerProps>(
|
||||
({ children, size: sizeProp = "small", className = "", ...props }) => {
|
||||
const size = sizeProp;
|
||||
const sizeStyles: Record<string, string> = {
|
||||
small: "flex -space-x-[var(--spacing-scale-008)]",
|
||||
medium: "flex -space-x-[9px]",
|
||||
large: "flex -space-x-[var(--spacing-scale-010)]",
|
||||
xlarge: "flex -space-x-[13px]",
|
||||
};
|
||||
|
||||
const baseStyles = `items-center ${sizeStyles[size]} ${className}`;
|
||||
|
||||
return (
|
||||
<div className={baseStyles} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
AvatarContainer.displayName = "AvatarContainer";
|
||||
|
||||
export default AvatarContainer;
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default } from "./AvatarContainer";
|
||||
export type { AvatarContainerSizeValue } from "./AvatarContainer";
|
||||
@@ -1,99 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback, useState } from "react";
|
||||
import { CardStackView } from "./CardStack.view";
|
||||
import type { CardStackProps } from "./CardStack.types";
|
||||
|
||||
const DEFAULT_TOGGLE_LABEL = "See all communication approaches";
|
||||
const DEFAULT_SHOW_LESS_LABEL = "Show less";
|
||||
|
||||
/**
|
||||
* Figma: "Utility / CardStack" (TODO(figma)). Selectable stack of cards with
|
||||
* an optional "see all"/"show less" expand toggle.
|
||||
*/
|
||||
const CardStackContainer = memo<CardStackProps>(
|
||||
({
|
||||
cards,
|
||||
selectedId: controlledSelectedId,
|
||||
selectedIds: controlledSelectedIds,
|
||||
onCardSelect: controlledOnCardSelect,
|
||||
expanded: controlledExpanded,
|
||||
onToggleExpand: controlledOnToggleExpand,
|
||||
hasMore = true,
|
||||
toggleLabel = DEFAULT_TOGGLE_LABEL,
|
||||
showLessLabel = DEFAULT_SHOW_LESS_LABEL,
|
||||
title = "",
|
||||
description = "",
|
||||
layout = "default",
|
||||
compactRecommendedLimit = 5,
|
||||
compactCardIds,
|
||||
compactDesktopLayout: compactDesktopLayoutProp = "grid",
|
||||
headerLockupSize,
|
||||
toggleAlignment = "center",
|
||||
className = "",
|
||||
}) => {
|
||||
const [internalExpanded, setInternalExpanded] = useState(false);
|
||||
const [internalSelectedIds, setInternalSelectedIds] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
const expanded =
|
||||
controlledExpanded !== undefined ? controlledExpanded : internalExpanded;
|
||||
|
||||
const handleToggleExpand = useCallback(() => {
|
||||
if (controlledOnToggleExpand) {
|
||||
controlledOnToggleExpand();
|
||||
} else {
|
||||
setInternalExpanded((prev) => !prev);
|
||||
}
|
||||
}, [controlledOnToggleExpand]);
|
||||
|
||||
const selectedIds =
|
||||
controlledSelectedIds !== undefined
|
||||
? controlledSelectedIds
|
||||
: controlledSelectedId !== undefined
|
||||
? controlledSelectedId
|
||||
? [controlledSelectedId]
|
||||
: []
|
||||
: internalSelectedIds;
|
||||
|
||||
const handleCardSelect = useCallback(
|
||||
(id: string) => {
|
||||
if (controlledOnCardSelect) {
|
||||
controlledOnCardSelect(id);
|
||||
} else {
|
||||
setInternalSelectedIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
|
||||
);
|
||||
}
|
||||
},
|
||||
[controlledOnCardSelect],
|
||||
);
|
||||
|
||||
return (
|
||||
<CardStackView
|
||||
cards={cards}
|
||||
selectedIds={selectedIds}
|
||||
onCardSelect={handleCardSelect}
|
||||
expanded={expanded}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
hasMore={hasMore}
|
||||
toggleLabel={toggleLabel}
|
||||
showLessLabel={showLessLabel}
|
||||
title={title}
|
||||
description={description}
|
||||
layout={layout}
|
||||
compactRecommendedLimit={compactRecommendedLimit}
|
||||
compactCardIds={compactCardIds}
|
||||
compactDesktopLayout={compactDesktopLayoutProp}
|
||||
headerLockupSize={headerLockupSize}
|
||||
toggleAlignment={toggleAlignment}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
CardStackContainer.displayName = "CardStack";
|
||||
|
||||
export default CardStackContainer;
|
||||
@@ -1,68 +0,0 @@
|
||||
import type { HeaderLockupSizeValue } from "../../type/HeaderLockup/HeaderLockup.types";
|
||||
|
||||
export interface CardStackItem {
|
||||
id: string;
|
||||
label: string;
|
||||
supportText?: string;
|
||||
recommended?: boolean;
|
||||
}
|
||||
|
||||
export interface CardStackProps {
|
||||
cards: CardStackItem[];
|
||||
selectedId?: string | null;
|
||||
selectedIds?: string[];
|
||||
onCardSelect?: (id: string) => void;
|
||||
expanded?: boolean;
|
||||
onToggleExpand?: () => void;
|
||||
hasMore?: boolean;
|
||||
toggleLabel?: string;
|
||||
showLessLabel?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
/** "default" = compact grid/column + expanded grid; "singleStack" = always one column, expand shows more in same stack */
|
||||
layout?: "default" | "singleStack";
|
||||
/**
|
||||
* Max recommended cards in compact (non-expanded) mode. Default 5; Figma compact stack uses 3.
|
||||
*/
|
||||
compactRecommendedLimit?: number;
|
||||
/**
|
||||
* Optional explicit list of card ids to render in the compact slot, in
|
||||
* order. When provided, this overrides the default
|
||||
* `cards.filter(c => c.recommended)` selection — the `recommended` flag
|
||||
* then only controls the visual "Recommended" badge. Used by the
|
||||
* create-flow card-deck steps so facet scores can pick the compact set
|
||||
* (and badge only the truly matched subset). Cards whose ids are not in
|
||||
* `cards` are silently dropped.
|
||||
*/
|
||||
compactCardIds?: string[];
|
||||
/**
|
||||
* At `md+`, how compact recommended cards are laid out. `flexWrap` matches Figma Flow — Compact Card Stack (three cards in a row).
|
||||
* `pyramidFive` = two rows (3 + 2) centered for five recommended cards (membership step).
|
||||
*/
|
||||
compactDesktopLayout?: "grid" | "flexWrap" | "pyramidFive";
|
||||
/** Optional title/description lockup size (create-flow passes `md`-matched `L`/`M`). Defaults to `L`. */
|
||||
headerLockupSize?: HeaderLockupSizeValue;
|
||||
/** Alignment of the expand/collapse control in `singleStack` layout (Figma right-rail: end). */
|
||||
toggleAlignment?: "center" | "end";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface CardStackViewProps {
|
||||
cards: CardStackItem[];
|
||||
selectedIds: string[];
|
||||
onCardSelect: (id: string) => void;
|
||||
expanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
hasMore: boolean;
|
||||
toggleLabel: string;
|
||||
showLessLabel: string;
|
||||
title: string;
|
||||
description: string;
|
||||
layout: "default" | "singleStack";
|
||||
compactRecommendedLimit: number;
|
||||
compactCardIds: string[] | undefined;
|
||||
compactDesktopLayout: "grid" | "flexWrap" | "pyramidFive";
|
||||
headerLockupSize: HeaderLockupSizeValue | undefined;
|
||||
toggleAlignment: "center" | "end";
|
||||
className: string;
|
||||
}
|
||||
@@ -1,383 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import HeaderLockup from "../../type/HeaderLockup";
|
||||
import Card from "../../cards/Card";
|
||||
import type { CardStackViewProps } from "./CardStack.types";
|
||||
|
||||
export function CardStackView({
|
||||
cards,
|
||||
selectedIds,
|
||||
onCardSelect,
|
||||
expanded,
|
||||
onToggleExpand,
|
||||
hasMore,
|
||||
toggleLabel,
|
||||
showLessLabel,
|
||||
title,
|
||||
description,
|
||||
layout,
|
||||
compactRecommendedLimit,
|
||||
compactCardIds,
|
||||
compactDesktopLayout,
|
||||
headerLockupSize,
|
||||
toggleAlignment,
|
||||
className,
|
||||
}: CardStackViewProps) {
|
||||
const lockupSize = headerLockupSize ?? "L";
|
||||
const isSelected = (id: string) => selectedIds.includes(id);
|
||||
// Compact: explicit `compactCardIds` (caller-driven, used by create-flow
|
||||
// facet ranker) takes precedence over the legacy `recommended`-filter so
|
||||
// the screen can show un-tagged cards in the compact slot when there is
|
||||
// no facet signal yet (CR-88 §10).
|
||||
const compactCards = (() => {
|
||||
if (compactCardIds && compactCardIds.length > 0) {
|
||||
const byId = new Map(cards.map((c) => [c.id, c]));
|
||||
return compactCardIds
|
||||
.map((id) => byId.get(id))
|
||||
.filter((c): c is (typeof cards)[number] => c !== undefined)
|
||||
.slice(0, compactRecommendedLimit);
|
||||
}
|
||||
return cards
|
||||
.filter((c) => c.recommended ?? false)
|
||||
.slice(0, compactRecommendedLimit);
|
||||
})();
|
||||
|
||||
// Single stack: always one column; expand reveals more in same stack (scrollable)
|
||||
if (layout === "singleStack") {
|
||||
const displayedCards = expanded ? cards : compactCards;
|
||||
return (
|
||||
<div className={`flex w-full flex-col gap-6 min-w-0 ${className}`}>
|
||||
{title || description ? (
|
||||
<div className="min-w-0 shrink-0">
|
||||
<HeaderLockup
|
||||
title={title}
|
||||
description={description}
|
||||
justification="center"
|
||||
size={lockupSize}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex w-full min-w-0 flex-col gap-2">
|
||||
{displayedCards.map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
label={item.label}
|
||||
supportText={item.supportText}
|
||||
recommended={item.recommended ?? false}
|
||||
selected={isSelected(item.id)}
|
||||
orientation="vertical"
|
||||
showInfoIcon={true}
|
||||
onClick={() => onCardSelect(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{hasMore ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleExpand}
|
||||
className={`font-inter text-base font-normal leading-6 text-[var(--color-gray-000)] underline hover:opacity-90 focus:outline-none cursor-pointer ${
|
||||
toggleAlignment === "end" ? "self-end" : "self-center"
|
||||
}`}
|
||||
>
|
||||
{expanded ? showLessLabel : toggleLabel}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex w-full flex-col gap-6 min-w-0 ${className}`}>
|
||||
{title || description ? (
|
||||
<div className="min-w-0">
|
||||
<HeaderLockup
|
||||
title={title}
|
||||
description={description}
|
||||
justification="center"
|
||||
size={lockupSize}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{expanded ? (
|
||||
<div className="mx-auto grid w-full max-w-[min(100%,860px)] grid-cols-1 gap-x-4 gap-y-6 md:grid-cols-2">
|
||||
{cards.map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
label={item.label}
|
||||
supportText={item.supportText}
|
||||
recommended={item.recommended ?? false}
|
||||
selected={isSelected(item.id)}
|
||||
orientation="vertical"
|
||||
showInfoIcon={true}
|
||||
onClick={() => onCardSelect(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : compactDesktopLayout === "pyramidFive" ? (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-2 md:hidden">
|
||||
{compactCards.map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
label={item.label}
|
||||
supportText={item.supportText}
|
||||
recommended={item.recommended ?? false}
|
||||
selected={isSelected(item.id)}
|
||||
orientation="horizontal"
|
||||
showInfoIcon={false}
|
||||
className="min-h-[142px]"
|
||||
onClick={() => onCardSelect(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="mx-auto hidden w-full max-w-[min(100%,860px)] md:block">
|
||||
{/*
|
||||
lg+: fixed 3 + 2 rows (no flex-wrap on the top row — avoids 2+1+2 when the first row wraps).
|
||||
md–lg: same shell as the 3-card step — each row is `flex justify-center gap-2` so cards
|
||||
stay a tight cluster with gap-2 until lg expands to the 3+2 pyramid.
|
||||
*/}
|
||||
<div className="hidden flex-col gap-2 lg:flex">
|
||||
<div className="flex justify-center gap-2">
|
||||
{compactCards.slice(0, 3).map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
label={item.label}
|
||||
supportText={item.supportText}
|
||||
recommended={item.recommended ?? false}
|
||||
selected={isSelected(item.id)}
|
||||
orientation="horizontal"
|
||||
showInfoIcon={false}
|
||||
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
|
||||
onClick={() => onCardSelect(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{compactCards.length > 3 ? (
|
||||
<div className="flex justify-center gap-2">
|
||||
{compactCards
|
||||
.slice(3, compactRecommendedLimit)
|
||||
.map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
label={item.label}
|
||||
supportText={item.supportText}
|
||||
recommended={item.recommended ?? false}
|
||||
selected={isSelected(item.id)}
|
||||
orientation="horizontal"
|
||||
showInfoIcon={false}
|
||||
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
|
||||
onClick={() => onCardSelect(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="hidden flex-col gap-2 md:flex lg:hidden">
|
||||
<div className="flex justify-center gap-2">
|
||||
{compactCards.slice(0, 2).map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
label={item.label}
|
||||
supportText={item.supportText}
|
||||
recommended={item.recommended ?? false}
|
||||
selected={isSelected(item.id)}
|
||||
orientation="horizontal"
|
||||
showInfoIcon={false}
|
||||
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
|
||||
onClick={() => onCardSelect(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-center gap-2">
|
||||
{compactCards.slice(2, 4).map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
label={item.label}
|
||||
supportText={item.supportText}
|
||||
recommended={item.recommended ?? false}
|
||||
selected={isSelected(item.id)}
|
||||
orientation="horizontal"
|
||||
showInfoIcon={false}
|
||||
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
|
||||
onClick={() => onCardSelect(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{compactCards[4] ? (
|
||||
<div className="flex justify-center gap-2">
|
||||
<Card
|
||||
id={compactCards[4].id}
|
||||
label={compactCards[4].label}
|
||||
supportText={compactCards[4].supportText}
|
||||
recommended={compactCards[4].recommended ?? false}
|
||||
selected={isSelected(compactCards[4].id)}
|
||||
orientation="horizontal"
|
||||
showInfoIcon={false}
|
||||
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
|
||||
onClick={() => onCardSelect(compactCards[4].id)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : compactDesktopLayout === "flexWrap" ? (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-2 md:hidden">
|
||||
{compactCards.map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
label={item.label}
|
||||
supportText={item.supportText}
|
||||
recommended={item.recommended ?? false}
|
||||
selected={isSelected(item.id)}
|
||||
orientation="horizontal"
|
||||
showInfoIcon={false}
|
||||
className="min-h-[142px]"
|
||||
onClick={() => onCardSelect(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* md–lg: pyramid (2 + 1), each row centered; lg+: one centered row (not edge-to-edge in a 2-col grid) */}
|
||||
{compactCards.length === 3 ? (
|
||||
<>
|
||||
<div className="mx-auto hidden w-full max-w-[min(100%,860px)] flex-col gap-2 md:flex lg:hidden">
|
||||
<div className="flex justify-center gap-2">
|
||||
{compactCards.slice(0, 2).map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
label={item.label}
|
||||
supportText={item.supportText}
|
||||
recommended={item.recommended ?? false}
|
||||
selected={isSelected(item.id)}
|
||||
orientation="horizontal"
|
||||
showInfoIcon={false}
|
||||
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
|
||||
onClick={() => onCardSelect(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<Card
|
||||
id={compactCards[2].id}
|
||||
label={compactCards[2].label}
|
||||
supportText={compactCards[2].supportText}
|
||||
recommended={compactCards[2].recommended ?? false}
|
||||
selected={isSelected(compactCards[2].id)}
|
||||
orientation="horizontal"
|
||||
showInfoIcon={false}
|
||||
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
|
||||
onClick={() => onCardSelect(compactCards[2].id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto hidden w-full max-w-[min(100%,860px)] flex-wrap justify-center gap-2 lg:flex">
|
||||
{compactCards.map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
label={item.label}
|
||||
supportText={item.supportText}
|
||||
recommended={item.recommended ?? false}
|
||||
selected={isSelected(item.id)}
|
||||
orientation="horizontal"
|
||||
showInfoIcon={false}
|
||||
className="h-[142px] min-h-[142px] max-h-[142px] w-[281px] max-w-[281px] shrink-0"
|
||||
onClick={() => onCardSelect(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="mx-auto hidden w-full max-w-[min(100%,860px)] flex-wrap justify-center gap-2 md:flex">
|
||||
{compactCards.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex w-full min-w-0 shrink-0 justify-center md:w-[281px] md:max-w-[281px]"
|
||||
>
|
||||
<Card
|
||||
id={item.id}
|
||||
label={item.label}
|
||||
supportText={item.supportText}
|
||||
recommended={item.recommended ?? false}
|
||||
selected={isSelected(item.id)}
|
||||
orientation="horizontal"
|
||||
showInfoIcon={false}
|
||||
className="h-[142px] min-h-[142px] max-h-[142px] w-full max-w-[281px]"
|
||||
onClick={() => onCardSelect(item.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Compact under 640: single column, up to 5 recommended cards */}
|
||||
<div className="flex w-full flex-col gap-2 md:hidden">
|
||||
{compactCards.map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
label={item.label}
|
||||
supportText={item.supportText}
|
||||
recommended={item.recommended ?? false}
|
||||
selected={isSelected(item.id)}
|
||||
orientation="vertical"
|
||||
showInfoIcon={true}
|
||||
onClick={() => onCardSelect(item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{/* Compact 640+: 6-col grid so each card spans 2; second row centered (cols 2–3 and 4–5) */}
|
||||
<div className="hidden md:grid grid-cols-6 gap-x-4 gap-y-6 w-full">
|
||||
{compactCards.map((item, index) => {
|
||||
const colClass =
|
||||
index <= 2
|
||||
? "md:col-span-2"
|
||||
: index === 3 && compactCards.length === 4
|
||||
? "md:col-start-3 md:col-span-2"
|
||||
: index === 3
|
||||
? "md:col-start-2 md:col-span-2"
|
||||
: "md:col-start-4 md:col-span-2";
|
||||
return (
|
||||
<div key={item.id} className={colClass}>
|
||||
<Card
|
||||
id={item.id}
|
||||
label={item.label}
|
||||
supportText={item.supportText}
|
||||
recommended={item.recommended ?? false}
|
||||
selected={isSelected(item.id)}
|
||||
orientation="horizontal"
|
||||
showInfoIcon={false}
|
||||
onClick={() => onCardSelect(item.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{hasMore ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleExpand}
|
||||
className="font-inter text-base font-normal leading-6 text-[var(--color-gray-000)] underline hover:opacity-90 focus:outline-none self-center cursor-pointer"
|
||||
>
|
||||
{expanded ? showLessLabel : toggleLabel}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default } from "./CardStack.container";
|
||||
export type { CardStackProps, CardStackItem } from "./CardStack.types";
|
||||
@@ -1,35 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { CreateFlowFooterView } from "./CreateFlowFooter.view";
|
||||
import type { CreateFlowFooterProps } from "./CreateFlowFooter.types";
|
||||
|
||||
/**
|
||||
* Figma: "Utility / CreateFlowFooter" (TODO(figma)). Sticky footer for the
|
||||
* create flow with a back action, optional secondary button, and progress bar.
|
||||
*/
|
||||
const CreateFlowFooterContainer = memo<CreateFlowFooterProps>(
|
||||
({
|
||||
secondButton,
|
||||
progressBar = true,
|
||||
proportionBarProgress,
|
||||
proportionBarVariant,
|
||||
onBackClick,
|
||||
className = "",
|
||||
}) => {
|
||||
return (
|
||||
<CreateFlowFooterView
|
||||
secondButton={secondButton}
|
||||
progressBar={progressBar}
|
||||
proportionBarProgress={proportionBarProgress}
|
||||
proportionBarVariant={proportionBarVariant}
|
||||
onBackClick={onBackClick}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
CreateFlowFooterContainer.displayName = "CreateFlowFooter";
|
||||
|
||||
export default CreateFlowFooterContainer;
|
||||
@@ -1,39 +0,0 @@
|
||||
import type {
|
||||
ProportionBarState,
|
||||
ProportionBarVariant,
|
||||
} from "../../progress/ProportionBar/ProportionBar.types";
|
||||
|
||||
/**
|
||||
* Type definitions for CreateFlowFooter component
|
||||
*
|
||||
* Footer component for the create rule flow with progress bar and buttons.
|
||||
*/
|
||||
export interface CreateFlowFooterProps {
|
||||
/**
|
||||
* The second button (typically "Next" button) to display on the right side
|
||||
*/
|
||||
secondButton?: React.ReactNode;
|
||||
/**
|
||||
* Whether to show the progress bar
|
||||
* @default true
|
||||
*/
|
||||
progressBar?: boolean;
|
||||
/**
|
||||
* `ProportionBar` state when the bar is shown (driven by create-flow step).
|
||||
* @default "1-0"
|
||||
*/
|
||||
proportionBarProgress?: ProportionBarState;
|
||||
/**
|
||||
* `ProportionBar` layout variant (Figma create-flow footer uses `segmented`).
|
||||
* @default "default"
|
||||
*/
|
||||
proportionBarVariant?: ProportionBarVariant;
|
||||
/**
|
||||
* Callback function for Back button click
|
||||
*/
|
||||
onBackClick?: () => void;
|
||||
/**
|
||||
* Additional CSS classes
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import ProportionBar from "../../progress/ProportionBar";
|
||||
import Button from "../../buttons/Button";
|
||||
import type { CreateFlowFooterProps } from "./CreateFlowFooter.types";
|
||||
|
||||
export function CreateFlowFooterView({
|
||||
secondButton,
|
||||
progressBar = true,
|
||||
proportionBarProgress = "1-0",
|
||||
proportionBarVariant: proportionBarVariantProp,
|
||||
onBackClick,
|
||||
className = "",
|
||||
}: CreateFlowFooterProps) {
|
||||
const proportionBarVariant = proportionBarVariantProp ?? "default";
|
||||
return (
|
||||
<footer
|
||||
className={`bg-black w-full ${className}`}
|
||||
role="contentinfo"
|
||||
aria-label="Create Flow Footer"
|
||||
>
|
||||
{/* Progress Bar - Top */}
|
||||
{progressBar && (
|
||||
<div className="px-[var(--spacing-measures-spacing-500,20px)] md:px-[var(--spacing-measures-spacing-1200,48px)] pt-[var(--spacing-measures-spacing-300,12px)]">
|
||||
<ProportionBar
|
||||
progress={proportionBarProgress}
|
||||
variant={proportionBarVariant}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Buttons Container */}
|
||||
<div className="flex items-center justify-between mx-auto max-w-[639px] md:max-w-[1920px] px-[var(--spacing-measures-spacing-500,20px)] md:px-[var(--spacing-measures-spacing-1200,48px)] py-[var(--spacing-measures-spacing-300,12px)] gap-[var(--spacing-measures-spacing-300,12px)]">
|
||||
{/* Back Button - Left */}
|
||||
<Button
|
||||
buttonType="ghost"
|
||||
palette="default"
|
||||
size="xsmall"
|
||||
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={onBackClick}
|
||||
disabled={!onBackClick}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
|
||||
{/* Second Button - Right */}
|
||||
{secondButton && <div className="flex-shrink-0">{secondButton}</div>}
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default } from "./CreateFlowFooter.container";
|
||||
export type { CreateFlowFooterProps } from "./CreateFlowFooter.types";
|
||||
@@ -1,55 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { CreateFlowTopNavView } from "./CreateFlowTopNav.view";
|
||||
import type { CreateFlowTopNavProps } from "./CreateFlowTopNav.types";
|
||||
|
||||
/**
|
||||
* Figma: "Utility / CreateFlowTopNav" (TODO(figma)). Top navigation bar for
|
||||
* the create flow with exit, share, export, and edit actions.
|
||||
*/
|
||||
const CreateFlowTopNavContainer = memo<CreateFlowTopNavProps>(
|
||||
({
|
||||
hasShare = false,
|
||||
hasExport = false,
|
||||
hasEdit = false,
|
||||
saveDraftOnExit = false,
|
||||
onShare,
|
||||
onExport,
|
||||
onEdit,
|
||||
onExit,
|
||||
buttonPalette,
|
||||
className = "",
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
|
||||
const handleExit = (options?: { saveDraft?: boolean }) => {
|
||||
if (onExit) {
|
||||
onExit(options);
|
||||
} else {
|
||||
// Default behavior: navigate to home
|
||||
router.push("/");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<CreateFlowTopNavView
|
||||
hasShare={hasShare}
|
||||
hasExport={hasExport}
|
||||
hasEdit={hasEdit}
|
||||
saveDraftOnExit={saveDraftOnExit}
|
||||
onShare={onShare}
|
||||
onExport={onExport}
|
||||
onEdit={onEdit}
|
||||
onExit={handleExit}
|
||||
buttonPalette={buttonPalette}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
CreateFlowTopNavContainer.displayName = "CreateFlowTopNav";
|
||||
|
||||
export default CreateFlowTopNavContainer;
|
||||
@@ -1,56 +0,0 @@
|
||||
/**
|
||||
* Type definitions for CreateFlowTopNav component
|
||||
*
|
||||
* Top navigation bar for the create rule flow.
|
||||
* Includes logo and action buttons (Share, Export, Edit, Exit).
|
||||
*/
|
||||
|
||||
export interface CreateFlowTopNavProps {
|
||||
/**
|
||||
* Whether to show the Share button
|
||||
* @default false
|
||||
*/
|
||||
hasShare?: boolean;
|
||||
/**
|
||||
* Whether to show the Export button
|
||||
* @default false
|
||||
*/
|
||||
hasExport?: boolean;
|
||||
/**
|
||||
* Whether to show the Edit button
|
||||
* @default false
|
||||
*/
|
||||
hasEdit?: boolean;
|
||||
/**
|
||||
* When true, exit control is "Save & Exit" and `onExit` receives `{ saveDraft: true }`.
|
||||
* When false, shows "Exit" and `{ saveDraft: false }` (caller may confirm data loss).
|
||||
* @default false
|
||||
*/
|
||||
saveDraftOnExit?: boolean;
|
||||
/**
|
||||
* Callback when Share button is clicked
|
||||
*/
|
||||
onShare?: () => void;
|
||||
/**
|
||||
* Callback when Export button is clicked
|
||||
*/
|
||||
onExport?: () => void;
|
||||
/**
|
||||
* Callback when Edit button is clicked
|
||||
*/
|
||||
onEdit?: () => void;
|
||||
/**
|
||||
* Callback when Exit/Save & Exit button is clicked.
|
||||
* When `saveDraftOnExit` is true, called with `{ saveDraft: true }`.
|
||||
*/
|
||||
onExit?: (options?: { saveDraft?: boolean }) => void;
|
||||
/**
|
||||
* Palette for nav buttons (e.g. "inverse" on completed page to match teal background)
|
||||
* @default "default"
|
||||
*/
|
||||
buttonPalette?: "default" | "inverse";
|
||||
/**
|
||||
* Additional CSS classes
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import Logo from "../../asset/logo";
|
||||
import Button from "../../buttons/Button";
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import type { CreateFlowTopNavProps } from "./CreateFlowTopNav.types";
|
||||
|
||||
const exitButtonFigmaClass =
|
||||
"!rounded-[var(--radius-measures-radius-full,9999px)] !border-[1.25px] !px-[var(--spacing-measures-spacing-250,10px)] !py-[var(--spacing-measures-spacing-200,8px)] md:!text-[12px] md:!leading-[14px]";
|
||||
|
||||
export function CreateFlowTopNavView({
|
||||
hasShare = false,
|
||||
hasExport = false,
|
||||
hasEdit = false,
|
||||
saveDraftOnExit = false,
|
||||
onShare,
|
||||
onExport,
|
||||
onEdit,
|
||||
onExit,
|
||||
buttonPalette = "default",
|
||||
className = "",
|
||||
}: CreateFlowTopNavProps) {
|
||||
const t = useTranslation("create.topNav");
|
||||
const exitButtonText = saveDraftOnExit ? t("saveAndExit") : t("exit");
|
||||
|
||||
return (
|
||||
<header
|
||||
className={`bg-black w-full ${className}`}
|
||||
role="banner"
|
||||
aria-label="Create Rule Flow Navigation"
|
||||
>
|
||||
<nav
|
||||
className="flex items-center justify-between mx-auto max-w-[639px] md:max-w-[1920px] px-[var(--spacing-measures-spacing-500,20px)] md:px-[48px] py-[var(--spacing-measures-spacing-300,12px)] md:py-[var(--spacing-measures-spacing-016,16px)]"
|
||||
role="navigation"
|
||||
aria-label="Create Flow Navigation"
|
||||
>
|
||||
<Logo size="createFlow" wordmark palette={buttonPalette} />
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-[var(--spacing-scale-012,12px)]">
|
||||
{hasShare && (
|
||||
<Button
|
||||
buttonType="outline"
|
||||
palette={buttonPalette}
|
||||
size="xsmall"
|
||||
onClick={onShare}
|
||||
ariaLabel={t("shareAriaLabel")}
|
||||
className="md:!text-[12px] md:!leading-[14px] !text-[10px] !leading-[12px] !px-[var(--spacing-scale-006,6px)] md:!px-[var(--spacing-scale-008,8px)] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]"
|
||||
>
|
||||
{t("share")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{hasExport && (
|
||||
<Button
|
||||
buttonType="outline"
|
||||
palette={buttonPalette}
|
||||
size="xsmall"
|
||||
onClick={onExport}
|
||||
ariaLabel={t("exportAriaLabel")}
|
||||
className="justify-center gap-[var(--spacing-scale-002,2px)] !pl-[var(--spacing-scale-012,12px)] !pr-[var(--spacing-scale-006,6px)] md:!pr-[var(--spacing-scale-006,6px)] !text-[10px] md:!text-[12px] !leading-[12px] md:!leading-[14px] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]"
|
||||
>
|
||||
<span>{t("export")}</span>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="shrink-0 md:w-[14px] md:h-[14px]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{hasEdit && (
|
||||
<Button
|
||||
buttonType="outline"
|
||||
palette={buttonPalette}
|
||||
size="xsmall"
|
||||
onClick={onEdit}
|
||||
ariaLabel={t("editAriaLabel")}
|
||||
className="md:!text-[12px] md:!leading-[14px] !text-[10px] !leading-[12px] !px-[var(--spacing-scale-006,6px)] md:!px-[var(--spacing-scale-008,8px)] !py-[6px] md:!py-[8px] !border md:!border-[1.5px]"
|
||||
>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
buttonType="outline"
|
||||
palette={buttonPalette}
|
||||
size="xsmall"
|
||||
type="button"
|
||||
onClick={() => void onExit?.({ saveDraft: saveDraftOnExit })}
|
||||
ariaLabel={exitButtonText}
|
||||
className={`md:!text-[12px] md:!leading-[14px] !text-[10px] !leading-[12px] !py-[6px] md:!py-[8px] shrink-0 ${exitButtonFigmaClass}`}
|
||||
>
|
||||
{exitButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default } from "./CreateFlowTopNav.container";
|
||||
export type { CreateFlowTopNavProps } from "./CreateFlowTopNav.types";
|
||||
@@ -1,44 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import DecisionMakingSidebarView from "./DecisionMakingSidebar.view";
|
||||
import type { DecisionMakingSidebarProps } from "./DecisionMakingSidebar.types";
|
||||
|
||||
/**
|
||||
* Figma: "Utility / DecisionMakingSidebar" (TODO(figma)). Sidebar pairing a
|
||||
* header lockup with an `InfoMessageBox` checklist for decision-making screens.
|
||||
*/
|
||||
const DecisionMakingSidebarContainer = memo<DecisionMakingSidebarProps>(
|
||||
({
|
||||
title,
|
||||
description,
|
||||
messageBoxTitle,
|
||||
messageBoxItems,
|
||||
messageBoxCheckedIds,
|
||||
onMessageBoxCheckboxChange,
|
||||
size: sizeProp = "L",
|
||||
justification: justificationProp = "left",
|
||||
className = "",
|
||||
}) => {
|
||||
const size = sizeProp;
|
||||
const justification = justificationProp;
|
||||
|
||||
return (
|
||||
<DecisionMakingSidebarView
|
||||
title={title}
|
||||
description={description}
|
||||
messageBoxTitle={messageBoxTitle}
|
||||
messageBoxItems={messageBoxItems}
|
||||
messageBoxCheckedIds={messageBoxCheckedIds}
|
||||
onMessageBoxCheckboxChange={onMessageBoxCheckboxChange}
|
||||
size={size}
|
||||
justification={justification}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
DecisionMakingSidebarContainer.displayName = "DecisionMakingSidebar";
|
||||
|
||||
export default DecisionMakingSidebarContainer;
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
import type {
|
||||
HeaderLockupJustificationValue,
|
||||
HeaderLockupSizeValue,
|
||||
} from "../../type/HeaderLockup/HeaderLockup.types";
|
||||
import type { InfoMessageBoxItem } from "../InfoMessageBox/InfoMessageBox.types";
|
||||
|
||||
export interface DecisionMakingSidebarProps {
|
||||
title: string;
|
||||
/** Description text or ReactNode (e.g. with underlined "add") */
|
||||
description?: string | ReactNode;
|
||||
messageBoxTitle: string;
|
||||
messageBoxItems: InfoMessageBoxItem[];
|
||||
messageBoxCheckedIds?: string[];
|
||||
onMessageBoxCheckboxChange?: (id: string, checked: boolean) => void;
|
||||
size?: HeaderLockupSizeValue;
|
||||
justification?: HeaderLockupJustificationValue;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface DecisionMakingSidebarViewProps {
|
||||
title: string;
|
||||
description: string | ReactNode | undefined;
|
||||
messageBoxTitle: string;
|
||||
messageBoxItems: InfoMessageBoxItem[];
|
||||
messageBoxCheckedIds: string[] | undefined;
|
||||
onMessageBoxCheckboxChange:
|
||||
| ((id: string, checked: boolean) => void)
|
||||
| undefined;
|
||||
size: "L" | "M";
|
||||
justification: "left" | "center";
|
||||
className: string;
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import HeaderLockup from "../../type/HeaderLockup";
|
||||
import InfoMessageBox from "../InfoMessageBox";
|
||||
import type { DecisionMakingSidebarViewProps } from "./DecisionMakingSidebar.types";
|
||||
|
||||
function DecisionMakingSidebarView({
|
||||
title,
|
||||
description,
|
||||
messageBoxTitle,
|
||||
messageBoxItems,
|
||||
messageBoxCheckedIds,
|
||||
onMessageBoxCheckboxChange,
|
||||
size,
|
||||
justification,
|
||||
className,
|
||||
}: DecisionMakingSidebarViewProps) {
|
||||
const isL = size === "L";
|
||||
const isLeft = justification === "left";
|
||||
const isStringDescription = typeof description === "string";
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-3 w-full min-w-0 ${className}`}>
|
||||
{isStringDescription ? (
|
||||
<HeaderLockup
|
||||
title={title}
|
||||
description={description as string}
|
||||
justification={justification}
|
||||
size={size}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`flex flex-col gap-[var(--measures-spacing-200,8px)] py-[12px] relative ${
|
||||
isLeft ? "items-start" : "items-center"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center relative shrink-0 w-full">
|
||||
<h1
|
||||
className={`flex-[1_0_0] min-h-px min-w-px overflow-hidden relative text-[var(--color-content-default-primary,white)] text-ellipsis whitespace-pre-wrap ${
|
||||
isLeft ? "text-left" : "text-center"
|
||||
} ${
|
||||
isL
|
||||
? "font-bricolage-grotesque font-extrabold text-[36px] leading-[44px]"
|
||||
: "font-bricolage-grotesque font-bold text-[28px] leading-[36px]"
|
||||
}`}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
{description != null && (
|
||||
<p
|
||||
className={`font-inter font-normal max-w-[640px] overflow-hidden relative shrink-0 text-[var(--color-content-default-tertiary,#b4b4b4)] text-ellipsis w-full whitespace-pre-wrap ${
|
||||
isLeft ? "" : "text-center"
|
||||
} ${
|
||||
isL ? "text-[18px] leading-[1.3]" : "text-[14px] leading-[20px]"
|
||||
}`}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<InfoMessageBox
|
||||
title={messageBoxTitle}
|
||||
items={messageBoxItems}
|
||||
checkedIds={messageBoxCheckedIds}
|
||||
onCheckboxChange={onMessageBoxCheckboxChange ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
DecisionMakingSidebarView.displayName = "DecisionMakingSidebarView";
|
||||
|
||||
export default memo(DecisionMakingSidebarView);
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default } from "./DecisionMakingSidebar.container";
|
||||
export type { DecisionMakingSidebarProps } from "./DecisionMakingSidebar.types";
|
||||
@@ -1,60 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useCallback, useState } from "react";
|
||||
import InfoMessageBoxView from "./InfoMessageBox.view";
|
||||
import type { InfoMessageBoxProps } from "./InfoMessageBox.types";
|
||||
|
||||
/**
|
||||
* Figma: "Utility / InfoMessageBox" (TODO(figma)). Bordered message box that
|
||||
* lists checkbox items under a title with an optional leading icon.
|
||||
*/
|
||||
const InfoMessageBoxContainer = memo<InfoMessageBoxProps>(
|
||||
({
|
||||
title,
|
||||
items,
|
||||
icon,
|
||||
checkedIds: controlledCheckedIds,
|
||||
onCheckboxChange,
|
||||
className = "",
|
||||
}) => {
|
||||
const [internalCheckedIds, setInternalCheckedIds] = useState<string[]>([]);
|
||||
const checkedIds =
|
||||
controlledCheckedIds !== undefined
|
||||
? controlledCheckedIds
|
||||
: internalCheckedIds;
|
||||
|
||||
const handleGroupChange = useCallback(
|
||||
(newValue: string[]) => {
|
||||
if (controlledCheckedIds === undefined) {
|
||||
setInternalCheckedIds(newValue);
|
||||
}
|
||||
if (!onCheckboxChange) return;
|
||||
const prevSet = new Set(checkedIds);
|
||||
const newSet = new Set(newValue);
|
||||
items.forEach((item) => {
|
||||
const nowChecked = newSet.has(item.id);
|
||||
const wasChecked = prevSet.has(item.id);
|
||||
if (nowChecked !== wasChecked) {
|
||||
onCheckboxChange(item.id, nowChecked);
|
||||
}
|
||||
});
|
||||
},
|
||||
[checkedIds, controlledCheckedIds, items, onCheckboxChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<InfoMessageBoxView
|
||||
title={title}
|
||||
items={items}
|
||||
icon={icon}
|
||||
checkedIds={checkedIds}
|
||||
onGroupChange={handleGroupChange}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
InfoMessageBoxContainer.displayName = "InfoMessageBox";
|
||||
|
||||
export default InfoMessageBoxContainer;
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface InfoMessageBoxItem {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface InfoMessageBoxProps {
|
||||
/** Heading text for the message box */
|
||||
title: string;
|
||||
/** Checkbox items (id used as value for CheckboxGroup) */
|
||||
items: InfoMessageBoxItem[];
|
||||
/** Optional icon (e.g. exclamation); default exclamation icon used if not provided */
|
||||
icon?: ReactNode;
|
||||
/** Controlled checked ids; if undefined, uncontrolled */
|
||||
checkedIds?: string[];
|
||||
/** Callback when a checkbox is toggled */
|
||||
onCheckboxChange?: (id: string, checked: boolean) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface InfoMessageBoxViewProps {
|
||||
title: string;
|
||||
items: InfoMessageBoxItem[];
|
||||
icon?: ReactNode;
|
||||
checkedIds: string[];
|
||||
onGroupChange: (value: string[]) => void;
|
||||
className: string;
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import CheckboxGroup from "../../controls/CheckboxGroup";
|
||||
import type { InfoMessageBoxViewProps } from "./InfoMessageBox.types";
|
||||
|
||||
/** Exclamation icon per Figma 19751:35053 – vertical bar + dot inside circle; circle bg white 10% opacity, no border */
|
||||
function ExclamationIconInline() {
|
||||
const fillColor = "var(--color-content-default-primary, white)";
|
||||
return (
|
||||
<svg
|
||||
width={24}
|
||||
height={24}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="shrink-0"
|
||||
aria-hidden
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(255,255,255,0.1)" />
|
||||
<path
|
||||
d="M11.25 14.0386V5.53857H12.75V14.0386H11.25ZM11.25 18.4616V16.9616H12.75V18.4616H11.25Z"
|
||||
fill={fillColor}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoMessageBoxView({
|
||||
title,
|
||||
items,
|
||||
icon,
|
||||
checkedIds,
|
||||
onGroupChange,
|
||||
className,
|
||||
}: InfoMessageBoxViewProps) {
|
||||
const options = items.map((item) => ({
|
||||
value: item.id,
|
||||
label: item.label,
|
||||
}));
|
||||
|
||||
const handleChange = (data: { value: string[] }) => {
|
||||
onGroupChange(data.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col gap-[12px] p-[var(--spacing-measures-spacing-500,20px)] rounded-[var(--measures-radius-300,12px)] border-l-2 border-solid border-[var(--color-border-default-secondary,#1f1f1f)] bg-[var(--color-content-inverse-secondary,#1f1f1f)] w-full min-w-0 ${className}`}
|
||||
role="region"
|
||||
aria-label={title}
|
||||
>
|
||||
<div className="flex items-center gap-[var(--measures-spacing-200,8px)] min-w-0">
|
||||
<div
|
||||
className="relative shrink-0 size-6 flex items-center justify-center"
|
||||
data-name="Asset / Icon / exclamation"
|
||||
>
|
||||
{icon ?? <ExclamationIconInline />}
|
||||
</div>
|
||||
<p className="font-inter font-medium text-[14px] leading-[16px] text-[var(--color-content-default-primary,white)] min-w-0">
|
||||
{title}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-[12px] [&_label]:gap-[6px] [&_label_span]:text-[12px] [&_label_span]:leading-[16px] [&_label_span]:opacity-80 pl-8">
|
||||
<CheckboxGroup
|
||||
mode="standard"
|
||||
value={checkedIds}
|
||||
onChange={handleChange}
|
||||
options={options}
|
||||
aria-label={title}
|
||||
className="flex flex-col gap-[12px] !space-y-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(InfoMessageBoxView);
|
||||
@@ -1,5 +0,0 @@
|
||||
export { default } from "./InfoMessageBox.container";
|
||||
export type {
|
||||
InfoMessageBoxProps,
|
||||
InfoMessageBoxItem,
|
||||
} from "./InfoMessageBox.types";
|
||||
@@ -1,40 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import InputLabelView from "./InputLabel.view";
|
||||
import type { InputLabelProps } from "./InputLabel.types";
|
||||
|
||||
/**
|
||||
* Figma: "Utility / InputLabel" (TODO(figma)). Reusable form-input label with
|
||||
* optional asterisk, help icon, and helper text.
|
||||
*/
|
||||
const InputLabelContainer = memo<InputLabelProps>(
|
||||
({
|
||||
label,
|
||||
helpIcon = false,
|
||||
asterisk = false,
|
||||
helperText = false,
|
||||
size: sizeProp = "s",
|
||||
palette: paletteProp = "default",
|
||||
className = "",
|
||||
}) => {
|
||||
const size = sizeProp;
|
||||
const palette = paletteProp;
|
||||
|
||||
return (
|
||||
<InputLabelView
|
||||
label={label}
|
||||
helpIcon={helpIcon}
|
||||
asterisk={asterisk}
|
||||
helperText={helperText}
|
||||
size={size}
|
||||
palette={palette}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
InputLabelContainer.displayName = "InputLabel";
|
||||
|
||||
export default InputLabelContainer;
|
||||
@@ -1,42 +0,0 @@
|
||||
export type InputLabelSizeValue = "s" | "m";
|
||||
export type InputLabelPaletteValue = "default" | "inverse";
|
||||
|
||||
export interface InputLabelProps {
|
||||
/**
|
||||
* The label text to display
|
||||
*/
|
||||
label: string;
|
||||
/**
|
||||
* Show help icon next to label
|
||||
*/
|
||||
helpIcon?: boolean;
|
||||
/**
|
||||
* Show asterisk (*) to indicate required field
|
||||
*/
|
||||
asterisk?: boolean;
|
||||
/**
|
||||
* Helper text to display on the right side.
|
||||
* If boolean true, shows "Optional text".
|
||||
* If string, shows the provided text.
|
||||
*/
|
||||
helperText?: boolean | string;
|
||||
/**
|
||||
* Size variant: "s" (small) or "m" (medium)
|
||||
*/
|
||||
size?: InputLabelSizeValue;
|
||||
/**
|
||||
* Palette variant: "default" or "inverse"
|
||||
*/
|
||||
palette?: InputLabelPaletteValue;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface InputLabelViewProps {
|
||||
label: string;
|
||||
helpIcon: boolean;
|
||||
asterisk: boolean;
|
||||
helperText: boolean | string;
|
||||
size: "s" | "m";
|
||||
palette: "default" | "inverse";
|
||||
className: string;
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { getAssetPath, ASSETS } from "../../../../lib/assetUtils";
|
||||
import type { InputLabelViewProps } from "./InputLabel.types";
|
||||
|
||||
function InputLabelView({
|
||||
label,
|
||||
helpIcon,
|
||||
asterisk,
|
||||
helperText,
|
||||
size,
|
||||
palette,
|
||||
className = "",
|
||||
}: InputLabelViewProps) {
|
||||
const isSmall = size === "s";
|
||||
const isInverse = palette === "inverse";
|
||||
|
||||
// Size-based typography
|
||||
const labelTextSize = isSmall
|
||||
? "text-[length:var(--sizing-350,14px)] leading-[20px]"
|
||||
: "text-[length:var(--sizing-400,16px)] leading-[24px]";
|
||||
|
||||
const helperTextSize = isSmall
|
||||
? "text-[length:var(--measures-sizing-250,10px)] leading-[var(--measures-spacing-350,14px)]"
|
||||
: "text-[length:var(--sizing-300,12px)] leading-[16px]";
|
||||
|
||||
const asteriskSize = isSmall
|
||||
? "text-[length:var(--measures-sizing-250,10px)] leading-[var(--measures-spacing-300,12px)]"
|
||||
: "text-[length:var(--measures-spacing-300,12px)] leading-[var(--measures-spacing-300,12px)]";
|
||||
|
||||
// Palette-based colors
|
||||
const labelColor = isInverse
|
||||
? "text-[color:var(--color-content-inverse-secondary,#1f1f1f)]"
|
||||
: "text-[color:var(--color-content-default-secondary,#d2d2d2)]";
|
||||
|
||||
const helperTextColor =
|
||||
"text-[color:var(--color-content-default-tertiary,#b4b4b4)]";
|
||||
|
||||
// Layout: S uses flex-wrap with baseline, M uses flex with center
|
||||
const containerClass = isSmall
|
||||
? "flex flex-wrap gap-[var(--measures-spacing-200,4px_8px)] items-baseline pr-[var(--measures-spacing-100,4px)] relative w-full"
|
||||
: "flex gap-[4px] items-center relative w-full";
|
||||
|
||||
const labelContainerClass = isSmall
|
||||
? "flex gap-[var(--measures-spacing-050,2px)] items-center relative shrink-0"
|
||||
: "flex gap-[var(--measures-spacing-100,4px)] items-center relative shrink-0";
|
||||
|
||||
const helpIconSize = isSmall ? "size-[12px]" : "size-[16px]";
|
||||
|
||||
// Help icon color filter based on palette
|
||||
// Default: Light yellow (#f6f06f / rgba(246, 240, 111, 1)) - SVG already has this color
|
||||
// Inverse: Dark yellow (#8c8505 / rgba(140, 133, 5, 1))
|
||||
// For default, no filter needed as SVG already has the correct yellow
|
||||
// For inverse, darken the yellow
|
||||
const helpIconFilter = isInverse
|
||||
? "brightness(0.57) saturate(100%)" // Dark yellow (#8c8505) - darken the existing yellow
|
||||
: undefined; // No filter for default - use SVG's native yellow color
|
||||
|
||||
return (
|
||||
<div className={`${containerClass} ${className}`}>
|
||||
<div className={labelContainerClass}>
|
||||
<div className="flex gap-px items-start relative shrink-0">
|
||||
<p
|
||||
className={`font-inter font-normal ${labelTextSize} ${labelColor} relative shrink-0`}
|
||||
>
|
||||
{label}
|
||||
</p>
|
||||
{asterisk && (
|
||||
<p
|
||||
className={`font-inter font-medium ${asteriskSize} relative shrink-0 text-[color:var(--color-content-default-negative-primary,#ea4845)]`}
|
||||
>
|
||||
*
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{helpIcon && (
|
||||
<div className={`relative shrink-0 ${helpIconSize}`}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- icon from asset path */}
|
||||
<img
|
||||
src={getAssetPath(ASSETS.ICON_HELP)}
|
||||
alt="Help"
|
||||
className="block max-w-none size-full"
|
||||
style={
|
||||
helpIconFilter
|
||||
? {
|
||||
filter: helpIconFilter,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{helperText && (
|
||||
<p
|
||||
className={`flex-[1_0_0] font-inter font-normal ${helperTextSize} min-h-px min-w-px relative ${helperTextColor} text-right`}
|
||||
>
|
||||
{typeof helperText === "string" ? helperText : "Optional text"}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
InputLabelView.displayName = "InputLabelView";
|
||||
|
||||
export default memo(InputLabelView);
|
||||
@@ -1,3 +0,0 @@
|
||||
import InputLabel from "./InputLabel.container";
|
||||
|
||||
export default InputLabel;
|
||||
@@ -1,18 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { ModalFooterView } from "./ModalFooter.view";
|
||||
import type { ModalFooterProps } from "./ModalFooter.types";
|
||||
|
||||
/**
|
||||
* Figma: "Utility / ModalFooter" (TODO(figma)).
|
||||
* Sticky modal footer slot used by the create-flow + login modals to host
|
||||
* primary/secondary actions.
|
||||
*/
|
||||
const ModalFooterContainer = memo<ModalFooterProps>((props) => {
|
||||
return <ModalFooterView {...props} />;
|
||||
});
|
||||
|
||||
ModalFooterContainer.displayName = "ModalFooter";
|
||||
|
||||
export default ModalFooterContainer;
|
||||
@@ -1,25 +0,0 @@
|
||||
export interface ModalFooterProps {
|
||||
showBackButton?: boolean;
|
||||
showNextButton?: boolean;
|
||||
onBack?: () => void;
|
||||
onNext?: () => void;
|
||||
/**
|
||||
* Custom back button text. If not provided, uses localized "Back" from common.json
|
||||
*/
|
||||
backButtonText?: string;
|
||||
/**
|
||||
* Custom next button text. If not provided, uses localized "Next" from common.json
|
||||
*/
|
||||
nextButtonText?: string;
|
||||
nextButtonDisabled?: boolean;
|
||||
currentStep?: number;
|
||||
totalSteps?: number;
|
||||
/**
|
||||
* Whether to show the stepper component in the footer (Figma prop).
|
||||
* Defaults to true if currentStep and totalSteps are provided.
|
||||
* @default true
|
||||
*/
|
||||
stepper?: boolean;
|
||||
footerContent?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "../../../contexts/MessagesContext";
|
||||
import Button from "../../buttons/Button";
|
||||
import Stepper from "../../progress/Stepper";
|
||||
import type { ModalFooterProps } from "./ModalFooter.types";
|
||||
|
||||
export function ModalFooterView({
|
||||
showBackButton = false,
|
||||
showNextButton = false,
|
||||
onBack,
|
||||
onNext,
|
||||
backButtonText,
|
||||
nextButtonText,
|
||||
nextButtonDisabled = false,
|
||||
currentStep,
|
||||
totalSteps,
|
||||
stepper: stepperProp,
|
||||
footerContent,
|
||||
className = "",
|
||||
}: ModalFooterProps) {
|
||||
const t = useTranslation("common");
|
||||
|
||||
// Use localized defaults if text not provided
|
||||
const defaultBackText = backButtonText || t("buttons.back");
|
||||
const defaultNextText = nextButtonText || t("buttons.next");
|
||||
|
||||
// Determine if stepper should be shown
|
||||
// Defaults to true if currentStep and totalSteps are provided, unless explicitly set to false
|
||||
const shouldShowStepper =
|
||||
stepperProp !== undefined
|
||||
? stepperProp
|
||||
: currentStep !== undefined && totalSteps !== undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`h-[64px] bg-[var(--color-surface-default-primary)] rounded-bl-[var(--radius-300,12px)] rounded-br-[var(--radius-300,12px)] shrink-0 relative ${className}`}
|
||||
>
|
||||
{/* Back Button - Absolutely positioned bottom left */}
|
||||
{showBackButton && (
|
||||
<div className="absolute left-[16px] top-[12px]">
|
||||
<Button
|
||||
buttonType="outline"
|
||||
palette="default"
|
||||
size="medium"
|
||||
onClick={onBack}
|
||||
>
|
||||
{defaultBackText}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stepper (Centered) */}
|
||||
{shouldShowStepper && currentStep && totalSteps && (
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
<Stepper active={currentStep} totalSteps={totalSteps} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Next Button - Absolutely positioned bottom right */}
|
||||
{showNextButton && (
|
||||
<div className="absolute right-[16px] top-[12px]">
|
||||
<Button
|
||||
buttonType="filled"
|
||||
palette="default"
|
||||
size="medium"
|
||||
onClick={onNext}
|
||||
disabled={nextButtonDisabled}
|
||||
>
|
||||
{defaultNextText}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom Footer Content */}
|
||||
{footerContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default } from "./ModalFooter.container";
|
||||
export type { ModalFooterProps } from "./ModalFooter.types";
|
||||
@@ -1,18 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { ModalHeaderView } from "./ModalHeader.view";
|
||||
import type { ModalHeaderProps } from "./ModalHeader.types";
|
||||
|
||||
/**
|
||||
* Figma: "Utility / ModalHeader" (TODO(figma)).
|
||||
* Sticky 48px modal header with optional close (left) and more-options
|
||||
* (right) icon buttons.
|
||||
*/
|
||||
const ModalHeaderContainer = memo<ModalHeaderProps>((props) => {
|
||||
return <ModalHeaderView {...props} />;
|
||||
});
|
||||
|
||||
ModalHeaderContainer.displayName = "ModalHeader";
|
||||
|
||||
export default ModalHeaderContainer;
|
||||
@@ -1,7 +0,0 @@
|
||||
export interface ModalHeaderProps {
|
||||
onClose?: () => void;
|
||||
onMoreOptions?: () => void;
|
||||
showCloseButton?: boolean;
|
||||
showMoreOptionsButton?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { getAssetPath } from "../../../../lib/assetUtils";
|
||||
import type { ModalHeaderProps } from "./ModalHeader.types";
|
||||
|
||||
const iconButtonClass =
|
||||
"absolute bg-[var(--color-surface-default-secondary)] h-[24px] w-[24px] rounded-full flex items-center justify-center cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-invert-primary)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-surface-default-primary)]";
|
||||
|
||||
export function ModalHeaderView({
|
||||
onClose,
|
||||
onMoreOptions,
|
||||
showCloseButton = true,
|
||||
showMoreOptionsButton = true,
|
||||
className = "",
|
||||
}: ModalHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
className={`border-b border-[var(--color-border-default-secondary)] h-[48px] shrink-0 sticky top-0 bg-[var(--color-surface-default-primary)] z-[2] ${className}`}
|
||||
>
|
||||
{/* Close Button - Left */}
|
||||
{showCloseButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={`${iconButtonClass} left-[24px] top-[12px]`}
|
||||
aria-label="Close dialog"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- icon asset */}
|
||||
<img
|
||||
src={getAssetPath("assets/Icon_Close.svg")}
|
||||
alt=""
|
||||
className="w-[16px] h-[16px]"
|
||||
style={{
|
||||
filter: "brightness(0) invert(1)",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* More Options Button - Right */}
|
||||
{showMoreOptionsButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMoreOptions}
|
||||
className={`${iconButtonClass} right-[24px] top-[12px]`}
|
||||
aria-label="More options"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle cx="4" cy="8" r="1.5" fill="white" />
|
||||
<circle cx="8" cy="8" r="1.5" fill="white" />
|
||||
<circle cx="12" cy="8" r="1.5" fill="white" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default } from "./ModalHeader.container";
|
||||
export type { ModalHeaderProps } from "./ModalHeader.types";
|
||||
@@ -5,7 +5,7 @@ import { ScrollbarView } from "./Scrollbar.view";
|
||||
import type { ScrollbarProps } from "./Scrollbar.types";
|
||||
|
||||
/**
|
||||
* Figma: "Utility / Scrollbar" (TODO(figma)).
|
||||
* Figma: "Utility / Scrollbar".
|
||||
* Custom-styled scrollable wrapper. Most surfaces should attach
|
||||
* `SCROLLBAR_DESIGN_CLASS` directly instead of nesting through this view.
|
||||
*/
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { memo } from "react";
|
||||
|
||||
const Separator = memo(() => {
|
||||
return (
|
||||
<div className="flex flex-col items-center self-stretch">
|
||||
<div className="flex items-start self-stretch h-px w-full bg-[var(--border-color-default-secondary)]" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Separator.displayName = "Separator";
|
||||
|
||||
export default Separator;
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from "./Separator";
|
||||
@@ -10,7 +10,7 @@ const DEFAULT_LABELS: Record<TagProps["variant"], string> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Figma: "Utility / Tag" (TODO(figma)). Small status pill (e.g. "RECOMMENDED"
|
||||
* Figma: "Utility / Tag". Small status pill (e.g. "RECOMMENDED"
|
||||
* or "SELECTED") used to annotate cards and options.
|
||||
*/
|
||||
const TagContainer = memo<TagProps>(({ variant, children, className = "" }) => {
|
||||
|
||||
@@ -3,19 +3,20 @@
|
||||
import type { TagViewProps } from "./Tag.types";
|
||||
|
||||
/**
|
||||
* Tag view – Figma 17861-22238.
|
||||
* Recommended: light yellow bg (#F6EEA7), dark text (#3F3F3F).
|
||||
* Selected: dark bg (#3F3F3F), white text (#FFFFFF).
|
||||
* Typography: Inter Medium 10px, line-height 12, uppercase.
|
||||
* Tag view – Figma 17861-22238; **`Selection`** (Figma Card / CardSelection, `16775:28762`).
|
||||
*/
|
||||
export function TagView({ variant, children, className }: TagViewProps) {
|
||||
const isRecommended = variant === "recommended";
|
||||
const bgClass = isRecommended ? "bg-[#F6EEA7]" : "bg-[#3F3F3F]";
|
||||
const textClass = isRecommended ? "text-[#3F3F3F]" : "text-[#FFFFFF]";
|
||||
const bgClass = isRecommended
|
||||
? "bg-[var(--color-surface-inverse-brand-accent)]"
|
||||
: "bg-[var(--color-gray-1000)]";
|
||||
const textClass = isRecommended
|
||||
? "text-[var(--color-content-inverse-brand-primary)]"
|
||||
: "text-[var(--color-gray-000)]";
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex w-[6rem] min-w-[6rem] items-center justify-center rounded px-2 py-0.5 font-inter text-[10px] font-medium uppercase leading-3 ${bgClass} ${textClass} ${className}`}
|
||||
className={`inline-flex shrink-0 items-center justify-center rounded-[2px] px-1 py-0.5 font-inter text-[10px] font-medium uppercase leading-3 ${bgClass} ${textClass} ${className}`}
|
||||
role="status"
|
||||
>
|
||||
{children}
|
||||
|
||||
Reference in New Issue
Block a user