Quote Block #12
@@ -1,5 +1,29 @@
|
|||||||
import "../app/globals.css";
|
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 } */
|
/** @type { import('@storybook/react').Preview } */
|
||||||
const preview = {
|
const preview = {
|
||||||
parameters: {
|
parameters: {
|
||||||
@@ -11,6 +35,15 @@ const preview = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
<div
|
||||||
|
className={`${inter.variable} ${bricolageGrotesque.variable} ${spaceGrotesk.variable} font-sans`}
|
||||||
|
>
|
||||||
|
<Story />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default preview;
|
export default preview;
|
||||||
|
|||||||
+15
-4
@@ -1,18 +1,27 @@
|
|||||||
import "../app/globals.css";
|
import "../app/globals.css";
|
||||||
|
|
||||||
// Import Google Fonts for Storybook
|
// 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({
|
const inter = Inter({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
weight: ["400", "500"],
|
weight: ["400", "500", "600", "700"],
|
||||||
variable: "--font-inter",
|
variable: "--font-inter",
|
||||||
|
display: "swap",
|
||||||
});
|
});
|
||||||
|
|
||||||
const bricolageGrotesque = Bricolage_Grotesque({
|
const bricolageGrotesque = Bricolage_Grotesque({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
weight: ["400", "500"],
|
weight: ["400", "500", "700", "800"],
|
||||||
variable: "--font-bricolage-grotesque",
|
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 } */
|
/** @type { import('@storybook/react').Preview } */
|
||||||
@@ -28,7 +37,9 @@ const preview = {
|
|||||||
},
|
},
|
||||||
decorators: [
|
decorators: [
|
||||||
(Story) => (
|
(Story) => (
|
||||||
<div className={`${inter.variable} ${bricolageGrotesque.variable}`}>
|
<div
|
||||||
|
className={`${inter.variable} ${bricolageGrotesque.variable} ${spaceGrotesk.variable} font-sans`}
|
||||||
|
>
|
||||||
<Story />
|
<Story />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
import "../app/globals.css";
|
import "../app/globals.css";
|
||||||
|
|
||||||
// Import Google Fonts for Storybook
|
// 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({
|
const inter = Inter({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
weight: ["400", "500"],
|
weight: ["400", "500", "600", "700"],
|
||||||
variable: "--font-inter",
|
variable: "--font-inter",
|
||||||
|
display: "swap",
|
||||||
});
|
});
|
||||||
|
|
||||||
const bricolageGrotesque = Bricolage_Grotesque({
|
const bricolageGrotesque = Bricolage_Grotesque({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
weight: ["400", "500"],
|
weight: ["400", "500", "700", "800"],
|
||||||
variable: "--font-bricolage-grotesque",
|
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 } */
|
/** @type { import('@storybook/react').Preview } */
|
||||||
@@ -28,7 +37,9 @@ const preview = {
|
|||||||
},
|
},
|
||||||
decorators: [
|
decorators: [
|
||||||
(Story) => (
|
(Story) => (
|
||||||
<div className={`${inter.variable} ${bricolageGrotesque.variable}`}>
|
<div
|
||||||
|
className={`${inter.variable} ${bricolageGrotesque.variable} ${spaceGrotesk.variable} font-sans`}
|
||||||
|
>
|
||||||
<Story />
|
<Story />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<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`}
|
||||||
|
>
|
||||||
|
{/* 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={avatarSrc}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QuoteBlock;
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
const QuoteDecor = ({ className = "" }) => {
|
||||||
|
return (
|
||||||
|
<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}`}
|
||||||
|
viewBox="400 0 442 163"
|
||||||
|
aria-hidden="true"
|
||||||
|
overflow="visible"
|
||||||
|
preserveAspectRatio="xMinYMin meet"
|
||||||
|
>
|
||||||
|
<g fill="currentColor">
|
||||||
|
{/* Mobile ellipses */}
|
||||||
|
<g className="md:hidden">
|
||||||
|
{/* First ellipse - top left */}
|
||||||
|
<ellipse
|
||||||
|
cx="490"
|
||||||
|
cy="80"
|
||||||
|
rx="300"
|
||||||
|
ry="100"
|
||||||
|
transform="rotate(-20 600 90)"
|
||||||
|
/>
|
||||||
|
{/* Second ellipse - middle */}
|
||||||
|
<ellipse
|
||||||
|
cx="508"
|
||||||
|
cy="250"
|
||||||
|
rx="300"
|
||||||
|
ry="110"
|
||||||
|
transform="rotate(-25 600 90)"
|
||||||
|
/>
|
||||||
|
{/* Third ellipse - bottom right */}
|
||||||
|
<ellipse
|
||||||
|
cx="550"
|
||||||
|
cy="420"
|
||||||
|
rx="300"
|
||||||
|
ry="120"
|
||||||
|
transform="rotate(-25 600 90)"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* MD+ ellipses */}
|
||||||
|
<g className="hidden md:block">
|
||||||
|
{/* First ellipse - top left */}
|
||||||
|
<ellipse
|
||||||
|
cx="590"
|
||||||
|
cy="70"
|
||||||
|
rx="300"
|
||||||
|
ry="110"
|
||||||
|
transform="rotate(-30 600 90)"
|
||||||
|
/>
|
||||||
|
{/* Second ellipse - middle */}
|
||||||
|
<ellipse
|
||||||
|
cx="680"
|
||||||
|
cy="250"
|
||||||
|
rx="300"
|
||||||
|
ry="110"
|
||||||
|
transform="rotate(-30 600 90)"
|
||||||
|
/>
|
||||||
|
{/* Third ellipse - bottom right */}
|
||||||
|
<ellipse
|
||||||
|
cx="670"
|
||||||
|
cy="400"
|
||||||
|
rx="300"
|
||||||
|
ry="120"
|
||||||
|
transform="rotate(-30 600 90)"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default QuoteDecor;
|
||||||
+19
-13
@@ -1,26 +1,32 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import SectionHeader from "./SectionHeader";
|
import React from "react";
|
||||||
|
import Image from "next/image";
|
||||||
import RuleCard from "./RuleCard";
|
import RuleCard from "./RuleCard";
|
||||||
import Button from "./Button";
|
import Button from "./Button";
|
||||||
import Image from "next/image";
|
|
||||||
|
|
||||||
const RuleStack = ({ children, className = "" }) => {
|
const RuleStack = ({ className = "" }) => {
|
||||||
const handleTemplateClick = (templateName) => {
|
const handleTemplateClick = (templateName) => {
|
||||||
console.log(`Template selected: ${templateName}`);
|
// Basic analytics tracking
|
||||||
// This would typically navigate to template details or open a modal
|
if (typeof window !== "undefined") {
|
||||||
// For now, we'll just log the selection
|
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 (
|
return (
|
||||||
<div
|
<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}`}
|
||||||
>
|
>
|
||||||
<SectionHeader
|
|
||||||
title="Popular templates"
|
|
||||||
subtitle="These are popular patterns for making decisions in mutual aid and open source communities. You can use them as they are or as a starting place for customizing your own CommunityRule."
|
|
||||||
variant="multi-line"
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col gap-[18px] xmd:grid xmd:grid-cols-2 lg:gap-[var(--spacing-scale-024)]">
|
<div className="flex flex-col gap-[18px] xmd:grid xmd:grid-cols-2 lg:gap-[var(--spacing-scale-024)]">
|
||||||
<RuleCard
|
<RuleCard
|
||||||
title="Consensus clusters"
|
title="Consensus clusters"
|
||||||
@@ -90,7 +96,7 @@ const RuleStack = ({ children, className = "" }) => {
|
|||||||
See all templates
|
See all templates
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import NumberedCards from "./components/NumberedCards";
|
|||||||
import HeroBanner from "./components/HeroBanner";
|
import HeroBanner from "./components/HeroBanner";
|
||||||
import LogoWall from "./components/LogoWall";
|
import LogoWall from "./components/LogoWall";
|
||||||
import RuleStack from "./components/RuleStack";
|
import RuleStack from "./components/RuleStack";
|
||||||
|
import QuoteBlock from "./components/QuoteBlock";
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const heroBannerData = {
|
const heroBannerData = {
|
||||||
@@ -41,6 +42,7 @@ export default function Page() {
|
|||||||
<LogoWall />
|
<LogoWall />
|
||||||
<NumberedCards {...numberedCardsData} />
|
<NumberedCards {...numberedCardsData} />
|
||||||
<RuleStack />
|
<RuleStack />
|
||||||
|
<QuoteBlock />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 41 KiB |
@@ -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
|
||||||
|
<QuoteBlock
|
||||||
|
variant="standard"
|
||||||
|
quote="Your quote text here..."
|
||||||
|
author="Author Name"
|
||||||
|
source="Source Title"
|
||||||
|
avatarSrc="path/to/avatar.jpg"
|
||||||
|
/>
|
||||||
|
\`\`\`
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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: () => (
|
||||||
|
<div className="space-y-8 p-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold mb-4">Compact Variant</h3>
|
||||||
|
<QuoteBlock
|
||||||
|
variant="compact"
|
||||||
|
quote="The rules of decision-making must be open and available to everyone."
|
||||||
|
author="Jo Freeman"
|
||||||
|
source="The Tyranny of Structurelessness"
|
||||||
|
avatarSrc="assets/Quote_Avatar.svg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold mb-4">Standard Variant</h3>
|
||||||
|
<QuoteBlock
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold mb-4">Extended Variant</h3>
|
||||||
|
<QuoteBlock
|
||||||
|
variant="extended"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
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.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user