diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 1d3ff2e..5ac471f 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -2,10 +2,12 @@ name: CI Pipeline run-name: "${{ gitea.actor }} triggered CI pipeline" on: - workflow_dispatch: {} - pull_request: - branches: [main] - types: [opened, reopened, synchronize] + workflow_dispatch: {} # Manual trigger only - run tests locally before merging + # Auto-runs disabled for solo development + # Re-enable when ready for collaborators: + # pull_request: + # branches: [main] + # types: [opened, reopened, synchronize] env: NODE_VERSION: "20" diff --git a/app/components/AskOrganizer.tsx b/app/components/AskOrganizer.tsx deleted file mode 100644 index 4b61b5a..0000000 --- a/app/components/AskOrganizer.tsx +++ /dev/null @@ -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( - ({ - 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, - ) => { - // 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 ( -
-
- {/* Content Lockup */} - - - {/* Button */} -
- -
-
-
- ); - }, -); - -AskOrganizer.displayName = "AskOrganizer"; - -export default AskOrganizer; diff --git a/app/components/AskOrganizer/AskOrganizer.container.tsx b/app/components/AskOrganizer/AskOrganizer.container.tsx new file mode 100644 index 0000000..6614bac --- /dev/null +++ b/app/components/AskOrganizer/AskOrganizer.container.tsx @@ -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( + ({ + 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, + ) => { + 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) => void) + | undefined, + ); + + // Preserve existing button behavior (no preventDefault here) + // while still tracking analytics. + return event; + }; + + return ( + + ); + }, +); + +AskOrganizerContainer.displayName = "AskOrganizer"; + +export default AskOrganizerContainer; diff --git a/app/components/AskOrganizer/AskOrganizer.types.ts b/app/components/AskOrganizer/AskOrganizer.types.ts new file mode 100644 index 0000000..e7bd031 --- /dev/null +++ b/app/components/AskOrganizer/AskOrganizer.types.ts @@ -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, + ) => void; +} diff --git a/app/components/AskOrganizer/AskOrganizer.view.tsx b/app/components/AskOrganizer/AskOrganizer.view.tsx new file mode 100644 index 0000000..c16d816 --- /dev/null +++ b/app/components/AskOrganizer/AskOrganizer.view.tsx @@ -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 ( +
+
+ {/* Content Lockup */} + + + {/* Button */} +
+ +
+
+
+ ); +} + +export default AskOrganizerView; diff --git a/app/components/AskOrganizer/index.tsx b/app/components/AskOrganizer/index.tsx new file mode 100644 index 0000000..49e4669 --- /dev/null +++ b/app/components/AskOrganizer/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./AskOrganizer.container"; +export * from "./AskOrganizer.types"; diff --git a/app/components/Checkbox.tsx b/app/components/Checkbox/Checkbox.container.tsx similarity index 52% rename from app/components/Checkbox.tsx rename to app/components/Checkbox/Checkbox.container.tsx index 97e8cd3..1961614 100644 --- a/app/components/Checkbox.tsx +++ b/app/components/Checkbox/Checkbox.container.tsx @@ -1,36 +1,15 @@ "use client"; 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 { - 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( +const CheckboxContainer = memo( ({ checked = false, - mode = "standard", // "standard" | "inverse" - state = "default", // "default" | "hover" | "focus" + mode = "standard", + state = "default", disabled = false, label, className = "", @@ -109,72 +88,39 @@ const Checkbox = memo( ...props, }; + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === " " || e.key === "Enter") { + e.preventDefault(); + handleToggle(e); + } + }; + return ( - + ); }, ); -Checkbox.displayName = "Checkbox"; +CheckboxContainer.displayName = "Checkbox"; -export default Checkbox; +export default CheckboxContainer; diff --git a/app/components/Checkbox/Checkbox.types.ts b/app/components/Checkbox/Checkbox.types.ts new file mode 100644 index 0000000..2a0d28d --- /dev/null +++ b/app/components/Checkbox/Checkbox.types.ts @@ -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; + onToggle: (_e: React.MouseEvent | React.KeyboardEvent) => void; + onKeyDown: (_e: React.KeyboardEvent) => void; +} diff --git a/app/components/Checkbox/Checkbox.view.tsx b/app/components/Checkbox/Checkbox.view.tsx new file mode 100644 index 0000000..f4d4144 --- /dev/null +++ b/app/components/Checkbox/Checkbox.view.tsx @@ -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 ( + + ); +} diff --git a/app/components/Checkbox/index.tsx b/app/components/Checkbox/index.tsx new file mode 100644 index 0000000..d90c09b --- /dev/null +++ b/app/components/Checkbox/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./Checkbox.container"; +export type { CheckboxProps } from "./Checkbox.types"; diff --git a/app/components/ConditionalHeader.tsx b/app/components/ConditionalHeader.tsx deleted file mode 100644 index 875c1db..0000000 --- a/app/components/ConditionalHeader.tsx +++ /dev/null @@ -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 ; - } - - // Show regular Header on all other pages - return
; -} diff --git a/app/components/ConditionalHeader/ConditionalHeader.container.tsx b/app/components/ConditionalHeader/ConditionalHeader.container.tsx new file mode 100644 index 0000000..5f006f1 --- /dev/null +++ b/app/components/ConditionalHeader/ConditionalHeader.container.tsx @@ -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(() => { + const pathname = usePathname(); + const isHomePage = pathname === "/"; + + return ; +}); + +ConditionalHeaderContainer.displayName = "ConditionalHeader"; + +export default ConditionalHeaderContainer; diff --git a/app/components/ConditionalHeader/ConditionalHeader.types.ts b/app/components/ConditionalHeader/ConditionalHeader.types.ts new file mode 100644 index 0000000..61af22a --- /dev/null +++ b/app/components/ConditionalHeader/ConditionalHeader.types.ts @@ -0,0 +1,7 @@ +export interface ConditionalHeaderProps { + // Currently no props, but keeping interface for future extensibility +} + +export interface ConditionalHeaderViewProps { + isHomePage: boolean; +} diff --git a/app/components/ConditionalHeader/ConditionalHeader.view.tsx b/app/components/ConditionalHeader/ConditionalHeader.view.tsx new file mode 100644 index 0000000..ff36612 --- /dev/null +++ b/app/components/ConditionalHeader/ConditionalHeader.view.tsx @@ -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 ; + } + return
; +} diff --git a/app/components/ConditionalHeader/index.tsx b/app/components/ConditionalHeader/index.tsx new file mode 100644 index 0000000..a05fd67 --- /dev/null +++ b/app/components/ConditionalHeader/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./ConditionalHeader.container"; +export type { ConditionalHeaderProps } from "./ConditionalHeader.types"; diff --git a/app/components/ContentContainer.tsx b/app/components/ContentContainer.tsx deleted file mode 100644 index 97ce3ee..0000000 --- a/app/components/ContentContainer.tsx +++ /dev/null @@ -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( - ({ 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 ( -
- {/* Content Container - gap between icon and text */} -
- {/* Icon */} -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {`Icon -
- - {/* Text Container */} -
- {/* Title */} -

- {post.frontmatter.title} -

- - {/* Description */} -

- {post.frontmatter.description} -

-
-
- - {/* Metadata Container - horizontal with 8px gap */} -
- {/* Author Name */} - - {post.frontmatter.author} - - - {/* Date */} - - {new Date(post.frontmatter.date).toLocaleDateString("en-US", { - year: "numeric", - month: "long", - })} - -
-
- ); - }, -); - -ContentContainer.displayName = "ContentContainer"; - -export default ContentContainer; diff --git a/app/components/ContentContainer/ContentContainer.container.tsx b/app/components/ContentContainer/ContentContainer.container.tsx new file mode 100644 index 0000000..5c201b7 --- /dev/null +++ b/app/components/ContentContainer/ContentContainer.container.tsx @@ -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( + ({ 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 ( + + ); + }, +); + +ContentContainerContainer.displayName = "ContentContainer"; + +export default ContentContainerContainer; diff --git a/app/components/ContentContainer/ContentContainer.types.ts b/app/components/ContentContainer/ContentContainer.types.ts new file mode 100644 index 0000000..f29387e --- /dev/null +++ b/app/components/ContentContainer/ContentContainer.types.ts @@ -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; +} diff --git a/app/components/ContentContainer/ContentContainer.view.tsx b/app/components/ContentContainer/ContentContainer.view.tsx new file mode 100644 index 0000000..9233107 --- /dev/null +++ b/app/components/ContentContainer/ContentContainer.view.tsx @@ -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 ( +
+ {/* Content Container - gap between icon and text */} +
+ {/* Icon */} +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {`Icon +
+ + {/* Text Container */} +
+ {/* Title */} +

{post.frontmatter.title}

+ + {/* Description */} +

{post.frontmatter.description}

+
+
+ + {/* Metadata Container - horizontal with 8px gap */} +
+ {/* Author Name */} + {post.frontmatter.author} + + {/* Date */} + {formattedDate} +
+
+ ); +} + +ContentContainerView.displayName = "ContentContainerView"; + +export default memo(ContentContainerView); diff --git a/app/components/ContentContainer/index.tsx b/app/components/ContentContainer/index.tsx new file mode 100644 index 0000000..0b06c35 --- /dev/null +++ b/app/components/ContentContainer/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./ContentContainer.container"; +export type { ContentContainerProps } from "./ContentContainer.types"; diff --git a/app/components/ContentLockup.tsx b/app/components/ContentLockup/ContentLockup.container.tsx similarity index 57% rename from app/components/ContentLockup.tsx rename to app/components/ContentLockup/ContentLockup.container.tsx index 2d4c7b9..2e1ad5f 100644 --- a/app/components/ContentLockup.tsx +++ b/app/components/ContentLockup/ContentLockup.container.tsx @@ -1,39 +1,10 @@ "use client"; import { memo } from "react"; -import Button from "./Button"; -import { getAssetPath } from "../../lib/assetUtils"; +import ContentLockupView from "./ContentLockup.view"; +import type { ContentLockupProps, VariantStyle } from "./ContentLockup.types"; -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; -} - -interface VariantStyle { - container: string; - textContainer: string; - titleGroup: string; - titleContainer: string; - title: string; - subtitle: string; - description?: string; - shape: string; -} - -const ContentLockup = memo( +const ContentLockupContainer = memo( ({ title, subtitle, @@ -125,102 +96,23 @@ const ContentLockup = memo( const styles = variantStyles[variant] || variantStyles.hero; return ( -
- {variant === "ask" || variant === "ask-inverse" ? ( - /* Simplified structure for ask variant */ -
-
- {title ? ( -

- {title} -

- ) : null} -
- {subtitle ?

{subtitle}

: null} -
- ) : ( - /* Full structure for other variants */ -
- {/* Title and subtitle group */} -
- {/* Title container */} -
- {title ? ( -

- {title} -

- ) : null} - {variant === "hero" && ( - - )} -
- - {/* Subtitle */} - {subtitle ? ( -

{subtitle}

- ) : null} -
- - {/* Description */} - {description &&

{description}

} -
- )} - - {/* Link for feature variant */} - {variant === "feature" && linkText && ( - - {linkText} - - )} - - {/* CTA Button */} - {ctaText && ( -
- {/* Small button for xsm and sm breakpoints */} -
- -
- {/* Large button for md and lg breakpoints */} -
- -
- {/* XLarge button for xl breakpoint */} -
- -
-
- )} -
+ ); }, ); -ContentLockup.displayName = "ContentLockup"; +ContentLockupContainer.displayName = "ContentLockup"; -export default ContentLockup; +export default ContentLockupContainer; diff --git a/app/components/ContentLockup/ContentLockup.types.ts b/app/components/ContentLockup/ContentLockup.types.ts new file mode 100644 index 0000000..be1c2d9 --- /dev/null +++ b/app/components/ContentLockup/ContentLockup.types.ts @@ -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; +} diff --git a/app/components/ContentLockup/ContentLockup.view.tsx b/app/components/ContentLockup/ContentLockup.view.tsx new file mode 100644 index 0000000..6f13269 --- /dev/null +++ b/app/components/ContentLockup/ContentLockup.view.tsx @@ -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 ( +
+ {variant === "ask" || variant === "ask-inverse" ? ( + /* Simplified structure for ask variant */ +
+
+ {title ? ( +

+ {title} +

+ ) : null} +
+ {subtitle ?

{subtitle}

: null} +
+ ) : ( + /* Full structure for other variants */ +
+ {/* Title and subtitle group */} +
+ {/* Title container */} +
+ {title ? ( +

+ {title} +

+ ) : null} + {variant === "hero" && ( + + )} +
+ + {/* Subtitle */} + {subtitle ?

{subtitle}

: null} +
+ + {/* Description */} + {description &&

{description}

} +
+ )} + + {/* Link for feature variant */} + {variant === "feature" && linkText && ( + + {linkText} + + )} + + {/* CTA Button */} + {ctaText && ( +
+ {/* Small button for xsm and sm breakpoints */} +
+ +
+ {/* Large button for md and lg breakpoints */} +
+ +
+ {/* XLarge button for xl breakpoint */} +
+ +
+
+ )} +
+ ); +} + +ContentLockupView.displayName = "ContentLockupView"; + +export default memo(ContentLockupView); diff --git a/app/components/ContentLockup/index.tsx b/app/components/ContentLockup/index.tsx new file mode 100644 index 0000000..9ccd08e --- /dev/null +++ b/app/components/ContentLockup/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./ContentLockup.container"; +export type { ContentLockupProps } from "./ContentLockup.types"; diff --git a/app/components/ContentThumbnailTemplate.tsx b/app/components/ContentThumbnailTemplate.tsx deleted file mode 100644 index 31b49c4..0000000 --- a/app/components/ContentThumbnailTemplate.tsx +++ /dev/null @@ -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( - ({ 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 = { - vertical: getAssetPath(ASSETS.VERTICAL_1), - horizontal: getAssetPath(ASSETS.HORIZONTAL_1), - }; - - return fallbackImages[variant] || fallbackImages.vertical; - }; - - const backgroundImage = getBackgroundImage(post, variant); - - if (variant === "vertical") { - return ( - -
- {/* Background SVG - fills container with maintained aspect */} -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {`Background - {/* Gradient overlay for better text readability */} -
-
- - {/* Content Section - positioned within the padding constraints */} - -
- - ); - } - - // Horizontal variant - return ( - -
- {/* Background SVG - sized to fit the 320x225.5 container exactly */} -
- {/* eslint-disable-next-line @next/next/no-img-element */} - {`Background - {/* Gradient overlay */} -
-
- - {/* Content - positioned within the padding constraints */} - -
- - ); - }, -); - -ContentThumbnailTemplate.displayName = "ContentThumbnailTemplate"; - -export default ContentThumbnailTemplate; diff --git a/app/components/ContentThumbnailTemplate/ContentThumbnailTemplate.container.tsx b/app/components/ContentThumbnailTemplate/ContentThumbnailTemplate.container.tsx new file mode 100644 index 0000000..6fdddbc --- /dev/null +++ b/app/components/ContentThumbnailTemplate/ContentThumbnailTemplate.container.tsx @@ -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( + ({ 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 = { + vertical: getAssetPath(ASSETS.VERTICAL_1), + horizontal: getAssetPath(ASSETS.HORIZONTAL_1), + }; + + return fallbackImages[variant] || fallbackImages.vertical; + }; + + const backgroundImage = getBackgroundImage(post, variant); + + return ( + + ); + }, +); + +ContentThumbnailTemplateContainer.displayName = "ContentThumbnailTemplate"; + +export default ContentThumbnailTemplateContainer; diff --git a/app/components/ContentThumbnailTemplate/ContentThumbnailTemplate.types.ts b/app/components/ContentThumbnailTemplate/ContentThumbnailTemplate.types.ts new file mode 100644 index 0000000..c680288 --- /dev/null +++ b/app/components/ContentThumbnailTemplate/ContentThumbnailTemplate.types.ts @@ -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; +} diff --git a/app/components/ContentThumbnailTemplate/ContentThumbnailTemplate.view.tsx b/app/components/ContentThumbnailTemplate/ContentThumbnailTemplate.view.tsx new file mode 100644 index 0000000..0fe1e15 --- /dev/null +++ b/app/components/ContentThumbnailTemplate/ContentThumbnailTemplate.view.tsx @@ -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 ( + +
+ {/* Background SVG - fills container with maintained aspect */} +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {`Background + {/* Gradient overlay for better text readability */} +
+
+ + {/* Content Section - positioned within the padding constraints */} + +
+ + ); + } + + // Horizontal variant + return ( + +
+ {/* Background SVG - sized to fit the 320x225.5 container exactly */} +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {`Background + {/* Gradient overlay */} +
+
+ + {/* Content - positioned within the padding constraints */} + +
+ + ); +} + +ContentThumbnailTemplateView.displayName = "ContentThumbnailTemplateView"; + +export default memo(ContentThumbnailTemplateView); diff --git a/app/components/ContentThumbnailTemplate/index.tsx b/app/components/ContentThumbnailTemplate/index.tsx new file mode 100644 index 0000000..c5ed5c6 --- /dev/null +++ b/app/components/ContentThumbnailTemplate/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./ContentThumbnailTemplate.container"; +export type { ContentThumbnailTemplateProps } from "./ContentThumbnailTemplate.types"; diff --git a/app/components/SelectOption.tsx b/app/components/ContextMenuItem/ContextMenuItem.container.tsx similarity index 59% rename from app/components/SelectOption.tsx rename to app/components/ContextMenuItem/ContextMenuItem.container.tsx index fc66f43..ddaa795 100644 --- a/app/components/SelectOption.tsx +++ b/app/components/ContextMenuItem/ContextMenuItem.container.tsx @@ -1,23 +1,18 @@ "use client"; import { forwardRef, memo, useCallback } from "react"; +import { ContextMenuItemView } from "./ContextMenuItem.view"; +import type { ContextMenuItemProps } from "./ContextMenuItem.types"; -interface SelectOptionProps { - children?: React.ReactNode; - selected?: boolean; - disabled?: boolean; - className?: string; - onClick?: ( - _e: React.MouseEvent | React.KeyboardEvent, - ) => void; - size?: "small" | "medium" | "large"; -} - -const SelectOption = forwardRef( +const ContextMenuItemContainer = forwardRef< + HTMLDivElement, + ContextMenuItemProps +>( ( { children, selected = false, + hasSubmenu = false, disabled = false, className = "", onClick, @@ -83,40 +78,23 @@ const SelectOption = forwardRef( ); return ( -
-
- {selected && ( - - - - )} - {children} -
-
+ {children} + ); }, ); -SelectOption.displayName = "SelectOption"; +ContextMenuItemContainer.displayName = "ContextMenuItem"; -export default memo(SelectOption); +export default memo(ContextMenuItemContainer); diff --git a/app/components/ContextMenuItem/ContextMenuItem.types.ts b/app/components/ContextMenuItem/ContextMenuItem.types.ts new file mode 100644 index 0000000..1580f10 --- /dev/null +++ b/app/components/ContextMenuItem/ContextMenuItem.types.ts @@ -0,0 +1,22 @@ +export interface ContextMenuItemProps extends React.HTMLAttributes { + children?: React.ReactNode; + selected?: boolean; + hasSubmenu?: boolean; + disabled?: boolean; + className?: string; + onClick?: ( + _e: React.MouseEvent | React.KeyboardEvent, + ) => 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) => void; + handleKeyDown: (_e: React.KeyboardEvent) => void; +} diff --git a/app/components/ContextMenuItem/ContextMenuItem.view.tsx b/app/components/ContextMenuItem/ContextMenuItem.view.tsx new file mode 100644 index 0000000..aa67acc --- /dev/null +++ b/app/components/ContextMenuItem/ContextMenuItem.view.tsx @@ -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 ( +
+
+ {selected && ( + + + + )} + {children} +
+ {hasSubmenu && ( + + + + )} +
+ ); + }, +); + +ContextMenuItemView.displayName = "ContextMenuItemView"; diff --git a/app/components/ContextMenuItem/index.tsx b/app/components/ContextMenuItem/index.tsx new file mode 100644 index 0000000..e7bbe20 --- /dev/null +++ b/app/components/ContextMenuItem/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./ContextMenuItem.container"; +export type { ContextMenuItemProps } from "./ContextMenuItem.types"; diff --git a/app/components/FeatureGrid.tsx b/app/components/FeatureGrid.tsx deleted file mode 100644 index e72fe83..0000000 --- a/app/components/FeatureGrid.tsx +++ /dev/null @@ -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( - ({ 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 ( -
-
-
- {/* Feature Content Lockup */} -
- -
- - {/* MiniCard Grid */} -
- {features.map((feature, index) => ( - - ))} -
-
-
-
- ); - }, -); - -FeatureGrid.displayName = "FeatureGrid"; - -export default FeatureGrid; diff --git a/app/components/FeatureGrid/FeatureGrid.container.tsx b/app/components/FeatureGrid/FeatureGrid.container.tsx new file mode 100644 index 0000000..1ef87a8 --- /dev/null +++ b/app/components/FeatureGrid/FeatureGrid.container.tsx @@ -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( + ({ 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 ( + + ); + }, +); + +FeatureGridContainer.displayName = "FeatureGrid"; + +export default FeatureGridContainer; diff --git a/app/components/FeatureGrid/FeatureGrid.types.ts b/app/components/FeatureGrid/FeatureGrid.types.ts new file mode 100644 index 0000000..31e061a --- /dev/null +++ b/app/components/FeatureGrid/FeatureGrid.types.ts @@ -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; +} diff --git a/app/components/FeatureGrid/FeatureGrid.view.tsx b/app/components/FeatureGrid/FeatureGrid.view.tsx new file mode 100644 index 0000000..4429d93 --- /dev/null +++ b/app/components/FeatureGrid/FeatureGrid.view.tsx @@ -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 ( +
+
+
+ {/* Feature Content Lockup */} +
+ +
+ + {/* MiniCard Grid */} +
+ {features.map((feature, index) => ( + + ))} +
+
+
+
+ ); +} + +export default FeatureGridView; diff --git a/app/components/FeatureGrid/index.tsx b/app/components/FeatureGrid/index.tsx new file mode 100644 index 0000000..8fc1ebd --- /dev/null +++ b/app/components/FeatureGrid/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./FeatureGrid.container"; +export * from "./FeatureGrid.types"; diff --git a/app/components/Header/Header.container.tsx b/app/components/Header/Header.container.tsx new file mode 100644 index 0000000..165f2bf --- /dev/null +++ b/app/components/Header/Header.container.tsx @@ -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(() => { + 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) => ( + + {item.text} + + )); + }; + + const renderAvatarGroup = ( + containerSize: "small" | "medium" | "large" | "xlarge", + avatarSize: "small" | "medium" | "large" | "xlarge", + ) => { + return ( + + {avatarImages.map((avatar, index) => ( + + ))} + + ); + }; + + const renderLoginButton = (size: NavSize) => { + return ( + + Log in + + ); + }; + + const renderCreateRuleButton = ( + buttonSize: "xsmall" | "small" | "medium" | "large" | "xlarge", + containerSize: "small" | "medium" | "large" | "xlarge", + avatarSize: "small" | "medium" | "large" | "xlarge", + ) => { + return ( + + ); + }; + + const renderLogo = ( + size: + | "default" + | "homeHeaderXsmall" + | "homeHeaderSm" + | "homeHeaderMd" + | "homeHeaderLg" + | "homeHeaderXl" + | "header" + | "headerMd" + | "headerLg" + | "headerXl" + | "footer" + | "footerLg", + showText: boolean, + ) => { + return ; + }; + + return ( + + ); +}); + +HeaderContainer.displayName = "Header"; + +export default HeaderContainer; diff --git a/app/components/Header/Header.types.ts b/app/components/Header/Header.types.ts new file mode 100644 index 0000000..b47f5d4 --- /dev/null +++ b/app/components/Header/Header.types.ts @@ -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"; diff --git a/app/components/Header.tsx b/app/components/Header/Header.view.tsx similarity index 54% rename from app/components/Header.tsx rename to app/components/Header/Header.view.tsx index 93d833f..2ea7986 100644 --- a/app/components/Header.tsx +++ b/app/components/Header/Header.view.tsx @@ -1,151 +1,14 @@ -"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 { 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) => ( - - {item.text} - - )); - }; - - const renderAvatarGroup = ( - containerSize: "small" | "medium" | "large" | "xlarge", - avatarSize: "small" | "medium" | "large" | "xlarge", - ) => { - return ( - - {avatarImages.map((avatar, index) => ( - - ))} - - ); - }; - - const renderLoginButton = (size: NavSize) => { - return ( - - Log in - - ); - }; - - const renderCreateRuleButton = ( - buttonSize: "xsmall" | "small" | "medium" | "large" | "xlarge", - containerSize: "small" | "medium" | "large" | "xlarge", - avatarSize: "small" | "medium" | "large" | "xlarge", - ) => { - return ( - - ); - }; - - const renderLogo = ( - size: - | "default" - | "homeHeaderXsmall" - | "homeHeaderSm" - | "homeHeaderMd" - | "homeHeaderLg" - | "homeHeaderXl" - | "header" - | "headerMd" - | "headerLg" - | "headerXl" - | "footer" - | "footerLg", - showText: boolean, - ) => { - return ; - }; +import MenuBar from "../MenuBar"; +import type { HeaderViewProps } from "./Header.types"; +export function HeaderView({ + schemaData, + logoConfig, + renderNavigationItems, + renderLoginButton, + renderCreateRuleButton, + renderLogo, +}: HeaderViewProps) { return ( <>