diff --git a/.storybook/preview.github.js b/.storybook/preview.github.js index 1fd25df..8b6689a 100644 --- a/.storybook/preview.github.js +++ b/.storybook/preview.github.js @@ -1,5 +1,29 @@ import "../app/globals.css"; +// Import Google Fonts for Storybook +import { Inter, Bricolage_Grotesque, Space_Grotesk } from "next/font/google"; + +const inter = Inter({ + subsets: ["latin"], + weight: ["400", "500", "600", "700"], + variable: "--font-inter", + display: "swap", +}); + +const bricolageGrotesque = Bricolage_Grotesque({ + subsets: ["latin"], + weight: ["400", "500", "700", "800"], + variable: "--font-bricolage-grotesque", + display: "swap", +}); + +const spaceGrotesk = Space_Grotesk({ + subsets: ["latin"], + weight: ["400", "500", "700"], + variable: "--font-space-grotesk", + display: "swap", +}); + /** @type { import('@storybook/react').Preview } */ const preview = { parameters: { @@ -11,6 +35,15 @@ const preview = { }, }, }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], }; export default preview; diff --git a/.storybook/preview.js b/.storybook/preview.js index 59394fb..8b6689a 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -1,18 +1,27 @@ import "../app/globals.css"; // Import Google Fonts for Storybook -import { Inter, Bricolage_Grotesque } from "next/font/google"; +import { Inter, Bricolage_Grotesque, Space_Grotesk } from "next/font/google"; const inter = Inter({ subsets: ["latin"], - weight: ["400", "500"], + weight: ["400", "500", "600", "700"], variable: "--font-inter", + display: "swap", }); const bricolageGrotesque = Bricolage_Grotesque({ subsets: ["latin"], - weight: ["400", "500"], + weight: ["400", "500", "700", "800"], variable: "--font-bricolage-grotesque", + display: "swap", +}); + +const spaceGrotesk = Space_Grotesk({ + subsets: ["latin"], + weight: ["400", "500", "700"], + variable: "--font-space-grotesk", + display: "swap", }); /** @type { import('@storybook/react').Preview } */ @@ -28,7 +37,9 @@ const preview = { }, decorators: [ (Story) => ( -
+
), diff --git a/.storybook/preview.local.js b/.storybook/preview.local.js index 59394fb..8b6689a 100644 --- a/.storybook/preview.local.js +++ b/.storybook/preview.local.js @@ -1,18 +1,27 @@ import "../app/globals.css"; // Import Google Fonts for Storybook -import { Inter, Bricolage_Grotesque } from "next/font/google"; +import { Inter, Bricolage_Grotesque, Space_Grotesk } from "next/font/google"; const inter = Inter({ subsets: ["latin"], - weight: ["400", "500"], + weight: ["400", "500", "600", "700"], variable: "--font-inter", + display: "swap", }); const bricolageGrotesque = Bricolage_Grotesque({ subsets: ["latin"], - weight: ["400", "500"], + weight: ["400", "500", "700", "800"], variable: "--font-bricolage-grotesque", + display: "swap", +}); + +const spaceGrotesk = Space_Grotesk({ + subsets: ["latin"], + weight: ["400", "500", "700"], + variable: "--font-space-grotesk", + display: "swap", }); /** @type { import('@storybook/react').Preview } */ @@ -28,7 +37,9 @@ const preview = { }, decorators: [ (Story) => ( -
+
), diff --git a/app/components/QuoteBlock.js b/app/components/QuoteBlock.js new file mode 100644 index 0000000..3598a2f --- /dev/null +++ b/app/components/QuoteBlock.js @@ -0,0 +1,238 @@ +"use client"; + +import React, { useState } from "react"; +import Image from "next/image"; +import QuoteDecor from "./QuoteDecor"; + +const QuoteBlock = ({ + 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", // Fallback avatar + onError, // Error callback +}) => { + const [imageError, setImageError] = useState(false); + const [imageLoading, setImageLoading] = useState(true); + + // Variant configurations + const variants = { + 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) => { + console.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) { + console.error("QuoteBlock: Missing required props (quote or author)"); + if (onError) { + onError({ + type: "missing_props", + message: "QuoteBlock requires quote and author props", + quote: !!quote, + author: !!author, + }); + } + return null; // Don't render if missing required props + } + + // Determine which avatar to use + const currentAvatarSrc = imageError ? fallbackAvatarSrc : avatarSrc; + + return ( +
+
+ {/* DECORATIONS (behind content) */} + {config.showDecor && ( +
+ ); +}; + +export default QuoteBlock; diff --git a/app/components/QuoteDecor.js b/app/components/QuoteDecor.js new file mode 100644 index 0000000..cd2a5f4 --- /dev/null +++ b/app/components/QuoteDecor.js @@ -0,0 +1,73 @@ +"use client"; + +const QuoteDecor = ({ className = "" }) => { + return ( + + ); +}; + +export default QuoteDecor; diff --git a/app/components/RuleStack.js b/app/components/RuleStack.js index 0e43a6b..f5b9733 100644 --- a/app/components/RuleStack.js +++ b/app/components/RuleStack.js @@ -1,26 +1,32 @@ "use client"; -import SectionHeader from "./SectionHeader"; +import React from "react"; +import Image from "next/image"; import RuleCard from "./RuleCard"; import Button from "./Button"; -import Image from "next/image"; -const RuleStack = ({ children, className = "" }) => { +const RuleStack = ({ className = "" }) => { const handleTemplateClick = (templateName) => { - console.log(`Template selected: ${templateName}`); - // This would typically navigate to template details or open a modal - // For now, we'll just log the selection + // 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, + }); + } + } + console.log(`${templateName} template clicked`); }; return ( -
-
{ See all templates
-
+ ); }; diff --git a/app/page.js b/app/page.js index e6a48ad..ac8d1fd 100644 --- a/app/page.js +++ b/app/page.js @@ -2,6 +2,7 @@ import NumberedCards from "./components/NumberedCards"; import HeroBanner from "./components/HeroBanner"; import LogoWall from "./components/LogoWall"; import RuleStack from "./components/RuleStack"; +import QuoteBlock from "./components/QuoteBlock"; export default function Page() { const heroBannerData = { @@ -41,6 +42,7 @@ export default function Page() { +
); } diff --git a/public/assets/Quote_Avatar.svg b/public/assets/Quote_Avatar.svg new file mode 100644 index 0000000..50c8704 --- /dev/null +++ b/public/assets/Quote_Avatar.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/stories/QuoteBlock.stories.js b/stories/QuoteBlock.stories.js new file mode 100644 index 0000000..3b4f0f6 --- /dev/null +++ b/stories/QuoteBlock.stories.js @@ -0,0 +1,146 @@ +import QuoteBlock from "../app/components/QuoteBlock"; + +export default { + title: "Components/QuoteBlock", + component: QuoteBlock, + parameters: { + layout: "fullscreen", + docs: { + description: { + component: ` +A responsive quote section component that displays inspirational governance quotes with author attribution and decorative geometric elements. + +## Features +- **Three variants**: compact, standard, and extended layouts +- **Responsive design**: Adapts across all breakpoints +- **Error handling**: Graceful fallbacks for image loading failures +- **Accessibility**: WCAG 2.1 AA compliant with proper ARIA labels +- **Design system integration**: Uses design tokens for consistent styling + +## Usage +\`\`\`jsx + +\`\`\` + `, + }, + }, + }, + argTypes: { + variant: { + control: { type: "select" }, + options: ["compact", "standard", "extended"], + description: "Layout variant for different use cases", + }, + quote: { + control: { type: "text" }, + description: "The quote text to display", + }, + author: { + control: { type: "text" }, + description: "Author name for attribution", + }, + source: { + control: { type: "text" }, + description: "Source title (book, article, etc.)", + }, + avatarSrc: { + control: { type: "text" }, + description: "Path to author avatar image", + }, + fallbackAvatarSrc: { + control: { type: "text" }, + description: "Fallback avatar image path", + }, + onError: { + action: "error", + description: "Error callback function", + }, + }, +}; + +// Default story +export const Default = { + args: { + variant: "standard", + 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", + }, +}; + +// All variants comparison +export const AllVariants = { + render: () => ( +
+
+

Compact Variant

+ +
+ +
+

Standard Variant

+ +
+ +
+

Extended Variant

+ +
+
+ ), + parameters: { + docs: { + description: { + story: + "Side-by-side comparison of all three variants to show the differences in layout, typography, and spacing.", + }, + }, + }, +}; + +// Error state simulation +export const ErrorState = { + args: { + variant: "standard", + 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: "invalid-image-path.jpg", // This will trigger error state + onError: (error) => console.log("QuoteBlock error:", error), + }, + parameters: { + docs: { + description: { + story: + "Error state when avatar image fails to load. Shows initials fallback and error handling.", + }, + }, + }, +};