Refactor Components #29
@@ -2,10 +2,12 @@ name: CI Pipeline
|
|||||||
run-name: "${{ gitea.actor }} triggered CI pipeline"
|
run-name: "${{ gitea.actor }} triggered CI pipeline"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch: {}
|
workflow_dispatch: {} # Manual trigger only - run tests locally before merging
|
||||||
pull_request:
|
# Auto-runs disabled for solo development
|
||||||
branches: [main]
|
# Re-enable when ready for collaborators:
|
||||||
types: [opened, reopened, synchronize]
|
# pull_request:
|
||||||
|
# branches: [main]
|
||||||
|
# types: [opened, reopened, synchronize]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: "20"
|
NODE_VERSION: "20"
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { memo } from "react";
|
|
||||||
import ContentLockup from "./ContentLockup";
|
|
||||||
import Button from "./Button";
|
|
||||||
import { useAnalytics } from "../hooks";
|
|
||||||
|
|
||||||
interface AskOrganizerProps {
|
|
||||||
title?: string;
|
|
||||||
subtitle?: string;
|
|
||||||
description?: string;
|
|
||||||
buttonText?: string;
|
|
||||||
buttonHref?: string;
|
|
||||||
className?: string;
|
|
||||||
variant?: "centered" | "left-aligned" | "compact" | "inverse";
|
|
||||||
onContactClick?: (_data: {
|
|
||||||
event: string;
|
|
||||||
component: string;
|
|
||||||
variant: string;
|
|
||||||
buttonText: string;
|
|
||||||
buttonHref: string;
|
|
||||||
timestamp: string;
|
|
||||||
}) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AskOrganizer = memo<AskOrganizerProps>(
|
|
||||||
({
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
description,
|
|
||||||
buttonText = "Ask an organizer",
|
|
||||||
buttonHref = "#",
|
|
||||||
className = "",
|
|
||||||
variant = "centered",
|
|
||||||
onContactClick,
|
|
||||||
}) => {
|
|
||||||
const { trackEvent, trackCustomEvent } = useAnalytics();
|
|
||||||
|
|
||||||
// Analytics tracking for contact button clicks
|
|
||||||
const handleContactClick = (
|
|
||||||
_event: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>,
|
|
||||||
) => {
|
|
||||||
// Track with standard analytics
|
|
||||||
trackEvent({
|
|
||||||
event: "contact_button_click",
|
|
||||||
category: "engagement",
|
|
||||||
label: "ask_organizer",
|
|
||||||
component: "AskOrganizer",
|
|
||||||
variant,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Also call custom callback if provided
|
|
||||||
trackCustomEvent(
|
|
||||||
"contact_button_click",
|
|
||||||
{
|
|
||||||
component: "AskOrganizer",
|
|
||||||
variant,
|
|
||||||
buttonText,
|
|
||||||
buttonHref,
|
|
||||||
},
|
|
||||||
onContactClick,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Variant-specific styling
|
|
||||||
const variantStyles: Record<
|
|
||||||
string,
|
|
||||||
{ container: string; buttonContainer: string }
|
|
||||||
> = {
|
|
||||||
centered: {
|
|
||||||
container: "text-center",
|
|
||||||
buttonContainer: "flex justify-center",
|
|
||||||
},
|
|
||||||
"left-aligned": {
|
|
||||||
container: "text-left",
|
|
||||||
buttonContainer: "flex justify-start",
|
|
||||||
},
|
|
||||||
compact: {
|
|
||||||
container: "text-center",
|
|
||||||
buttonContainer: "flex justify-center",
|
|
||||||
},
|
|
||||||
inverse: {
|
|
||||||
container: "text-center",
|
|
||||||
buttonContainer: "flex justify-center",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = variantStyles[variant] || variantStyles.centered;
|
|
||||||
|
|
||||||
// Section padding based on variant
|
|
||||||
const sectionPadding =
|
|
||||||
variant === "compact"
|
|
||||||
? "py-[var(--spacing-scale-016)] px-[var(--spacing-scale-016)] md:py-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-032)]"
|
|
||||||
: "py-[var(--spacing-scale-032)] px-[var(--spacing-scale-032)] md:py-[var(--spacing-scale-096)] md:px-[var(--spacing-scale-064)]";
|
|
||||||
|
|
||||||
// Gap between content and button based on variant
|
|
||||||
const contentGap =
|
|
||||||
variant === "compact"
|
|
||||||
? "gap-[var(--spacing-scale-020)]"
|
|
||||||
: "gap-[var(--spacing-scale-040)]";
|
|
||||||
|
|
||||||
const labelledBy = title ? "ask-organizer-headline" : undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
className={`${sectionPadding} ${className}`}
|
|
||||||
aria-labelledby={labelledBy}
|
|
||||||
aria-label={labelledBy ? undefined : "Ask an organizer"}
|
|
||||||
tabIndex={-1}
|
|
||||||
>
|
|
||||||
<div className={`flex flex-col ${contentGap} ${styles.container}`}>
|
|
||||||
{/* Content Lockup */}
|
|
||||||
<ContentLockup
|
|
||||||
title={title}
|
|
||||||
subtitle={subtitle}
|
|
||||||
description={description}
|
|
||||||
variant={variant === "inverse" ? "ask-inverse" : "ask"}
|
|
||||||
alignment={variant === "left-aligned" ? "left" : "center"}
|
|
||||||
titleId={labelledBy}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Button */}
|
|
||||||
<div className={styles.buttonContainer}>
|
|
||||||
<Button
|
|
||||||
href={buttonHref}
|
|
||||||
size="large"
|
|
||||||
variant={variant === "inverse" ? "primary" : "default"}
|
|
||||||
className="xl:!px-[var(--spacing-scale-020)] xl:!py-[var(--spacing-scale-012)] xl:!text-[24px] xl:!leading-[28px]"
|
|
||||||
onClick={handleContactClick}
|
|
||||||
ariaLabel={`${buttonText} - Contact an organizer for help`}
|
|
||||||
>
|
|
||||||
{buttonText}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
AskOrganizer.displayName = "AskOrganizer";
|
|
||||||
|
|
||||||
export default AskOrganizer;
|
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { memo } from "react";
|
||||||
|
import { useAnalytics } from "../../hooks";
|
||||||
|
import AskOrganizerView from "./AskOrganizer.view";
|
||||||
|
import type {
|
||||||
|
AskOrganizerProps,
|
||||||
|
AskOrganizerVariant,
|
||||||
|
} from "./AskOrganizer.types";
|
||||||
|
|
||||||
|
const VARIANT_STYLES: Record<
|
||||||
|
AskOrganizerVariant,
|
||||||
|
{ container: string; buttonContainer: string }
|
||||||
|
> = {
|
||||||
|
centered: {
|
||||||
|
container: "text-center",
|
||||||
|
buttonContainer: "flex justify-center",
|
||||||
|
},
|
||||||
|
"left-aligned": {
|
||||||
|
container: "text-left",
|
||||||
|
buttonContainer: "flex justify-start",
|
||||||
|
},
|
||||||
|
compact: {
|
||||||
|
container: "text-center",
|
||||||
|
buttonContainer: "flex justify-center",
|
||||||
|
},
|
||||||
|
inverse: {
|
||||||
|
container: "text-center",
|
||||||
|
buttonContainer: "flex justify-center",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const AskOrganizerContainer = memo<AskOrganizerProps>(
|
||||||
|
({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
description,
|
||||||
|
buttonText = "Ask an organizer",
|
||||||
|
buttonHref = "#",
|
||||||
|
className = "",
|
||||||
|
variant = "centered",
|
||||||
|
onContactClick,
|
||||||
|
}) => {
|
||||||
|
const { trackEvent, trackCustomEvent } = useAnalytics();
|
||||||
|
|
||||||
|
const resolvedVariant: AskOrganizerVariant = variant ?? "centered";
|
||||||
|
const styles = VARIANT_STYLES[resolvedVariant] ?? VARIANT_STYLES.centered;
|
||||||
|
|
||||||
|
const sectionPadding =
|
||||||
|
resolvedVariant === "compact"
|
||||||
|
? "py-[var(--spacing-scale-016)] px-[var(--spacing-scale-016)] md:py-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-032)]"
|
||||||
|
: "py-[var(--spacing-scale-032)] px-[var(--spacing-scale-032)] md:py-[var(--spacing-scale-096)] md:px-[var(--spacing-scale-064)]";
|
||||||
|
|
||||||
|
const contentGap =
|
||||||
|
resolvedVariant === "compact"
|
||||||
|
? "gap-[var(--spacing-scale-020)]"
|
||||||
|
: "gap-[var(--spacing-scale-040)]";
|
||||||
|
|
||||||
|
const labelledBy = title ? "ask-organizer-headline" : undefined;
|
||||||
|
|
||||||
|
const handleContactClick = (
|
||||||
|
event: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>,
|
||||||
|
) => {
|
||||||
|
trackEvent({
|
||||||
|
event: "contact_button_click",
|
||||||
|
category: "engagement",
|
||||||
|
label: "ask_organizer",
|
||||||
|
component: "AskOrganizer",
|
||||||
|
variant: resolvedVariant,
|
||||||
|
});
|
||||||
|
|
||||||
|
trackCustomEvent(
|
||||||
|
"contact_button_click",
|
||||||
|
{
|
||||||
|
component: "AskOrganizer",
|
||||||
|
variant: resolvedVariant,
|
||||||
|
buttonText,
|
||||||
|
buttonHref,
|
||||||
|
},
|
||||||
|
onContactClick as
|
||||||
|
| ((_data: Record<string, unknown>) => void)
|
||||||
|
| undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Preserve existing button behavior (no preventDefault here)
|
||||||
|
// while still tracking analytics.
|
||||||
|
return event;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AskOrganizerView
|
||||||
|
title={title}
|
||||||
|
subtitle={subtitle}
|
||||||
|
description={description}
|
||||||
|
buttonText={buttonText}
|
||||||
|
buttonHref={buttonHref}
|
||||||
|
className={className}
|
||||||
|
sectionPadding={sectionPadding}
|
||||||
|
contentGap={`${contentGap} ${styles.container}`}
|
||||||
|
buttonContainerClass={styles.buttonContainer}
|
||||||
|
variant={resolvedVariant}
|
||||||
|
labelledBy={labelledBy}
|
||||||
|
onContactClick={handleContactClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
AskOrganizerContainer.displayName = "AskOrganizer";
|
||||||
|
|
||||||
|
export default AskOrganizerContainer;
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type React from "react";
|
||||||
|
|
||||||
|
export type AskOrganizerVariant =
|
||||||
|
| "centered"
|
||||||
|
| "left-aligned"
|
||||||
|
| "compact"
|
||||||
|
| "inverse";
|
||||||
|
|
||||||
|
export interface AskOrganizerProps {
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
description?: string;
|
||||||
|
buttonText?: string;
|
||||||
|
buttonHref?: string;
|
||||||
|
className?: string;
|
||||||
|
variant?: AskOrganizerVariant;
|
||||||
|
onContactClick?: (_data: {
|
||||||
|
event: string;
|
||||||
|
component: string;
|
||||||
|
variant: string;
|
||||||
|
buttonText: string;
|
||||||
|
buttonHref: string;
|
||||||
|
timestamp: string;
|
||||||
|
}) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AskOrganizerViewProps {
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
description?: string;
|
||||||
|
buttonText: string;
|
||||||
|
buttonHref: string;
|
||||||
|
className: string;
|
||||||
|
sectionPadding: string;
|
||||||
|
contentGap: string;
|
||||||
|
buttonContainerClass: string;
|
||||||
|
variant: AskOrganizerVariant;
|
||||||
|
labelledBy?: string;
|
||||||
|
onContactClick: (
|
||||||
|
_event: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>,
|
||||||
|
) => void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import ContentLockup from "../ContentLockup";
|
||||||
|
import Button from "../Button";
|
||||||
|
import type { AskOrganizerViewProps } from "./AskOrganizer.types";
|
||||||
|
|
||||||
|
function AskOrganizerView({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
description,
|
||||||
|
buttonText,
|
||||||
|
buttonHref,
|
||||||
|
className,
|
||||||
|
sectionPadding,
|
||||||
|
contentGap,
|
||||||
|
buttonContainerClass,
|
||||||
|
variant,
|
||||||
|
labelledBy,
|
||||||
|
onContactClick,
|
||||||
|
}: AskOrganizerViewProps) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={`${sectionPadding} ${className}`}
|
||||||
|
aria-labelledby={labelledBy}
|
||||||
|
aria-label={labelledBy ? undefined : "Ask an organizer"}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<div className={`flex flex-col ${contentGap}`}>
|
||||||
|
{/* Content Lockup */}
|
||||||
|
<ContentLockup
|
||||||
|
title={title}
|
||||||
|
subtitle={subtitle}
|
||||||
|
description={description}
|
||||||
|
variant={variant === "inverse" ? "ask-inverse" : "ask"}
|
||||||
|
alignment={variant === "left-aligned" ? "left" : "center"}
|
||||||
|
titleId={labelledBy}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Button */}
|
||||||
|
<div className={buttonContainerClass}>
|
||||||
|
<Button
|
||||||
|
href={buttonHref}
|
||||||
|
size="large"
|
||||||
|
variant={variant === "inverse" ? "primary" : "default"}
|
||||||
|
className="xl:!px-[var(--spacing-scale-020)] xl:!py-[var(--spacing-scale-012)] xl:!text-[24px] xl:!leading-[28px]"
|
||||||
|
onClick={onContactClick}
|
||||||
|
ariaLabel={`${buttonText} - Contact an organizer for help`}
|
||||||
|
>
|
||||||
|
{buttonText}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AskOrganizerView;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from "./AskOrganizer.container";
|
||||||
|
export * from "./AskOrganizer.types";
|
||||||
@@ -1,36 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import { useComponentId } from "../hooks";
|
import { useComponentId } from "../../hooks";
|
||||||
|
import { CheckboxView } from "./Checkbox.view";
|
||||||
|
import type { CheckboxProps } from "./Checkbox.types";
|
||||||
|
|
||||||
interface CheckboxProps {
|
const CheckboxContainer = memo<CheckboxProps>(
|
||||||
checked?: boolean;
|
|
||||||
mode?: "standard" | "inverse";
|
|
||||||
state?: "default" | "hover" | "focus";
|
|
||||||
disabled?: boolean;
|
|
||||||
label?: string;
|
|
||||||
className?: string;
|
|
||||||
onChange?: (_data: {
|
|
||||||
checked: boolean;
|
|
||||||
value?: string;
|
|
||||||
event: React.MouseEvent | React.KeyboardEvent;
|
|
||||||
}) => void;
|
|
||||||
id?: string;
|
|
||||||
name?: string;
|
|
||||||
value?: string;
|
|
||||||
ariaLabel?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checkbox
|
|
||||||
* A basic controlled checkbox with visual modes and interaction states.
|
|
||||||
* This is a minimal first pass; visuals will be refined collaboratively.
|
|
||||||
*/
|
|
||||||
const Checkbox = memo<CheckboxProps>(
|
|
||||||
({
|
({
|
||||||
checked = false,
|
checked = false,
|
||||||
mode = "standard", // "standard" | "inverse"
|
mode = "standard",
|
||||||
state = "default", // "default" | "hover" | "focus"
|
state = "default",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
label,
|
label,
|
||||||
className = "",
|
className = "",
|
||||||
@@ -109,72 +88,39 @@ const Checkbox = memo<CheckboxProps>(
|
|||||||
...props,
|
...props,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => {
|
||||||
|
if (e.key === " " || e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleToggle(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label
|
<CheckboxView
|
||||||
className={`inline-flex items-center gap-[8px] cursor-pointer select-none ${
|
labelId={labelId}
|
||||||
disabled ? "opacity-60 cursor-not-allowed" : ""
|
checked={checked}
|
||||||
} ${className}`}
|
mode={mode}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
state={state}
|
||||||
>
|
disabled={disabled}
|
||||||
<span
|
label={label}
|
||||||
{...accessibilityProps}
|
name={name}
|
||||||
onClick={handleToggle}
|
value={value}
|
||||||
onKeyDown={(e) => {
|
className={className}
|
||||||
if (e.key === " " || e.key === "Enter") {
|
combinedBoxStyles={combinedBoxStyles}
|
||||||
e.preventDefault();
|
defaultOutlineClass={defaultOutlineClass}
|
||||||
handleToggle(e);
|
conditionalHoverOutlineClass={conditionalHoverOutlineClass}
|
||||||
}
|
conditionalFocusClass={conditionalFocusClass}
|
||||||
}}
|
backgroundWhenChecked={backgroundWhenChecked}
|
||||||
className={`${combinedBoxStyles} ${defaultOutlineClass} ${conditionalHoverOutlineClass} ${conditionalFocusClass} p-[var(--measures-spacing-004)]`}
|
checkGlyphColor={checkGlyphColor}
|
||||||
style={{
|
labelColor={labelColor}
|
||||||
backgroundColor: backgroundWhenChecked,
|
accessibilityProps={accessibilityProps}
|
||||||
}}
|
onToggle={handleToggle}
|
||||||
>
|
onKeyDown={handleKeyDown}
|
||||||
{/* Simple check glyph */}
|
/>
|
||||||
<svg
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 12 12"
|
|
||||||
aria-hidden="true"
|
|
||||||
focusable="false"
|
|
||||||
>
|
|
||||||
<polyline
|
|
||||||
points="2.5 6 5 8.5 10 3.5"
|
|
||||||
stroke={checkGlyphColor}
|
|
||||||
strokeWidth="1.25"
|
|
||||||
fill="none"
|
|
||||||
strokeLinecap="square"
|
|
||||||
strokeLinejoin="miter"
|
|
||||||
vectorEffect="non-scaling-stroke"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
{label && (
|
|
||||||
<span
|
|
||||||
id={labelId}
|
|
||||||
className="font-inter text-[14px] leading-[18px]"
|
|
||||||
style={{ color: labelColor }}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{/* Hidden native input for form compatibility (optional for now) */}
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
name={name}
|
|
||||||
value={value}
|
|
||||||
checked={checked}
|
|
||||||
onChange={() => {}}
|
|
||||||
tabIndex={-1}
|
|
||||||
aria-hidden="true"
|
|
||||||
className="sr-only"
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
Checkbox.displayName = "Checkbox";
|
CheckboxContainer.displayName = "Checkbox";
|
||||||
|
|
||||||
export default Checkbox;
|
export default CheckboxContainer;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
export interface CheckboxProps {
|
||||||
|
checked?: boolean;
|
||||||
|
mode?: "standard" | "inverse";
|
||||||
|
state?: "default" | "hover" | "focus";
|
||||||
|
disabled?: boolean;
|
||||||
|
label?: string;
|
||||||
|
className?: string;
|
||||||
|
onChange?: (_data: {
|
||||||
|
checked: boolean;
|
||||||
|
value?: string;
|
||||||
|
event: React.MouseEvent | React.KeyboardEvent;
|
||||||
|
}) => void;
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
value?: string;
|
||||||
|
ariaLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CheckboxViewProps {
|
||||||
|
labelId: string;
|
||||||
|
checked: boolean;
|
||||||
|
mode: "standard" | "inverse";
|
||||||
|
state: "default" | "hover" | "focus";
|
||||||
|
disabled: boolean;
|
||||||
|
label?: string;
|
||||||
|
name?: string;
|
||||||
|
value?: string;
|
||||||
|
className: string;
|
||||||
|
combinedBoxStyles: string;
|
||||||
|
defaultOutlineClass: string;
|
||||||
|
conditionalHoverOutlineClass: string;
|
||||||
|
conditionalFocusClass: string;
|
||||||
|
backgroundWhenChecked: string;
|
||||||
|
checkGlyphColor: string;
|
||||||
|
labelColor: string;
|
||||||
|
accessibilityProps: React.HTMLAttributes<HTMLSpanElement>;
|
||||||
|
onToggle: (_e: React.MouseEvent | React.KeyboardEvent) => void;
|
||||||
|
onKeyDown: (_e: React.KeyboardEvent<HTMLSpanElement>) => void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { CheckboxViewProps } from "./Checkbox.types";
|
||||||
|
|
||||||
|
export function CheckboxView({
|
||||||
|
labelId,
|
||||||
|
checked,
|
||||||
|
disabled,
|
||||||
|
label,
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
className,
|
||||||
|
combinedBoxStyles,
|
||||||
|
defaultOutlineClass,
|
||||||
|
conditionalHoverOutlineClass,
|
||||||
|
conditionalFocusClass,
|
||||||
|
backgroundWhenChecked,
|
||||||
|
checkGlyphColor,
|
||||||
|
labelColor,
|
||||||
|
accessibilityProps,
|
||||||
|
onToggle,
|
||||||
|
onKeyDown,
|
||||||
|
}: CheckboxViewProps) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
className={`inline-flex items-center gap-[8px] cursor-pointer select-none ${
|
||||||
|
disabled ? "opacity-60 cursor-not-allowed" : ""
|
||||||
|
} ${className}`}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
{...accessibilityProps}
|
||||||
|
onClick={onToggle}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
className={`${combinedBoxStyles} ${defaultOutlineClass} ${conditionalHoverOutlineClass} ${conditionalFocusClass} p-[var(--measures-spacing-004)]`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: backgroundWhenChecked,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Simple check glyph */}
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 12 12"
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
|
>
|
||||||
|
<polyline
|
||||||
|
points="2.5 6 5 8.5 10 3.5"
|
||||||
|
stroke={checkGlyphColor}
|
||||||
|
strokeWidth="1.25"
|
||||||
|
fill="none"
|
||||||
|
strokeLinecap="square"
|
||||||
|
strokeLinejoin="miter"
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{label && (
|
||||||
|
<span
|
||||||
|
id={labelId}
|
||||||
|
className="font-inter text-[14px] leading-[18px]"
|
||||||
|
style={{ color: labelColor }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Hidden native input for form compatibility (optional for now) */}
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => {}}
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="sr-only"
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from "./Checkbox.container";
|
||||||
|
export type { CheckboxProps } from "./Checkbox.types";
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import Header from "./Header";
|
|
||||||
import HomeHeader from "./HomeHeader";
|
|
||||||
|
|
||||||
export default function ConditionalHeader() {
|
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
// Show HomeHeader only on the homepage (/)
|
|
||||||
if (pathname === "/") {
|
|
||||||
return <HomeHeader />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show regular Header on all other pages
|
|
||||||
return <Header />;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { memo } from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { ConditionalHeaderView } from "./ConditionalHeader.view";
|
||||||
|
import type { ConditionalHeaderProps } from "./ConditionalHeader.types";
|
||||||
|
|
||||||
|
const ConditionalHeaderContainer = memo<ConditionalHeaderProps>(() => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const isHomePage = pathname === "/";
|
||||||
|
|
||||||
|
return <ConditionalHeaderView isHomePage={isHomePage} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
ConditionalHeaderContainer.displayName = "ConditionalHeader";
|
||||||
|
|
||||||
|
export default ConditionalHeaderContainer;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export interface ConditionalHeaderProps {
|
||||||
|
// Currently no props, but keeping interface for future extensibility
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConditionalHeaderViewProps {
|
||||||
|
isHomePage: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import HomeHeader from "../HomeHeader";
|
||||||
|
import Header from "../Header";
|
||||||
|
import type { ConditionalHeaderViewProps } from "./ConditionalHeader.types";
|
||||||
|
|
||||||
|
export function ConditionalHeaderView({
|
||||||
|
isHomePage,
|
||||||
|
}: ConditionalHeaderViewProps) {
|
||||||
|
if (isHomePage) {
|
||||||
|
return <HomeHeader />;
|
||||||
|
}
|
||||||
|
return <Header />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from "./ConditionalHeader.container";
|
||||||
|
export type { ConditionalHeaderProps } from "./ConditionalHeader.types";
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { memo } from "react";
|
|
||||||
import { getAssetPath, ASSETS } from "../../lib/assetUtils";
|
|
||||||
import type { BlogPost } from "../../lib/content";
|
|
||||||
|
|
||||||
interface ContentContainerProps {
|
|
||||||
post: BlogPost;
|
|
||||||
width?: string;
|
|
||||||
size?: "xs" | "responsive";
|
|
||||||
}
|
|
||||||
|
|
||||||
const ContentContainer = memo<ContentContainerProps>(
|
|
||||||
({ post, width = "200px", size = "responsive" }) => {
|
|
||||||
// Get the corresponding icon based on the same logic as background images
|
|
||||||
const getIconImage = (slug: string): string => {
|
|
||||||
const icons = [
|
|
||||||
getAssetPath(ASSETS.ICON_1),
|
|
||||||
getAssetPath(ASSETS.ICON_2),
|
|
||||||
getAssetPath(ASSETS.ICON_3),
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!slug) return icons[0];
|
|
||||||
|
|
||||||
// Use the same cycling logic as background images to ensure matching
|
|
||||||
const slugOrder = [
|
|
||||||
"building-community-trust",
|
|
||||||
"operational-security-mutual-aid",
|
|
||||||
"making-decisions-without-hierarchy",
|
|
||||||
"resolving-active-conflicts",
|
|
||||||
];
|
|
||||||
const index = slugOrder.indexOf(slug);
|
|
||||||
const finalIndex = index >= 0 ? index % icons.length : 0;
|
|
||||||
return icons[finalIndex];
|
|
||||||
};
|
|
||||||
|
|
||||||
const iconImage = getIconImage(post.slug);
|
|
||||||
|
|
||||||
// Choose styling based on size prop
|
|
||||||
const containerClasses =
|
|
||||||
size === "xs"
|
|
||||||
? "relative z-20 h-full flex flex-col gap-[var(--measures-spacing-012)]"
|
|
||||||
: "relative z-20 h-full flex flex-col gap-[var(--measures-spacing-012)] sm:gap-[var(--measures-spacing-016)] md:gap-[18px] lg:gap-[var(--measures-spacing-024)]";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`${containerClasses} ${
|
|
||||||
size === "responsive"
|
|
||||||
? "max-w-[298px] sm:max-w-[479px] lg:max-w-[365px] xl:max-w-[623px]"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
style={size === "responsive" ? {} : { width }}
|
|
||||||
>
|
|
||||||
{/* Content Container - gap between icon and text */}
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
size === "xs"
|
|
||||||
? "flex flex-col gap-[var(--measures-spacing-008)]"
|
|
||||||
: "flex flex-col gap-[var(--measures-spacing-008)] sm:gap-[var(--measures-spacing-012)] md:gap-[var(--measures-spacing-008)] lg:gap-[var(--measures-spacing-016)] xl:gap-[var(--measures-spacing-004)]"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* Icon */}
|
|
||||||
<div className="w-[60px] h-[30px] flex items-center justify-center">
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={iconImage}
|
|
||||||
alt={`Icon for ${post.frontmatter.title}`}
|
|
||||||
className="w-[60px] h-[30px] object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Text Container */}
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
size === "xs"
|
|
||||||
? "flex flex-col gap-[var(--measures-spacing-004)]"
|
|
||||||
: "flex flex-col gap-[var(--measures-spacing-004)] md:gap-[var(--measures-spacing-002)] lg:gap-[var(--measures-spacing-004)]"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{/* Title */}
|
|
||||||
<h3
|
|
||||||
className={
|
|
||||||
size === "xs"
|
|
||||||
? "font-bricolage font-medium text-[18px] leading-[120%] text-[var(--color-content-inverse-brand-royal)] group-hover:text-blue-200 transition-colors"
|
|
||||||
: "font-bricolage font-medium text-[18px] leading-[120%] sm:text-[24px] sm:leading-[24px] md:text-[32px] md:leading-[110%] lg:text-[44px] lg:leading-[110%] xl:text-[64px] xl:leading-[110%] text-[var(--color-content-inverse-brand-royal)] group-hover:text-blue-200 transition-colors"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{post.frontmatter.title}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<p
|
|
||||||
className={
|
|
||||||
size === "xs"
|
|
||||||
? "font-inter font-normal text-[12px] leading-[16px] text-[var(--color-content-inverse-brand-royal)] max-w-md"
|
|
||||||
: "font-inter font-normal text-[12px] leading-[16px] sm:text-[14px] sm:leading-[20px] md:text-[14px] md:leading-[20px] lg:text-[18px] lg:leading-[130%] xl:text-[24px] xl:leading-[32px] text-[var(--color-content-inverse-brand-royal)]"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{post.frontmatter.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Metadata Container - horizontal with 8px gap */}
|
|
||||||
<div className="flex items-center gap-[var(--measures-spacing-008)]">
|
|
||||||
{/* Author Name */}
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
size === "xs"
|
|
||||||
? "font-inter font-normal text-[10px] leading-[14px] text-[var(--color-content-inverse-brand-royal)]"
|
|
||||||
: "font-inter font-normal text-[10px] leading-[14px] md:text-[12px] md:leading-[16px] lg:text-[14px] lg:leading-[20px] xl:text-[18px] xl:leading-[130%] text-[var(--color-content-inverse-brand-royal)]"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{post.frontmatter.author}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Date */}
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
size === "xs"
|
|
||||||
? "font-inter font-normal text-[10px] leading-[14px] text-[var(--color-content-inverse-brand-royal)]"
|
|
||||||
: "font-inter font-normal text-[10px] leading-[14px] md:text-[12px] md:leading-[16px] lg:text-[14px] lg:leading-[20px] xl:text-[18px] xl:leading-[130%] text-[var(--color-content-inverse-brand-royal)]"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{new Date(post.frontmatter.date).toLocaleDateString("en-US", {
|
|
||||||
year: "numeric",
|
|
||||||
month: "long",
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
ContentContainer.displayName = "ContentContainer";
|
|
||||||
|
|
||||||
export default ContentContainer;
|
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { memo } from "react";
|
||||||
|
import { getAssetPath, ASSETS } from "../../../lib/assetUtils";
|
||||||
|
import ContentContainerView from "./ContentContainer.view";
|
||||||
|
import type { ContentContainerProps } from "./ContentContainer.types";
|
||||||
|
|
||||||
|
const ContentContainerContainer = memo<ContentContainerProps>(
|
||||||
|
({ post, width = "200px", size = "responsive" }) => {
|
||||||
|
// Get the corresponding icon based on the same logic as background images
|
||||||
|
const getIconImage = (slug: string): string => {
|
||||||
|
const icons = [
|
||||||
|
getAssetPath(ASSETS.ICON_1),
|
||||||
|
getAssetPath(ASSETS.ICON_2),
|
||||||
|
getAssetPath(ASSETS.ICON_3),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!slug) return icons[0];
|
||||||
|
|
||||||
|
// Use the same cycling logic as background images to ensure matching
|
||||||
|
const slugOrder = [
|
||||||
|
"building-community-trust",
|
||||||
|
"operational-security-mutual-aid",
|
||||||
|
"making-decisions-without-hierarchy",
|
||||||
|
"resolving-active-conflicts",
|
||||||
|
];
|
||||||
|
const index = slugOrder.indexOf(slug);
|
||||||
|
const finalIndex = index >= 0 ? index % icons.length : 0;
|
||||||
|
return icons[finalIndex];
|
||||||
|
};
|
||||||
|
|
||||||
|
const iconImage = getIconImage(post.slug);
|
||||||
|
|
||||||
|
// Format date
|
||||||
|
const formattedDate = new Date(post.frontmatter.date).toLocaleDateString(
|
||||||
|
"en-US",
|
||||||
|
{
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Choose styling based on size prop
|
||||||
|
const containerClasses =
|
||||||
|
size === "xs"
|
||||||
|
? "relative z-20 h-full flex flex-col gap-[var(--measures-spacing-012)]"
|
||||||
|
: "relative z-20 h-full flex flex-col gap-[var(--measures-spacing-012)] sm:gap-[var(--measures-spacing-016)] md:gap-[18px] lg:gap-[var(--measures-spacing-024)]";
|
||||||
|
|
||||||
|
const contentGapClasses =
|
||||||
|
size === "xs"
|
||||||
|
? "flex flex-col gap-[var(--measures-spacing-008)]"
|
||||||
|
: "flex flex-col gap-[var(--measures-spacing-008)] sm:gap-[var(--measures-spacing-012)] md:gap-[var(--measures-spacing-008)] lg:gap-[var(--measures-spacing-016)] xl:gap-[var(--measures-spacing-004)]";
|
||||||
|
|
||||||
|
const textGapClasses =
|
||||||
|
size === "xs"
|
||||||
|
? "flex flex-col gap-[var(--measures-spacing-004)]"
|
||||||
|
: "flex flex-col gap-[var(--measures-spacing-004)] md:gap-[var(--measures-spacing-002)] lg:gap-[var(--measures-spacing-004)]";
|
||||||
|
|
||||||
|
const titleClasses =
|
||||||
|
size === "xs"
|
||||||
|
? "font-bricolage font-medium text-[18px] leading-[120%] text-[var(--color-content-inverse-brand-royal)] group-hover:text-blue-200 transition-colors"
|
||||||
|
: "font-bricolage font-medium text-[18px] leading-[120%] sm:text-[24px] sm:leading-[24px] md:text-[32px] md:leading-[110%] lg:text-[44px] lg:leading-[110%] xl:text-[64px] xl:leading-[110%] text-[var(--color-content-inverse-brand-royal)] group-hover:text-blue-200 transition-colors";
|
||||||
|
|
||||||
|
const descriptionClasses =
|
||||||
|
size === "xs"
|
||||||
|
? "font-inter font-normal text-[12px] leading-[16px] text-[var(--color-content-inverse-brand-royal)] max-w-md"
|
||||||
|
: "font-inter font-normal text-[12px] leading-[16px] sm:text-[14px] sm:leading-[20px] md:text-[14px] md:leading-[20px] lg:text-[18px] lg:leading-[130%] xl:text-[24px] xl:leading-[32px] text-[var(--color-content-inverse-brand-royal)]";
|
||||||
|
|
||||||
|
const authorClasses =
|
||||||
|
size === "xs"
|
||||||
|
? "font-inter font-normal text-[10px] leading-[14px] text-[var(--color-content-inverse-brand-royal)]"
|
||||||
|
: "font-inter font-normal text-[10px] leading-[14px] md:text-[12px] md:leading-[16px] lg:text-[14px] lg:leading-[20px] xl:text-[18px] xl:leading-[130%] text-[var(--color-content-inverse-brand-royal)]";
|
||||||
|
|
||||||
|
const dateClasses =
|
||||||
|
size === "xs"
|
||||||
|
? "font-inter font-normal text-[10px] leading-[14px] text-[var(--color-content-inverse-brand-royal)]"
|
||||||
|
: "font-inter font-normal text-[10px] leading-[14px] md:text-[12px] md:leading-[16px] lg:text-[14px] lg:leading-[20px] xl:text-[18px] xl:leading-[130%] text-[var(--color-content-inverse-brand-royal)]";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContentContainerView
|
||||||
|
post={post}
|
||||||
|
width={width}
|
||||||
|
size={size}
|
||||||
|
iconImage={iconImage}
|
||||||
|
containerClasses={containerClasses}
|
||||||
|
contentGapClasses={contentGapClasses}
|
||||||
|
textGapClasses={textGapClasses}
|
||||||
|
titleClasses={titleClasses}
|
||||||
|
descriptionClasses={descriptionClasses}
|
||||||
|
authorClasses={authorClasses}
|
||||||
|
dateClasses={dateClasses}
|
||||||
|
formattedDate={formattedDate}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ContentContainerContainer.displayName = "ContentContainer";
|
||||||
|
|
||||||
|
export default ContentContainerContainer;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { BlogPost } from "../../../lib/content";
|
||||||
|
|
||||||
|
export interface ContentContainerProps {
|
||||||
|
post: BlogPost;
|
||||||
|
width?: string;
|
||||||
|
size?: "xs" | "responsive";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContentContainerViewProps {
|
||||||
|
post: BlogPost;
|
||||||
|
width: string;
|
||||||
|
size: "xs" | "responsive";
|
||||||
|
iconImage: string;
|
||||||
|
containerClasses: string;
|
||||||
|
contentGapClasses: string;
|
||||||
|
textGapClasses: string;
|
||||||
|
titleClasses: string;
|
||||||
|
descriptionClasses: string;
|
||||||
|
authorClasses: string;
|
||||||
|
dateClasses: string;
|
||||||
|
formattedDate: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { memo } from "react";
|
||||||
|
import type { ContentContainerViewProps } from "./ContentContainer.types";
|
||||||
|
|
||||||
|
function ContentContainerView({
|
||||||
|
post,
|
||||||
|
width,
|
||||||
|
size,
|
||||||
|
iconImage,
|
||||||
|
containerClasses,
|
||||||
|
contentGapClasses,
|
||||||
|
textGapClasses,
|
||||||
|
titleClasses,
|
||||||
|
descriptionClasses,
|
||||||
|
authorClasses,
|
||||||
|
dateClasses,
|
||||||
|
formattedDate,
|
||||||
|
}: ContentContainerViewProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={containerClasses}
|
||||||
|
style={size === "responsive" ? {} : { width }}
|
||||||
|
>
|
||||||
|
{/* Content Container - gap between icon and text */}
|
||||||
|
<div className={contentGapClasses}>
|
||||||
|
{/* Icon */}
|
||||||
|
<div className="w-[60px] h-[30px] flex items-center justify-center">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={iconImage}
|
||||||
|
alt={`Icon for ${post.frontmatter.title}`}
|
||||||
|
className="w-[60px] h-[30px] object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Text Container */}
|
||||||
|
<div className={textGapClasses}>
|
||||||
|
{/* Title */}
|
||||||
|
<h3 className={titleClasses}>{post.frontmatter.title}</h3>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className={descriptionClasses}>{post.frontmatter.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metadata Container - horizontal with 8px gap */}
|
||||||
|
<div className="flex items-center gap-[var(--measures-spacing-008)]">
|
||||||
|
{/* Author Name */}
|
||||||
|
<span className={authorClasses}>{post.frontmatter.author}</span>
|
||||||
|
|
||||||
|
{/* Date */}
|
||||||
|
<span className={dateClasses}>{formattedDate}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ContentContainerView.displayName = "ContentContainerView";
|
||||||
|
|
||||||
|
export default memo(ContentContainerView);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from "./ContentContainer.container";
|
||||||
|
export type { ContentContainerProps } from "./ContentContainer.types";
|
||||||
+18
-126
@@ -1,39 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
import Button from "./Button";
|
import ContentLockupView from "./ContentLockup.view";
|
||||||
import { getAssetPath } from "../../lib/assetUtils";
|
import type { ContentLockupProps, VariantStyle } from "./ContentLockup.types";
|
||||||
|
|
||||||
interface ContentLockupProps {
|
const ContentLockupContainer = memo<ContentLockupProps>(
|
||||||
title?: string;
|
|
||||||
subtitle?: string;
|
|
||||||
description?: string;
|
|
||||||
ctaText?: string;
|
|
||||||
ctaHref?: string;
|
|
||||||
buttonClassName?: string;
|
|
||||||
variant?: "hero" | "feature" | "learn" | "ask" | "ask-inverse";
|
|
||||||
linkText?: string;
|
|
||||||
linkHref?: string;
|
|
||||||
alignment?: "center" | "left";
|
|
||||||
/**
|
|
||||||
* Optional id to attach to the primary title heading.
|
|
||||||
* Useful when a parent section uses aria-labelledby.
|
|
||||||
*/
|
|
||||||
titleId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VariantStyle {
|
|
||||||
container: string;
|
|
||||||
textContainer: string;
|
|
||||||
titleGroup: string;
|
|
||||||
titleContainer: string;
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
description?: string;
|
|
||||||
shape: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ContentLockup = memo<ContentLockupProps>(
|
|
||||||
({
|
({
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
@@ -125,102 +96,23 @@ const ContentLockup = memo<ContentLockupProps>(
|
|||||||
const styles = variantStyles[variant] || variantStyles.hero;
|
const styles = variantStyles[variant] || variantStyles.hero;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<ContentLockupView
|
||||||
{variant === "ask" || variant === "ask-inverse" ? (
|
title={title}
|
||||||
/* Simplified structure for ask variant */
|
subtitle={subtitle}
|
||||||
<div
|
description={description}
|
||||||
className={`${styles.titleGroup} ${
|
ctaText={ctaText}
|
||||||
alignment === "left" ? "text-left" : "text-center"
|
buttonClassName={buttonClassName}
|
||||||
}`}
|
variant={variant}
|
||||||
>
|
linkText={linkText}
|
||||||
<div
|
linkHref={linkHref}
|
||||||
className={`${styles.titleContainer} ${
|
alignment={alignment}
|
||||||
alignment === "left" ? "justify-start" : "justify-center"
|
titleId={titleId}
|
||||||
}`}
|
styles={styles}
|
||||||
>
|
/>
|
||||||
{title ? (
|
|
||||||
<h1 id={titleId} className={styles.title}>
|
|
||||||
{title}
|
|
||||||
</h1>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{subtitle ? <h2 className={styles.subtitle}>{subtitle}</h2> : null}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
/* Full structure for other variants */
|
|
||||||
<div className={styles.textContainer}>
|
|
||||||
{/* Title and subtitle group */}
|
|
||||||
<div className={styles.titleGroup}>
|
|
||||||
{/* Title container */}
|
|
||||||
<div className={styles.titleContainer}>
|
|
||||||
{title ? (
|
|
||||||
<h1 id={titleId} className={styles.title}>
|
|
||||||
{title}
|
|
||||||
</h1>
|
|
||||||
) : null}
|
|
||||||
{variant === "hero" && (
|
|
||||||
<img
|
|
||||||
src={getAssetPath("assets/Shapes_1.svg")}
|
|
||||||
alt=""
|
|
||||||
className={styles.shape}
|
|
||||||
role="presentation"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Subtitle */}
|
|
||||||
{subtitle ? (
|
|
||||||
<h2 className={styles.subtitle}>{subtitle}</h2>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
{description && <p className={styles.description}>{description}</p>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Link for feature variant */}
|
|
||||||
{variant === "feature" && linkText && (
|
|
||||||
<a
|
|
||||||
href={linkHref || "#"}
|
|
||||||
className="font-inter font-medium text-[16px] leading-[20px] underline text-[var(--color-content-default-primary)] hover:text-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-surface-default-brand-royal)] focus:ring-offset-2 focus:ring-offset-[#171717] rounded-sm px-1 py-0.5"
|
|
||||||
>
|
|
||||||
{linkText}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* CTA Button */}
|
|
||||||
{ctaText && (
|
|
||||||
<div className="flex justify-start">
|
|
||||||
{/* Small button for xsm and sm breakpoints */}
|
|
||||||
<div className="block md:hidden">
|
|
||||||
<Button variant="primary" size="small">
|
|
||||||
{ctaText}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{/* Large button for md and lg breakpoints */}
|
|
||||||
<div className="hidden md:block xl:hidden">
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="large"
|
|
||||||
className={buttonClassName}
|
|
||||||
>
|
|
||||||
{ctaText}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{/* XLarge button for xl breakpoint */}
|
|
||||||
<div className="hidden xl:block">
|
|
||||||
<Button variant="primary" size="xlarge">
|
|
||||||
{ctaText}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
ContentLockup.displayName = "ContentLockup";
|
ContentLockupContainer.displayName = "ContentLockup";
|
||||||
|
|
||||||
export default ContentLockup;
|
export default ContentLockupContainer;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
export interface ContentLockupProps {
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
description?: string;
|
||||||
|
ctaText?: string;
|
||||||
|
ctaHref?: string;
|
||||||
|
buttonClassName?: string;
|
||||||
|
variant?: "hero" | "feature" | "learn" | "ask" | "ask-inverse";
|
||||||
|
linkText?: string;
|
||||||
|
linkHref?: string;
|
||||||
|
alignment?: "center" | "left";
|
||||||
|
/**
|
||||||
|
* Optional id to attach to the primary title heading.
|
||||||
|
* Useful when a parent section uses aria-labelledby.
|
||||||
|
*/
|
||||||
|
titleId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VariantStyle {
|
||||||
|
container: string;
|
||||||
|
textContainer: string;
|
||||||
|
titleGroup: string;
|
||||||
|
titleContainer: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
description?: string;
|
||||||
|
shape: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContentLockupViewProps {
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
description?: string;
|
||||||
|
ctaText?: string;
|
||||||
|
ctaHref?: string;
|
||||||
|
buttonClassName: string;
|
||||||
|
variant: "hero" | "feature" | "learn" | "ask" | "ask-inverse";
|
||||||
|
linkText?: string;
|
||||||
|
linkHref?: string;
|
||||||
|
alignment: "center" | "left";
|
||||||
|
titleId?: string;
|
||||||
|
styles: VariantStyle;
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { memo } from "react";
|
||||||
|
import Button from "../Button";
|
||||||
|
import { getAssetPath } from "../../../lib/assetUtils";
|
||||||
|
import type { ContentLockupViewProps } from "./ContentLockup.types";
|
||||||
|
|
||||||
|
function ContentLockupView({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
description,
|
||||||
|
ctaText,
|
||||||
|
buttonClassName,
|
||||||
|
variant,
|
||||||
|
linkText,
|
||||||
|
linkHref,
|
||||||
|
alignment,
|
||||||
|
titleId,
|
||||||
|
styles,
|
||||||
|
}: ContentLockupViewProps) {
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{variant === "ask" || variant === "ask-inverse" ? (
|
||||||
|
/* Simplified structure for ask variant */
|
||||||
|
<div
|
||||||
|
className={`${styles.titleGroup} ${
|
||||||
|
alignment === "left" ? "text-left" : "text-center"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`${styles.titleContainer} ${
|
||||||
|
alignment === "left" ? "justify-start" : "justify-center"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{title ? (
|
||||||
|
<h1 id={titleId} className={styles.title}>
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{subtitle ? <h2 className={styles.subtitle}>{subtitle}</h2> : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Full structure for other variants */
|
||||||
|
<div className={styles.textContainer}>
|
||||||
|
{/* Title and subtitle group */}
|
||||||
|
<div className={styles.titleGroup}>
|
||||||
|
{/* Title container */}
|
||||||
|
<div className={styles.titleContainer}>
|
||||||
|
{title ? (
|
||||||
|
<h1 id={titleId} className={styles.title}>
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
) : null}
|
||||||
|
{variant === "hero" && (
|
||||||
|
<img
|
||||||
|
src={getAssetPath("assets/Shapes_1.svg")}
|
||||||
|
alt=""
|
||||||
|
className={styles.shape}
|
||||||
|
role="presentation"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subtitle */}
|
||||||
|
{subtitle ? <h2 className={styles.subtitle}>{subtitle}</h2> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{description && <p className={styles.description}>{description}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Link for feature variant */}
|
||||||
|
{variant === "feature" && linkText && (
|
||||||
|
<a
|
||||||
|
href={linkHref || "#"}
|
||||||
|
className="font-inter font-medium text-[16px] leading-[20px] underline text-[var(--color-content-default-primary)] hover:text-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-[var(--color-surface-default-brand-royal)] focus:ring-offset-2 focus:ring-offset-[#171717] rounded-sm px-1 py-0.5"
|
||||||
|
>
|
||||||
|
{linkText}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* CTA Button */}
|
||||||
|
{ctaText && (
|
||||||
|
<div className="flex justify-start">
|
||||||
|
{/* Small button for xsm and sm breakpoints */}
|
||||||
|
<div className="block md:hidden">
|
||||||
|
<Button variant="primary" size="small">
|
||||||
|
{ctaText}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/* Large button for md and lg breakpoints */}
|
||||||
|
<div className="hidden md:block xl:hidden">
|
||||||
|
<Button variant="primary" size="large" className={buttonClassName}>
|
||||||
|
{ctaText}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/* XLarge button for xl breakpoint */}
|
||||||
|
<div className="hidden xl:block">
|
||||||
|
<Button variant="primary" size="xlarge">
|
||||||
|
{ctaText}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ContentLockupView.displayName = "ContentLockupView";
|
||||||
|
|
||||||
|
export default memo(ContentLockupView);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from "./ContentLockup.container";
|
||||||
|
export type { ContentLockupProps } from "./ContentLockup.types";
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { memo } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import ContentContainer from "./ContentContainer";
|
|
||||||
import { getAssetPath, ASSETS } from "../../lib/assetUtils";
|
|
||||||
import type { BlogPost } from "../../lib/content";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ContentThumbnailTemplate component for displaying blog post previews
|
|
||||||
* Simplified version to debug infinite loop
|
|
||||||
*/
|
|
||||||
interface ContentThumbnailTemplateProps {
|
|
||||||
post: BlogPost;
|
|
||||||
className?: string;
|
|
||||||
variant?: "vertical" | "horizontal";
|
|
||||||
slugOrder?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const ContentThumbnailTemplate = memo<ContentThumbnailTemplateProps>(
|
|
||||||
({ post, className = "", variant = "vertical" }) => {
|
|
||||||
// Get article-specific background image from frontmatter
|
|
||||||
const getBackgroundImage = (
|
|
||||||
post: BlogPost,
|
|
||||||
variant: "vertical" | "horizontal",
|
|
||||||
): string => {
|
|
||||||
// Check if post has thumbnail images defined in frontmatter
|
|
||||||
if (post.frontmatter?.thumbnail) {
|
|
||||||
const imageName =
|
|
||||||
variant === "vertical"
|
|
||||||
? post.frontmatter.thumbnail.vertical
|
|
||||||
: post.frontmatter.thumbnail.horizontal;
|
|
||||||
|
|
||||||
if (imageName) {
|
|
||||||
// Return path to image in public/content/blog directory
|
|
||||||
return `/content/blog/${imageName}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to default images if no thumbnail specified
|
|
||||||
const fallbackImages: Record<string, string> = {
|
|
||||||
vertical: getAssetPath(ASSETS.VERTICAL_1),
|
|
||||||
horizontal: getAssetPath(ASSETS.HORIZONTAL_1),
|
|
||||||
};
|
|
||||||
|
|
||||||
return fallbackImages[variant] || fallbackImages.vertical;
|
|
||||||
};
|
|
||||||
|
|
||||||
const backgroundImage = getBackgroundImage(post, variant);
|
|
||||||
|
|
||||||
if (variant === "vertical") {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={`/blog/${post.slug}`}
|
|
||||||
className={`block transition-transform duration-200 hover:scale-[1.02] ${className}`}
|
|
||||||
>
|
|
||||||
<div className="relative w-full aspect-[2/3] overflow-hidden pt-[18px] pl-[18px] pr-[42px] pb-[212px]">
|
|
||||||
{/* Background SVG - fills container with maintained aspect */}
|
|
||||||
<div className="absolute inset-0 z-0">
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={backgroundImage}
|
|
||||||
alt={`Background for ${post.frontmatter.title}`}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
{/* Gradient overlay for better text readability */}
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-black/60 z-10" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content Section - positioned within the padding constraints */}
|
|
||||||
<ContentContainer post={post} width="200px" size="xs" />
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Horizontal variant
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={`/blog/${post.slug}`}
|
|
||||||
className={`block transition-transform duration-200 hover:scale-[1.02] ${className}`}
|
|
||||||
>
|
|
||||||
<div className="relative min-w-[320px] max-w-[800px] h-[225.5px] overflow-hidden pt-[13.75px] pr-[76px] pb-[73.75px] pl-[14px]">
|
|
||||||
{/* Background SVG - sized to fit the 320x225.5 container exactly */}
|
|
||||||
<div className="absolute inset-0 z-0">
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={backgroundImage}
|
|
||||||
alt={`Background for ${post.frontmatter.title}`}
|
|
||||||
className="w-full h-[225.5px] object-cover"
|
|
||||||
/>
|
|
||||||
{/* Gradient overlay */}
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-transparent to-black/70 z-10" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content - positioned within the padding constraints */}
|
|
||||||
<ContentContainer post={post} width="230px" size="xs" />
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
ContentThumbnailTemplate.displayName = "ContentThumbnailTemplate";
|
|
||||||
|
|
||||||
export default ContentThumbnailTemplate;
|
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { memo } from "react";
|
||||||
|
import { getAssetPath, ASSETS } from "../../../lib/assetUtils";
|
||||||
|
import ContentThumbnailTemplateView from "./ContentThumbnailTemplate.view";
|
||||||
|
import type { ContentThumbnailTemplateProps } from "./ContentThumbnailTemplate.types";
|
||||||
|
|
||||||
|
const ContentThumbnailTemplateContainer = memo<ContentThumbnailTemplateProps>(
|
||||||
|
({ post, className = "", variant = "vertical" }) => {
|
||||||
|
// Get article-specific background image from frontmatter
|
||||||
|
const getBackgroundImage = (
|
||||||
|
post: ContentThumbnailTemplateProps["post"],
|
||||||
|
variant: "vertical" | "horizontal",
|
||||||
|
): string => {
|
||||||
|
// Check if post has thumbnail images defined in frontmatter
|
||||||
|
if (post.frontmatter?.thumbnail) {
|
||||||
|
const imageName =
|
||||||
|
variant === "vertical"
|
||||||
|
? post.frontmatter.thumbnail.vertical
|
||||||
|
: post.frontmatter.thumbnail.horizontal;
|
||||||
|
|
||||||
|
if (imageName) {
|
||||||
|
// Return path to image in public/content/blog directory
|
||||||
|
return `/content/blog/${imageName}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to default images if no thumbnail specified
|
||||||
|
const fallbackImages: Record<string, string> = {
|
||||||
|
vertical: getAssetPath(ASSETS.VERTICAL_1),
|
||||||
|
horizontal: getAssetPath(ASSETS.HORIZONTAL_1),
|
||||||
|
};
|
||||||
|
|
||||||
|
return fallbackImages[variant] || fallbackImages.vertical;
|
||||||
|
};
|
||||||
|
|
||||||
|
const backgroundImage = getBackgroundImage(post, variant);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContentThumbnailTemplateView
|
||||||
|
post={post}
|
||||||
|
className={className}
|
||||||
|
variant={variant}
|
||||||
|
backgroundImage={backgroundImage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ContentThumbnailTemplateContainer.displayName = "ContentThumbnailTemplate";
|
||||||
|
|
||||||
|
export default ContentThumbnailTemplateContainer;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { BlogPost } from "../../../lib/content";
|
||||||
|
|
||||||
|
export interface ContentThumbnailTemplateProps {
|
||||||
|
post: BlogPost;
|
||||||
|
className?: string;
|
||||||
|
variant?: "vertical" | "horizontal";
|
||||||
|
slugOrder?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContentThumbnailTemplateViewProps {
|
||||||
|
post: BlogPost;
|
||||||
|
className: string;
|
||||||
|
variant: "vertical" | "horizontal";
|
||||||
|
backgroundImage: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { memo } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import ContentContainer from "../ContentContainer";
|
||||||
|
import type { ContentThumbnailTemplateViewProps } from "./ContentThumbnailTemplate.types";
|
||||||
|
|
||||||
|
function ContentThumbnailTemplateView({
|
||||||
|
post,
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
backgroundImage,
|
||||||
|
}: ContentThumbnailTemplateViewProps) {
|
||||||
|
if (variant === "vertical") {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/blog/${post.slug}`}
|
||||||
|
className={`block transition-transform duration-200 hover:scale-[1.02] ${className}`}
|
||||||
|
>
|
||||||
|
<div className="relative w-full aspect-[2/3] overflow-hidden pt-[18px] pl-[18px] pr-[42px] pb-[212px]">
|
||||||
|
{/* Background SVG - fills container with maintained aspect */}
|
||||||
|
<div className="absolute inset-0 z-0">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={backgroundImage}
|
||||||
|
alt={`Background for ${post.frontmatter.title}`}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
{/* Gradient overlay for better text readability */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-black/60 z-10" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Section - positioned within the padding constraints */}
|
||||||
|
<ContentContainer post={post} width="200px" size="xs" />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontal variant
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/blog/${post.slug}`}
|
||||||
|
className={`block transition-transform duration-200 hover:scale-[1.02] ${className}`}
|
||||||
|
>
|
||||||
|
<div className="relative min-w-[320px] max-w-[800px] h-[225.5px] overflow-hidden pt-[13.75px] pr-[76px] pb-[73.75px] pl-[14px]">
|
||||||
|
{/* Background SVG - sized to fit the 320x225.5 container exactly */}
|
||||||
|
<div className="absolute inset-0 z-0">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={backgroundImage}
|
||||||
|
alt={`Background for ${post.frontmatter.title}`}
|
||||||
|
className="w-full h-[225.5px] object-cover"
|
||||||
|
/>
|
||||||
|
{/* Gradient overlay */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-transparent to-black/70 z-10" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content - positioned within the padding constraints */}
|
||||||
|
<ContentContainer post={post} width="230px" size="xs" />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ContentThumbnailTemplateView.displayName = "ContentThumbnailTemplateView";
|
||||||
|
|
||||||
|
export default memo(ContentThumbnailTemplateView);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from "./ContentThumbnailTemplate.container";
|
||||||
|
export type { ContentThumbnailTemplateProps } from "./ContentThumbnailTemplate.types";
|
||||||
+19
-41
@@ -1,23 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { forwardRef, memo, useCallback } from "react";
|
import { forwardRef, memo, useCallback } from "react";
|
||||||
|
import { ContextMenuItemView } from "./ContextMenuItem.view";
|
||||||
|
import type { ContextMenuItemProps } from "./ContextMenuItem.types";
|
||||||
|
|
||||||
interface SelectOptionProps {
|
const ContextMenuItemContainer = forwardRef<
|
||||||
children?: React.ReactNode;
|
HTMLDivElement,
|
||||||
selected?: boolean;
|
ContextMenuItemProps
|
||||||
disabled?: boolean;
|
>(
|
||||||
className?: string;
|
|
||||||
onClick?: (
|
|
||||||
_e: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>,
|
|
||||||
) => void;
|
|
||||||
size?: "small" | "medium" | "large";
|
|
||||||
}
|
|
||||||
|
|
||||||
const SelectOption = forwardRef<HTMLDivElement, SelectOptionProps>(
|
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
children,
|
children,
|
||||||
selected = false,
|
selected = false,
|
||||||
|
hasSubmenu = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
className = "",
|
className = "",
|
||||||
onClick,
|
onClick,
|
||||||
@@ -83,40 +78,23 @@ const SelectOption = forwardRef<HTMLDivElement, SelectOptionProps>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<ContextMenuItemView
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={itemClasses}
|
selected={selected}
|
||||||
role="option"
|
hasSubmenu={hasSubmenu}
|
||||||
tabIndex={disabled ? -1 : 0}
|
disabled={disabled}
|
||||||
aria-selected={selected}
|
className={className}
|
||||||
aria-disabled={disabled}
|
itemClasses={itemClasses}
|
||||||
onClick={handleClick}
|
handleClick={handleClick}
|
||||||
onKeyDown={handleKeyDown}
|
handleKeyDown={handleKeyDown}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-[8px]">
|
{children}
|
||||||
{selected && (
|
</ContextMenuItemView>
|
||||||
<svg
|
|
||||||
className="w-4 h-4 text-[var(--color-content-default-brand-primary)]"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M5 13l4 4L19 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
<span>{children}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
SelectOption.displayName = "SelectOption";
|
ContextMenuItemContainer.displayName = "ContextMenuItem";
|
||||||
|
|
||||||
export default memo(SelectOption);
|
export default memo(ContextMenuItemContainer);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export interface ContextMenuItemProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
selected?: boolean;
|
||||||
|
hasSubmenu?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
onClick?: (
|
||||||
|
_e: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>,
|
||||||
|
) => void;
|
||||||
|
size?: "small" | "medium" | "large";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContextMenuItemViewProps {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
selected: boolean;
|
||||||
|
hasSubmenu: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
className: string;
|
||||||
|
itemClasses: string;
|
||||||
|
handleClick: (_e: React.MouseEvent<HTMLDivElement>) => void;
|
||||||
|
handleKeyDown: (_e: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { forwardRef } from "react";
|
||||||
|
import type { ContextMenuItemViewProps } from "./ContextMenuItem.types";
|
||||||
|
|
||||||
|
export const ContextMenuItemView = forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
ContextMenuItemViewProps
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
children,
|
||||||
|
selected,
|
||||||
|
hasSubmenu,
|
||||||
|
disabled,
|
||||||
|
itemClasses,
|
||||||
|
handleClick,
|
||||||
|
handleKeyDown,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={itemClasses}
|
||||||
|
role="menuitem"
|
||||||
|
tabIndex={disabled ? -1 : 0}
|
||||||
|
aria-current={selected ? "true" : undefined}
|
||||||
|
aria-disabled={disabled}
|
||||||
|
onClick={handleClick}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-[8px]">
|
||||||
|
{selected && (
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 text-[var(--color-content-default-brand-primary)]"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M5 13l4 4L19 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
<span>{children}</span>
|
||||||
|
</div>
|
||||||
|
{hasSubmenu && (
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 text-[var(--color-content-default-brand-primary)]"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 5l7 7-7 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ContextMenuItemView.displayName = "ContextMenuItemView";
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from "./ContextMenuItem.container";
|
||||||
|
export type { ContextMenuItemProps } from "./ContextMenuItem.types";
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { memo, useMemo } from "react";
|
|
||||||
import ContentLockup from "./ContentLockup";
|
|
||||||
import MiniCard from "./MiniCard";
|
|
||||||
|
|
||||||
interface FeatureGridProps {
|
|
||||||
title?: string;
|
|
||||||
subtitle?: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FeatureGrid = memo<FeatureGridProps>(
|
|
||||||
({ title, subtitle, className = "" }) => {
|
|
||||||
// Memoize the feature data to prevent unnecessary re-renders
|
|
||||||
const features = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
backgroundColor: "bg-[var(--color-surface-default-brand-royal)]",
|
|
||||||
labelLine1: "Decision-making",
|
|
||||||
labelLine2: "support",
|
|
||||||
panelContent: "/assets/Feature_Support.png",
|
|
||||||
ariaLabel: "Decision-making support tools",
|
|
||||||
href: "#decision-making",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
backgroundColor: "bg-[#D1FFE2]",
|
|
||||||
labelLine1: "Values alignment",
|
|
||||||
labelLine2: "exercises",
|
|
||||||
panelContent: "/assets/Feature_Exercises.png",
|
|
||||||
ariaLabel: "Values alignment exercises",
|
|
||||||
href: "#values-alignment",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
backgroundColor: "bg-[#F4CAFF]",
|
|
||||||
labelLine1: "Membership",
|
|
||||||
labelLine2: "guidance",
|
|
||||||
panelContent: "/assets/Feature_Guidance.png",
|
|
||||||
ariaLabel: "Membership guidance resources",
|
|
||||||
href: "#membership-guidance",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
backgroundColor: "bg-[#CBDDFF]",
|
|
||||||
labelLine1: "Conflict resolution",
|
|
||||||
labelLine2: "tools",
|
|
||||||
panelContent: "/assets/Feature_Tools.png",
|
|
||||||
ariaLabel: "Conflict resolution tools",
|
|
||||||
href: "#conflict-resolution",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const labelledBy = title ? "feature-grid-headline" : undefined;
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
className={`p-0 lg:p-[var(--spacing-scale-064)] ${className}`}
|
|
||||||
aria-labelledby={labelledBy}
|
|
||||||
aria-label={labelledBy ? undefined : "Feature tools and services"}
|
|
||||||
>
|
|
||||||
<div className="py-[var(--spacing-scale-032)] px-[var(--spacing-scale-020)] md:pt-[var(--spacing-scale-076)] md:pb-[var(--spacing-scale-048)] lg:pb-[var(--spacing-scale-076)] md:px-[var(--spacing-scale-048)] bg-[#171717] rounded-[var(--radius-measures-radius-xlarge)] focus-within:ring-2 focus-within:ring-[var(--color-surface-default-brand-royal)] focus-within:ring-offset-2">
|
|
||||||
<div className="w-full mx-auto gap-[var(--spacing-scale-048)] lg:flex lg:items-start lg:gap-[var(--spacing-scale-048)] [container-type:inline-size]">
|
|
||||||
{/* Feature Content Lockup */}
|
|
||||||
<div className="lg:shrink lg:min-w-0">
|
|
||||||
<ContentLockup
|
|
||||||
title={title}
|
|
||||||
subtitle={subtitle}
|
|
||||||
variant="feature"
|
|
||||||
linkText="Learn more"
|
|
||||||
linkHref="#"
|
|
||||||
titleId={labelledBy}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* MiniCard Grid */}
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-[var(--spacing-scale-012)] mt-[var(--spacing-scale-048)] lg:mt-0 lg:flex-grow lg:shrink-0">
|
|
||||||
{features.map((feature, index) => (
|
|
||||||
<MiniCard
|
|
||||||
key={index}
|
|
||||||
backgroundColor={feature.backgroundColor}
|
|
||||||
labelLine1={feature.labelLine1}
|
|
||||||
labelLine2={feature.labelLine2}
|
|
||||||
panelContent={feature.panelContent}
|
|
||||||
ariaLabel={feature.ariaLabel}
|
|
||||||
href={feature.href}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
FeatureGrid.displayName = "FeatureGrid";
|
|
||||||
|
|
||||||
export default FeatureGrid;
|
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { memo, useMemo } from "react";
|
||||||
|
import FeatureGridView from "./FeatureGrid.view";
|
||||||
|
import type { FeatureGridProps, Feature } from "./FeatureGrid.types";
|
||||||
|
|
||||||
|
const FeatureGridContainer = memo<FeatureGridProps>(
|
||||||
|
({ title, subtitle, className = "" }) => {
|
||||||
|
const features: Feature[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
backgroundColor: "bg-[var(--color-surface-default-brand-royal)]",
|
||||||
|
labelLine1: "Decision-making",
|
||||||
|
labelLine2: "support",
|
||||||
|
panelContent: "/assets/Feature_Support.png",
|
||||||
|
ariaLabel: "Decision-making support tools",
|
||||||
|
href: "#decision-making",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
backgroundColor: "bg-[#D1FFE2]",
|
||||||
|
labelLine1: "Values alignment",
|
||||||
|
labelLine2: "exercises",
|
||||||
|
panelContent: "/assets/Feature_Exercises.png",
|
||||||
|
ariaLabel: "Values alignment exercises",
|
||||||
|
href: "#values-alignment",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
backgroundColor: "bg-[#F4CAFF]",
|
||||||
|
labelLine1: "Membership",
|
||||||
|
labelLine2: "guidance",
|
||||||
|
panelContent: "/assets/Feature_Guidance.png",
|
||||||
|
ariaLabel: "Membership guidance resources",
|
||||||
|
href: "#membership-guidance",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
backgroundColor: "bg-[#CBDDFF]",
|
||||||
|
labelLine1: "Conflict resolution",
|
||||||
|
labelLine2: "tools",
|
||||||
|
panelContent: "/assets/Feature_Tools.png",
|
||||||
|
ariaLabel: "Conflict resolution tools",
|
||||||
|
href: "#conflict-resolution",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const labelledBy = title ? "feature-grid-headline" : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FeatureGridView
|
||||||
|
title={title}
|
||||||
|
subtitle={subtitle}
|
||||||
|
className={className}
|
||||||
|
features={features}
|
||||||
|
labelledBy={labelledBy}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
FeatureGridContainer.displayName = "FeatureGrid";
|
||||||
|
|
||||||
|
export default FeatureGridContainer;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
export interface FeatureGridProps {
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Feature {
|
||||||
|
backgroundColor: string;
|
||||||
|
labelLine1: string;
|
||||||
|
labelLine2: string;
|
||||||
|
panelContent: string;
|
||||||
|
ariaLabel: string;
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeatureGridViewProps extends FeatureGridProps {
|
||||||
|
features: Feature[];
|
||||||
|
labelledBy?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import ContentLockup from "../ContentLockup";
|
||||||
|
import MiniCard from "../MiniCard";
|
||||||
|
import type { FeatureGridViewProps } from "./FeatureGrid.types";
|
||||||
|
|
||||||
|
function FeatureGridView({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
className = "",
|
||||||
|
features,
|
||||||
|
labelledBy,
|
||||||
|
}: FeatureGridViewProps) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={`p-0 lg:p-[var(--spacing-scale-064)] ${className}`}
|
||||||
|
aria-labelledby={labelledBy}
|
||||||
|
aria-label={labelledBy ? undefined : "Feature tools and services"}
|
||||||
|
>
|
||||||
|
<div className="py-[var(--spacing-scale-032)] px-[var(--spacing-scale-020)] md:pt-[var(--spacing-scale-076)] md:pb-[var(--spacing-scale-048)] lg:pb-[var(--spacing-scale-076)] md:px-[var(--spacing-scale-048)] bg-[#171717] rounded-[var(--radius-measures-radius-xlarge)] focus-within:ring-2 focus-within:ring-[var(--color-surface-default-brand-royal)] focus-within:ring-offset-2">
|
||||||
|
<div className="w-full mx-auto gap-[var(--spacing-scale-048)] lg:flex lg:items-start lg:gap-[var(--spacing-scale-048)] [container-type:inline-size]">
|
||||||
|
{/* Feature Content Lockup */}
|
||||||
|
<div className="lg:shrink lg:min-w-0">
|
||||||
|
<ContentLockup
|
||||||
|
title={title}
|
||||||
|
subtitle={subtitle}
|
||||||
|
variant="feature"
|
||||||
|
linkText="Learn more"
|
||||||
|
linkHref="#"
|
||||||
|
titleId={labelledBy}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* MiniCard Grid */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-[var(--spacing-scale-012)] mt-[var(--spacing-scale-048)] lg:mt-0 lg:flex-grow lg:shrink-0">
|
||||||
|
{features.map((feature, index) => (
|
||||||
|
<MiniCard
|
||||||
|
key={index}
|
||||||
|
backgroundColor={feature.backgroundColor}
|
||||||
|
labelLine1={feature.labelLine1}
|
||||||
|
labelLine2={feature.labelLine2}
|
||||||
|
panelContent={feature.panelContent}
|
||||||
|
ariaLabel={feature.ariaLabel}
|
||||||
|
href={feature.href}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FeatureGridView;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from "./FeatureGrid.container";
|
||||||
|
export * from "./FeatureGrid.types";
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { memo } from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import MenuBarItem from "../MenuBarItem";
|
||||||
|
import Button from "../Button";
|
||||||
|
import AvatarContainer from "../AvatarContainer";
|
||||||
|
import Avatar from "../Avatar";
|
||||||
|
import Logo from "../Logo";
|
||||||
|
import { getAssetPath, ASSETS } from "../../../lib/assetUtils";
|
||||||
|
import { HeaderView } from "./Header.view";
|
||||||
|
import type { HeaderProps, NavSize } from "./Header.types";
|
||||||
|
|
||||||
|
// Configuration data for testing
|
||||||
|
export const navigationItems = [
|
||||||
|
{ href: "#", text: "Use cases", extraPadding: true },
|
||||||
|
{ href: "/learn", text: "Learn" },
|
||||||
|
{ href: "#", text: "About" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const avatarImages = [
|
||||||
|
{ src: getAssetPath(ASSETS.AVATAR_1), alt: "Avatar 1" },
|
||||||
|
{ src: getAssetPath(ASSETS.AVATAR_2), alt: "Avatar 2" },
|
||||||
|
{ src: getAssetPath(ASSETS.AVATAR_3), alt: "Avatar 3" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const logoConfig = [
|
||||||
|
{ breakpoint: "block sm:hidden", size: "header" as const, showText: false },
|
||||||
|
{
|
||||||
|
breakpoint: "hidden sm:block md:hidden",
|
||||||
|
size: "header" as const,
|
||||||
|
showText: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
breakpoint: "hidden md:block lg:hidden",
|
||||||
|
size: "headerMd" as const,
|
||||||
|
showText: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
breakpoint: "hidden lg:block xl:hidden",
|
||||||
|
size: "headerLg" as const,
|
||||||
|
showText: true,
|
||||||
|
},
|
||||||
|
{ breakpoint: "hidden xl:block", size: "headerXl" as const, showText: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const HeaderContainer = memo<HeaderProps>(() => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
// Schema markup for site navigation
|
||||||
|
const schemaData = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebSite",
|
||||||
|
name: "CommunityRule",
|
||||||
|
url: "https://communityrule.com",
|
||||||
|
potentialAction: {
|
||||||
|
"@type": "SearchAction",
|
||||||
|
target: "https://communityrule.com/search?q={search_term_string}",
|
||||||
|
"query-input": "required name=search_term_string",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderNavigationItems = (size: NavSize) => {
|
||||||
|
return navigationItems.map((item, index) => (
|
||||||
|
<MenuBarItem
|
||||||
|
key={index}
|
||||||
|
href={item.href}
|
||||||
|
size={item.extraPadding && size === "xsmall" ? "xsmallUseCases" : size}
|
||||||
|
isActive={pathname === item.href}
|
||||||
|
ariaLabel={`Navigate to ${item.text} page`}
|
||||||
|
>
|
||||||
|
{item.text}
|
||||||
|
</MenuBarItem>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderAvatarGroup = (
|
||||||
|
containerSize: "small" | "medium" | "large" | "xlarge",
|
||||||
|
avatarSize: "small" | "medium" | "large" | "xlarge",
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<AvatarContainer size={containerSize}>
|
||||||
|
{avatarImages.map((avatar, index) => (
|
||||||
|
<Avatar
|
||||||
|
key={index}
|
||||||
|
src={avatar.src}
|
||||||
|
alt={avatar.alt}
|
||||||
|
size={avatarSize}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AvatarContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderLoginButton = (size: NavSize) => {
|
||||||
|
return (
|
||||||
|
<MenuBarItem href="#" size={size} ariaLabel="Log in to your account">
|
||||||
|
Log in
|
||||||
|
</MenuBarItem>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCreateRuleButton = (
|
||||||
|
buttonSize: "xsmall" | "small" | "medium" | "large" | "xlarge",
|
||||||
|
containerSize: "small" | "medium" | "large" | "xlarge",
|
||||||
|
avatarSize: "small" | "medium" | "large" | "xlarge",
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size={buttonSize}
|
||||||
|
ariaLabel="Create a new rule with avatar decoration"
|
||||||
|
>
|
||||||
|
{renderAvatarGroup(containerSize, avatarSize)}
|
||||||
|
<span>Create rule</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderLogo = (
|
||||||
|
size:
|
||||||
|
| "default"
|
||||||
|
| "homeHeaderXsmall"
|
||||||
|
| "homeHeaderSm"
|
||||||
|
| "homeHeaderMd"
|
||||||
|
| "homeHeaderLg"
|
||||||
|
| "homeHeaderXl"
|
||||||
|
| "header"
|
||||||
|
| "headerMd"
|
||||||
|
| "headerLg"
|
||||||
|
| "headerXl"
|
||||||
|
| "footer"
|
||||||
|
| "footerLg",
|
||||||
|
showText: boolean,
|
||||||
|
) => {
|
||||||
|
return <Logo size={size} showText={showText} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HeaderView
|
||||||
|
schemaData={schemaData}
|
||||||
|
logoConfig={logoConfig}
|
||||||
|
renderNavigationItems={renderNavigationItems}
|
||||||
|
renderLoginButton={renderLoginButton}
|
||||||
|
renderCreateRuleButton={renderCreateRuleButton}
|
||||||
|
renderLogo={renderLogo}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
HeaderContainer.displayName = "Header";
|
||||||
|
|
||||||
|
export default HeaderContainer;
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
export interface HeaderProps {
|
||||||
|
// No props currently, but keeping interface for future extensibility
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeaderViewProps {
|
||||||
|
schemaData: {
|
||||||
|
"@context": string;
|
||||||
|
"@type": string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
potentialAction: {
|
||||||
|
"@type": string;
|
||||||
|
target: string;
|
||||||
|
"query-input": string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
logoConfig: Array<{
|
||||||
|
breakpoint: string;
|
||||||
|
size:
|
||||||
|
| "default"
|
||||||
|
| "homeHeaderXsmall"
|
||||||
|
| "homeHeaderSm"
|
||||||
|
| "homeHeaderMd"
|
||||||
|
| "homeHeaderLg"
|
||||||
|
| "homeHeaderXl"
|
||||||
|
| "header"
|
||||||
|
| "headerMd"
|
||||||
|
| "headerLg"
|
||||||
|
| "headerXl"
|
||||||
|
| "footer"
|
||||||
|
| "footerLg";
|
||||||
|
showText: boolean;
|
||||||
|
}>;
|
||||||
|
renderNavigationItems: (_size: NavSize) => React.ReactNode;
|
||||||
|
renderLoginButton: (_size: NavSize) => React.ReactNode;
|
||||||
|
renderCreateRuleButton: (
|
||||||
|
_buttonSize: "xsmall" | "small" | "medium" | "large" | "xlarge",
|
||||||
|
_containerSize: "small" | "medium" | "large" | "xlarge",
|
||||||
|
_avatarSize: "small" | "medium" | "large" | "xlarge",
|
||||||
|
) => React.ReactNode;
|
||||||
|
renderLogo: (
|
||||||
|
_size:
|
||||||
|
| "default"
|
||||||
|
| "homeHeaderXsmall"
|
||||||
|
| "homeHeaderSm"
|
||||||
|
| "homeHeaderMd"
|
||||||
|
| "homeHeaderLg"
|
||||||
|
| "homeHeaderXl"
|
||||||
|
| "header"
|
||||||
|
| "headerMd"
|
||||||
|
| "headerLg"
|
||||||
|
| "headerXl"
|
||||||
|
| "footer"
|
||||||
|
| "footerLg",
|
||||||
|
_showText: boolean,
|
||||||
|
) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NavSize =
|
||||||
|
| "default"
|
||||||
|
| "xsmall"
|
||||||
|
| "xsmallUseCases"
|
||||||
|
| "home"
|
||||||
|
| "homeMd"
|
||||||
|
| "homeUseCases"
|
||||||
|
| "large"
|
||||||
|
| "largeUseCases"
|
||||||
|
| "homeXlarge"
|
||||||
|
| "xlarge";
|
||||||
@@ -1,151 +1,14 @@
|
|||||||
"use client";
|
import MenuBar from "../MenuBar";
|
||||||
|
import type { HeaderViewProps } from "./Header.types";
|
||||||
import { memo } from "react";
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import Logo from "./Logo";
|
|
||||||
import MenuBar from "./MenuBar";
|
|
||||||
import MenuBarItem from "./MenuBarItem";
|
|
||||||
import Button from "./Button";
|
|
||||||
import AvatarContainer from "./AvatarContainer";
|
|
||||||
import Avatar from "./Avatar";
|
|
||||||
import { getAssetPath, ASSETS } from "../../lib/assetUtils";
|
|
||||||
|
|
||||||
// Configuration data for testing
|
|
||||||
export const navigationItems = [
|
|
||||||
{ href: "#", text: "Use cases", extraPadding: true },
|
|
||||||
{ href: "/learn", text: "Learn" },
|
|
||||||
{ href: "#", text: "About" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const avatarImages = [
|
|
||||||
{ src: getAssetPath(ASSETS.AVATAR_1), alt: "Avatar 1" },
|
|
||||||
{ src: getAssetPath(ASSETS.AVATAR_2), alt: "Avatar 2" },
|
|
||||||
{ src: getAssetPath(ASSETS.AVATAR_3), alt: "Avatar 3" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const logoConfig = [
|
|
||||||
{ breakpoint: "block sm:hidden", size: "header" as const, showText: false },
|
|
||||||
{
|
|
||||||
breakpoint: "hidden sm:block md:hidden",
|
|
||||||
size: "header" as const,
|
|
||||||
showText: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
breakpoint: "hidden md:block lg:hidden",
|
|
||||||
size: "headerMd" as const,
|
|
||||||
showText: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
breakpoint: "hidden lg:block xl:hidden",
|
|
||||||
size: "headerLg" as const,
|
|
||||||
showText: true,
|
|
||||||
},
|
|
||||||
{ breakpoint: "hidden xl:block", size: "headerXl" as const, showText: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
const Header = memo(() => {
|
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
// Schema markup for site navigation
|
|
||||||
const schemaData = {
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "WebSite",
|
|
||||||
name: "CommunityRule",
|
|
||||||
url: "https://communityrule.com",
|
|
||||||
potentialAction: {
|
|
||||||
"@type": "SearchAction",
|
|
||||||
target: "https://communityrule.com/search?q={search_term_string}",
|
|
||||||
"query-input": "required name=search_term_string",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
type NavSize =
|
|
||||||
| "default"
|
|
||||||
| "xsmall"
|
|
||||||
| "xsmallUseCases"
|
|
||||||
| "home"
|
|
||||||
| "homeMd"
|
|
||||||
| "homeUseCases"
|
|
||||||
| "large"
|
|
||||||
| "largeUseCases"
|
|
||||||
| "homeXlarge"
|
|
||||||
| "xlarge";
|
|
||||||
|
|
||||||
const renderNavigationItems = (size: NavSize) => {
|
|
||||||
return navigationItems.map((item, index) => (
|
|
||||||
<MenuBarItem
|
|
||||||
key={index}
|
|
||||||
href={item.href}
|
|
||||||
size={item.extraPadding && size === "xsmall" ? "xsmallUseCases" : size}
|
|
||||||
isActive={pathname === item.href}
|
|
||||||
ariaLabel={`Navigate to ${item.text} page`}
|
|
||||||
>
|
|
||||||
{item.text}
|
|
||||||
</MenuBarItem>
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderAvatarGroup = (
|
|
||||||
containerSize: "small" | "medium" | "large" | "xlarge",
|
|
||||||
avatarSize: "small" | "medium" | "large" | "xlarge",
|
|
||||||
) => {
|
|
||||||
return (
|
|
||||||
<AvatarContainer size={containerSize}>
|
|
||||||
{avatarImages.map((avatar, index) => (
|
|
||||||
<Avatar
|
|
||||||
key={index}
|
|
||||||
src={avatar.src}
|
|
||||||
alt={avatar.alt}
|
|
||||||
size={avatarSize}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</AvatarContainer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderLoginButton = (size: NavSize) => {
|
|
||||||
return (
|
|
||||||
<MenuBarItem href="#" size={size} ariaLabel="Log in to your account">
|
|
||||||
Log in
|
|
||||||
</MenuBarItem>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderCreateRuleButton = (
|
|
||||||
buttonSize: "xsmall" | "small" | "medium" | "large" | "xlarge",
|
|
||||||
containerSize: "small" | "medium" | "large" | "xlarge",
|
|
||||||
avatarSize: "small" | "medium" | "large" | "xlarge",
|
|
||||||
) => {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
size={buttonSize}
|
|
||||||
ariaLabel="Create a new rule with avatar decoration"
|
|
||||||
>
|
|
||||||
{renderAvatarGroup(containerSize, avatarSize)}
|
|
||||||
<span>Create rule</span>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderLogo = (
|
|
||||||
size:
|
|
||||||
| "default"
|
|
||||||
| "homeHeaderXsmall"
|
|
||||||
| "homeHeaderSm"
|
|
||||||
| "homeHeaderMd"
|
|
||||||
| "homeHeaderLg"
|
|
||||||
| "homeHeaderXl"
|
|
||||||
| "header"
|
|
||||||
| "headerMd"
|
|
||||||
| "headerLg"
|
|
||||||
| "headerXl"
|
|
||||||
| "footer"
|
|
||||||
| "footerLg",
|
|
||||||
showText: boolean,
|
|
||||||
) => {
|
|
||||||
return <Logo size={size} showText={showText} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
export function HeaderView({
|
||||||
|
schemaData,
|
||||||
|
logoConfig,
|
||||||
|
renderNavigationItems,
|
||||||
|
renderLoginButton,
|
||||||
|
renderCreateRuleButton,
|
||||||
|
renderLogo,
|
||||||
|
}: HeaderViewProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<script
|
<script
|
||||||
@@ -253,8 +116,4 @@ const Header = memo(() => {
|
|||||||
</header>
|
</header>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
Header.displayName = "Header";
|
|
||||||
|
|
||||||
export default Header;
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { default } from "./Header.container";
|
||||||
|
export type { HeaderProps } from "./Header.types";
|
||||||
|
export { navigationItems, avatarImages, logoConfig } from "./Header.container";
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { memo } from "react";
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import Logo from "./Logo";
|
|
||||||
import MenuBar from "./MenuBar";
|
|
||||||
import MenuBarItem from "./MenuBarItem";
|
|
||||||
import Button from "./Button";
|
|
||||||
import AvatarContainer from "./AvatarContainer";
|
|
||||||
import Avatar from "./Avatar";
|
|
||||||
import HeaderTab from "./HeaderTab";
|
|
||||||
|
|
||||||
const HomeHeader = memo(() => {
|
|
||||||
const pathname = usePathname();
|
|
||||||
|
|
||||||
// Schema markup for site navigation (home page specific)
|
|
||||||
const schemaData = {
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "WebSite",
|
|
||||||
name: "CommunityRule",
|
|
||||||
url: "https://communityrule.com",
|
|
||||||
description: "Build operating manuals for successful communities",
|
|
||||||
potentialAction: {
|
|
||||||
"@type": "SearchAction",
|
|
||||||
target: "https://communityrule.com/search?q={search_term_string}",
|
|
||||||
"query-input": "required name=search_term_string",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const navigationItems = [
|
|
||||||
{ href: "#", text: "Use cases", extraPadding: true },
|
|
||||||
{ href: "/learn", text: "Learn" },
|
|
||||||
{ href: "#", text: "About" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const avatarImages = [
|
|
||||||
{ src: "/assets/Avatar_1.png", alt: "Avatar 1" },
|
|
||||||
{ src: "/assets/Avatar_2.png", alt: "Avatar 2" },
|
|
||||||
{ src: "/assets/Avatar_3.png", alt: "Avatar 3" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const logoConfig = [
|
|
||||||
{
|
|
||||||
breakpoint: "block sm:hidden",
|
|
||||||
size: "homeHeaderXsmall" as const,
|
|
||||||
showText: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
breakpoint: "hidden sm:block md:hidden",
|
|
||||||
size: "homeHeaderSm" as const,
|
|
||||||
showText: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
breakpoint: "hidden md:block lg:hidden",
|
|
||||||
size: "homeHeaderMd" as const,
|
|
||||||
showText: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
breakpoint: "hidden lg:block xl:hidden",
|
|
||||||
size: "homeHeaderLg" as const,
|
|
||||||
showText: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
breakpoint: "hidden xl:block",
|
|
||||||
size: "homeHeaderXl" as const,
|
|
||||||
showText: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
type NavSize =
|
|
||||||
| "default"
|
|
||||||
| "xsmall"
|
|
||||||
| "xsmallUseCases"
|
|
||||||
| "home"
|
|
||||||
| "homeMd"
|
|
||||||
| "homeUseCases"
|
|
||||||
| "large"
|
|
||||||
| "largeUseCases"
|
|
||||||
| "homeXlarge"
|
|
||||||
| "xlarge";
|
|
||||||
|
|
||||||
const renderNavigationItems = (size: NavSize) => {
|
|
||||||
return navigationItems.map((item, index) => (
|
|
||||||
<MenuBarItem
|
|
||||||
key={index}
|
|
||||||
href={item.href}
|
|
||||||
size={
|
|
||||||
item.extraPadding &&
|
|
||||||
(size === "xsmall" ||
|
|
||||||
size === "default" ||
|
|
||||||
size === "home" ||
|
|
||||||
size === "homeMd" ||
|
|
||||||
size === "large" ||
|
|
||||||
size === "homeXlarge")
|
|
||||||
? size === "home" || size === "homeMd"
|
|
||||||
? "homeMd"
|
|
||||||
: size === "large"
|
|
||||||
? "large"
|
|
||||||
: size === "homeXlarge"
|
|
||||||
? "homeXlarge"
|
|
||||||
: "xsmallUseCases"
|
|
||||||
: size
|
|
||||||
}
|
|
||||||
variant={
|
|
||||||
size === "xsmall" ||
|
|
||||||
size === "default" ||
|
|
||||||
size === "home" ||
|
|
||||||
size === "homeMd" ||
|
|
||||||
size === "large" ||
|
|
||||||
size === "homeXlarge"
|
|
||||||
? "home"
|
|
||||||
: "default"
|
|
||||||
}
|
|
||||||
isActive={pathname === item.href}
|
|
||||||
ariaLabel={`Navigate to ${item.text} page`}
|
|
||||||
>
|
|
||||||
{item.text}
|
|
||||||
</MenuBarItem>
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderAvatarGroup = (
|
|
||||||
containerSize: "small" | "medium" | "large" | "xlarge",
|
|
||||||
avatarSize: "small" | "medium" | "large" | "xlarge",
|
|
||||||
) => {
|
|
||||||
return (
|
|
||||||
<AvatarContainer size={containerSize}>
|
|
||||||
{avatarImages.map((avatar, index) => (
|
|
||||||
<Avatar
|
|
||||||
key={index}
|
|
||||||
src={avatar.src}
|
|
||||||
alt={avatar.alt}
|
|
||||||
size={avatarSize}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</AvatarContainer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderLoginButton = (size: NavSize) => {
|
|
||||||
return (
|
|
||||||
<MenuBarItem
|
|
||||||
href="#"
|
|
||||||
size={size}
|
|
||||||
variant={size === "xsmall" || size === "default" ? "home" : "default"}
|
|
||||||
ariaLabel="Log in to your account"
|
|
||||||
>
|
|
||||||
Log in
|
|
||||||
</MenuBarItem>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderCreateRuleButton = (
|
|
||||||
buttonSize: "xsmall" | "small" | "medium" | "large" | "xlarge",
|
|
||||||
containerSize: "small" | "medium" | "large" | "xlarge",
|
|
||||||
avatarSize: "small" | "medium" | "large" | "xlarge",
|
|
||||||
) => {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
size={buttonSize}
|
|
||||||
variant="secondary"
|
|
||||||
ariaLabel="Create a new rule with avatar decoration"
|
|
||||||
>
|
|
||||||
{renderAvatarGroup(containerSize, avatarSize)}
|
|
||||||
<span>Create rule</span>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderLogo = (
|
|
||||||
size:
|
|
||||||
| "default"
|
|
||||||
| "homeHeaderXsmall"
|
|
||||||
| "homeHeaderSm"
|
|
||||||
| "homeHeaderMd"
|
|
||||||
| "homeHeaderLg"
|
|
||||||
| "homeHeaderXl"
|
|
||||||
| "header"
|
|
||||||
| "headerMd"
|
|
||||||
| "headerLg"
|
|
||||||
| "headerXl"
|
|
||||||
| "footer"
|
|
||||||
| "footerLg",
|
|
||||||
showText: boolean,
|
|
||||||
) => {
|
|
||||||
return <Logo size={size} showText={showText} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<script
|
|
||||||
type="application/ld+json"
|
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaData) }}
|
|
||||||
/>
|
|
||||||
<header
|
|
||||||
className="w-full bg-transparent overflow-hidden"
|
|
||||||
role="banner"
|
|
||||||
aria-label="Home page navigation header"
|
|
||||||
>
|
|
||||||
<nav
|
|
||||||
className="relative flex items-center justify-between mx-auto h-[50px] sm:h-[62px] md:h-[68px] lg:h-[68px] xl:h-[88px] px-[var(--spacing-scale-008)] pr-[var(--spacing-scale-016)] pt-[var(--spacing-scale-010)] sm:px-[var(--spacing-scale-010)] sm:pr-[var(--spacing-scale-020)] sm:pt-[var(--spacing-scale-010)] md:px-[var(--spacing-scale-016)] md:pr-[var(--spacing-scale-032)] md:pt-[var(--spacing-scale-016)] lg:pl-[var(--spacing-scale-024)] lg:pt-[var(--spacing-scale-016)] lg:pr-[var(--spacing-scale-056)] xl:pl-[var(--spacing-scale-048)] xl:pt-[var(--spacing-scale-024)] xl:pr-[var(--spacing-scale-056)]"
|
|
||||||
role="navigation"
|
|
||||||
aria-label="Main navigation"
|
|
||||||
>
|
|
||||||
<HeaderTab className="flex items-center self-end" stretch={true}>
|
|
||||||
{/* Logo - Consistent left positioning within HeaderTab */}
|
|
||||||
<div>
|
|
||||||
{logoConfig.map((config, index) => (
|
|
||||||
<div key={index} className={config.breakpoint}>
|
|
||||||
{renderLogo(config.size, config.showText)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* XSmall menu bar - positioned next to logo */}
|
|
||||||
<div className="block sm:hidden -me-[2px]">
|
|
||||||
<MenuBar size="default">
|
|
||||||
{renderNavigationItems("xsmall")}
|
|
||||||
{renderLoginButton("xsmall")}
|
|
||||||
</MenuBar>
|
|
||||||
</div>
|
|
||||||
</HeaderTab>
|
|
||||||
|
|
||||||
{/* Navigation Links - Centered in header for SM and up */}
|
|
||||||
<div className="absolute left-1/2 transform -translate-x-1/2 hidden sm:block">
|
|
||||||
<div className="hidden sm:block md:hidden">
|
|
||||||
<MenuBar size="default">
|
|
||||||
{renderNavigationItems("xsmall")}
|
|
||||||
{renderLoginButton("xsmall")}
|
|
||||||
</MenuBar>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hidden md:block lg:hidden">
|
|
||||||
<MenuBar size="medium">{renderNavigationItems("homeMd")}</MenuBar>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hidden lg:block xl:hidden">
|
|
||||||
<MenuBar size="large">{renderNavigationItems("large")}</MenuBar>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="hidden xl:block">
|
|
||||||
<MenuBar size="large">
|
|
||||||
{renderNavigationItems("homeXlarge")}
|
|
||||||
</MenuBar>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Authentication Elements - Consistent right alignment outside HeaderTab */}
|
|
||||||
<div className="flex items-center">
|
|
||||||
{/* XSmall and Small breakpoints - create rule button outside HeaderTab */}
|
|
||||||
<div className="block md:hidden">
|
|
||||||
{renderCreateRuleButton("xsmall", "small", "small")}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Medium breakpoint - login outside HeaderTab, create rule outside */}
|
|
||||||
<div className="hidden md:block lg:hidden absolute right-[var(--spacing-measures-spacing-016)]">
|
|
||||||
<div className="flex items-center gap-[var(--spacing-scale-010)]">
|
|
||||||
{renderLoginButton("homeMd")}
|
|
||||||
{renderCreateRuleButton("small", "medium", "medium")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Large breakpoint */}
|
|
||||||
<div className="hidden lg:flex xl:hidden items-center">
|
|
||||||
<div className="flex items-center gap-[var(--spacing-scale-004)]">
|
|
||||||
{renderLoginButton("large")}
|
|
||||||
{renderCreateRuleButton("large", "large", "large")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* XLarge breakpoint */}
|
|
||||||
<div className="hidden xl:flex items-center">
|
|
||||||
<div className="flex items-center gap-[var(--spacing-scale-004)]">
|
|
||||||
{renderLoginButton("homeXlarge")}
|
|
||||||
{renderCreateRuleButton("xlarge", "xlarge", "xlarge")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
HomeHeader.displayName = "HomeHeader";
|
|
||||||
|
|
||||||
export default HomeHeader;
|
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { memo } from "react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import MenuBarItem from "../MenuBarItem";
|
||||||
|
import Button from "../Button";
|
||||||
|
import AvatarContainer from "../AvatarContainer";
|
||||||
|
import Avatar from "../Avatar";
|
||||||
|
import Logo from "../Logo";
|
||||||
|
import { getAssetPath, ASSETS } from "../../../lib/assetUtils";
|
||||||
|
import HomeHeaderView from "./HomeHeader.view";
|
||||||
|
import type { HomeHeaderProps, NavSize } from "./HomeHeader.types";
|
||||||
|
|
||||||
|
// Configuration data for testing
|
||||||
|
export const navigationItems = [
|
||||||
|
{ href: "#", text: "Use cases", extraPadding: true },
|
||||||
|
{ href: "/learn", text: "Learn" },
|
||||||
|
{ href: "#", text: "About" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const avatarImages = [
|
||||||
|
{ src: getAssetPath(ASSETS.AVATAR_1), alt: "Avatar 1" },
|
||||||
|
{ src: getAssetPath(ASSETS.AVATAR_2), alt: "Avatar 2" },
|
||||||
|
{ src: getAssetPath(ASSETS.AVATAR_3), alt: "Avatar 3" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const logoConfig = [
|
||||||
|
{
|
||||||
|
breakpoint: "block sm:hidden",
|
||||||
|
size: "homeHeaderXsmall" as const,
|
||||||
|
showText: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
breakpoint: "hidden sm:block md:hidden",
|
||||||
|
size: "homeHeaderSm" as const,
|
||||||
|
showText: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
breakpoint: "hidden md:block lg:hidden",
|
||||||
|
size: "homeHeaderMd" as const,
|
||||||
|
showText: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
breakpoint: "hidden lg:block xl:hidden",
|
||||||
|
size: "homeHeaderLg" as const,
|
||||||
|
showText: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
breakpoint: "hidden xl:block",
|
||||||
|
size: "homeHeaderXl" as const,
|
||||||
|
showText: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const HomeHeaderContainer = memo<HomeHeaderProps>(() => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
// Schema markup for site navigation (home page specific)
|
||||||
|
const schemaData = {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "WebSite",
|
||||||
|
name: "CommunityRule",
|
||||||
|
url: "https://communityrule.com",
|
||||||
|
description: "Build operating manuals for successful communities",
|
||||||
|
potentialAction: {
|
||||||
|
"@type": "SearchAction",
|
||||||
|
target: "https://communityrule.com/search?q={search_term_string}",
|
||||||
|
"query-input": "required name=search_term_string",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderNavigationItems = (size: NavSize) => {
|
||||||
|
return navigationItems.map((item, index) => (
|
||||||
|
<MenuBarItem
|
||||||
|
key={index}
|
||||||
|
href={item.href}
|
||||||
|
size={
|
||||||
|
item.extraPadding &&
|
||||||
|
(size === "xsmall" ||
|
||||||
|
size === "default" ||
|
||||||
|
size === "home" ||
|
||||||
|
size === "homeMd" ||
|
||||||
|
size === "large" ||
|
||||||
|
size === "homeXlarge")
|
||||||
|
? size === "home" || size === "homeMd"
|
||||||
|
? "homeMd"
|
||||||
|
: size === "large"
|
||||||
|
? "large"
|
||||||
|
: size === "homeXlarge"
|
||||||
|
? "homeXlarge"
|
||||||
|
: "xsmallUseCases"
|
||||||
|
: size
|
||||||
|
}
|
||||||
|
variant={
|
||||||
|
size === "xsmall" ||
|
||||||
|
size === "default" ||
|
||||||
|
size === "home" ||
|
||||||
|
size === "homeMd" ||
|
||||||
|
size === "large" ||
|
||||||
|
size === "homeXlarge"
|
||||||
|
? "home"
|
||||||
|
: "default"
|
||||||
|
}
|
||||||
|
isActive={pathname === item.href}
|
||||||
|
ariaLabel={`Navigate to ${item.text} page`}
|
||||||
|
>
|
||||||
|
{item.text}
|
||||||
|
</MenuBarItem>
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderAvatarGroup = (
|
||||||
|
containerSize: "small" | "medium" | "large" | "xlarge",
|
||||||
|
avatarSize: "small" | "medium" | "large" | "xlarge",
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<AvatarContainer size={containerSize}>
|
||||||
|
{avatarImages.map((avatar, index) => (
|
||||||
|
<Avatar
|
||||||
|
key={index}
|
||||||
|
src={avatar.src}
|
||||||
|
alt={avatar.alt}
|
||||||
|
size={avatarSize}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AvatarContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderLoginButton = (size: NavSize) => {
|
||||||
|
return (
|
||||||
|
<MenuBarItem
|
||||||
|
href="#"
|
||||||
|
size={size}
|
||||||
|
variant={size === "xsmall" || size === "default" ? "home" : "default"}
|
||||||
|
ariaLabel="Log in to your account"
|
||||||
|
>
|
||||||
|
Log in
|
||||||
|
</MenuBarItem>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCreateRuleButton = (
|
||||||
|
buttonSize: "xsmall" | "small" | "medium" | "large" | "xlarge",
|
||||||
|
containerSize: "small" | "medium" | "large" | "xlarge",
|
||||||
|
avatarSize: "small" | "medium" | "large" | "xlarge",
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size={buttonSize}
|
||||||
|
variant="secondary"
|
||||||
|
ariaLabel="Create a new rule with avatar decoration"
|
||||||
|
>
|
||||||
|
{renderAvatarGroup(containerSize, avatarSize)}
|
||||||
|
<span>Create rule</span>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderLogo = (
|
||||||
|
size:
|
||||||
|
| "default"
|
||||||
|
| "homeHeaderXsmall"
|
||||||
|
| "homeHeaderSm"
|
||||||
|
| "homeHeaderMd"
|
||||||
|
| "homeHeaderLg"
|
||||||
|
| "homeHeaderXl"
|
||||||
|
| "header"
|
||||||
|
| "headerMd"
|
||||||
|
| "headerLg"
|
||||||
|
| "headerXl"
|
||||||
|
| "footer"
|
||||||
|
| "footerLg",
|
||||||
|
showText: boolean,
|
||||||
|
) => {
|
||||||
|
return <Logo size={size} showText={showText} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HomeHeaderView
|
||||||
|
schemaData={schemaData}
|
||||||
|
logoConfig={logoConfig}
|
||||||
|
renderNavigationItems={renderNavigationItems}
|
||||||
|
renderLoginButton={renderLoginButton}
|
||||||
|
renderCreateRuleButton={renderCreateRuleButton}
|
||||||
|
renderLogo={renderLogo}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
HomeHeaderContainer.displayName = "HomeHeader";
|
||||||
|
|
||||||
|
export default HomeHeaderContainer;
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type React from "react";
|
||||||
|
|
||||||
|
export interface HomeHeaderProps {
|
||||||
|
// Currently no props, but keeping interface for future extensibility
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NavSize =
|
||||||
|
| "default"
|
||||||
|
| "xsmall"
|
||||||
|
| "xsmallUseCases"
|
||||||
|
| "home"
|
||||||
|
| "homeMd"
|
||||||
|
| "homeUseCases"
|
||||||
|
| "large"
|
||||||
|
| "largeUseCases"
|
||||||
|
| "homeXlarge"
|
||||||
|
| "xlarge";
|
||||||
|
|
||||||
|
export interface HomeHeaderViewProps {
|
||||||
|
schemaData: object;
|
||||||
|
logoConfig: Array<{
|
||||||
|
breakpoint: string;
|
||||||
|
size:
|
||||||
|
| "default"
|
||||||
|
| "homeHeaderXsmall"
|
||||||
|
| "homeHeaderSm"
|
||||||
|
| "homeHeaderMd"
|
||||||
|
| "homeHeaderLg"
|
||||||
|
| "homeHeaderXl"
|
||||||
|
| "header"
|
||||||
|
| "headerMd"
|
||||||
|
| "headerLg"
|
||||||
|
| "headerXl"
|
||||||
|
| "footer"
|
||||||
|
| "footerLg";
|
||||||
|
showText: boolean;
|
||||||
|
}>;
|
||||||
|
renderNavigationItems: (_size: NavSize) => React.ReactNode;
|
||||||
|
renderLoginButton: (_size: NavSize) => React.ReactNode;
|
||||||
|
renderCreateRuleButton: (
|
||||||
|
_buttonSize: "xsmall" | "small" | "medium" | "large" | "xlarge",
|
||||||
|
_containerSize: "small" | "medium" | "large" | "xlarge",
|
||||||
|
_avatarSize: "small" | "medium" | "large" | "xlarge",
|
||||||
|
) => React.ReactNode;
|
||||||
|
renderLogo: (
|
||||||
|
_size:
|
||||||
|
| "default"
|
||||||
|
| "homeHeaderXsmall"
|
||||||
|
| "homeHeaderSm"
|
||||||
|
| "homeHeaderMd"
|
||||||
|
| "homeHeaderLg"
|
||||||
|
| "homeHeaderXl"
|
||||||
|
| "header"
|
||||||
|
| "headerMd"
|
||||||
|
| "headerLg"
|
||||||
|
| "headerXl"
|
||||||
|
| "footer"
|
||||||
|
| "footerLg",
|
||||||
|
_showText: boolean,
|
||||||
|
) => React.ReactNode;
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { memo } from "react";
|
||||||
|
import Script from "next/script";
|
||||||
|
import HeaderTab from "../HeaderTab";
|
||||||
|
import MenuBar from "../MenuBar";
|
||||||
|
import type { HomeHeaderViewProps } from "./HomeHeader.types";
|
||||||
|
|
||||||
|
function HomeHeaderView({
|
||||||
|
schemaData,
|
||||||
|
logoConfig,
|
||||||
|
renderNavigationItems,
|
||||||
|
renderLoginButton,
|
||||||
|
renderCreateRuleButton,
|
||||||
|
renderLogo,
|
||||||
|
}: HomeHeaderViewProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Script
|
||||||
|
id="home-header-schema"
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaData) }}
|
||||||
|
/>
|
||||||
|
<header
|
||||||
|
className="w-full bg-transparent overflow-hidden"
|
||||||
|
role="banner"
|
||||||
|
aria-label="Home page navigation header"
|
||||||
|
>
|
||||||
|
<nav
|
||||||
|
className="relative flex items-center justify-between mx-auto h-[50px] sm:h-[62px] md:h-[68px] lg:h-[68px] xl:h-[88px] px-[var(--spacing-scale-008)] pr-[var(--spacing-scale-016)] pt-[var(--spacing-scale-010)] sm:px-[var(--spacing-scale-010)] sm:pr-[var(--spacing-scale-020)] sm:pt-[var(--spacing-scale-010)] md:px-[var(--spacing-scale-016)] md:pr-[var(--spacing-scale-032)] md:pt-[var(--spacing-scale-016)] lg:pl-[var(--spacing-scale-024)] lg:pt-[var(--spacing-scale-016)] lg:pr-[var(--spacing-scale-056)] xl:pl-[var(--spacing-scale-048)] xl:pt-[var(--spacing-scale-024)] xl:pr-[var(--spacing-scale-056)]"
|
||||||
|
role="navigation"
|
||||||
|
aria-label="Main navigation"
|
||||||
|
>
|
||||||
|
<HeaderTab className="flex items-center self-end" stretch={true}>
|
||||||
|
{/* Logo - Consistent left positioning within HeaderTab */}
|
||||||
|
<div>
|
||||||
|
{logoConfig.map((config, index) => (
|
||||||
|
<div key={index} className={config.breakpoint}>
|
||||||
|
{renderLogo(config.size, config.showText)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* XSmall menu bar - positioned next to logo */}
|
||||||
|
<div className="block sm:hidden -me-[2px]">
|
||||||
|
<MenuBar size="default">
|
||||||
|
{renderNavigationItems("xsmall")}
|
||||||
|
{renderLoginButton("xsmall")}
|
||||||
|
</MenuBar>
|
||||||
|
</div>
|
||||||
|
</HeaderTab>
|
||||||
|
|
||||||
|
{/* Navigation Links - Centered in header for SM and up */}
|
||||||
|
<div className="absolute left-1/2 transform -translate-x-1/2 hidden sm:block">
|
||||||
|
<div className="hidden sm:block md:hidden">
|
||||||
|
<MenuBar size="default">
|
||||||
|
{renderNavigationItems("xsmall")}
|
||||||
|
{renderLoginButton("xsmall")}
|
||||||
|
</MenuBar>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden md:block lg:hidden">
|
||||||
|
<MenuBar size="medium">{renderNavigationItems("homeMd")}</MenuBar>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden lg:block xl:hidden">
|
||||||
|
<MenuBar size="large">{renderNavigationItems("large")}</MenuBar>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="hidden xl:block">
|
||||||
|
<MenuBar size="large">
|
||||||
|
{renderNavigationItems("homeXlarge")}
|
||||||
|
</MenuBar>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Authentication Elements - Consistent right alignment outside HeaderTab */}
|
||||||
|
<div className="flex items-center">
|
||||||
|
{/* XSmall and Small breakpoints - create rule button outside HeaderTab */}
|
||||||
|
<div className="block md:hidden">
|
||||||
|
{renderCreateRuleButton("xsmall", "small", "small")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Medium breakpoint - login outside HeaderTab, create rule outside */}
|
||||||
|
<div className="hidden md:block lg:hidden absolute right-[var(--spacing-measures-spacing-016)]">
|
||||||
|
<div className="flex items-center gap-[var(--spacing-scale-010)]">
|
||||||
|
{renderLoginButton("homeMd")}
|
||||||
|
{renderCreateRuleButton("small", "medium", "medium")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Large breakpoint */}
|
||||||
|
<div className="hidden lg:flex xl:hidden items-center">
|
||||||
|
<div className="flex items-center gap-[var(--spacing-scale-004)]">
|
||||||
|
{renderLoginButton("large")}
|
||||||
|
{renderCreateRuleButton("large", "large", "large")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* XLarge breakpoint */}
|
||||||
|
<div className="hidden xl:flex items-center">
|
||||||
|
<div className="flex items-center gap-[var(--spacing-scale-004)]">
|
||||||
|
{renderLoginButton("homeXlarge")}
|
||||||
|
{renderCreateRuleButton("xlarge", "xlarge", "xlarge")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
HomeHeaderView.displayName = "HomeHeaderView";
|
||||||
|
|
||||||
|
export default memo(HomeHeaderView);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from "./HomeHeader.container";
|
||||||
|
export type { HomeHeaderProps } from "./HomeHeader.types";
|
||||||
@@ -1,27 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { memo, forwardRef } from "react";
|
import { memo, forwardRef } from "react";
|
||||||
import { useComponentId, useFormField } from "../hooks";
|
import { useComponentId, useFormField } from "../../hooks";
|
||||||
|
import { InputView } from "./Input.view";
|
||||||
|
import type { InputProps } from "./Input.types";
|
||||||
|
|
||||||
interface InputProps extends Omit<
|
const InputContainer = forwardRef<HTMLInputElement, InputProps>(
|
||||||
React.InputHTMLAttributes<HTMLInputElement>,
|
|
||||||
"size" | "onChange" | "onFocus" | "onBlur"
|
|
||||||
> {
|
|
||||||
size?: "small" | "medium" | "large";
|
|
||||||
labelVariant?: "default" | "horizontal";
|
|
||||||
state?: "default" | "active" | "hover" | "focus";
|
|
||||||
disabled?: boolean;
|
|
||||||
error?: boolean;
|
|
||||||
label?: string;
|
|
||||||
placeholder?: string;
|
|
||||||
value?: string;
|
|
||||||
onChange?: (_e: React.ChangeEvent<HTMLInputElement>) => void;
|
|
||||||
onFocus?: (_e: React.FocusEvent<HTMLInputElement>) => void;
|
|
||||||
onBlur?: (_e: React.FocusEvent<HTMLInputElement>) => void;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Input = forwardRef<HTMLInputElement, InputProps>(
|
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
size = "medium",
|
size = "medium",
|
||||||
@@ -159,38 +143,34 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={containerClasses}>
|
<InputView
|
||||||
{label && (
|
ref={ref}
|
||||||
<label
|
inputId={inputId}
|
||||||
id={labelId}
|
labelId={labelId}
|
||||||
htmlFor={inputId}
|
size={size}
|
||||||
className={`${labelClasses} font-inter font-medium text-[var(--color-content-default-secondary)]`}
|
labelVariant={labelVariant}
|
||||||
>
|
state={state}
|
||||||
{label}
|
disabled={disabled}
|
||||||
</label>
|
error={error}
|
||||||
)}
|
label={label}
|
||||||
<div className={disabled ? "opacity-40" : ""}>
|
placeholder={placeholder}
|
||||||
<input
|
value={value}
|
||||||
ref={ref}
|
name={name}
|
||||||
id={inputId}
|
type={type}
|
||||||
name={name}
|
className={className}
|
||||||
type={type}
|
containerClasses={containerClasses}
|
||||||
value={value}
|
labelClasses={labelClasses}
|
||||||
placeholder={placeholder}
|
inputClasses={inputClasses}
|
||||||
onChange={handleChange}
|
borderRadius={currentSize.radius}
|
||||||
onFocus={handleFocus}
|
handleChange={handleChange}
|
||||||
onBlur={handleBlur}
|
handleFocus={handleFocus}
|
||||||
disabled={disabled}
|
handleBlur={handleBlur}
|
||||||
className={inputClasses}
|
{...props}
|
||||||
style={{ borderRadius: currentSize.radius }}
|
/>
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
Input.displayName = "Input";
|
InputContainer.displayName = "Input";
|
||||||
|
|
||||||
export default memo(Input);
|
export default memo(InputContainer);
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
export interface InputProps extends Omit<
|
||||||
|
React.InputHTMLAttributes<HTMLInputElement>,
|
||||||
|
"size" | "onChange" | "onFocus" | "onBlur"
|
||||||
|
> {
|
||||||
|
size?: "small" | "medium" | "large";
|
||||||
|
labelVariant?: "default" | "horizontal";
|
||||||
|
state?: "default" | "active" | "hover" | "focus";
|
||||||
|
disabled?: boolean;
|
||||||
|
error?: boolean;
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
value?: string;
|
||||||
|
onChange?: (_e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
onFocus?: (_e: React.FocusEvent<HTMLInputElement>) => void;
|
||||||
|
onBlur?: (_e: React.FocusEvent<HTMLInputElement>) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InputViewProps {
|
||||||
|
inputId: string;
|
||||||
|
labelId: string;
|
||||||
|
size: "small" | "medium" | "large";
|
||||||
|
labelVariant: "default" | "horizontal";
|
||||||
|
state: "default" | "active" | "hover" | "focus";
|
||||||
|
disabled: boolean;
|
||||||
|
error: boolean;
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
value?: string;
|
||||||
|
name?: string;
|
||||||
|
type: string;
|
||||||
|
className: string;
|
||||||
|
containerClasses: string;
|
||||||
|
labelClasses: string;
|
||||||
|
inputClasses: string;
|
||||||
|
borderRadius: string;
|
||||||
|
handleChange: (_e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
handleFocus: (_e: React.FocusEvent<HTMLInputElement>) => void;
|
||||||
|
handleBlur: (_e: React.FocusEvent<HTMLInputElement>) => void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { forwardRef } from "react";
|
||||||
|
import type { InputViewProps } from "./Input.types";
|
||||||
|
|
||||||
|
export const InputView = forwardRef<HTMLInputElement, InputViewProps>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
inputId,
|
||||||
|
labelId,
|
||||||
|
label,
|
||||||
|
placeholder,
|
||||||
|
value,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
disabled,
|
||||||
|
size: _size,
|
||||||
|
labelVariant: _labelVariant,
|
||||||
|
state: _state,
|
||||||
|
error: _error,
|
||||||
|
className: _className,
|
||||||
|
containerClasses,
|
||||||
|
labelClasses,
|
||||||
|
inputClasses,
|
||||||
|
borderRadius,
|
||||||
|
handleChange,
|
||||||
|
handleFocus,
|
||||||
|
handleBlur,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<div className={containerClasses}>
|
||||||
|
{label && (
|
||||||
|
<label
|
||||||
|
id={labelId}
|
||||||
|
htmlFor={inputId}
|
||||||
|
className={`${labelClasses} font-inter font-medium text-[var(--color-content-default-secondary)]`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className={disabled ? "opacity-40" : ""}>
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
id={inputId}
|
||||||
|
name={name}
|
||||||
|
type={type}
|
||||||
|
value={value}
|
||||||
|
placeholder={placeholder}
|
||||||
|
onChange={handleChange}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
disabled={disabled}
|
||||||
|
className={inputClasses}
|
||||||
|
style={{ borderRadius }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
InputView.displayName = "InputView";
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from "./Input.container";
|
||||||
|
export type { InputProps } from "./Input.types";
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect, memo } from "react";
|
|
||||||
import Image from "next/image";
|
|
||||||
|
|
||||||
interface Logo {
|
|
||||||
src: string;
|
|
||||||
alt: string;
|
|
||||||
size?: string;
|
|
||||||
order?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LogoWallProps {
|
|
||||||
logos?: Logo[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const LogoWall = memo<LogoWallProps>(({ logos = [] }) => {
|
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
|
||||||
|
|
||||||
// Default logos if none provided - ordered for mobile (3 rows × 2 columns)
|
|
||||||
const defaultLogos: Logo[] = [
|
|
||||||
{
|
|
||||||
src: "/assets/Section/Logo_FoodNotBombs.png",
|
|
||||||
alt: "Food Not Bombs",
|
|
||||||
size: "h-11 lg:h-14 xl:h-[70px]",
|
|
||||||
order: "order-1 sm:order-4", // Mobile: row 1 col 1, SM: row 2 col 1 (bottom left)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: "/assets/Section/Logo_StartCOOP.png",
|
|
||||||
alt: "Start COOP",
|
|
||||||
size: "h-[42px] lg:h-[53px] xl:h-[66px]",
|
|
||||||
order: "order-2 sm:order-2", // Mobile: row 1 col 2, SM: row 1 col 2 (top middle)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: "/assets/Section/Logo_Metagov.png",
|
|
||||||
alt: "Metagov",
|
|
||||||
size: "h-6 lg:h-8 xl:h-[41px]",
|
|
||||||
order: "order-3 sm:order-1", // Mobile: row 2 col 1, SM: row 1 col 1 (top left)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: "/assets/Section/Logo_OpenCivics.png",
|
|
||||||
alt: "Open Civics",
|
|
||||||
size: "h-8 lg:h-10 xl:h-[50px]",
|
|
||||||
order: "order-4 sm:order-5 md:order-6", // Mobile: row 2 col 2, SM: row 2 col 2, MD: swapped with Mutual Aid CO
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: "/assets/Section/Logo_MutualAidCO.png",
|
|
||||||
alt: "Mutual Aid CO",
|
|
||||||
size: "h-11 lg:h-14 xl:h-[70px]",
|
|
||||||
order: "order-5 sm:order-6 md:order-5", // Mobile: row 3 col 1, SM: row 2 col 3, MD: swapped with OpenCivics
|
|
||||||
},
|
|
||||||
{
|
|
||||||
src: "/assets/Section/Logo_CUBoulder.png",
|
|
||||||
alt: "CU Boulder",
|
|
||||||
size: "h-10 lg:h-12 xl:h-[60px]",
|
|
||||||
order: "order-6 sm:order-3", // Mobile: row 3 col 2, SM: row 1 col 3 (top right)
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const displayLogos = logos.length > 0 ? logos : defaultLogos;
|
|
||||||
|
|
||||||
// Simple fade-in effect after component mounts
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setIsVisible(true);
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section className="p-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-024)] md:py-[var(--spacing-scale-032)] lg:px-[var(--spacing-scale-064)] lg:py-[var(--spacing-scale-048)] xl:px-[160px] xl:py-[var(--spacing-scale-064)]">
|
|
||||||
<div className="flex flex-col gap-[var(--spacing-scale-032)] md:gap-[var(--spacing-scale-024)] xl:gap-[var(--spacing-scale-032)]">
|
|
||||||
{/* Label */}
|
|
||||||
<p className="font-inter font-medium text-[10px] leading-[12px] xl:text-[14px] xl:leading-[12px] uppercase text-[var(--color-content-default-secondary)] text-center">
|
|
||||||
Trusted by leading cooperators
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Logo Grid Container */}
|
|
||||||
<div
|
|
||||||
className={`transition-opacity duration-500 ${
|
|
||||||
isVisible ? "opacity-60" : "opacity-0"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-2 grid-rows-3 sm:grid-cols-3 sm:grid-rows-2 md:flex md:justify-between md:items-center gap-x-[var(--spacing-scale-032)] gap-y-[var(--spacing-scale-032)] sm:gap-y-[var(--spacing-scale-048)]">
|
|
||||||
{displayLogos.map((logo, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={`flex items-center justify-center transition-opacity duration-500 hover:opacity-100 ${
|
|
||||||
logo.order || ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={logo.src}
|
|
||||||
alt={logo.alt}
|
|
||||||
className={`${
|
|
||||||
logo.size || "h-8"
|
|
||||||
} w-auto object-contain transition-transform duration-500 hover:scale-105`}
|
|
||||||
priority={index < 2} // Prioritize first 2 logos for above-the-fold loading
|
|
||||||
unoptimized // Skip optimization for local images
|
|
||||||
width={0}
|
|
||||||
height={0}
|
|
||||||
sizes="100vw"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
LogoWall.displayName = "LogoWall";
|
|
||||||
|
|
||||||
export default LogoWall;
|
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { memo, useState, useEffect, useMemo } from "react";
|
||||||
|
import LogoWallView from "./LogoWall.view";
|
||||||
|
import type { LogoWallProps } from "./LogoWall.types";
|
||||||
|
|
||||||
|
const defaultLogos = [
|
||||||
|
{
|
||||||
|
src: "/assets/Section/Logo_FoodNotBombs.png",
|
||||||
|
alt: "Food Not Bombs",
|
||||||
|
size: "h-11 lg:h-14 xl:h-[70px]",
|
||||||
|
order: "order-1 sm:order-4", // Mobile: row 1 col 1, SM: row 2 col 1 (bottom left)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/assets/Section/Logo_StartCOOP.png",
|
||||||
|
alt: "Start COOP",
|
||||||
|
size: "h-[42px] lg:h-[53px] xl:h-[66px]",
|
||||||
|
order: "order-2 sm:order-2", // Mobile: row 1 col 2, SM: row 1 col 2 (top middle)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/assets/Section/Logo_Metagov.png",
|
||||||
|
alt: "Metagov",
|
||||||
|
size: "h-6 lg:h-8 xl:h-[41px]",
|
||||||
|
order: "order-3 sm:order-1", // Mobile: row 2 col 1, SM: row 1 col 1 (top left)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/assets/Section/Logo_OpenCivics.png",
|
||||||
|
alt: "Open Civics",
|
||||||
|
size: "h-8 lg:h-10 xl:h-[50px]",
|
||||||
|
order: "order-4 sm:order-5 md:order-6", // Mobile: row 2 col 2, SM: row 2 col 2, MD: swapped with Mutual Aid CO
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/assets/Section/Logo_MutualAidCO.png",
|
||||||
|
alt: "Mutual Aid CO",
|
||||||
|
size: "h-11 lg:h-14 xl:h-[70px]",
|
||||||
|
order: "order-5 sm:order-6 md:order-5", // Mobile: row 3 col 1, SM: row 2 col 3, MD: swapped with OpenCivics
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: "/assets/Section/Logo_CUBoulder.png",
|
||||||
|
alt: "CU Boulder",
|
||||||
|
size: "h-10 lg:h-12 xl:h-[60px]",
|
||||||
|
order: "order-6 sm:order-3", // Mobile: row 3 col 2, SM: row 1 col 3 (top right)
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const LogoWallContainer = memo<LogoWallProps>(({ logos, className = "" }) => {
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
|
const displayLogos = useMemo(
|
||||||
|
() => (logos && logos.length > 0 ? logos : defaultLogos),
|
||||||
|
[logos],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Trigger fade-in animation after component mounts
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setIsVisible(true);
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LogoWallView
|
||||||
|
isVisible={isVisible}
|
||||||
|
displayLogos={displayLogos}
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
LogoWallContainer.displayName = "LogoWall";
|
||||||
|
|
||||||
|
export default LogoWallContainer;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export interface LogoWallProps {
|
||||||
|
logos?: Array<{
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
size?: string;
|
||||||
|
order?: string;
|
||||||
|
}>;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogoWallViewProps {
|
||||||
|
isVisible: boolean;
|
||||||
|
displayLogos: Array<{
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
size?: string;
|
||||||
|
order?: string;
|
||||||
|
}>;
|
||||||
|
className: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { memo } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import type { LogoWallViewProps } from "./LogoWall.types";
|
||||||
|
|
||||||
|
function LogoWallView({
|
||||||
|
isVisible,
|
||||||
|
displayLogos,
|
||||||
|
className,
|
||||||
|
}: LogoWallViewProps) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={`p-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-024)] md:py-[var(--spacing-scale-032)] lg:px-[var(--spacing-scale-064)] lg:py-[var(--spacing-scale-048)] xl:px-[160px] xl:py-[var(--spacing-scale-064)] ${className}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-[var(--spacing-scale-032)] md:gap-[var(--spacing-scale-024)] xl:gap-[var(--spacing-scale-032)]">
|
||||||
|
{/* Label */}
|
||||||
|
<p className="font-inter font-medium text-[10px] leading-[12px] xl:text-[14px] xl:leading-[12px] uppercase text-[var(--color-content-default-secondary)] text-center">
|
||||||
|
Trusted by leading cooperators
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Logo Grid Container */}
|
||||||
|
<div
|
||||||
|
className={`transition-opacity duration-500 ${
|
||||||
|
isVisible ? "opacity-60" : "opacity-0"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-2 grid-rows-3 sm:grid-cols-3 sm:grid-rows-2 md:flex md:justify-between md:items-center gap-x-[var(--spacing-scale-032)] gap-y-[var(--spacing-scale-032)] sm:gap-y-[var(--spacing-scale-048)]">
|
||||||
|
{displayLogos.map((logo, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`flex items-center justify-center transition-opacity duration-500 hover:opacity-100 ${
|
||||||
|
logo.order || ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={logo.src}
|
||||||
|
alt={logo.alt}
|
||||||
|
className={`${
|
||||||
|
logo.size || "h-8"
|
||||||
|
} w-auto object-contain transition-transform duration-500 hover:scale-105`}
|
||||||
|
priority={index < 2} // Prioritize first 2 logos for above-the-fold loading
|
||||||
|
unoptimized // Skip optimization for local images
|
||||||
|
width={0}
|
||||||
|
height={0}
|
||||||
|
sizes="100vw"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
LogoWallView.displayName = "LogoWallView";
|
||||||
|
|
||||||
|
export default memo(LogoWallView);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from "./LogoWall.container";
|
||||||
|
export type { LogoWallProps } from "./LogoWall.types";
|
||||||
+15
-34
@@ -1,27 +1,10 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
|
import MenuBarItemView from "./MenuBarItem.view";
|
||||||
|
import type { MenuBarItemProps } from "./MenuBarItem.types";
|
||||||
|
|
||||||
interface MenuBarItemProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
const MenuBarItemContainer = memo<MenuBarItemProps>(
|
||||||
href?: string;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
variant?: "default" | "home";
|
|
||||||
size?:
|
|
||||||
| "default"
|
|
||||||
| "xsmall"
|
|
||||||
| "xsmallUseCases"
|
|
||||||
| "home"
|
|
||||||
| "homeMd"
|
|
||||||
| "homeUseCases"
|
|
||||||
| "large"
|
|
||||||
| "largeUseCases"
|
|
||||||
| "homeXlarge"
|
|
||||||
| "xlarge";
|
|
||||||
className?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
isActive?: boolean;
|
|
||||||
ariaLabel?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MenuBarItem = memo<MenuBarItemProps>(
|
|
||||||
({
|
({
|
||||||
href = "#",
|
href = "#",
|
||||||
children,
|
children,
|
||||||
@@ -166,22 +149,20 @@ const MenuBarItem = memo<MenuBarItemProps>(
|
|||||||
...props,
|
...props,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (disabled) {
|
|
||||||
return (
|
|
||||||
<span className={combinedStyles} {...accessibilityProps}>
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a href={href} className={combinedStyles} {...accessibilityProps}>
|
<MenuBarItemView
|
||||||
|
href={href}
|
||||||
|
disabled={disabled}
|
||||||
|
className={className}
|
||||||
|
combinedStyles={combinedStyles}
|
||||||
|
accessibilityProps={accessibilityProps}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</a>
|
</MenuBarItemView>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
MenuBarItem.displayName = "MenuBarItem";
|
MenuBarItemContainer.displayName = "MenuBarItem";
|
||||||
|
|
||||||
export default MenuBarItem;
|
export default MenuBarItemContainer;
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
export interface MenuBarItemProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||||
|
href?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
variant?: "default" | "home";
|
||||||
|
size?:
|
||||||
|
| "default"
|
||||||
|
| "xsmall"
|
||||||
|
| "xsmallUseCases"
|
||||||
|
| "home"
|
||||||
|
| "homeMd"
|
||||||
|
| "homeUseCases"
|
||||||
|
| "large"
|
||||||
|
| "largeUseCases"
|
||||||
|
| "homeXlarge"
|
||||||
|
| "xlarge";
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
isActive?: boolean;
|
||||||
|
ariaLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MenuBarItemViewProps {
|
||||||
|
href: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
disabled: boolean;
|
||||||
|
className: string;
|
||||||
|
combinedStyles: string;
|
||||||
|
accessibilityProps: React.HTMLAttributes<HTMLAnchorElement | HTMLSpanElement>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { memo } from "react";
|
||||||
|
import type { MenuBarItemViewProps } from "./MenuBarItem.types";
|
||||||
|
|
||||||
|
function MenuBarItemView({
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
disabled,
|
||||||
|
combinedStyles,
|
||||||
|
accessibilityProps,
|
||||||
|
}: MenuBarItemViewProps) {
|
||||||
|
if (disabled) {
|
||||||
|
return (
|
||||||
|
<span className={combinedStyles} {...accessibilityProps}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a href={href} className={combinedStyles} {...accessibilityProps}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
MenuBarItemView.displayName = "MenuBarItemView";
|
||||||
|
|
||||||
|
export default memo(MenuBarItemView);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from "./MenuBarItem.container";
|
||||||
|
export type { MenuBarItemProps } from "./MenuBarItem.types";
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { memo } from "react";
|
|
||||||
import Image from "next/image";
|
|
||||||
|
|
||||||
interface MiniCardProps {
|
|
||||||
children?: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
backgroundColor?: string;
|
|
||||||
panelContent?: string;
|
|
||||||
label?: string;
|
|
||||||
labelLine1?: string;
|
|
||||||
labelLine2?: string;
|
|
||||||
onClick?: () => void;
|
|
||||||
href?: string;
|
|
||||||
ariaLabel?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MiniCard = memo<MiniCardProps>(
|
|
||||||
({
|
|
||||||
children,
|
|
||||||
className = "",
|
|
||||||
backgroundColor = "bg-[var(--color-surface-default-brand-royal)]",
|
|
||||||
panelContent,
|
|
||||||
label,
|
|
||||||
labelLine1,
|
|
||||||
labelLine2,
|
|
||||||
onClick,
|
|
||||||
href,
|
|
||||||
ariaLabel,
|
|
||||||
}) => {
|
|
||||||
const cardContent = (
|
|
||||||
<div className={`h-[186px] flex flex-col gap-[7px] ${className}`}>
|
|
||||||
{/* Top part - Inner panel */}
|
|
||||||
<div
|
|
||||||
className={`flex-1 rounded-[var(--radius-measures-radius-xlarge)] border border-[1px] py-[var(--spacing-scale-032)] px-[var(--spacing-scale-024)] ${backgroundColor} flex items-center justify-center transition-all duration-200 hover:scale-[1.02] hover:shadow-lg`}
|
|
||||||
>
|
|
||||||
{/* Content for the inner panel */}
|
|
||||||
{panelContent && (
|
|
||||||
<div className="flex items-center justify-center w-full h-full">
|
|
||||||
<Image
|
|
||||||
src={panelContent}
|
|
||||||
alt={
|
|
||||||
ariaLabel ||
|
|
||||||
`${labelLine1} ${labelLine2}` ||
|
|
||||||
label ||
|
|
||||||
"Feature icon"
|
|
||||||
}
|
|
||||||
className="max-w-[58px] max-h-[58px] w-auto h-auto object-contain"
|
|
||||||
width={58}
|
|
||||||
height={58}
|
|
||||||
sizes="(max-width: 768px) 50vw, 25vw"
|
|
||||||
loading="lazy"
|
|
||||||
placeholder="blur"
|
|
||||||
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k="
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom part - Text container */}
|
|
||||||
<div className="font-inter font-medium text-[12px] leading-[14px] text-center text-[var(--color-content-default-primary)]">
|
|
||||||
{labelLine1 && labelLine2 ? (
|
|
||||||
<>
|
|
||||||
<div>{labelLine1}</div>
|
|
||||||
<div>{labelLine2}</div>
|
|
||||||
<div> </div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
label
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// If href is provided, render as a link
|
|
||||||
if (href) {
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href={href}
|
|
||||||
className="block focus:outline-none focus:ring-2 focus:ring-[var(--color-surface-default-brand-royal)] focus:ring-offset-2 rounded-[var(--radius-measures-radius-xlarge)] transition-all duration-200 hover:scale-[1.02]"
|
|
||||||
aria-label={
|
|
||||||
ariaLabel ||
|
|
||||||
`${labelLine1} ${labelLine2}` ||
|
|
||||||
label ||
|
|
||||||
"Feature card"
|
|
||||||
}
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
{cardContent}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If onClick is provided, render as a button
|
|
||||||
if (onClick) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
className="block w-full text-left focus:outline-none focus:ring-2 focus:ring-[var(--color-surface-default-brand-royal)] focus:ring-offset-2 rounded-[var(--radius-measures-radius-xlarge)] transition-all duration-200 hover:scale-[1.02]"
|
|
||||||
aria-label={
|
|
||||||
ariaLabel ||
|
|
||||||
`${labelLine1} ${labelLine2}` ||
|
|
||||||
label ||
|
|
||||||
"Feature card"
|
|
||||||
}
|
|
||||||
tabIndex={0}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
|
||||||
e.preventDefault();
|
|
||||||
onClick();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{cardContent}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default render as a div
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="block"
|
|
||||||
aria-label={
|
|
||||||
ariaLabel || `${labelLine1} ${labelLine2}` || label || "Feature card"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{cardContent}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
MiniCard.displayName = "MiniCard";
|
|
||||||
|
|
||||||
export default MiniCard;
|
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { memo, useMemo } from "react";
|
||||||
|
import MiniCardView from "./MiniCard.view";
|
||||||
|
import type { MiniCardProps } from "./MiniCard.types";
|
||||||
|
|
||||||
|
const MiniCardContainer = memo<MiniCardProps>(
|
||||||
|
({
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
backgroundColor = "bg-[var(--color-surface-default-brand-royal)]",
|
||||||
|
panelContent,
|
||||||
|
label,
|
||||||
|
labelLine1,
|
||||||
|
labelLine2,
|
||||||
|
onClick,
|
||||||
|
href,
|
||||||
|
ariaLabel,
|
||||||
|
}) => {
|
||||||
|
// Compute aria-label
|
||||||
|
const computedAriaLabel = useMemo(
|
||||||
|
() =>
|
||||||
|
ariaLabel ||
|
||||||
|
(labelLine1 && labelLine2
|
||||||
|
? `${labelLine1} ${labelLine2}`
|
||||||
|
: label || "Feature card"),
|
||||||
|
[ariaLabel, labelLine1, labelLine2, label],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Determine wrapper element and props
|
||||||
|
const { wrapperElement, wrapperProps } = useMemo(() => {
|
||||||
|
const baseProps = {
|
||||||
|
"aria-label": computedAriaLabel,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
return {
|
||||||
|
wrapperElement: "a" as const,
|
||||||
|
wrapperProps: {
|
||||||
|
...baseProps,
|
||||||
|
href,
|
||||||
|
className:
|
||||||
|
"block focus:outline-none focus:ring-2 focus:ring-[var(--color-surface-default-brand-royal)] focus:ring-offset-2 rounded-[var(--radius-measures-radius-xlarge)] transition-all duration-200 hover:scale-[1.02]",
|
||||||
|
tabIndex: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onClick) {
|
||||||
|
return {
|
||||||
|
wrapperElement: "button" as const,
|
||||||
|
wrapperProps: {
|
||||||
|
...baseProps,
|
||||||
|
onClick,
|
||||||
|
className:
|
||||||
|
"block w-full text-left focus:outline-none focus:ring-2 focus:ring-[var(--color-surface-default-brand-royal)] focus:ring-offset-2 rounded-[var(--radius-measures-radius-xlarge)] transition-all duration-200 hover:scale-[1.02]",
|
||||||
|
tabIndex: 0,
|
||||||
|
onKeyDown: (e: React.KeyboardEvent<HTMLButtonElement>) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
onClick();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
wrapperElement: "div" as const,
|
||||||
|
wrapperProps: {
|
||||||
|
...baseProps,
|
||||||
|
className: "block",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [href, onClick, computedAriaLabel]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MiniCardView
|
||||||
|
className={className}
|
||||||
|
backgroundColor={backgroundColor}
|
||||||
|
panelContent={panelContent}
|
||||||
|
label={label}
|
||||||
|
labelLine1={labelLine1}
|
||||||
|
labelLine2={labelLine2}
|
||||||
|
computedAriaLabel={computedAriaLabel}
|
||||||
|
wrapperElement={wrapperElement}
|
||||||
|
wrapperProps={wrapperProps}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</MiniCardView>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
MiniCardContainer.displayName = "MiniCard";
|
||||||
|
|
||||||
|
export default MiniCardContainer;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export interface MiniCardProps {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
backgroundColor?: string;
|
||||||
|
panelContent?: string;
|
||||||
|
label?: string;
|
||||||
|
labelLine1?: string;
|
||||||
|
labelLine2?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
href?: string;
|
||||||
|
ariaLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MiniCardViewProps {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
className: string;
|
||||||
|
backgroundColor: string;
|
||||||
|
panelContent?: string;
|
||||||
|
label?: string;
|
||||||
|
labelLine1?: string;
|
||||||
|
labelLine2?: string;
|
||||||
|
computedAriaLabel: string;
|
||||||
|
wrapperElement: "a" | "button" | "div";
|
||||||
|
wrapperProps:
|
||||||
|
| React.AnchorHTMLAttributes<HTMLAnchorElement>
|
||||||
|
| React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||||
|
| React.HTMLAttributes<HTMLDivElement>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { memo } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import type { MiniCardViewProps } from "./MiniCard.types";
|
||||||
|
|
||||||
|
function MiniCardView({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
backgroundColor,
|
||||||
|
panelContent,
|
||||||
|
label,
|
||||||
|
labelLine1,
|
||||||
|
labelLine2,
|
||||||
|
computedAriaLabel,
|
||||||
|
wrapperElement,
|
||||||
|
wrapperProps,
|
||||||
|
}: MiniCardViewProps) {
|
||||||
|
const cardContentElement = (
|
||||||
|
<div className={`h-[186px] flex flex-col gap-[7px] ${className}`}>
|
||||||
|
{/* Top part - Inner panel */}
|
||||||
|
<div
|
||||||
|
className={`flex-1 rounded-[var(--radius-measures-radius-xlarge)] border border-[1px] py-[var(--spacing-scale-032)] px-[var(--spacing-scale-024)] ${backgroundColor} flex items-center justify-center transition-all duration-200 hover:scale-[1.02] hover:shadow-lg`}
|
||||||
|
>
|
||||||
|
{/* Content for the inner panel */}
|
||||||
|
{panelContent && (
|
||||||
|
<div className="flex items-center justify-center w-full h-full">
|
||||||
|
<Image
|
||||||
|
src={panelContent}
|
||||||
|
alt={computedAriaLabel}
|
||||||
|
className="max-w-[58px] max-h-[58px] w-auto h-auto object-contain"
|
||||||
|
width={58}
|
||||||
|
height={58}
|
||||||
|
sizes="(max-width: 768px) 50vw, 25vw"
|
||||||
|
loading="lazy"
|
||||||
|
placeholder="blur"
|
||||||
|
blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k="
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom part - Text container */}
|
||||||
|
<div className="font-inter font-medium text-[12px] leading-[14px] text-center text-[var(--color-content-default-primary)]">
|
||||||
|
{labelLine1 && labelLine2 ? (
|
||||||
|
<>
|
||||||
|
<div>{labelLine1}</div>
|
||||||
|
<div>{labelLine2}</div>
|
||||||
|
<div> </div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
label
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (wrapperElement === "a") {
|
||||||
|
return (
|
||||||
|
<a {...(wrapperProps as React.AnchorHTMLAttributes<HTMLAnchorElement>)}>
|
||||||
|
{cardContentElement}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wrapperElement === "button") {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
{...(wrapperProps as React.ButtonHTMLAttributes<HTMLButtonElement>)}
|
||||||
|
>
|
||||||
|
{cardContentElement}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div {...(wrapperProps as React.HTMLAttributes<HTMLDivElement>)}>
|
||||||
|
{cardContentElement}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
MiniCardView.displayName = "MiniCardView";
|
||||||
|
|
||||||
|
export default memo(MiniCardView);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from "./MiniCard.container";
|
||||||
|
export type { MiniCardProps } from "./MiniCard.types";
|
||||||
+21
-22
@@ -1,15 +1,10 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import { memo } from "react";
|
import { memo } from "react";
|
||||||
|
import NavigationItemView from "./NavigationItem.view";
|
||||||
|
import type { NavigationItemProps } from "./NavigationItem.types";
|
||||||
|
|
||||||
interface NavigationItemProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
const NavigationItemContainer = memo<NavigationItemProps>(
|
||||||
href?: string;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
variant?: "default";
|
|
||||||
size?: "default" | "xsmall";
|
|
||||||
className?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NavigationItem = memo<NavigationItemProps>(
|
|
||||||
({
|
({
|
||||||
href = "#",
|
href = "#",
|
||||||
children,
|
children,
|
||||||
@@ -17,6 +12,7 @@ const NavigationItem = memo<NavigationItemProps>(
|
|||||||
size = "default",
|
size = "default",
|
||||||
className = "",
|
className = "",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
isActive = false,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
// Variant styles
|
// Variant styles
|
||||||
@@ -48,24 +44,27 @@ const NavigationItem = memo<NavigationItemProps>(
|
|||||||
finalVariant = "default"; // The disabled state is handled by disabled: utilities
|
finalVariant = "default"; // The disabled state is handled by disabled: utilities
|
||||||
}
|
}
|
||||||
|
|
||||||
const combinedStyles = `${baseStyles} ${variantStyles[finalVariant]} ${className}`;
|
// Active state styling
|
||||||
|
const activeStyles = isActive
|
||||||
|
? "!border-[var(--color-content-default-brand-primary)] !text-[var(--color-content-default-brand-primary)]"
|
||||||
|
: "";
|
||||||
|
|
||||||
if (disabled) {
|
const combinedStyles = `${baseStyles} ${variantStyles[finalVariant]} ${activeStyles} ${className}`;
|
||||||
return (
|
|
||||||
<span className={combinedStyles} {...props}>
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<a href={href} className={combinedStyles} {...props}>
|
<NavigationItemView
|
||||||
|
href={href}
|
||||||
|
disabled={disabled}
|
||||||
|
className={className}
|
||||||
|
combinedStyles={combinedStyles}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</a>
|
</NavigationItemView>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
NavigationItem.displayName = "NavigationItem";
|
NavigationItemContainer.displayName = "NavigationItem";
|
||||||
|
|
||||||
export default NavigationItem;
|
export default NavigationItemContainer;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export interface NavigationItemProps extends Omit<
|
||||||
|
React.AnchorHTMLAttributes<HTMLAnchorElement>,
|
||||||
|
"isActive"
|
||||||
|
> {
|
||||||
|
href?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
variant?: "default";
|
||||||
|
size?: "default" | "xsmall";
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NavigationItemViewProps {
|
||||||
|
href: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
disabled: boolean;
|
||||||
|
className: string;
|
||||||
|
combinedStyles: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { memo } from "react";
|
||||||
|
import type { NavigationItemViewProps } from "./NavigationItem.types";
|
||||||
|
|
||||||
|
function NavigationItemView({
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
disabled,
|
||||||
|
combinedStyles,
|
||||||
|
...props
|
||||||
|
}: NavigationItemViewProps) {
|
||||||
|
if (disabled) {
|
||||||
|
return (
|
||||||
|
<span className={combinedStyles} {...props}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a href={href} className={combinedStyles} {...props}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
NavigationItemView.displayName = "NavigationItemView";
|
||||||
|
|
||||||
|
export default memo(NavigationItemView);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from "./NavigationItem.container";
|
||||||
|
export type { NavigationItemProps } from "./NavigationItem.types";
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { memo } from "react";
|
||||||
|
import { useSchemaData } from "../../hooks";
|
||||||
|
import NumberedCardsView from "./NumberedCards.view";
|
||||||
|
import type { NumberedCardsProps } from "./NumberedCards.types";
|
||||||
|
|
||||||
|
const NumberedCardsContainer = memo<NumberedCardsProps>(
|
||||||
|
({ title, subtitle, cards }) => {
|
||||||
|
const schemaData = useSchemaData({
|
||||||
|
type: "HowTo",
|
||||||
|
name: title,
|
||||||
|
description: subtitle,
|
||||||
|
steps: cards.map((card) => ({
|
||||||
|
name: card.text,
|
||||||
|
text: card.text,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const schemaJson = JSON.stringify(schemaData);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NumberedCardsView
|
||||||
|
title={title}
|
||||||
|
subtitle={subtitle}
|
||||||
|
cards={cards}
|
||||||
|
schemaJson={schemaJson}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
NumberedCardsContainer.displayName = "NumberedCards";
|
||||||
|
|
||||||
|
export default NumberedCardsContainer;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export interface Card {
|
||||||
|
text: string;
|
||||||
|
iconShape?: string;
|
||||||
|
iconColor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NumberedCardsProps {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
cards: Card[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NumberedCardsViewProps extends NumberedCardsProps {
|
||||||
|
schemaJson: string;
|
||||||
|
}
|
||||||
+13
-36
@@ -1,40 +1,19 @@
|
|||||||
"use client";
|
import SectionHeader from "../SectionHeader";
|
||||||
|
import NumberedCard from "../NumberedCard";
|
||||||
import { memo } from "react";
|
import Button from "../Button";
|
||||||
import NumberedCard from "./NumberedCard";
|
import type { NumberedCardsViewProps } from "./NumberedCards.types";
|
||||||
import SectionHeader from "./SectionHeader";
|
|
||||||
import Button from "./Button";
|
|
||||||
import { useSchemaData } from "../hooks";
|
|
||||||
|
|
||||||
interface Card {
|
|
||||||
text: string;
|
|
||||||
iconShape?: string;
|
|
||||||
iconColor?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NumberedCardsProps {
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
cards: Card[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const NumberedCards = memo<NumberedCardsProps>(({ title, subtitle, cards }) => {
|
|
||||||
// Generate schema data using hook
|
|
||||||
const schemaData = useSchemaData({
|
|
||||||
type: "HowTo",
|
|
||||||
name: title,
|
|
||||||
description: subtitle,
|
|
||||||
steps: cards.map((card) => ({
|
|
||||||
name: card.text,
|
|
||||||
text: card.text,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
|
function NumberedCardsView({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
cards,
|
||||||
|
schemaJson,
|
||||||
|
}: NumberedCardsViewProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<script
|
<script
|
||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaData) }}
|
dangerouslySetInnerHTML={{ __html: schemaJson }}
|
||||||
/>
|
/>
|
||||||
<section className="bg-transparent py-[var(--spacing-scale-032)] px-[var(--spacing-scale-020)] sm:py-[var(--spacing-scale-048)] sm:px-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-064)] lg:px-[var(--spacing-scale-064)] xl:py-[var(--spacing-scale-076)] xl:px-[var(--spacing-scale-064)]">
|
<section className="bg-transparent py-[var(--spacing-scale-032)] px-[var(--spacing-scale-020)] sm:py-[var(--spacing-scale-048)] sm:px-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-064)] lg:px-[var(--spacing-scale-064)] xl:py-[var(--spacing-scale-076)] xl:px-[var(--spacing-scale-064)]">
|
||||||
<div className="max-w-[var(--spacing-measures-max-width-lg)] mx-auto">
|
<div className="max-w-[var(--spacing-measures-max-width-lg)] mx-auto">
|
||||||
@@ -81,8 +60,6 @@ const NumberedCards = memo<NumberedCardsProps>(({ title, subtitle, cards }) => {
|
|||||||
</section>
|
</section>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
NumberedCards.displayName = "NumberedCards";
|
export default NumberedCardsView;
|
||||||
|
|
||||||
export default NumberedCards;
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from "./NumberedCards.container";
|
||||||
|
export * from "./NumberedCards.types";
|
||||||
@@ -1,284 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, memo } from "react";
|
|
||||||
import Image from "next/image";
|
|
||||||
import QuoteDecor from "./QuoteDecor";
|
|
||||||
import { logger } from "../../lib/logger";
|
|
||||||
|
|
||||||
interface QuoteBlockProps {
|
|
||||||
variant?: "compact" | "standard" | "extended";
|
|
||||||
className?: string;
|
|
||||||
quote?: string;
|
|
||||||
author?: string;
|
|
||||||
source?: string;
|
|
||||||
avatarSrc?: string;
|
|
||||||
id?: string;
|
|
||||||
fallbackAvatarSrc?: string;
|
|
||||||
onError?: (_error: {
|
|
||||||
type: string;
|
|
||||||
message: string;
|
|
||||||
author?: string;
|
|
||||||
avatarSrc?: string;
|
|
||||||
error?: unknown;
|
|
||||||
quote?: boolean;
|
|
||||||
}) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const QuoteBlock = memo<QuoteBlockProps>(
|
|
||||||
({
|
|
||||||
variant = "standard",
|
|
||||||
className = "",
|
|
||||||
quote = "The rules of decision-making must be open and available to everyone, and this can happen only if they are formalized.",
|
|
||||||
author = "Jo Freeman",
|
|
||||||
source = "The Tyranny of Structurelessness",
|
|
||||||
avatarSrc = "/assets/Quote_Avatar.svg",
|
|
||||||
id,
|
|
||||||
fallbackAvatarSrc = "/assets/Quote_Avatar.svg",
|
|
||||||
onError,
|
|
||||||
}) => {
|
|
||||||
const [imageError, setImageError] = useState(false);
|
|
||||||
const [imageLoading, setImageLoading] = useState(true);
|
|
||||||
|
|
||||||
// Variant configurations
|
|
||||||
interface VariantConfig {
|
|
||||||
container: string;
|
|
||||||
card: string;
|
|
||||||
gap: string;
|
|
||||||
avatarGap: string;
|
|
||||||
avatar: string;
|
|
||||||
quote: string;
|
|
||||||
author: string;
|
|
||||||
source: string;
|
|
||||||
showDecor: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const variants: Record<string, VariantConfig> = {
|
|
||||||
compact: {
|
|
||||||
container:
|
|
||||||
"py-[var(--spacing-scale-032)] px-[var(--spacing-scale-016)]",
|
|
||||||
card: "py-[var(--spacing-scale-032)] px-[var(--spacing-scale-016)] md:py-[var(--spacing-scale-040)] md:px-[var(--spacing-scale-024)] rounded-[var(--radius-measures-radius-small)]",
|
|
||||||
gap: "gap-[var(--spacing-scale-016)] md:gap-[var(--spacing-scale-024)]",
|
|
||||||
avatarGap: "gap-[var(--spacing-scale-012)]",
|
|
||||||
avatar: "w-[48px] h-[48px] md:w-[64px] md:h-[64px]",
|
|
||||||
quote: "text-[16px] leading-[120%] md:text-[20px] md:leading-[110%]",
|
|
||||||
author: "text-[10px] leading-[120%] md:text-[12px]",
|
|
||||||
source: "text-[10px] leading-[120%] md:text-[12px]",
|
|
||||||
showDecor: false,
|
|
||||||
},
|
|
||||||
standard: {
|
|
||||||
container:
|
|
||||||
"md:py-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-016)] lg:p-[var(--spacing-scale-064)]",
|
|
||||||
card: "py-[var(--spacing-scale-064)] px-[var(--spacing-scale-020)] md:py-[var(--spacing-scale-064)] md:px-[var(--spacing-scale-048)] md:rounded-[var(--radius-measures-radius-medium)] lg:py-[var(--spacing-scale-064)] lg:pl-[120px] lg:pr-[320px]",
|
|
||||||
gap: "gap-[var(--spacing-scale-024)] md:gap-[var(--spacing-scale-048)] lg:gap-[var(--spacing-scale-064)] xl:gap-[105px]",
|
|
||||||
avatarGap:
|
|
||||||
"gap-[var(--spacing-scale-020)] lg:gap-[var(--spacing-scale-018)] xl:gap-[var(--spacing-scale-032)]",
|
|
||||||
avatar:
|
|
||||||
"md:w-[120px] md:h-[120px] lg:w-[150px] lg:h-[150px] xl:w-[200px] xl:h-[200px]",
|
|
||||||
quote:
|
|
||||||
"text-[18px] leading-[120%] md:text-[36px] md:leading-[110%] md:tracking-[0px] lg:text-[52px] xl:text-[64px]",
|
|
||||||
author:
|
|
||||||
"text-[12px] leading-[120%] md:text-[18px] md:leading-[120%] md:tracking-[0.24px] lg:text-[24px] xl:text-[32px]",
|
|
||||||
source:
|
|
||||||
"text-[12px] leading-[120%] md:text-[18px] md:leading-[120%] md:tracking-[0.24px] lg:text-[24px] xl:text-[32px]",
|
|
||||||
showDecor: true,
|
|
||||||
},
|
|
||||||
extended: {
|
|
||||||
container:
|
|
||||||
"py-[var(--spacing-scale-048)] px-[var(--spacing-scale-024)] md:py-[var(--spacing-scale-064)] md:px-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-080)] lg:px-[var(--spacing-scale-048)]",
|
|
||||||
card: "py-[var(--spacing-scale-080)] px-[var(--spacing-scale-032)] md:py-[var(--spacing-scale-096)] md:px-[var(--spacing-scale-064)] md:rounded-[var(--radius-measures-radius-large)] lg:py-[var(--spacing-scale-112)] lg:pl-[160px] lg:pr-[400px]",
|
|
||||||
gap: "gap-[var(--spacing-scale-032)] md:gap-[var(--spacing-scale-064)] lg:gap-[var(--spacing-scale-080)] xl:gap-[140px]",
|
|
||||||
avatarGap:
|
|
||||||
"gap-[var(--spacing-scale-032)] lg:gap-[var(--spacing-scale-040)] xl:gap-[var(--spacing-scale-048)]",
|
|
||||||
avatar:
|
|
||||||
"w-[80px] h-[80px] md:w-[140px] md:h-[140px] lg:w-[180px] lg:h-[180px] xl:w-[240px] xl:h-[240px]",
|
|
||||||
quote:
|
|
||||||
"text-[20px] leading-[120%] md:text-[40px] md:leading-[110%] md:tracking-[0px] lg:text-[60px] xl:text-[72px]",
|
|
||||||
author:
|
|
||||||
"text-[14px] leading-[120%] md:text-[20px] md:leading-[120%] md:tracking-[0.24px] lg:text-[28px] xl:text-[36px]",
|
|
||||||
source:
|
|
||||||
"text-[14px] leading-[120%] md:text-[20px] md:leading-[120%] md:tracking-[0.24px] lg:text-[28px] xl:text-[36px]",
|
|
||||||
showDecor: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const config = variants[variant] || variants.standard;
|
|
||||||
|
|
||||||
// Use provided ID or generate a stable one based on content
|
|
||||||
const baseId = id || `quote-${author.toLowerCase().replace(/\s+/g, "-")}`;
|
|
||||||
const quoteId = `${baseId}-content`;
|
|
||||||
const authorId = `${baseId}-author`;
|
|
||||||
|
|
||||||
// Error handling functions
|
|
||||||
const handleImageError = (error: unknown) => {
|
|
||||||
logger.warn(
|
|
||||||
`QuoteBlock: Failed to load avatar image for ${author}:`,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
setImageError(true);
|
|
||||||
setImageLoading(false);
|
|
||||||
|
|
||||||
// Call error callback if provided
|
|
||||||
if (onError) {
|
|
||||||
onError({
|
|
||||||
type: "image_load_error",
|
|
||||||
message: `Failed to load avatar for ${author}`,
|
|
||||||
author,
|
|
||||||
avatarSrc,
|
|
||||||
error,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleImageLoad = () => {
|
|
||||||
setImageLoading(false);
|
|
||||||
setImageError(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Validate required props
|
|
||||||
if (!quote || !author) {
|
|
||||||
logger.error("QuoteBlock: Missing required props (quote or author)");
|
|
||||||
if (onError) {
|
|
||||||
onError({
|
|
||||||
type: "missing_props",
|
|
||||||
message: "QuoteBlock requires quote and author props",
|
|
||||||
quote: !!quote,
|
|
||||||
author,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return null; // Don't render if missing required props
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine which avatar to use
|
|
||||||
const currentAvatarSrc = imageError ? fallbackAvatarSrc : avatarSrc;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
className={`${config.container} ${className}`}
|
|
||||||
aria-labelledby={quoteId}
|
|
||||||
role="region"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`${config.card} bg-[var(--color-surface-default-brand-darker-accent)] relative overflow-hidden`}
|
|
||||||
>
|
|
||||||
{/* Background with noise texture */}
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 bg-[var(--color-surface-default-brand-darker-accent)]"
|
|
||||||
style={{
|
|
||||||
filter:
|
|
||||||
'url(\'data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg"><defs><filter id="grain" filterUnits="objectBoundingBox" x="0" y="0" width="1" height="1" colorInterpolationFilters="sRGB"><feTurbulence type="fractalNoise" baseFrequency="0.4" numOctaves="3" seed="7" stitchTiles="stitch" result="noise"/><feColorMatrix in="noise" result="softNoise" type="matrix" values="0.8 0 0 0 0.3 0 0.6 0 0 0.2 0 0 1.0 0 0.4 0 0 0 0.25 0"/><feComposite in="softNoise" in2="SourceAlpha" operator="in" result="maskedNoise"/><feBlend in="SourceGraphic" in2="maskedNoise" mode="multiply"/></filter></defs></svg>#grain\')',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* DECORATIONS (behind content) */}
|
|
||||||
{config.showDecor && (
|
|
||||||
<QuoteDecor
|
|
||||||
className="pointer-events-none absolute z-0
|
|
||||||
left-0 top-0
|
|
||||||
w-full h-full"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={`flex flex-col ${config.gap} relative z-10`}>
|
|
||||||
<div className={`flex flex-col ${config.avatarGap}`}>
|
|
||||||
{/* Avatar with error handling */}
|
|
||||||
<div className="relative">
|
|
||||||
{!imageError ? (
|
|
||||||
<Image
|
|
||||||
src={currentAvatarSrc}
|
|
||||||
alt={`Portrait of ${author}`}
|
|
||||||
width={64}
|
|
||||||
height={64}
|
|
||||||
className={`filter sepia ${
|
|
||||||
config.avatar
|
|
||||||
} transition-opacity duration-300 ${
|
|
||||||
imageLoading ? "opacity-0" : "opacity-100"
|
|
||||||
}`}
|
|
||||||
loading="lazy"
|
|
||||||
onError={handleImageError}
|
|
||||||
onLoad={handleImageLoad}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* Loading state */}
|
|
||||||
{imageLoading && !imageError && (
|
|
||||||
<div
|
|
||||||
className={`absolute inset-0 bg-gray-200 animate-pulse rounded-full ${config.avatar}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error state - show initials */}
|
|
||||||
{imageError && (
|
|
||||||
<div
|
|
||||||
className={`flex items-center justify-center bg-gray-300 rounded-full ${config.avatar} text-gray-600 font-bold`}
|
|
||||||
>
|
|
||||||
<span className="text-sm md:text-base lg:text-lg xl:text-xl">
|
|
||||||
{author
|
|
||||||
.split(" ")
|
|
||||||
.map((n) => n[0])
|
|
||||||
.join("")
|
|
||||||
.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<blockquote
|
|
||||||
id={quoteId}
|
|
||||||
aria-labelledby={authorId}
|
|
||||||
className="relative"
|
|
||||||
>
|
|
||||||
<p
|
|
||||||
data-qopen="“"
|
|
||||||
data-qclose="”"
|
|
||||||
className={[
|
|
||||||
"font-bricolage-grotesque font-normal",
|
|
||||||
config.quote,
|
|
||||||
"text-[var(--color-content-inverse-primary)]",
|
|
||||||
// give space for the hanging open-quote so it's not clipped:
|
|
||||||
"pl-[0.6em] -indent-[0.6em]",
|
|
||||||
// inject quotes
|
|
||||||
"relative before:content-[attr(data-qopen)] after:content-[attr(data-qclose)]",
|
|
||||||
// lock quote glyphs to your display face
|
|
||||||
"before:[font-family:var(--font-bricolage-grotesque)]",
|
|
||||||
"after:[font-family:var(--font-bricolage-grotesque)]",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
{quote}
|
|
||||||
</p>
|
|
||||||
</blockquote>
|
|
||||||
</div>
|
|
||||||
<footer className="flex flex-col gap-[var(--spacing-scale-008)] md:gap-[var(--spacing-scale-012)] xl:gap-[var(--spacing-scale-020)]">
|
|
||||||
<cite
|
|
||||||
id={authorId}
|
|
||||||
className={`font-inter font-normal ${config.author} text-[var(--color-content-inverse-primary)] uppercase not-italic`}
|
|
||||||
>
|
|
||||||
{author}
|
|
||||||
</cite>
|
|
||||||
{source && (
|
|
||||||
<p
|
|
||||||
data-qopen="“"
|
|
||||||
data-qclose="”"
|
|
||||||
className={[
|
|
||||||
"font-inter font-normal",
|
|
||||||
config.source,
|
|
||||||
"text-[var(--color-content-inverse-primary)] uppercase",
|
|
||||||
"pl-[0.6em] -indent-[0.6em]",
|
|
||||||
"relative before:content-[attr(data-qopen)] after:content-[attr(data-qclose)]",
|
|
||||||
"before:[font-family:var(--font-inter)] after:[font-family:var(--font-inter)]",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
{source}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
QuoteBlock.displayName = "QuoteBlock";
|
|
||||||
|
|
||||||
export default QuoteBlock;
|
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { memo, useState } from "react";
|
||||||
|
import { logger } from "../../../lib/logger";
|
||||||
|
import QuoteBlockView from "./QuoteBlock.view";
|
||||||
|
import type { QuoteBlockProps, VariantConfig } from "./QuoteBlock.types";
|
||||||
|
|
||||||
|
const QuoteBlockContainer = memo<QuoteBlockProps>(
|
||||||
|
({
|
||||||
|
variant = "standard",
|
||||||
|
className = "",
|
||||||
|
quote = "The rules of decision-making must be open and available to everyone, and this can happen only if they are formalized.",
|
||||||
|
author = "Jo Freeman",
|
||||||
|
source = "The Tyranny of Structurelessness",
|
||||||
|
avatarSrc = "/assets/Quote_Avatar.svg",
|
||||||
|
id,
|
||||||
|
fallbackAvatarSrc = "/assets/Quote_Avatar.svg",
|
||||||
|
onError,
|
||||||
|
}) => {
|
||||||
|
const [imageError, setImageError] = useState(false);
|
||||||
|
const [imageLoading, setImageLoading] = useState(true);
|
||||||
|
|
||||||
|
// Variant configurations
|
||||||
|
const variants: Record<string, VariantConfig> = {
|
||||||
|
compact: {
|
||||||
|
container:
|
||||||
|
"py-[var(--spacing-scale-032)] px-[var(--spacing-scale-016)]",
|
||||||
|
card: "py-[var(--spacing-scale-032)] px-[var(--spacing-scale-016)] md:py-[var(--spacing-scale-040)] md:px-[var(--spacing-scale-024)] rounded-[var(--radius-measures-radius-small)]",
|
||||||
|
gap: "gap-[var(--spacing-scale-016)] md:gap-[var(--spacing-scale-024)]",
|
||||||
|
avatarGap: "gap-[var(--spacing-scale-012)]",
|
||||||
|
avatar: "w-[48px] h-[48px] md:w-[64px] md:h-[64px]",
|
||||||
|
quote: "text-[16px] leading-[120%] md:text-[20px] md:leading-[110%]",
|
||||||
|
author: "text-[10px] leading-[120%] md:text-[12px]",
|
||||||
|
source: "text-[10px] leading-[120%] md:text-[12px]",
|
||||||
|
showDecor: false,
|
||||||
|
},
|
||||||
|
standard: {
|
||||||
|
container:
|
||||||
|
"md:py-[var(--spacing-scale-032)] md:px-[var(--spacing-scale-016)] lg:p-[var(--spacing-scale-064)]",
|
||||||
|
card: "py-[var(--spacing-scale-064)] px-[var(--spacing-scale-020)] md:py-[var(--spacing-scale-064)] md:px-[var(--spacing-scale-048)] md:rounded-[var(--radius-measures-radius-medium)] lg:py-[var(--spacing-scale-064)] lg:pl-[120px] lg:pr-[320px]",
|
||||||
|
gap: "gap-[var(--spacing-scale-024)] md:gap-[var(--spacing-scale-048)] lg:gap-[var(--spacing-scale-064)] xl:gap-[105px]",
|
||||||
|
avatarGap:
|
||||||
|
"gap-[var(--spacing-scale-020)] lg:gap-[var(--spacing-scale-018)] xl:gap-[var(--spacing-scale-032)]",
|
||||||
|
avatar:
|
||||||
|
"md:w-[120px] md:h-[120px] lg:w-[150px] lg:h-[150px] xl:w-[200px] xl:h-[200px]",
|
||||||
|
quote:
|
||||||
|
"text-[18px] leading-[120%] md:text-[36px] md:leading-[110%] md:tracking-[0px] lg:text-[52px] xl:text-[64px]",
|
||||||
|
author:
|
||||||
|
"text-[12px] leading-[120%] md:text-[18px] md:leading-[120%] md:tracking-[0.24px] lg:text-[24px] xl:text-[32px]",
|
||||||
|
source:
|
||||||
|
"text-[12px] leading-[120%] md:text-[18px] md:leading-[120%] md:tracking-[0.24px] lg:text-[24px] xl:text-[32px]",
|
||||||
|
showDecor: true,
|
||||||
|
},
|
||||||
|
extended: {
|
||||||
|
container:
|
||||||
|
"py-[var(--spacing-scale-048)] px-[var(--spacing-scale-024)] md:py-[var(--spacing-scale-064)] md:px-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-080)] lg:px-[var(--spacing-scale-048)]",
|
||||||
|
card: "py-[var(--spacing-scale-080)] px-[var(--spacing-scale-032)] md:py-[var(--spacing-scale-096)] md:px-[var(--spacing-scale-064)] md:rounded-[var(--radius-measures-radius-large)] lg:py-[var(--spacing-scale-112)] lg:pl-[160px] lg:pr-[400px]",
|
||||||
|
gap: "gap-[var(--spacing-scale-032)] md:gap-[var(--spacing-scale-064)] lg:gap-[var(--spacing-scale-080)] xl:gap-[140px]",
|
||||||
|
avatarGap:
|
||||||
|
"gap-[var(--spacing-scale-032)] lg:gap-[var(--spacing-scale-040)] xl:gap-[var(--spacing-scale-048)]",
|
||||||
|
avatar:
|
||||||
|
"w-[80px] h-[80px] md:w-[140px] md:h-[140px] lg:w-[180px] lg:h-[180px] xl:w-[240px] xl:h-[240px]",
|
||||||
|
quote:
|
||||||
|
"text-[20px] leading-[120%] md:text-[40px] md:leading-[110%] md:tracking-[0px] lg:text-[60px] xl:text-[72px]",
|
||||||
|
author:
|
||||||
|
"text-[14px] leading-[120%] md:text-[20px] md:leading-[120%] md:tracking-[0.24px] lg:text-[28px] xl:text-[36px]",
|
||||||
|
source:
|
||||||
|
"text-[14px] leading-[120%] md:text-[20px] md:leading-[120%] md:tracking-[0.24px] lg:text-[28px] xl:text-[36px]",
|
||||||
|
showDecor: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = variants[variant] || variants.standard;
|
||||||
|
|
||||||
|
// Use provided ID or generate a stable one based on content
|
||||||
|
const baseId = id || `quote-${author.toLowerCase().replace(/\s+/g, "-")}`;
|
||||||
|
const quoteId = `${baseId}-content`;
|
||||||
|
const authorId = `${baseId}-author`;
|
||||||
|
|
||||||
|
// Error handling functions
|
||||||
|
const handleImageError = (error: unknown) => {
|
||||||
|
logger.warn(
|
||||||
|
`QuoteBlock: Failed to load avatar image for ${author}:`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
setImageError(true);
|
||||||
|
setImageLoading(false);
|
||||||
|
|
||||||
|
// Call error callback if provided
|
||||||
|
if (onError) {
|
||||||
|
onError({
|
||||||
|
type: "image_load_error",
|
||||||
|
message: `Failed to load avatar for ${author}`,
|
||||||
|
author,
|
||||||
|
avatarSrc,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageLoad = () => {
|
||||||
|
setImageLoading(false);
|
||||||
|
setImageError(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate required props
|
||||||
|
if (!quote || !author) {
|
||||||
|
logger.error("QuoteBlock: Missing required props (quote or author)");
|
||||||
|
if (onError) {
|
||||||
|
onError({
|
||||||
|
type: "missing_props",
|
||||||
|
message: "QuoteBlock requires quote and author props",
|
||||||
|
quote: !!quote,
|
||||||
|
author,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null; // Don't render if missing required props
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which avatar to use
|
||||||
|
const currentAvatarSrc = imageError ? fallbackAvatarSrc : avatarSrc;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QuoteBlockView
|
||||||
|
className={className}
|
||||||
|
quote={quote}
|
||||||
|
author={author}
|
||||||
|
source={source}
|
||||||
|
quoteId={quoteId}
|
||||||
|
authorId={authorId}
|
||||||
|
config={config}
|
||||||
|
imageError={imageError}
|
||||||
|
imageLoading={imageLoading}
|
||||||
|
currentAvatarSrc={currentAvatarSrc}
|
||||||
|
onImageLoad={handleImageLoad}
|
||||||
|
onImageError={handleImageError}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
QuoteBlockContainer.displayName = "QuoteBlock";
|
||||||
|
|
||||||
|
export default QuoteBlockContainer;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
export interface QuoteBlockProps {
|
||||||
|
variant?: "compact" | "standard" | "extended";
|
||||||
|
className?: string;
|
||||||
|
quote?: string;
|
||||||
|
author?: string;
|
||||||
|
source?: string;
|
||||||
|
avatarSrc?: string;
|
||||||
|
id?: string;
|
||||||
|
fallbackAvatarSrc?: string;
|
||||||
|
onError?: (_error: {
|
||||||
|
type: string;
|
||||||
|
message: string;
|
||||||
|
author?: string;
|
||||||
|
avatarSrc?: string;
|
||||||
|
error?: unknown;
|
||||||
|
quote?: boolean;
|
||||||
|
}) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VariantConfig {
|
||||||
|
container: string;
|
||||||
|
card: string;
|
||||||
|
gap: string;
|
||||||
|
avatarGap: string;
|
||||||
|
avatar: string;
|
||||||
|
quote: string;
|
||||||
|
author: string;
|
||||||
|
source: string;
|
||||||
|
showDecor: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuoteBlockViewProps {
|
||||||
|
className: string;
|
||||||
|
quote: string;
|
||||||
|
author: string;
|
||||||
|
source?: string;
|
||||||
|
quoteId: string;
|
||||||
|
authorId: string;
|
||||||
|
config: VariantConfig;
|
||||||
|
imageError: boolean;
|
||||||
|
imageLoading: boolean;
|
||||||
|
currentAvatarSrc: string;
|
||||||
|
onImageLoad: () => void;
|
||||||
|
onImageError: (_error: unknown) => void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { memo } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import QuoteDecor from "../QuoteDecor";
|
||||||
|
import type { QuoteBlockViewProps } from "./QuoteBlock.types";
|
||||||
|
|
||||||
|
function QuoteBlockView({
|
||||||
|
className,
|
||||||
|
quote,
|
||||||
|
author,
|
||||||
|
source,
|
||||||
|
quoteId,
|
||||||
|
authorId,
|
||||||
|
config,
|
||||||
|
imageError,
|
||||||
|
imageLoading,
|
||||||
|
currentAvatarSrc,
|
||||||
|
onImageLoad,
|
||||||
|
onImageError,
|
||||||
|
}: QuoteBlockViewProps) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={`${config.container} ${className}`}
|
||||||
|
aria-labelledby={quoteId}
|
||||||
|
role="region"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`${config.card} bg-[var(--color-surface-default-brand-darker-accent)] relative overflow-hidden`}
|
||||||
|
>
|
||||||
|
{/* Background with noise texture */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-[var(--color-surface-default-brand-darker-accent)]"
|
||||||
|
style={{
|
||||||
|
filter:
|
||||||
|
'url(\'data:image/svg+xml;charset=utf-8,<svg xmlns="http://www.w3.org/2000/svg"><defs><filter id="grain" filterUnits="objectBoundingBox" x="0" y="0" width="1" height="1" colorInterpolationFilters="sRGB"><feTurbulence type="fractalNoise" baseFrequency="0.4" numOctaves="3" seed="7" stitchTiles="stitch" result="noise"/><feColorMatrix in="noise" result="softNoise" type="matrix" values="0.8 0 0 0 0.3 0 0.6 0 0 0.2 0 0 1.0 0 0.4 0 0 0 0.25 0"/><feComposite in="softNoise" in2="SourceAlpha" operator="in" result="maskedNoise"/><feBlend in="SourceGraphic" in2="maskedNoise" mode="multiply"/></filter></defs></svg>#grain\')',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* DECORATIONS (behind content) */}
|
||||||
|
{config.showDecor && (
|
||||||
|
<QuoteDecor
|
||||||
|
className="pointer-events-none absolute z-0
|
||||||
|
left-0 top-0
|
||||||
|
w-full h-full"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={`flex flex-col ${config.gap} relative z-10`}>
|
||||||
|
<div className={`flex flex-col ${config.avatarGap}`}>
|
||||||
|
{/* Avatar with error handling */}
|
||||||
|
<div className="relative">
|
||||||
|
{!imageError ? (
|
||||||
|
<Image
|
||||||
|
src={currentAvatarSrc}
|
||||||
|
alt={`Portrait of ${author}`}
|
||||||
|
width={64}
|
||||||
|
height={64}
|
||||||
|
className={`filter sepia ${
|
||||||
|
config.avatar
|
||||||
|
} transition-opacity duration-300 ${
|
||||||
|
imageLoading ? "opacity-0" : "opacity-100"
|
||||||
|
}`}
|
||||||
|
loading="lazy"
|
||||||
|
onError={onImageError}
|
||||||
|
onLoad={onImageLoad}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{imageLoading && !imageError && (
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 bg-gray-200 animate-pulse rounded-full ${config.avatar}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error state - show initials */}
|
||||||
|
{imageError && (
|
||||||
|
<div
|
||||||
|
className={`flex items-center justify-center bg-gray-300 rounded-full ${config.avatar} text-gray-600 font-bold`}
|
||||||
|
>
|
||||||
|
<span className="text-sm md:text-base lg:text-lg xl:text-xl">
|
||||||
|
{author
|
||||||
|
.split(" ")
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join("")
|
||||||
|
.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<blockquote
|
||||||
|
id={quoteId}
|
||||||
|
aria-labelledby={authorId}
|
||||||
|
className="relative"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
data-qopen="“"
|
||||||
|
data-qclose="”"
|
||||||
|
className={[
|
||||||
|
"font-bricolage-grotesque font-normal",
|
||||||
|
config.quote,
|
||||||
|
"text-[var(--color-content-inverse-primary)]",
|
||||||
|
// give space for the hanging open-quote so it's not clipped:
|
||||||
|
"pl-[0.6em] -indent-[0.6em]",
|
||||||
|
// inject quotes
|
||||||
|
"relative before:content-[attr(data-qopen)] after:content-[attr(data-qclose)]",
|
||||||
|
// lock quote glyphs to your display face
|
||||||
|
"before:[font-family:var(--font-bricolage-grotesque)]",
|
||||||
|
"after:[font-family:var(--font-bricolage-grotesque)]",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{quote}
|
||||||
|
</p>
|
||||||
|
</blockquote>
|
||||||
|
</div>
|
||||||
|
<footer className="flex flex-col gap-[var(--spacing-scale-008)] md:gap-[var(--spacing-scale-012)] xl:gap-[var(--spacing-scale-020)]">
|
||||||
|
<cite
|
||||||
|
id={authorId}
|
||||||
|
className={`font-inter font-normal ${config.author} text-[var(--color-content-inverse-primary)] uppercase not-italic`}
|
||||||
|
>
|
||||||
|
{author}
|
||||||
|
</cite>
|
||||||
|
{source && (
|
||||||
|
<p
|
||||||
|
data-qopen="“"
|
||||||
|
data-qclose="”"
|
||||||
|
className={[
|
||||||
|
"font-inter font-normal",
|
||||||
|
config.source,
|
||||||
|
"text-[var(--color-content-inverse-primary)] uppercase",
|
||||||
|
"pl-[0.6em] -indent-[0.6em]",
|
||||||
|
"relative before:content-[attr(data-qopen)] after:content-[attr(data-qclose)]",
|
||||||
|
"before:[font-family:var(--font-inter)] after:[font-family:var(--font-inter)]",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{source}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
QuoteBlockView.displayName = "QuoteBlockView";
|
||||||
|
|
||||||
|
export default memo(QuoteBlockView);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from "./QuoteBlock.container";
|
||||||
|
export type { QuoteBlockProps } from "./QuoteBlock.types";
|
||||||
@@ -9,11 +9,12 @@ interface QuoteDecorProps {
|
|||||||
const QuoteDecor = memo<QuoteDecorProps>(({ className = "" }) => {
|
const QuoteDecor = memo<QuoteDecorProps>(({ className = "" }) => {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
className={`text-[var(--color-surface-inverse-brand-primary)] opacity-100 w-full h-full md:max-w-[640px] lg:max-w-[850px] xl:max-w-[1100px] ${className}`}
|
className={`text-[var(--color-surface-inverse-brand-primary)] opacity-100 ${className}`}
|
||||||
viewBox="400 0 442 163"
|
viewBox="400 0 442 163"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
overflow="visible"
|
overflow="visible"
|
||||||
preserveAspectRatio="xMinYMin meet"
|
preserveAspectRatio="xMinYMin meet"
|
||||||
|
style={{ width: "100%", height: "100%" }}
|
||||||
>
|
>
|
||||||
<g fill="currentColor">
|
<g fill="currentColor">
|
||||||
{/* Mobile ellipses */}
|
{/* Mobile ellipses */}
|
||||||
|
|||||||
+34
-74
@@ -1,22 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { memo, useCallback, useId } from "react";
|
import { memo, useCallback, useId } from "react";
|
||||||
|
import { RadioButtonView } from "./RadioButton.view";
|
||||||
|
import type { RadioButtonProps } from "./RadioButton.types";
|
||||||
|
|
||||||
interface RadioButtonProps {
|
const RadioButtonContainer = ({
|
||||||
checked?: boolean;
|
|
||||||
mode?: "standard" | "inverse";
|
|
||||||
state?: "default" | "hover" | "focus";
|
|
||||||
disabled?: boolean;
|
|
||||||
label?: string;
|
|
||||||
onChange?: (_data: { checked: boolean; value?: string }) => void;
|
|
||||||
id?: string;
|
|
||||||
name?: string;
|
|
||||||
value?: string;
|
|
||||||
ariaLabel?: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RadioButton = ({
|
|
||||||
checked = false,
|
checked = false,
|
||||||
mode = "standard",
|
mode = "standard",
|
||||||
state = "default",
|
state = "default",
|
||||||
@@ -91,67 +79,39 @@ const RadioButton = ({
|
|||||||
[disabled, onChange, checked, value],
|
[disabled, onChange, checked, value],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>) => {
|
||||||
|
if (e.key === " " || e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
handleToggle(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<label
|
<RadioButtonView
|
||||||
className={`inline-flex items-center gap-[8px] cursor-pointer select-none ${
|
radioId={radioId}
|
||||||
disabled ? "opacity-60 cursor-not-allowed" : ""
|
checked={checked}
|
||||||
} ${className}`}
|
mode={mode}
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
state={state}
|
||||||
onClick={handleToggle}
|
disabled={disabled}
|
||||||
>
|
label={label}
|
||||||
<span
|
name={name}
|
||||||
onKeyDown={(e) => {
|
value={value}
|
||||||
if (e.key === " " || e.key === "Enter") {
|
ariaLabel={ariaLabel}
|
||||||
e.preventDefault();
|
className={className}
|
||||||
handleToggle(e);
|
combinedBoxStyles={combinedBoxStyles}
|
||||||
}
|
defaultOutlineClass={defaultOutlineClass}
|
||||||
}}
|
conditionalHoverOutlineClass={conditionalHoverOutlineClass}
|
||||||
className={`${combinedBoxStyles} ${defaultOutlineClass} ${conditionalHoverOutlineClass} ${conditionalFocusClass} p-[var(--measures-spacing-004)]`}
|
conditionalFocusClass={conditionalFocusClass}
|
||||||
style={{
|
backgroundWhenChecked={backgroundWhenChecked}
|
||||||
backgroundColor: backgroundWhenChecked,
|
dotColor={dotColor}
|
||||||
}}
|
labelColor={labelColor}
|
||||||
tabIndex={0}
|
onToggle={handleToggle}
|
||||||
role="radio"
|
onKeyDown={handleKeyDown}
|
||||||
aria-checked={checked}
|
{...props}
|
||||||
{...(disabled && { "aria-disabled": true })}
|
/>
|
||||||
{...(ariaLabel && { "aria-label": ariaLabel })}
|
|
||||||
{...(label && !ariaLabel && { "aria-labelledby": `${radioId}-label` })}
|
|
||||||
id={radioId}
|
|
||||||
>
|
|
||||||
{/* Radio dot */}
|
|
||||||
<div
|
|
||||||
className="w-[16px] h-[16px] rounded-full transition-all duration-200"
|
|
||||||
style={{
|
|
||||||
backgroundColor: dotColor,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
{label && (
|
|
||||||
<span
|
|
||||||
id={`${radioId}-label`}
|
|
||||||
className="font-inter text-[14px] leading-[18px]"
|
|
||||||
style={{ color: labelColor }}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{/* Hidden input for form submission */}
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name={name}
|
|
||||||
value={value}
|
|
||||||
checked={checked}
|
|
||||||
onChange={() => {}}
|
|
||||||
disabled={disabled}
|
|
||||||
className="sr-only"
|
|
||||||
tabIndex={-1}
|
|
||||||
aria-hidden="true"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
RadioButton.displayName = "RadioButton";
|
RadioButtonContainer.displayName = "RadioButton";
|
||||||
|
|
||||||
export default memo(RadioButton);
|
export default memo(RadioButtonContainer);
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export interface RadioButtonProps {
|
||||||
|
checked?: boolean;
|
||||||
|
mode?: "standard" | "inverse";
|
||||||
|
state?: "default" | "hover" | "focus";
|
||||||
|
disabled?: boolean;
|
||||||
|
label?: string;
|
||||||
|
onChange?: (_data: { checked: boolean; value?: string }) => void;
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
value?: string;
|
||||||
|
ariaLabel?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RadioButtonViewProps {
|
||||||
|
radioId: string;
|
||||||
|
checked: boolean;
|
||||||
|
mode: "standard" | "inverse";
|
||||||
|
state: "default" | "hover" | "focus";
|
||||||
|
disabled: boolean;
|
||||||
|
label?: string;
|
||||||
|
name?: string;
|
||||||
|
value?: string;
|
||||||
|
ariaLabel?: string;
|
||||||
|
className: string;
|
||||||
|
combinedBoxStyles: string;
|
||||||
|
defaultOutlineClass: string;
|
||||||
|
conditionalHoverOutlineClass: string;
|
||||||
|
conditionalFocusClass: string;
|
||||||
|
backgroundWhenChecked: string;
|
||||||
|
dotColor: string;
|
||||||
|
labelColor: string;
|
||||||
|
onToggle: (_e: React.MouseEvent | React.KeyboardEvent) => void;
|
||||||
|
onKeyDown: (_e: React.KeyboardEvent<HTMLSpanElement>) => void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { RadioButtonViewProps } from "./RadioButton.types";
|
||||||
|
|
||||||
|
export function RadioButtonView({
|
||||||
|
radioId,
|
||||||
|
checked,
|
||||||
|
disabled,
|
||||||
|
label,
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
ariaLabel,
|
||||||
|
className,
|
||||||
|
combinedBoxStyles,
|
||||||
|
defaultOutlineClass,
|
||||||
|
conditionalHoverOutlineClass,
|
||||||
|
conditionalFocusClass,
|
||||||
|
backgroundWhenChecked,
|
||||||
|
dotColor,
|
||||||
|
labelColor,
|
||||||
|
onToggle,
|
||||||
|
onKeyDown,
|
||||||
|
...props
|
||||||
|
}: RadioButtonViewProps) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
className={`inline-flex items-center gap-[8px] cursor-pointer select-none ${
|
||||||
|
disabled ? "opacity-60 cursor-not-allowed" : ""
|
||||||
|
} ${className}`}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={onToggle}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
className={`${combinedBoxStyles} ${defaultOutlineClass} ${conditionalHoverOutlineClass} ${conditionalFocusClass} p-[var(--measures-spacing-004)]`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: backgroundWhenChecked,
|
||||||
|
}}
|
||||||
|
tabIndex={0}
|
||||||
|
role="radio"
|
||||||
|
aria-checked={checked}
|
||||||
|
{...(disabled && { "aria-disabled": true })}
|
||||||
|
{...(ariaLabel && { "aria-label": ariaLabel })}
|
||||||
|
{...(label && !ariaLabel && { "aria-labelledby": `${radioId}-label` })}
|
||||||
|
id={radioId}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{/* Radio dot */}
|
||||||
|
<div
|
||||||
|
className="w-[16px] h-[16px] rounded-full transition-all duration-200"
|
||||||
|
style={{
|
||||||
|
backgroundColor: dotColor,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
{label && (
|
||||||
|
<span
|
||||||
|
id={`${radioId}-label`}
|
||||||
|
className="font-inter text-[14px] leading-[18px]"
|
||||||
|
style={{ color: labelColor }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Hidden input for form submission */}
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => {}}
|
||||||
|
disabled={disabled}
|
||||||
|
className="sr-only"
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from "./RadioButton.container";
|
||||||
|
export type { RadioButtonProps } from "./RadioButton.types";
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { memo, useCallback, useId } from "react";
|
|
||||||
import RadioButton from "./RadioButton";
|
|
||||||
|
|
||||||
interface RadioOption {
|
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
ariaLabel?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RadioGroupProps {
|
|
||||||
name?: string;
|
|
||||||
value?: string;
|
|
||||||
onChange?: (_data: { value: string }) => void;
|
|
||||||
mode?: "standard" | "inverse";
|
|
||||||
state?: "default" | "hover" | "focus";
|
|
||||||
disabled?: boolean;
|
|
||||||
options?: RadioOption[];
|
|
||||||
className?: string;
|
|
||||||
"aria-label"?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RadioGroup = ({
|
|
||||||
name,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
mode = "standard",
|
|
||||||
state = "default",
|
|
||||||
disabled = false,
|
|
||||||
options = [],
|
|
||||||
className = "",
|
|
||||||
...props
|
|
||||||
}: RadioGroupProps) => {
|
|
||||||
// Generate unique ID for accessibility if not provided
|
|
||||||
const generatedId = useId();
|
|
||||||
const groupId = name || `radio-group-${generatedId}`;
|
|
||||||
|
|
||||||
const handleChange = useCallback(
|
|
||||||
(optionValue: string) => {
|
|
||||||
if (!disabled && onChange) {
|
|
||||||
onChange({ value: optionValue });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[disabled, onChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`space-y-[8px] ${className}`}
|
|
||||||
role="radiogroup"
|
|
||||||
aria-label={props["aria-label"]}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{options.map((option) => {
|
|
||||||
const isSelected = value === option.value;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RadioButton
|
|
||||||
key={option.value}
|
|
||||||
checked={isSelected}
|
|
||||||
mode={mode}
|
|
||||||
state={state}
|
|
||||||
disabled={disabled}
|
|
||||||
label={option.label}
|
|
||||||
name={groupId}
|
|
||||||
value={option.value}
|
|
||||||
ariaLabel={option.ariaLabel}
|
|
||||||
onChange={({ checked }) => {
|
|
||||||
if (checked) {
|
|
||||||
handleChange(option.value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
RadioGroup.displayName = "RadioGroup";
|
|
||||||
|
|
||||||
export default memo(RadioGroup);
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { memo, useCallback, useId } from "react";
|
||||||
|
import { RadioGroupView } from "./RadioGroup.view";
|
||||||
|
import type { RadioGroupProps } from "./RadioGroup.types";
|
||||||
|
|
||||||
|
const RadioGroupContainer = ({
|
||||||
|
name,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
mode = "standard",
|
||||||
|
state = "default",
|
||||||
|
disabled = false,
|
||||||
|
options = [],
|
||||||
|
className = "",
|
||||||
|
...props
|
||||||
|
}: RadioGroupProps) => {
|
||||||
|
// Generate unique ID for accessibility if not provided
|
||||||
|
const generatedId = useId();
|
||||||
|
const groupId = name || `radio-group-${generatedId}`;
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(optionValue: string) => {
|
||||||
|
if (!disabled && onChange) {
|
||||||
|
onChange({ value: optionValue });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[disabled, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RadioGroupView
|
||||||
|
groupId={groupId}
|
||||||
|
value={value}
|
||||||
|
mode={mode}
|
||||||
|
state={state}
|
||||||
|
disabled={disabled}
|
||||||
|
options={options}
|
||||||
|
className={className}
|
||||||
|
ariaLabel={props["aria-label"]}
|
||||||
|
onOptionChange={handleChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
RadioGroupContainer.displayName = "RadioGroup";
|
||||||
|
|
||||||
|
export default memo(RadioGroupContainer);
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
export interface RadioOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
ariaLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RadioGroupProps {
|
||||||
|
name?: string;
|
||||||
|
value?: string;
|
||||||
|
onChange?: (_data: { value: string }) => void;
|
||||||
|
mode?: "standard" | "inverse";
|
||||||
|
state?: "default" | "hover" | "focus";
|
||||||
|
disabled?: boolean;
|
||||||
|
options?: RadioOption[];
|
||||||
|
className?: string;
|
||||||
|
"aria-label"?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RadioGroupViewProps {
|
||||||
|
groupId: string;
|
||||||
|
value?: string;
|
||||||
|
mode: "standard" | "inverse";
|
||||||
|
state: "default" | "hover" | "focus";
|
||||||
|
disabled: boolean;
|
||||||
|
options: RadioOption[];
|
||||||
|
className: string;
|
||||||
|
ariaLabel?: string;
|
||||||
|
onOptionChange: (_optionValue: string) => void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import RadioButton from "../RadioButton";
|
||||||
|
import type { RadioGroupViewProps } from "./RadioGroup.types";
|
||||||
|
|
||||||
|
export function RadioGroupView({
|
||||||
|
groupId,
|
||||||
|
value,
|
||||||
|
mode,
|
||||||
|
state,
|
||||||
|
disabled,
|
||||||
|
options,
|
||||||
|
className,
|
||||||
|
ariaLabel,
|
||||||
|
onOptionChange,
|
||||||
|
}: RadioGroupViewProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`space-y-[8px] ${className}`}
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
{options.map((option) => {
|
||||||
|
const isSelected = value === option.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RadioButton
|
||||||
|
key={option.value}
|
||||||
|
checked={isSelected}
|
||||||
|
mode={mode}
|
||||||
|
state={state}
|
||||||
|
disabled={disabled}
|
||||||
|
label={option.label}
|
||||||
|
name={groupId}
|
||||||
|
value={option.value}
|
||||||
|
ariaLabel={option.ariaLabel}
|
||||||
|
onChange={({ checked }) => {
|
||||||
|
if (checked) {
|
||||||
|
onOptionChange(option.value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from "./RadioGroup.container";
|
||||||
|
export type { RadioGroupProps, RadioOption } from "./RadioGroup.types";
|
||||||
+14
-72
@@ -1,17 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, memo, useMemo, useCallback } from "react";
|
import { useState, useEffect, memo, useMemo, useCallback } from "react";
|
||||||
import ContentThumbnailTemplate from "./ContentThumbnailTemplate";
|
import { useIsMobile } from "../../hooks";
|
||||||
import type { BlogPost } from "../../lib/content";
|
import { RelatedArticlesView } from "./RelatedArticles.view";
|
||||||
import { useIsMobile } from "../hooks";
|
import type { RelatedArticlesProps } from "./RelatedArticles.types";
|
||||||
|
|
||||||
interface RelatedArticlesProps {
|
const RelatedArticlesContainer = memo<RelatedArticlesProps>(
|
||||||
relatedPosts: BlogPost[];
|
|
||||||
currentPostSlug: string;
|
|
||||||
slugOrder?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const RelatedArticles = memo<RelatedArticlesProps>(
|
|
||||||
({ relatedPosts, currentPostSlug, slugOrder = [] }) => {
|
({ relatedPosts, currentPostSlug, slugOrder = [] }) => {
|
||||||
// Memoize filtered posts to prevent unnecessary re-computations
|
// Memoize filtered posts to prevent unnecessary re-computations
|
||||||
const filteredPosts = useMemo(
|
const filteredPosts = useMemo(
|
||||||
@@ -73,8 +67,6 @@ const RelatedArticles = memo<RelatedArticlesProps>(
|
|||||||
[currentIndex, progress],
|
[currentIndex, progress],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mobile detection is now handled by useIsMobile hook
|
|
||||||
|
|
||||||
// Auto-advance every 3 seconds (only on mobile)
|
// Auto-advance every 3 seconds (only on mobile)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (filteredPosts.length <= 1 || !isMobile) return;
|
if (filteredPosts.length <= 1 || !isMobile) return;
|
||||||
@@ -103,69 +95,19 @@ const RelatedArticles = memo<RelatedArticlesProps>(
|
|||||||
return () => clearInterval(progressInterval);
|
return () => clearInterval(progressInterval);
|
||||||
}, [currentIndex, filteredPosts.length, isMobile]);
|
}, [currentIndex, filteredPosts.length, isMobile]);
|
||||||
|
|
||||||
if (filteredPosts.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<RelatedArticlesView
|
||||||
className="py-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-064)]"
|
filteredPosts={filteredPosts}
|
||||||
data-testid="related-articles"
|
slugOrder={slugOrder}
|
||||||
>
|
isMobile={isMobile}
|
||||||
<div className="flex flex-col gap-[var(--spacing-scale-032)] lg:gap-[51px]">
|
transformStyle={transformStyle}
|
||||||
<h2 className="text-[32px] lg:text-[44px] leading-[110%] font-medium text-[var(--color-content-inverse-primary)] text-center">
|
getProgressStyle={getProgressStyle}
|
||||||
Related Articles
|
onMouseDown={handleMouseDown}
|
||||||
</h2>
|
/>
|
||||||
|
|
||||||
{/* Horizontal Articles Row - Carousel on mobile, Scrollable slider on desktop */}
|
|
||||||
<div className="flex justify-center overflow-hidden">
|
|
||||||
<div
|
|
||||||
className={`flex gap-0 transition-transform duration-500 ease-in-out ${
|
|
||||||
!isMobile
|
|
||||||
? "overflow-x-auto scrollbar-hide cursor-grab active:cursor-grabbing"
|
|
||||||
: ""
|
|
||||||
}`}
|
|
||||||
style={transformStyle}
|
|
||||||
onMouseDown={!isMobile ? handleMouseDown : undefined}
|
|
||||||
>
|
|
||||||
{filteredPosts.map((relatedPost) => (
|
|
||||||
<div
|
|
||||||
key={relatedPost.slug}
|
|
||||||
className="flex flex-col items-center flex-shrink-0"
|
|
||||||
data-testid={`related-${relatedPost.slug}`}
|
|
||||||
>
|
|
||||||
<ContentThumbnailTemplate
|
|
||||||
post={relatedPost}
|
|
||||||
variant="vertical"
|
|
||||||
slugOrder={slugOrder}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress bars - only show on mobile */}
|
|
||||||
{isMobile && (
|
|
||||||
<div className="flex justify-center gap-[var(--measures-spacing-008)] px-[var(--measures-spacing-064)]">
|
|
||||||
{filteredPosts.map((relatedPost, index) => (
|
|
||||||
<div
|
|
||||||
key={relatedPost.slug}
|
|
||||||
className="max-w-[var(--measures-spacing-056)] w-full h-[var(--measures-spacing-004)] bg-gray-200 rounded-full overflow-hidden"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="h-full bg-gray-600 rounded-full transition-all duration-75 ease-linear"
|
|
||||||
style={getProgressStyle(index)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
RelatedArticles.displayName = "RelatedArticles";
|
RelatedArticlesContainer.displayName = "RelatedArticles";
|
||||||
|
|
||||||
export default RelatedArticles;
|
export default RelatedArticlesContainer;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { BlogPost } from "../../../lib/content";
|
||||||
|
|
||||||
|
export interface RelatedArticlesProps {
|
||||||
|
relatedPosts: BlogPost[];
|
||||||
|
currentPostSlug: string;
|
||||||
|
slugOrder?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RelatedArticlesViewProps {
|
||||||
|
filteredPosts: BlogPost[];
|
||||||
|
slugOrder: string[];
|
||||||
|
isMobile: boolean;
|
||||||
|
transformStyle: React.CSSProperties;
|
||||||
|
getProgressStyle: (_index: number) => React.CSSProperties;
|
||||||
|
onMouseDown?: (_e: React.MouseEvent<HTMLDivElement>) => void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import ContentThumbnailTemplate from "../ContentThumbnailTemplate";
|
||||||
|
import type { RelatedArticlesViewProps } from "./RelatedArticles.types";
|
||||||
|
|
||||||
|
export function RelatedArticlesView({
|
||||||
|
filteredPosts,
|
||||||
|
slugOrder,
|
||||||
|
isMobile,
|
||||||
|
transformStyle,
|
||||||
|
getProgressStyle,
|
||||||
|
onMouseDown,
|
||||||
|
}: RelatedArticlesViewProps) {
|
||||||
|
if (filteredPosts.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="py-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-064)]"
|
||||||
|
data-testid="related-articles"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-[var(--spacing-scale-032)] lg:gap-[51px]">
|
||||||
|
<h2 className="text-[32px] lg:text-[44px] leading-[110%] font-medium text-[var(--color-content-inverse-primary)] text-center">
|
||||||
|
Related Articles
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Horizontal Articles Row - Carousel on mobile, Scrollable slider on desktop */}
|
||||||
|
<div className="flex justify-center overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`flex gap-0 transition-transform duration-500 ease-in-out ${
|
||||||
|
!isMobile
|
||||||
|
? "overflow-x-auto scrollbar-hide cursor-grab active:cursor-grabbing"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
style={transformStyle}
|
||||||
|
onMouseDown={!isMobile ? onMouseDown : undefined}
|
||||||
|
>
|
||||||
|
{filteredPosts.map((relatedPost) => (
|
||||||
|
<div
|
||||||
|
key={relatedPost.slug}
|
||||||
|
className="flex flex-col items-center flex-shrink-0"
|
||||||
|
data-testid={`related-${relatedPost.slug}`}
|
||||||
|
>
|
||||||
|
<ContentThumbnailTemplate
|
||||||
|
post={relatedPost}
|
||||||
|
variant="vertical"
|
||||||
|
slugOrder={slugOrder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bars - only show on mobile */}
|
||||||
|
{isMobile && (
|
||||||
|
<div className="flex justify-center gap-[var(--measures-spacing-008)] px-[var(--measures-spacing-064)]">
|
||||||
|
{filteredPosts.map((relatedPost, index) => (
|
||||||
|
<div
|
||||||
|
key={relatedPost.slug}
|
||||||
|
className="max-w-[var(--measures-spacing-056)] w-full h-[var(--measures-spacing-004)] bg-gray-200 rounded-full overflow-hidden"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="h-full bg-gray-600 rounded-full transition-all duration-75 ease-linear"
|
||||||
|
style={getProgressStyle(index)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from "./RelatedArticles.container";
|
||||||
|
export type { RelatedArticlesProps } from "./RelatedArticles.types";
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { memo } from "react";
|
|
||||||
|
|
||||||
interface RuleCardProps {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
icon?: React.ReactNode;
|
|
||||||
backgroundColor?: string;
|
|
||||||
className?: string;
|
|
||||||
onClick?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
gtag?: (
|
|
||||||
_command: string,
|
|
||||||
_eventName: string,
|
|
||||||
_params?: Record<string, unknown>,
|
|
||||||
) => void;
|
|
||||||
analytics?: {
|
|
||||||
track: (_eventName: string, _params?: Record<string, unknown>) => void;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const RuleCard = memo<RuleCardProps>(
|
|
||||||
({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
icon,
|
|
||||||
backgroundColor = "bg-[var(--color-community-teal-100)]",
|
|
||||||
className = "",
|
|
||||||
onClick,
|
|
||||||
}) => {
|
|
||||||
const handleClick = () => {
|
|
||||||
// Basic analytics event tracking
|
|
||||||
if (typeof window !== "undefined" && window.gtag) {
|
|
||||||
window.gtag("event", "template_selected", {
|
|
||||||
template_name: title,
|
|
||||||
template_type: "governance_pattern",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom analytics event for other tracking systems
|
|
||||||
if (typeof window !== "undefined" && window.analytics) {
|
|
||||||
window.analytics.track("Template Selected", {
|
|
||||||
templateName: title,
|
|
||||||
templateType: "governance_pattern",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onClick) onClick();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
|
||||||
event.preventDefault();
|
|
||||||
handleClick();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`${backgroundColor} rounded-[var(--radius-measures-radius-small)] pt-[var(--spacing-scale-012)] pr-[var(--spacing-scale-012)] pl-[var(--spacing-scale-012)] pb-[var(--spacing-scale-024)] md:p-[var(--spacing-scale-024)] md:h-[210px] lg:h-[277px] flex flex-col gap-[18px] shadow-lg backdrop-blur-sm transition-all duration-500 ease-in-out hover:shadow-xl hover:scale-[1.02] focus:outline-none focus:ring-2 focus:ring-[var(--color-community-teal-500)] focus:ring-offset-2 cursor-pointer min-h-[44px] min-w-[44px] ${className}`}
|
|
||||||
tabIndex={0}
|
|
||||||
role="button"
|
|
||||||
aria-label={`Learn more about ${title} governance pattern`}
|
|
||||||
onClick={handleClick}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
>
|
|
||||||
{/* Header Container */}
|
|
||||||
<div className="grid grid-cols-[auto_1fr] h-[72px] md:h-[80px] lg:h-[138px] border-b border-[var(--color-surface-default-primary)]">
|
|
||||||
{/* Icon Container */}
|
|
||||||
{icon && (
|
|
||||||
<div className="p-[var(--spacing-scale-016)] md:p-[var(--spacing-scale-012)] lg:p-[var(--spacing-scale-024)] border-r border-[var(--color-surface-default-primary)] w-fit flex items-center justify-center">
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* Title Container */}
|
|
||||||
{title && (
|
|
||||||
<div className="pl-[var(--spacing-scale-008)] md:pl-[var(--spacing-scale-012)] lg:pl-[var(--spacing-scale-024)] flex items-center gap-[var(--spacing-scale-004)]">
|
|
||||||
<h3 className="font-space-grotesk font-bold text-[20px] md:text-[28px] lg:text-[36px] leading-[28px] md:leading-[36px] lg:leading-[44px] text-[--color-content-inverse-primary]">
|
|
||||||
{title}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{description && (
|
|
||||||
<p className="font-inter font-medium text-[12px] md:text-[14px] lg:text-[18px] leading-[14px] md:leading-[16px] lg:leading-[24px] text-[var(--color-content-inverse-primary)]">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
RuleCard.displayName = "RuleCard";
|
|
||||||
|
|
||||||
export default RuleCard;
|
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { memo } from "react";
|
||||||
|
import { RuleCardView } from "./RuleCard.view";
|
||||||
|
import type { RuleCardProps } from "./RuleCard.types";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
gtag?: (
|
||||||
|
_command: string,
|
||||||
|
_eventName: string,
|
||||||
|
_params?: Record<string, unknown>,
|
||||||
|
) => void;
|
||||||
|
analytics?: {
|
||||||
|
track: (_eventName: string, _params?: Record<string, unknown>) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const RuleCardContainer = memo<RuleCardProps>(
|
||||||
|
({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
backgroundColor = "bg-[var(--color-community-teal-100)]",
|
||||||
|
className = "",
|
||||||
|
onClick,
|
||||||
|
}) => {
|
||||||
|
const handleClick = () => {
|
||||||
|
// Basic analytics event tracking
|
||||||
|
if (typeof window !== "undefined" && window.gtag) {
|
||||||
|
window.gtag("event", "template_selected", {
|
||||||
|
template_name: title,
|
||||||
|
template_type: "governance_pattern",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom analytics event for other tracking systems
|
||||||
|
if (typeof window !== "undefined" && window.analytics) {
|
||||||
|
window.analytics.track("Template Selected", {
|
||||||
|
templateName: title,
|
||||||
|
templateType: "governance_pattern",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onClick) onClick();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
handleClick();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RuleCardView
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
icon={icon}
|
||||||
|
backgroundColor={backgroundColor}
|
||||||
|
className={className}
|
||||||
|
onClick={handleClick}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
RuleCardContainer.displayName = "RuleCard";
|
||||||
|
|
||||||
|
export default RuleCardContainer;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export interface RuleCardProps {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
backgroundColor?: string;
|
||||||
|
className?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuleCardViewProps {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
backgroundColor: string;
|
||||||
|
className: string;
|
||||||
|
onClick: () => void;
|
||||||
|
onKeyDown: (_event: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { RuleCardViewProps } from "./RuleCard.types";
|
||||||
|
|
||||||
|
export function RuleCardView({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
backgroundColor,
|
||||||
|
className,
|
||||||
|
onClick,
|
||||||
|
onKeyDown,
|
||||||
|
}: RuleCardViewProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${backgroundColor} rounded-[var(--radius-measures-radius-small)] pt-[var(--spacing-scale-012)] pr-[var(--spacing-scale-012)] pl-[var(--spacing-scale-012)] pb-[var(--spacing-scale-024)] md:p-[var(--spacing-scale-024)] md:h-[210px] lg:h-[277px] flex flex-col gap-[18px] shadow-lg backdrop-blur-sm transition-all duration-500 ease-in-out hover:shadow-xl hover:scale-[1.02] focus:outline-none focus:ring-2 focus:ring-[var(--color-community-teal-500)] focus:ring-offset-2 cursor-pointer min-h-[44px] min-w-[44px] ${className}`}
|
||||||
|
tabIndex={0}
|
||||||
|
role="button"
|
||||||
|
aria-label={`Learn more about ${title} governance pattern`}
|
||||||
|
onClick={onClick}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
>
|
||||||
|
{/* Header Container */}
|
||||||
|
<div className="grid grid-cols-[auto_1fr] h-[72px] md:h-[80px] lg:h-[138px] border-b border-[var(--color-surface-default-primary)]">
|
||||||
|
{/* Icon Container */}
|
||||||
|
{icon && (
|
||||||
|
<div className="p-[var(--spacing-scale-016)] md:p-[var(--spacing-scale-012)] lg:p-[var(--spacing-scale-024)] border-r border-[var(--color-surface-default-primary)] w-fit flex items-center justify-center">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Title Container */}
|
||||||
|
{title && (
|
||||||
|
<div className="pl-[var(--spacing-scale-008)] md:pl-[var(--spacing-scale-012)] lg:pl-[var(--spacing-scale-024)] flex items-center gap-[var(--spacing-scale-004)]">
|
||||||
|
<h3 className="font-space-grotesk font-bold text-[20px] md:text-[28px] lg:text-[36px] leading-[28px] md:leading-[36px] lg:leading-[44px] text-[--color-content-inverse-primary]">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{description && (
|
||||||
|
<p className="font-inter font-medium text-[12px] md:text-[14px] lg:text-[18px] leading-[14px] md:leading-[16px] lg:leading-[24px] text-[var(--color-content-inverse-primary)]">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from "./RuleCard.container";
|
||||||
|
export type { RuleCardProps } from "./RuleCard.types";
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { memo } from "react";
|
||||||
|
import { logger } from "../../../lib/logger";
|
||||||
|
import { RuleStackView } from "./RuleStack.view";
|
||||||
|
import type { RuleStackProps } from "./RuleStack.types";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
gtag?: (
|
||||||
|
_command: string,
|
||||||
|
_eventName: string,
|
||||||
|
_params?: Record<string, unknown>,
|
||||||
|
) => void;
|
||||||
|
analytics?: {
|
||||||
|
track: (_eventName: string, _params?: Record<string, unknown>) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const RuleStackContainer = memo<RuleStackProps>(({ className = "" }) => {
|
||||||
|
const handleTemplateClick = (templateName: string) => {
|
||||||
|
// Basic analytics tracking
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
if (window.gtag) {
|
||||||
|
window.gtag("event", "template_click", {
|
||||||
|
template_name: templateName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (window.analytics) {
|
||||||
|
window.analytics.track("Template Clicked", {
|
||||||
|
templateName: templateName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.debug(`${templateName} template clicked`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RuleStackView
|
||||||
|
className={className}
|
||||||
|
onTemplateClick={handleTemplateClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
RuleStackContainer.displayName = "RuleStack";
|
||||||
|
|
||||||
|
export default RuleStackContainer;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export interface RuleStackProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuleStackViewProps {
|
||||||
|
className: string;
|
||||||
|
onTemplateClick: (_templateName: string) => void;
|
||||||
|
}
|
||||||
@@ -1,47 +1,13 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { memo } from "react";
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import RuleCard from "./RuleCard";
|
import RuleCard from "../RuleCard";
|
||||||
import Button from "./Button";
|
import Button from "../Button";
|
||||||
import { getAssetPath } from "../../lib/assetUtils";
|
import { getAssetPath } from "../../../lib/assetUtils";
|
||||||
import { logger } from "../../lib/logger";
|
import type { RuleStackViewProps } from "./RuleStack.types";
|
||||||
|
|
||||||
interface RuleStackProps {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface Window {
|
|
||||||
gtag?: (
|
|
||||||
_command: string,
|
|
||||||
_eventName: string,
|
|
||||||
_params?: Record<string, unknown>,
|
|
||||||
) => void;
|
|
||||||
analytics?: {
|
|
||||||
track: (_eventName: string, _params?: Record<string, unknown>) => void;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const RuleStack = memo<RuleStackProps>(({ className = "" }) => {
|
|
||||||
const handleTemplateClick = (templateName: string) => {
|
|
||||||
// Basic analytics tracking
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
if (window.gtag) {
|
|
||||||
window.gtag("event", "template_click", {
|
|
||||||
template_name: templateName,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (window.analytics) {
|
|
||||||
window.analytics.track("Template Clicked", {
|
|
||||||
templateName: templateName,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
logger.debug(`${templateName} template clicked`);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
export function RuleStackView({
|
||||||
|
className,
|
||||||
|
onTemplateClick,
|
||||||
|
}: RuleStackViewProps) {
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className={`w-full bg-transparent py-[var(--spacing-scale-032)] px-[var(--spacing-scale-020)] md:py-[var(--spacing-scale-048)] md:px-[var(--spacing-scale-032)] xmd:py-[var(--spacing-scale-056)] xmd:px-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-064)] lg:px-[var(--spacing-scale-064)] xl:py-[var(--spacing-scale-064)] xl:px-[var(--spacing-scale-096)] flex flex-col gap-[var(--spacing-scale-024)] xmd:gap-[var(--spacing-scale-032)] lg:gap-[var(--spacing-scale-040)] ${className}`}
|
className={`w-full bg-transparent py-[var(--spacing-scale-032)] px-[var(--spacing-scale-020)] md:py-[var(--spacing-scale-048)] md:px-[var(--spacing-scale-032)] xmd:py-[var(--spacing-scale-056)] xmd:px-[var(--spacing-scale-032)] lg:py-[var(--spacing-scale-064)] lg:px-[var(--spacing-scale-064)] xl:py-[var(--spacing-scale-064)] xl:px-[var(--spacing-scale-096)] flex flex-col gap-[var(--spacing-scale-024)] xmd:gap-[var(--spacing-scale-032)] lg:gap-[var(--spacing-scale-040)] ${className}`}
|
||||||
@@ -60,7 +26,7 @@ const RuleStack = memo<RuleStackProps>(({ className = "" }) => {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
backgroundColor="bg-[var(--color-surface-default-brand-lime)]"
|
backgroundColor="bg-[var(--color-surface-default-brand-lime)]"
|
||||||
onClick={() => handleTemplateClick("Consensus clusters")}
|
onClick={() => onTemplateClick("Consensus clusters")}
|
||||||
/>
|
/>
|
||||||
<RuleCard
|
<RuleCard
|
||||||
title="Consensus"
|
title="Consensus"
|
||||||
@@ -75,7 +41,7 @@ const RuleStack = memo<RuleStackProps>(({ className = "" }) => {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
backgroundColor="bg-[var(--color-surface-default-brand-rust)]"
|
backgroundColor="bg-[var(--color-surface-default-brand-rust)]"
|
||||||
onClick={() => handleTemplateClick("Consensus")}
|
onClick={() => onTemplateClick("Consensus")}
|
||||||
/>
|
/>
|
||||||
<RuleCard
|
<RuleCard
|
||||||
title="Elected Board"
|
title="Elected Board"
|
||||||
@@ -90,7 +56,7 @@ const RuleStack = memo<RuleStackProps>(({ className = "" }) => {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
backgroundColor="bg-[var(--color-surface-default-brand-red)]"
|
backgroundColor="bg-[var(--color-surface-default-brand-red)]"
|
||||||
onClick={() => handleTemplateClick("Elected Board")}
|
onClick={() => onTemplateClick("Elected Board")}
|
||||||
/>
|
/>
|
||||||
<RuleCard
|
<RuleCard
|
||||||
title="Petition"
|
title="Petition"
|
||||||
@@ -105,7 +71,7 @@ const RuleStack = memo<RuleStackProps>(({ className = "" }) => {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
backgroundColor="bg-[var(--color-surface-default-brand-teal)]"
|
backgroundColor="bg-[var(--color-surface-default-brand-teal)]"
|
||||||
onClick={() => handleTemplateClick("Petition")}
|
onClick={() => onTemplateClick("Petition")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -117,8 +83,4 @@ const RuleStack = memo<RuleStackProps>(({ className = "" }) => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
RuleStack.displayName = "RuleStack";
|
|
||||||
|
|
||||||
export default RuleStack;
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user