@kolkrabbi/kol-component 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +11 -3
- package/src/atoms/AnimatedTitle.jsx +108 -0
- package/src/atoms/AssetGrid.jsx +29 -0
- package/src/atoms/Button.jsx +17 -6
- package/src/atoms/CurveOverlay.jsx +180 -0
- package/src/atoms/DocsToc.jsx +48 -0
- package/src/atoms/EmptyState.jsx +22 -0
- package/src/atoms/Figure.jsx +27 -0
- package/src/atoms/HlsVideo.jsx +56 -0
- package/src/atoms/OverlayGlassPanel.jsx +40 -0
- package/src/atoms/PriceDisplay.jsx +34 -0
- package/src/atoms/ProsePreview.jsx +53 -0
- package/src/atoms/RotaryDial.jsx +150 -0
- package/src/atoms/SearchInput.jsx +108 -0
- package/src/atoms/TextPressure.jsx +331 -0
- package/src/atoms/TiltCard.jsx +127 -0
- package/src/atoms/TypeSample.jsx +50 -0
- package/src/atoms/TypeSpecCard.jsx +42 -0
- package/src/hooks/cssVar.js +53 -0
- package/src/hooks/useAxisAnimation.js +91 -0
- package/src/hooks/usePrefersReducedMotion.js +21 -0
- package/src/hooks/useTilt.js +49 -0
- package/src/index.js +66 -1
- package/src/molecules/AlignmentGrid.jsx +53 -0
- package/src/molecules/ArticleCard.jsx +178 -0
- package/src/molecules/CardFeatureItem.jsx +130 -0
- package/src/molecules/ColorInputRow.jsx +179 -0
- package/src/molecules/ColorRamp.jsx +114 -0
- package/src/molecules/FramedMediaBand.jsx +56 -0
- package/src/molecules/ImageBlock.jsx +34 -0
- package/src/molecules/SelectionOverlay.jsx +108 -0
- package/src/molecules/ShapeDropdown.jsx +92 -0
- package/src/molecules/ShellDrawer.jsx +169 -0
- package/src/molecules/ShellSearchOverlay.jsx +177 -0
- package/src/molecules/SpecList.jsx +30 -0
- package/src/molecules/SpectrumControls.jsx +504 -0
- package/src/molecules/SwatchControls.jsx +217 -0
- package/src/molecules/TabsRow.jsx +87 -0
- package/src/molecules/VideoBlock.jsx +86 -0
- package/src/molecules/WorkListItem.jsx +83 -0
- package/src/molecules/foundry/SpecimenSectionHeader.jsx +89 -0
- package/src/organisms/ArticleHeader.jsx +95 -0
- package/src/organisms/AsciiCursor.jsx +526 -0
- package/src/organisms/BentoCard.jsx +187 -0
- package/src/organisms/Canvas.jsx +299 -0
- package/src/organisms/ColorLoader.jsx +155 -0
- package/src/organisms/CtaGlobal.jsx +67 -0
- package/src/organisms/DiagonalMarqueeRiver.jsx +138 -0
- package/src/organisms/EditorShell.jsx +111 -0
- package/src/organisms/ErrorBoundary.jsx +70 -0
- package/src/organisms/FeatureSplit.jsx +82 -0
- package/src/organisms/FeaturedCarousel.jsx +258 -0
- package/src/organisms/FeaturesCardSection.jsx +90 -0
- package/src/organisms/FullBleedHero.jsx +111 -0
- package/src/organisms/GalleryCarousel.jsx +83 -0
- package/src/organisms/LoaderOverlay.jsx +30 -0
- package/src/organisms/MediaViewer.jsx +95 -0
- package/src/organisms/NewsletterBand.jsx +122 -0
- package/src/organisms/ParallaxShelf.jsx +141 -0
- package/src/organisms/PortableTextRenderer.jsx +115 -0
- package/src/organisms/ProductDetailLayout.jsx +189 -0
- package/src/organisms/ScrollDriftGallery.jsx +214 -0
- package/src/organisms/SpectrumGrid.jsx +90 -0
- package/src/organisms/StackHero.jsx +83 -0
- package/src/organisms/WorkCard.jsx +120 -0
- package/src/organisms/WorkViewToggle.jsx +170 -0
- package/src/organisms/foundry/FontPreviewSection.jsx +187 -0
- package/src/organisms/foundry/FoundryCharacterSets.jsx +113 -0
- package/src/organisms/foundry/GlyphMetricsGrid.jsx +335 -0
- package/src/organisms/foundry/TypefaceHero.jsx +107 -0
- package/src/organisms/foundry/TypefaceStyleSection.jsx +163 -0
- package/src/organisms/foundry/VariableFontSection.jsx +158 -0
- package/src/organisms/foundry/glyphData.js +30 -0
- package/src/organisms/foundry/index.js +21 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { useId, useState } from 'react'
|
|
2
|
+
import Input from '../atoms/Input.jsx'
|
|
3
|
+
import Button from '../atoms/Button.jsx'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* NewsletterBand — centered newsletter-subscribe band: heading + lede over an
|
|
7
|
+
* email Input + submit Button, with an inline success/error status line under
|
|
8
|
+
* the form. The closing subscribe section on marketing pages.
|
|
9
|
+
*
|
|
10
|
+
* Owns only the local form state — the email value and idle / submitting /
|
|
11
|
+
* success / error, driven by the promise returned from `onSubmit(email)`.
|
|
12
|
+
* No fetch lives here: the consumer's handler posts wherever it wants;
|
|
13
|
+
* resolve → success (field clears), reject → error (logged). An empty submit
|
|
14
|
+
* errors immediately without calling the handler, and the submit Button
|
|
15
|
+
* disables while the promise is pending. Native `type="email"` validation
|
|
16
|
+
* still gates malformed addresses before the handler runs.
|
|
17
|
+
*
|
|
18
|
+
* A11y: the Input carries aria-required and, on error, aria-describedby
|
|
19
|
+
* pointing at the error line; the success line is role=status
|
|
20
|
+
* aria-live=polite, the error line role=alert aria-live=assertive. Field and
|
|
21
|
+
* error ids are useId-generated (or `inputId`) so multiple bands mount
|
|
22
|
+
* without collisions.
|
|
23
|
+
*
|
|
24
|
+
* @param {ReactNode} title heading (kol-sans-display-01)
|
|
25
|
+
* @param {ReactNode} description lede under the heading (kol-mono-14)
|
|
26
|
+
* @param {string} placeholder email Input placeholder
|
|
27
|
+
* @param {ReactNode} submitLabel submit Button label (also its accessible name)
|
|
28
|
+
* @param {Function} onSubmit (email) => Promise|void — resolve = success, reject = error
|
|
29
|
+
* @param {ReactNode} successCopy success status line
|
|
30
|
+
* @param {ReactNode} errorCopy error status line
|
|
31
|
+
* @param {string} id anchor id on the section (e.g. "signup")
|
|
32
|
+
* @param {string} inputId id override for the email input (default useId-generated)
|
|
33
|
+
* @param {string} className extra classes on the section
|
|
34
|
+
*/
|
|
35
|
+
export default function NewsletterBand({
|
|
36
|
+
title,
|
|
37
|
+
description,
|
|
38
|
+
placeholder,
|
|
39
|
+
submitLabel,
|
|
40
|
+
onSubmit,
|
|
41
|
+
successCopy = 'Thanks for subscribing!',
|
|
42
|
+
errorCopy = 'Please enter a valid email address.',
|
|
43
|
+
id,
|
|
44
|
+
inputId,
|
|
45
|
+
className = '',
|
|
46
|
+
}) {
|
|
47
|
+
const [email, setEmail] = useState('')
|
|
48
|
+
const [status, setStatus] = useState('idle') // 'idle' | 'submitting' | 'success' | 'error'
|
|
49
|
+
|
|
50
|
+
const autoId = useId()
|
|
51
|
+
const emailId = inputId ?? `${autoId}email`
|
|
52
|
+
const errorId = `${emailId}-error`
|
|
53
|
+
|
|
54
|
+
const handleSubmit = async (e) => {
|
|
55
|
+
e.preventDefault()
|
|
56
|
+
|
|
57
|
+
if (!email) {
|
|
58
|
+
setStatus('error')
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
if (!onSubmit) return
|
|
62
|
+
|
|
63
|
+
setStatus('submitting')
|
|
64
|
+
try {
|
|
65
|
+
await onSubmit(email)
|
|
66
|
+
setStatus('success')
|
|
67
|
+
setEmail('')
|
|
68
|
+
} catch (error) {
|
|
69
|
+
console.error('Newsletter signup error:', error)
|
|
70
|
+
setStatus('error')
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<section
|
|
76
|
+
id={id}
|
|
77
|
+
className={`w-full flex flex-col items-center justify-center text-center ${className}`.trim()}
|
|
78
|
+
>
|
|
79
|
+
<div className="max-w-[1400px] mx-auto py-24">
|
|
80
|
+
<h2 className="kol-sans-display-01 mb-6">{title}</h2>
|
|
81
|
+
|
|
82
|
+
<p className="kol-mono-14 text-auto mb-12 mx-auto max-w-[64rem]">{description}</p>
|
|
83
|
+
|
|
84
|
+
<form
|
|
85
|
+
onSubmit={handleSubmit}
|
|
86
|
+
className="flex flex-col gap-4 mb-16 sm:flex-row sm:items-center sm:justify-center sm:gap-3"
|
|
87
|
+
>
|
|
88
|
+
<Input
|
|
89
|
+
id={emailId}
|
|
90
|
+
type="email"
|
|
91
|
+
placeholder={placeholder}
|
|
92
|
+
value={email}
|
|
93
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
94
|
+
size="md"
|
|
95
|
+
aria-required="true"
|
|
96
|
+
aria-describedby={status === 'error' ? errorId : undefined}
|
|
97
|
+
className="w-full sm:max-w-[400px] md:max-w-[520px]"
|
|
98
|
+
/>
|
|
99
|
+
<Button
|
|
100
|
+
type="submit"
|
|
101
|
+
variant="primary"
|
|
102
|
+
disabled={status === 'submitting'}
|
|
103
|
+
className="w-full sm:w-auto"
|
|
104
|
+
>
|
|
105
|
+
{submitLabel}
|
|
106
|
+
</Button>
|
|
107
|
+
</form>
|
|
108
|
+
|
|
109
|
+
{status === 'success' && (
|
|
110
|
+
<p className="kol-mono-14 text-auto opacity-80" role="status" aria-live="polite">
|
|
111
|
+
{successCopy}
|
|
112
|
+
</p>
|
|
113
|
+
)}
|
|
114
|
+
{status === 'error' && (
|
|
115
|
+
<p id={errorId} className="kol-mono-14 text-auto opacity-80" role="alert" aria-live="assertive">
|
|
116
|
+
{errorCopy}
|
|
117
|
+
</p>
|
|
118
|
+
)}
|
|
119
|
+
</div>
|
|
120
|
+
</section>
|
|
121
|
+
)
|
|
122
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
+
import useEmblaCarousel from 'embla-carousel-react'
|
|
3
|
+
import WorkCard from './WorkCard.jsx'
|
|
4
|
+
import usePrefersReducedMotion from '../hooks/usePrefersReducedMotion.js'
|
|
5
|
+
|
|
6
|
+
/* Scroll-driven parallax strength — fraction of scroll delta applied to the
|
|
7
|
+
* shelf. Eases in quadratically with page-scroll progress (min → max). */
|
|
8
|
+
const SCROLL_PARALLAX_MIN = 0.1
|
|
9
|
+
const SCROLL_PARALLAX_MAX = 0.4
|
|
10
|
+
const DRAG_THRESHOLD_SQ = 25
|
|
11
|
+
/* Aligned edge gets the big gutter so the first/last card lands under a
|
|
12
|
+
* 1400px content column. */
|
|
13
|
+
const EDGE_GUTTER = 'max(4rem, calc((100vw - 1400px) / 2 + 16rem))'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* ParallaxShelf — one horizontal shelf row: a drag-free Embla carousel of
|
|
17
|
+
* project cards with scroll-driven parallax. As the page scrolls the track is
|
|
18
|
+
* nudged horizontally by `delta × parallax × direction`, the strength easing in
|
|
19
|
+
* the further down the page you are; alternating rows (`fromLeft`) scroll in
|
|
20
|
+
* opposite directions for a woven wall of shelves. Includes a drag-vs-click
|
|
21
|
+
* guard (a drag never fires a card link) and an edge-aligned category label.
|
|
22
|
+
*
|
|
23
|
+
* Calls `useEmblaCarousel` directly (FeaturedCarousel's precedent) because the
|
|
24
|
+
* parallax nudge needs `internalEngine()`, which DS Carousel encapsulates and
|
|
25
|
+
* must not be modified. The source's `WheelGesturesPlugin` is dropped (not a
|
|
26
|
+
* DS dependency). Parallax is disabled under prefers-reduced-motion and on
|
|
27
|
+
* small screens — both fall back to a plain wheel/drag carousel.
|
|
28
|
+
*
|
|
29
|
+
* Card-agnostic via `renderCard`; defaults to DS **WorkCard** with an
|
|
30
|
+
* `index`-staggered ragged height. Pass a plain `items` array of flat project
|
|
31
|
+
* objects (no Sanity keying).
|
|
32
|
+
*
|
|
33
|
+
* Text casing: the label renders verbatim (author `type.label` as e.g.
|
|
34
|
+
* "Client Work") — no text-transform.
|
|
35
|
+
*
|
|
36
|
+
* @param {{label:string}} type category; `label` renders in the edge caption
|
|
37
|
+
* @param {Array} items flat project objects passed to the card renderer
|
|
38
|
+
* @param {boolean} fromLeft direction of alignment / start index / parallax sign / label side (default false)
|
|
39
|
+
* @param {Function} renderCard (item, index) => ReactNode — card renderer (default WorkCard)
|
|
40
|
+
* @param {Function} onNavigate (href, event) => void — forwarded to the default WorkCard
|
|
41
|
+
* @param {string} className extra classes on the section
|
|
42
|
+
*/
|
|
43
|
+
export default function ParallaxShelf({
|
|
44
|
+
type,
|
|
45
|
+
items = [],
|
|
46
|
+
fromLeft = false,
|
|
47
|
+
renderCard,
|
|
48
|
+
onNavigate,
|
|
49
|
+
className = '',
|
|
50
|
+
}) {
|
|
51
|
+
const reduced = usePrefersReducedMotion()
|
|
52
|
+
const [isMobile] = useState(
|
|
53
|
+
() => typeof window !== 'undefined' && window.matchMedia('(max-width: 767px)').matches,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
const [emblaRef, emblaApi] = useEmblaCarousel({
|
|
57
|
+
dragFree: true,
|
|
58
|
+
align: fromLeft ? 'end' : 'start',
|
|
59
|
+
containScroll: 'trimSnaps',
|
|
60
|
+
...(fromLeft && { startIndex: items.length - 1 }),
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const sectionRef = useRef(null)
|
|
64
|
+
const lastScrollY = useRef(0)
|
|
65
|
+
|
|
66
|
+
// Scroll-driven parallax: page scroll nudges the carousel while in view.
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (!emblaApi || reduced || isMobile) return
|
|
69
|
+
lastScrollY.current = window.scrollY
|
|
70
|
+
|
|
71
|
+
const onScroll = () => {
|
|
72
|
+
const section = sectionRef.current
|
|
73
|
+
if (!section) return
|
|
74
|
+
const rect = section.getBoundingClientRect()
|
|
75
|
+
const inView = rect.bottom > 0 && rect.top < window.innerHeight
|
|
76
|
+
if (!inView) {
|
|
77
|
+
lastScrollY.current = window.scrollY
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
const delta = window.scrollY - lastScrollY.current
|
|
81
|
+
lastScrollY.current = window.scrollY
|
|
82
|
+
const scrollProgress = Math.min(window.scrollY / (document.body.scrollHeight - window.innerHeight), 1)
|
|
83
|
+
const parallax = SCROLL_PARALLAX_MIN + (SCROLL_PARALLAX_MAX - SCROLL_PARALLAX_MIN) * (scrollProgress * scrollProgress)
|
|
84
|
+
const engine = emblaApi.internalEngine()
|
|
85
|
+
const offset = delta * parallax * (fromLeft ? 1 : -1)
|
|
86
|
+
engine.scrollBody.useDuration(0)
|
|
87
|
+
engine.scrollTo.distance(offset, false)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
window.addEventListener('scroll', onScroll, { passive: true })
|
|
91
|
+
return () => window.removeEventListener('scroll', onScroll)
|
|
92
|
+
}, [emblaApi, fromLeft, reduced, isMobile])
|
|
93
|
+
|
|
94
|
+
// Drag-vs-click guard (5px): a drag suppresses the ensuing card-link click.
|
|
95
|
+
const dragged = useRef(false)
|
|
96
|
+
const pointerStart = useRef({ x: 0, y: 0 })
|
|
97
|
+
const onPointerDown = useCallback((e) => {
|
|
98
|
+
dragged.current = false
|
|
99
|
+
pointerStart.current = { x: e.clientX, y: e.clientY }
|
|
100
|
+
}, [])
|
|
101
|
+
const onPointerMove = useCallback((e) => {
|
|
102
|
+
if (dragged.current) return
|
|
103
|
+
const dx = e.clientX - pointerStart.current.x
|
|
104
|
+
const dy = e.clientY - pointerStart.current.y
|
|
105
|
+
if (dx * dx + dy * dy > DRAG_THRESHOLD_SQ) dragged.current = true
|
|
106
|
+
}, [])
|
|
107
|
+
const onClickCapture = useCallback((e) => {
|
|
108
|
+
if (dragged.current) e.preventDefault()
|
|
109
|
+
}, [])
|
|
110
|
+
|
|
111
|
+
const card = (item, i) =>
|
|
112
|
+
renderCard ? renderCard(item, i) : <WorkCard key={i} {...item} index={i} onNavigate={onNavigate} />
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<section ref={sectionRef} className={`py-6 md:py-16 ${className}`.trim()}>
|
|
116
|
+
<div
|
|
117
|
+
className="overflow-visible select-none"
|
|
118
|
+
ref={emblaRef}
|
|
119
|
+
style={{
|
|
120
|
+
paddingLeft: fromLeft ? undefined : EDGE_GUTTER,
|
|
121
|
+
paddingRight: fromLeft ? EDGE_GUTTER : undefined,
|
|
122
|
+
}}
|
|
123
|
+
>
|
|
124
|
+
<div
|
|
125
|
+
className="flex gap-8 items-end"
|
|
126
|
+
onPointerDown={onPointerDown}
|
|
127
|
+
onPointerMove={onPointerMove}
|
|
128
|
+
onClickCapture={onClickCapture}
|
|
129
|
+
>
|
|
130
|
+
{items.map((item, i) => card(item, i))}
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
{type?.label && (
|
|
135
|
+
<div className={`max-w-[1400px] mx-auto mt-4 md:mt-6 ${fromLeft ? 'pr-4 md:pr-64 text-right' : 'pl-4 md:pl-64'}`}>
|
|
136
|
+
<p className="kol-helper-12 text-auto">{type.label}</p>
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
139
|
+
</section>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { Fragment } from 'react'
|
|
2
|
+
import CodeBlock from '../molecules/CodeBlock.jsx'
|
|
3
|
+
import ImageBlock from '../molecules/ImageBlock.jsx'
|
|
4
|
+
import VideoBlock from '../molecules/VideoBlock.jsx'
|
|
5
|
+
import Table from './Table.jsx'
|
|
6
|
+
import Divider from '../atoms/Divider.jsx'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* slugify — heading text → anchor id (lowercase, non-word stripped, spaces to
|
|
10
|
+
* dashes, repeats collapsed). Feeds the h2/h3 `id`s that power in-page anchors
|
|
11
|
+
* / a table of contents. Kept from the source; it is the one bit of real logic.
|
|
12
|
+
*/
|
|
13
|
+
export function slugify(text = '') {
|
|
14
|
+
return String(text)
|
|
15
|
+
.toLowerCase()
|
|
16
|
+
.replace(/[^\w\s-]/g, '')
|
|
17
|
+
.replace(/\s+/g, '-')
|
|
18
|
+
.replace(/-+/g, '-')
|
|
19
|
+
.trim()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/* ── Inline marks ──────────────────────────────────────────────────────────
|
|
23
|
+
* A block's `text` is either a plain string or an array of inline nodes:
|
|
24
|
+
* 'plain text'
|
|
25
|
+
* { mark: 'link', href, text } → <a> (http* → new tab + noopener)
|
|
26
|
+
* { mark: 'segmentTitle', text } → <span class="kol-segment-title">
|
|
27
|
+
*/
|
|
28
|
+
function renderInline(text) {
|
|
29
|
+
if (text == null) return null
|
|
30
|
+
const nodes = Array.isArray(text) ? text : [text]
|
|
31
|
+
return nodes.map((node, i) => {
|
|
32
|
+
if (typeof node === 'string') return <Fragment key={i}>{node}</Fragment>
|
|
33
|
+
if (node.mark === 'link') {
|
|
34
|
+
const external = (node.href || '').startsWith('http')
|
|
35
|
+
return (
|
|
36
|
+
<a
|
|
37
|
+
key={i}
|
|
38
|
+
href={node.href}
|
|
39
|
+
target={external ? '_blank' : undefined}
|
|
40
|
+
rel={external ? 'noopener noreferrer' : undefined}
|
|
41
|
+
>
|
|
42
|
+
{node.text}
|
|
43
|
+
</a>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
if (node.mark === 'segmentTitle') {
|
|
47
|
+
return <span key={i} className="kol-segment-title">{node.text}</span>
|
|
48
|
+
}
|
|
49
|
+
return <Fragment key={i}>{node.text}</Fragment>
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const plainText = (text) =>
|
|
54
|
+
Array.isArray(text) ? text.map((n) => (typeof n === 'string' ? n : n.text)).join('') : String(text ?? '')
|
|
55
|
+
|
|
56
|
+
/* ── Block registry ────────────────────────────────────────────────────────
|
|
57
|
+
* Maps a plain { type, ... } block to a DS component / prose tag. This is the
|
|
58
|
+
* de-Sanitized counterpart to the app's Portable-Text components map: generic
|
|
59
|
+
* block-type names (`heading`/`quote`/`divider`/`table`/`video`), no CMS
|
|
60
|
+
* schema keys, no @portabletext/react host.
|
|
61
|
+
*/
|
|
62
|
+
const BLOCKS = {
|
|
63
|
+
heading: ({ level = 2, text, id }) => {
|
|
64
|
+
const Tag = `h${level}`
|
|
65
|
+
const anchorId = id ?? (level === 2 || level === 3 ? slugify(plainText(text)) : undefined)
|
|
66
|
+
return <Tag id={anchorId}>{renderInline(text)}</Tag>
|
|
67
|
+
},
|
|
68
|
+
paragraph: ({ text }) => <p>{renderInline(text)}</p>,
|
|
69
|
+
caption: ({ text }) => <p className="caption">{renderInline(text)}</p>,
|
|
70
|
+
quote: ({ text, cite }) => (
|
|
71
|
+
<blockquote>
|
|
72
|
+
<p>{renderInline(text)}</p>
|
|
73
|
+
{cite && <cite>{cite}</cite>}
|
|
74
|
+
</blockquote>
|
|
75
|
+
),
|
|
76
|
+
code: ({ language, code }) => <CodeBlock language={language}>{code}</CodeBlock>,
|
|
77
|
+
divider: () => <Divider className="my-10" />,
|
|
78
|
+
image: (block) => <ImageBlock {...block} />,
|
|
79
|
+
video: (block) => <VideoBlock {...block} />,
|
|
80
|
+
table: ({ columns, rows, caption }) => <Table columns={columns} rows={rows} caption={caption} variant="simple" />,
|
|
81
|
+
list: ({ style = 'bullet', items = [] }) => {
|
|
82
|
+
const Tag = style === 'number' ? 'ol' : 'ul'
|
|
83
|
+
return <Tag>{items.map((item, i) => <li key={i}>{renderInline(item)}</li>)}</Tag>
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* PortableTextRenderer — turns a plain block array into `.kol-prose`-styled
|
|
89
|
+
* long-form markup via one block-type registry. The design system's CMS
|
|
90
|
+
* renderer (the counterpart to ProseStylesViewer, which only *showcases* the
|
|
91
|
+
* prose styles) — both plug into the same `.kol-prose` stylesheet.
|
|
92
|
+
*
|
|
93
|
+
* The block shape is a simple `{ type, ... }` — NOT Sanity Portable Text and
|
|
94
|
+
* with no `@portabletext/react` host. Custom blocks (`image`/`video`/`table`/
|
|
95
|
+
* `code`/`divider`) resolve to DS components; text blocks render bare tags the
|
|
96
|
+
* `.kol-prose` sheet styles. Inline `link`/`segmentTitle` marks are supported
|
|
97
|
+
* via the array `text` form (see `renderInline`). Unknown types are skipped.
|
|
98
|
+
*
|
|
99
|
+
* All content — headings, paragraphs, captions — is authored as-is; the map
|
|
100
|
+
* applies no `text-transform`.
|
|
101
|
+
*
|
|
102
|
+
* @param {Array<{type:string}>} blocks the document, in order
|
|
103
|
+
* @param {boolean} prose wrap output in a `.kol-prose` container (default true)
|
|
104
|
+
* @param {string} className extra classes on the wrapper
|
|
105
|
+
*/
|
|
106
|
+
export default function PortableTextRenderer({ blocks = [], prose = true, className = '' }) {
|
|
107
|
+
const rendered = blocks.map((block, i) => {
|
|
108
|
+
const render = BLOCKS[block?.type]
|
|
109
|
+
if (!render) return null
|
|
110
|
+
return <Fragment key={block.key ?? i}>{render(block)}</Fragment>
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
if (!prose) return <>{rendered}</>
|
|
114
|
+
return <div className={`kol-prose ${className}`.trim()}>{rendered}</div>
|
|
115
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import PriceDisplay from '../atoms/PriceDisplay.jsx'
|
|
3
|
+
import Pill from '../atoms/Pill.jsx'
|
|
4
|
+
import Divider from '../atoms/Divider.jsx'
|
|
5
|
+
import QuantityInput from '../atoms/QuantityInput.jsx'
|
|
6
|
+
import SpecList from '../molecules/SpecList.jsx'
|
|
7
|
+
import TabsRow from '../molecules/TabsRow.jsx'
|
|
8
|
+
import Dropdown from '../molecules/Dropdown.jsx'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* ProductDetailLayout — the pure two-column PDP skeleton: a full-height media
|
|
12
|
+
* gallery beside a scrollable details column (eyebrow + title + tags, a
|
|
13
|
+
* SpecList of key facts, an underline TabsRow with its panel, then a purchase
|
|
14
|
+
* block of price / size / quantity / CTA / fine print / back link).
|
|
15
|
+
*
|
|
16
|
+
* Presentational and commerce-agnostic — the print-store source's PayPal URL,
|
|
17
|
+
* `print.*` reads, Sanity image normalization and route wiring are all dropped.
|
|
18
|
+
* Data enters through props; the DS parts (PriceDisplay, SpecList, TabsRow,
|
|
19
|
+
* QuantityInput, Dropdown) are composed here, everything else is a slot. Size,
|
|
20
|
+
* quantity and the active tab are owned internally (uncontrolled), seeded from
|
|
21
|
+
* props; pass the change callbacks to observe them.
|
|
22
|
+
*
|
|
23
|
+
* @param {ReactNode[]} mediaItems gallery frames (rendered nodes, e.g. inline SVG); drives the internal big-frame + thumbnail strip
|
|
24
|
+
* @param {ReactNode} media single media slot used when `mediaItems` is empty
|
|
25
|
+
* @param {ReactNode} eyebrow category caption above the title (authored verbatim)
|
|
26
|
+
* @param {ReactNode} title product name — rendered kol-heading-md (author case at the call site)
|
|
27
|
+
* @param {string[]} tags optional Pill row
|
|
28
|
+
* @param {Array<{label,value}>} specs key-facts rows → SpecList
|
|
29
|
+
* @param {Array<{id,label,content}>} tabs tab strip (→ TabsRow) + panel content per tab
|
|
30
|
+
* @param {{amount:number,currency?:string,locale?:string,secondary?:ReactNode}} price → PriceDisplay
|
|
31
|
+
* @param {string[]} sizeOptions size <select> options → Dropdown (default ['A3'])
|
|
32
|
+
* @param {string} defaultSize initially-selected size (default sizeOptions[0])
|
|
33
|
+
* @param {Function} onSizeChange (value) => void — fires on size select
|
|
34
|
+
* @param {number} defaultQuantity initial qty (default 1)
|
|
35
|
+
* @param {number} minQuantity qty floor (default 1)
|
|
36
|
+
* @param {number} maxQuantity qty ceiling (default 10)
|
|
37
|
+
* @param {Function} onQuantityChange (value) => void — fires on qty step
|
|
38
|
+
* @param {ReactNode} actions CTA slot — pass the "Add to cart" Button
|
|
39
|
+
* @param {ReactNode} shippingNote fine-print line under the CTA
|
|
40
|
+
* @param {ReactNode} backLink back-to-catalog link slot
|
|
41
|
+
*/
|
|
42
|
+
export default function ProductDetailLayout({
|
|
43
|
+
mediaItems = [],
|
|
44
|
+
media,
|
|
45
|
+
eyebrow,
|
|
46
|
+
title,
|
|
47
|
+
tags = [],
|
|
48
|
+
specs = [],
|
|
49
|
+
tabs = [],
|
|
50
|
+
price,
|
|
51
|
+
sizeOptions = ['A3'],
|
|
52
|
+
defaultSize,
|
|
53
|
+
onSizeChange,
|
|
54
|
+
defaultQuantity = 1,
|
|
55
|
+
minQuantity = 1,
|
|
56
|
+
maxQuantity = 10,
|
|
57
|
+
onQuantityChange,
|
|
58
|
+
actions,
|
|
59
|
+
shippingNote,
|
|
60
|
+
backLink,
|
|
61
|
+
}) {
|
|
62
|
+
const [activeMedia, setActiveMedia] = useState(0)
|
|
63
|
+
const [size, setSize] = useState(defaultSize ?? sizeOptions[0])
|
|
64
|
+
const [quantity, setQuantity] = useState(defaultQuantity)
|
|
65
|
+
const [activeTab, setActiveTab] = useState(tabs[0]?.id)
|
|
66
|
+
|
|
67
|
+
const activeTabItem = tabs.find((t) => t.id === activeTab) ?? tabs[0]
|
|
68
|
+
|
|
69
|
+
const handleSize = (value) => {
|
|
70
|
+
setSize(value)
|
|
71
|
+
onSizeChange?.(value)
|
|
72
|
+
}
|
|
73
|
+
const handleQuantity = (value) => {
|
|
74
|
+
setQuantity(value)
|
|
75
|
+
onQuantityChange?.(value)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<main className="min-h-screen w-full overflow-x-hidden bg-surface-primary text-auto">
|
|
80
|
+
<section className="grid h-dvh w-full gap-0 lg:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)]">
|
|
81
|
+
{/* Media column */}
|
|
82
|
+
<div className="relative flex h-dvh bg-surface-secondary">
|
|
83
|
+
<div className="relative flex-1 flex items-center justify-center px-8 py-24 lg:px-16 lg:py-32 overflow-hidden">
|
|
84
|
+
{mediaItems.length > 0 ? (
|
|
85
|
+
<div className="flex max-h-full max-w-full items-center justify-center">
|
|
86
|
+
{mediaItems[activeMedia] ?? mediaItems[0]}
|
|
87
|
+
</div>
|
|
88
|
+
) : (
|
|
89
|
+
media
|
|
90
|
+
)}
|
|
91
|
+
|
|
92
|
+
{mediaItems.length > 1 && (
|
|
93
|
+
<div className="absolute inset-x-0 bottom-0 flex gap-3 overflow-x-auto bg-surface-primary/80 px-6 py-4">
|
|
94
|
+
{mediaItems.map((item, index) => {
|
|
95
|
+
const isActive = index === activeMedia
|
|
96
|
+
return (
|
|
97
|
+
<button
|
|
98
|
+
key={index}
|
|
99
|
+
type="button"
|
|
100
|
+
aria-label={`View image ${index + 1}`}
|
|
101
|
+
aria-pressed={isActive}
|
|
102
|
+
onClick={() => setActiveMedia(index)}
|
|
103
|
+
className={`h-20 aspect-[4/5] overflow-hidden rounded border-2 transition-all ${
|
|
104
|
+
isActive
|
|
105
|
+
? 'border-auto opacity-100'
|
|
106
|
+
: 'border-transparent opacity-40 hover:opacity-100'
|
|
107
|
+
}`}
|
|
108
|
+
>
|
|
109
|
+
<span className="flex size-full items-center justify-center overflow-hidden">
|
|
110
|
+
{item}
|
|
111
|
+
</span>
|
|
112
|
+
</button>
|
|
113
|
+
)
|
|
114
|
+
})}
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
{/* Details column */}
|
|
121
|
+
<div className="h-dvh flex flex-col text-left px-6 pt-20 pb-8 sm:px-10 lg:px-16 lg:pt-24 lg:pb-12 bg-surface-primary overflow-y-auto">
|
|
122
|
+
<div className="mx-auto flex flex-1 w-full max-w-[640px] flex-col gap-6">
|
|
123
|
+
{/* Header */}
|
|
124
|
+
<header className="space-y-4">
|
|
125
|
+
{eyebrow != null && <p className="kol-helper-uc-xs text-accent-primary">{eyebrow}</p>}
|
|
126
|
+
{title != null && <h1 className="kol-heading-md">{title}</h1>}
|
|
127
|
+
{tags.length > 0 && (
|
|
128
|
+
<div className="flex flex-wrap gap-2">
|
|
129
|
+
{tags.map((tag) => (
|
|
130
|
+
<Pill key={tag} variant="subtle" size="sm">
|
|
131
|
+
{tag}
|
|
132
|
+
</Pill>
|
|
133
|
+
))}
|
|
134
|
+
</div>
|
|
135
|
+
)}
|
|
136
|
+
</header>
|
|
137
|
+
|
|
138
|
+
{/* Specs */}
|
|
139
|
+
{specs.length > 0 && <SpecList items={specs} framed />}
|
|
140
|
+
|
|
141
|
+
{/* Tabs */}
|
|
142
|
+
{tabs.length > 0 && (
|
|
143
|
+
<div className="space-y-4">
|
|
144
|
+
<div className="border-b border-auto">
|
|
145
|
+
<TabsRow tabs={tabs.map(({ id, label }) => ({ id, label }))} value={activeTab} onChange={setActiveTab} />
|
|
146
|
+
</div>
|
|
147
|
+
<div role="tabpanel" className="kol-mono-text text-fg-64 leading-relaxed space-y-4">
|
|
148
|
+
{activeTabItem?.content}
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
)}
|
|
152
|
+
|
|
153
|
+
{/* Purchase block */}
|
|
154
|
+
<div className="mt-auto space-y-6 border-t border-auto pt-6">
|
|
155
|
+
{price != null && <PriceDisplay {...price} />}
|
|
156
|
+
|
|
157
|
+
<div className="grid gap-4 sm:grid-cols-2">
|
|
158
|
+
<div className="space-y-2">
|
|
159
|
+
<span className="kol-helper-uc-xs text-fg-48">Size</span>
|
|
160
|
+
<Dropdown
|
|
161
|
+
options={sizeOptions.map((s) => ({ label: s, value: s }))}
|
|
162
|
+
value={size}
|
|
163
|
+
onChange={handleSize}
|
|
164
|
+
className="w-full"
|
|
165
|
+
/>
|
|
166
|
+
</div>
|
|
167
|
+
<div className="space-y-2">
|
|
168
|
+
<span className="kol-helper-uc-xs text-fg-48">Quantity</span>
|
|
169
|
+
<QuantityInput
|
|
170
|
+
value={quantity}
|
|
171
|
+
onChange={handleQuantity}
|
|
172
|
+
min={minQuantity}
|
|
173
|
+
max={maxQuantity}
|
|
174
|
+
className="w-full"
|
|
175
|
+
/>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
{actions}
|
|
180
|
+
|
|
181
|
+
{shippingNote != null && <p className="kol-mono-xs text-fg-48">{shippingNote}</p>}
|
|
182
|
+
{backLink}
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
</section>
|
|
187
|
+
</main>
|
|
188
|
+
)
|
|
189
|
+
}
|