@flamingo-stack/openframe-frontend-core 0.0.396 → 0.0.397-snapshot.20260710044003
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/dist/{chunk-HWJUWOXK.cjs → chunk-2DGA5LHT.cjs} +19 -19
- package/dist/{chunk-HWJUWOXK.cjs.map → chunk-2DGA5LHT.cjs.map} +1 -1
- package/dist/{chunk-J2UHW4UM.js → chunk-2TBXIW7L.js} +3 -3
- package/dist/{chunk-HNXZFKDQ.cjs → chunk-4BVRLCCA.cjs} +73 -73
- package/dist/{chunk-HNXZFKDQ.cjs.map → chunk-4BVRLCCA.cjs.map} +1 -1
- package/dist/{chunk-WWHPTP4B.js → chunk-4KWIQEZT.js} +2 -2
- package/dist/{chunk-6DGQPKVA.cjs → chunk-7Q2DZEUA.cjs} +32 -32
- package/dist/{chunk-6DGQPKVA.cjs.map → chunk-7Q2DZEUA.cjs.map} +1 -1
- package/dist/{chunk-SV7IJPIH.js → chunk-B2K5COVE.js} +5458 -5029
- package/dist/chunk-B2K5COVE.js.map +1 -0
- package/dist/{chunk-AODSKL5I.cjs → chunk-C4DHYDXB.cjs} +12 -12
- package/dist/{chunk-AODSKL5I.cjs.map → chunk-C4DHYDXB.cjs.map} +1 -1
- package/dist/{chunk-HEH47UTC.cjs → chunk-FZIMFLJG.cjs} +15 -15
- package/dist/{chunk-HEH47UTC.cjs.map → chunk-FZIMFLJG.cjs.map} +1 -1
- package/dist/{chunk-PQXUY6SB.js → chunk-G42EXM23.js} +2 -2
- package/dist/{chunk-TIC3UE6L.js → chunk-GKRJOS6S.js} +3 -3
- package/dist/{chunk-NAFW3LKI.cjs → chunk-HMTOQGYU.cjs} +7 -7
- package/dist/{chunk-NAFW3LKI.cjs.map → chunk-HMTOQGYU.cjs.map} +1 -1
- package/dist/{chunk-743FCO64.cjs → chunk-JWBRDNYH.cjs} +31 -31
- package/dist/{chunk-743FCO64.cjs.map → chunk-JWBRDNYH.cjs.map} +1 -1
- package/dist/{chunk-VSPNWVIE.cjs → chunk-LBDKAMOH.cjs} +75 -75
- package/dist/{chunk-VSPNWVIE.cjs.map → chunk-LBDKAMOH.cjs.map} +1 -1
- package/dist/{chunk-LWNPMLIH.cjs → chunk-LPH2JDOW.cjs} +56 -2
- package/dist/chunk-LPH2JDOW.cjs.map +1 -0
- package/dist/{chunk-OP3H7VB2.js → chunk-MGJZQ3EE.js} +2 -2
- package/dist/{chunk-GA3R77TB.js → chunk-MNQVBME4.js} +3 -3
- package/dist/{chunk-H4WIS6RJ.js → chunk-MQXUIYYN.js} +4 -4
- package/dist/{chunk-2PTEK3D6.js → chunk-N4R7TRF3.js} +2 -2
- package/dist/{chunk-LQHMXPOJ.js → chunk-N7YBNHVT.js} +563 -509
- package/dist/chunk-N7YBNHVT.js.map +1 -0
- package/dist/{chunk-KRMGVMIO.cjs → chunk-PE37UDPF.cjs} +40 -40
- package/dist/{chunk-KRMGVMIO.cjs.map → chunk-PE37UDPF.cjs.map} +1 -1
- package/dist/{chunk-FAUC7EA3.js → chunk-URODM26E.js} +6 -6
- package/dist/{chunk-EKYE2VJU.js → chunk-VDDNJ6XC.js} +5 -5
- package/dist/{chunk-Y36KVLBX.cjs → chunk-VVO7AZKZ.cjs} +1413 -984
- package/dist/chunk-VVO7AZKZ.cjs.map +1 -0
- package/dist/{chunk-2U3YPWHG.cjs → chunk-WBLRUO3F.cjs} +3 -3
- package/dist/{chunk-2U3YPWHG.cjs.map → chunk-WBLRUO3F.cjs.map} +1 -1
- package/dist/{chunk-RUYZ5QMK.cjs → chunk-WJSUUOPU.cjs} +40 -40
- package/dist/{chunk-RUYZ5QMK.cjs.map → chunk-WJSUUOPU.cjs.map} +1 -1
- package/dist/{chunk-STEM5CPV.js → chunk-WXOIEYXW.js} +5 -5
- package/dist/{chunk-XQILJ7FF.cjs → chunk-XYT5PIKZ.cjs} +5 -5
- package/dist/{chunk-XQILJ7FF.cjs.map → chunk-XYT5PIKZ.cjs.map} +1 -1
- package/dist/{chunk-NFRHBR7W.js → chunk-Y3FREU4B.js} +7 -7
- package/dist/{chunk-CCV6Z3CL.cjs → chunk-YEWGFWNM.cjs} +29 -29
- package/dist/{chunk-CCV6Z3CL.cjs.map → chunk-YEWGFWNM.cjs.map} +1 -1
- package/dist/{chunk-KQESCGVG.js → chunk-ZSZRJWY3.js} +2 -2
- package/dist/components/case-studies/index.cjs +14 -14
- package/dist/components/case-studies/index.js +4 -4
- package/dist/components/chat/entity-cards/blog-card.d.ts +6 -3
- package/dist/components/chat/entity-cards/blog-card.d.ts.map +1 -1
- package/dist/components/chat/entity-cards/case-study-card.d.ts +7 -3
- package/dist/components/chat/entity-cards/case-study-card.d.ts.map +1 -1
- package/dist/components/chat/entity-cards/customer-interview-card.d.ts +6 -3
- package/dist/components/chat/entity-cards/customer-interview-card.d.ts.map +1 -1
- package/dist/components/chat/entity-cards/entity-portrait-card.d.ts +59 -0
- package/dist/components/chat/entity-cards/entity-portrait-card.d.ts.map +1 -0
- package/dist/components/chat/entity-cards/index.d.ts +3 -0
- package/dist/components/chat/entity-cards/index.d.ts.map +1 -1
- package/dist/components/chat/entity-cards/investor-update-card.d.ts +6 -3
- package/dist/components/chat/entity-cards/investor-update-card.d.ts.map +1 -1
- package/dist/components/chat/entity-cards/onboarding-guide-card.d.ts +4 -2
- package/dist/components/chat/entity-cards/onboarding-guide-card.d.ts.map +1 -1
- package/dist/components/chat/entity-cards/program-card.d.ts +17 -5
- package/dist/components/chat/entity-cards/program-card.d.ts.map +1 -1
- package/dist/components/chat/entity-cards/use-cover-image-fallback.d.ts +26 -0
- package/dist/components/chat/entity-cards/use-cover-image-fallback.d.ts.map +1 -0
- package/dist/components/chat/index.cjs +12 -4
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.js +11 -3
- package/dist/components/chat/types/entities/program-types.d.ts +23 -0
- package/dist/components/chat/types/entities/program-types.d.ts.map +1 -1
- package/dist/components/contact/index.cjs +5 -5
- package/dist/components/contact/index.js +4 -4
- package/dist/components/docs/index.cjs +7 -7
- package/dist/components/docs/index.js +6 -6
- package/dist/components/embeds/index.cjs +5 -5
- package/dist/components/embeds/index.js +4 -4
- package/dist/components/faq/index.cjs +6 -6
- package/dist/components/faq/index.js +5 -5
- package/dist/components/features/cards-strip.d.ts.map +1 -1
- package/dist/components/features/index.cjs +8 -4
- package/dist/components/features/index.cjs.map +1 -1
- package/dist/components/features/index.d.ts +1 -0
- package/dist/components/features/index.d.ts.map +1 -1
- package/dist/components/features/index.js +7 -3
- package/dist/components/features/video-bites-strip.d.ts.map +1 -1
- package/dist/components/features/video-center-badge.d.ts +40 -0
- package/dist/components/features/video-center-badge.d.ts.map +1 -0
- package/dist/components/features/video.d.ts +7 -4
- package/dist/components/features/video.d.ts.map +1 -1
- package/dist/components/help-center-pages/index.cjs +23 -23
- package/dist/components/help-center-pages/index.js +14 -14
- package/dist/components/icons/index.cjs +6 -2
- package/dist/components/icons/index.cjs.map +1 -1
- package/dist/components/icons/index.d.ts +2 -0
- package/dist/components/icons/index.d.ts.map +1 -1
- package/dist/components/icons/index.js +5 -1
- package/dist/components/icons/play-filled-icon.d.ts +15 -0
- package/dist/components/icons/play-filled-icon.d.ts.map +1 -0
- package/dist/components/icons/volume-off-filled-icon.d.ts +14 -0
- package/dist/components/icons/volume-off-filled-icon.d.ts.map +1 -0
- package/dist/components/index.cjs +160 -142
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +30 -12
- package/dist/components/index.js.map +1 -1
- package/dist/components/media-carousel.d.ts.map +1 -1
- package/dist/components/navigation/index.cjs +4 -4
- package/dist/components/navigation/index.js +3 -3
- package/dist/components/onboarding-guides/index.cjs +8 -8
- package/dist/components/onboarding-guides/index.js +7 -7
- package/dist/components/related-content/index.cjs +6 -6
- package/dist/components/related-content/index.js +5 -5
- package/dist/components/tickets/index.cjs +7 -7
- package/dist/components/tickets/index.js +6 -6
- package/dist/components/ui/crossfade-panels.d.ts +52 -0
- package/dist/components/ui/crossfade-panels.d.ts.map +1 -0
- package/dist/components/ui/index.cjs +14 -4
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.d.ts +1 -0
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +13 -3
- package/dist/components/ui/tab-navigation.d.ts +6 -2
- package/dist/components/ui/tab-navigation.d.ts.map +1 -1
- package/dist/hooks/index.cjs +3 -3
- package/dist/hooks/index.js +2 -2
- package/dist/index.cjs +22 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +21 -3
- package/package.json +4 -4
- package/src/components/chat/entity-cards/blog-card.tsx +40 -7
- package/src/components/chat/entity-cards/case-study-card.tsx +32 -6
- package/src/components/chat/entity-cards/customer-interview-card.tsx +31 -6
- package/src/components/chat/entity-cards/entity-portrait-card.tsx +201 -0
- package/src/components/chat/entity-cards/index.ts +18 -0
- package/src/components/chat/entity-cards/investor-update-card.tsx +28 -2
- package/src/components/chat/entity-cards/onboarding-guide-card.tsx +30 -1
- package/src/components/chat/entity-cards/program-card.tsx +69 -17
- package/src/components/chat/entity-cards/use-cover-image-fallback.ts +50 -0
- package/src/components/chat/types/entities/program-types.ts +34 -0
- package/src/components/features/cards-strip.tsx +122 -8
- package/src/components/features/index.ts +1 -0
- package/src/components/features/video-bites-strip.tsx +77 -15
- package/src/components/features/video-center-badge.tsx +77 -0
- package/src/components/features/video.tsx +51 -28
- package/src/components/icons/index.ts +2 -0
- package/src/components/icons/play-filled-icon.tsx +36 -0
- package/src/components/icons/volume-off-filled-icon.tsx +38 -0
- package/src/components/media-carousel.tsx +2 -4
- package/src/components/ui/crossfade-panels.tsx +88 -0
- package/src/components/ui/index.ts +1 -0
- package/src/components/ui/tab-navigation.tsx +31 -14
- package/src/styles/app-globals.css +23 -0
- package/dist/chunk-LQHMXPOJ.js.map +0 -1
- package/dist/chunk-LWNPMLIH.cjs.map +0 -1
- package/dist/chunk-SV7IJPIH.js.map +0 -1
- package/dist/chunk-Y36KVLBX.cjs.map +0 -1
- /package/dist/{chunk-J2UHW4UM.js.map → chunk-2TBXIW7L.js.map} +0 -0
- /package/dist/{chunk-WWHPTP4B.js.map → chunk-4KWIQEZT.js.map} +0 -0
- /package/dist/{chunk-PQXUY6SB.js.map → chunk-G42EXM23.js.map} +0 -0
- /package/dist/{chunk-TIC3UE6L.js.map → chunk-GKRJOS6S.js.map} +0 -0
- /package/dist/{chunk-OP3H7VB2.js.map → chunk-MGJZQ3EE.js.map} +0 -0
- /package/dist/{chunk-GA3R77TB.js.map → chunk-MNQVBME4.js.map} +0 -0
- /package/dist/{chunk-H4WIS6RJ.js.map → chunk-MQXUIYYN.js.map} +0 -0
- /package/dist/{chunk-2PTEK3D6.js.map → chunk-N4R7TRF3.js.map} +0 -0
- /package/dist/{chunk-FAUC7EA3.js.map → chunk-URODM26E.js.map} +0 -0
- /package/dist/{chunk-EKYE2VJU.js.map → chunk-VDDNJ6XC.js.map} +0 -0
- /package/dist/{chunk-STEM5CPV.js.map → chunk-WXOIEYXW.js.map} +0 -0
- /package/dist/{chunk-NFRHBR7W.js.map → chunk-Y3FREU4B.js.map} +0 -0
- /package/dist/{chunk-KQESCGVG.js.map → chunk-ZSZRJWY3.js.map} +0 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* <EntityPortraitCard> — THE portrait (rail/strip) card anatomy, shared by
|
|
5
|
+
* EVERY entity card's `size="portrait"` density (case study, blog post,
|
|
6
|
+
* customer interview, program, investor update, onboarding guide, …).
|
|
7
|
+
*
|
|
8
|
+
* One rail = one anatomy (2026 card-UI practice): identical zone boxes across
|
|
9
|
+
* all content types —
|
|
10
|
+
* media : aspect-[1200/630] slot + content-type chip overlay
|
|
11
|
+
* title : fixed 72px zone, 3-line clamp (typography per entity via
|
|
12
|
+
* `titleClassName` — e.g. Azeret Mono for programs)
|
|
13
|
+
* footer : fixed 60px person zone (author avatar + name + subtitle, with
|
|
14
|
+
* the optional bottom-right icon overlay — the case-study MSP
|
|
15
|
+
* pattern)
|
|
16
|
+
* Card surface, padding (p-6/gap-6) and the hover treatment (accent border +
|
|
17
|
+
* accent shadow) are encoded HERE exactly once, so no per-card drift is
|
|
18
|
+
* possible. Entity cards only MAP their row onto these props.
|
|
19
|
+
*
|
|
20
|
+
* Media rule ("our common way"): the REAL cover always wins. Wide covers
|
|
21
|
+
* (OG-style 1200×630) fill the slot `object-cover`; a non-wide cover (square
|
|
22
|
+
* podcast artwork) renders contained on an `useImageEdgeColor` background —
|
|
23
|
+
* the SAME edge-sampled fill the media/news cards use (OGLinkPreview) — never
|
|
24
|
+
* a flat filler and never the generic placeholder while a real image exists.
|
|
25
|
+
* The cover → placeholder → hide resolution is the shared
|
|
26
|
+
* `useCoverImageFallback` chain (one fallback logic for all entity cards).
|
|
27
|
+
*
|
|
28
|
+
* The content-type chip is the common <StatusBadge> and lives ABOVE the title
|
|
29
|
+
* (an eyebrow inside the title zone), never overlaid on the artwork — branded
|
|
30
|
+
* covers carry their own marks (Flamingo logo top-left) that an overlay would
|
|
31
|
+
* collide with.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import React, { useEffect, useState } from 'react'
|
|
35
|
+
import Image from '../../../embed-shims/next-image'
|
|
36
|
+
import { Card } from '../../ui/card'
|
|
37
|
+
import { StatusBadge } from '../../ui/status-badge'
|
|
38
|
+
import { useImageEdgeColor } from '../../../hooks/ui/use-image-edge-color'
|
|
39
|
+
import { useCoverImageFallback } from './use-cover-image-fallback'
|
|
40
|
+
import { cn } from '../../../utils/cn'
|
|
41
|
+
|
|
42
|
+
/** Sources narrower than this are "not wide" → contained on the edge-color
|
|
43
|
+
* fill. Wide covers are 1200/630 ≈ 1.9; squares are 1.0; 1.3 splits safely. */
|
|
44
|
+
const MIN_WIDE_RATIO = 1.3
|
|
45
|
+
|
|
46
|
+
export interface EntityPortraitPerson {
|
|
47
|
+
name: string
|
|
48
|
+
avatarUrl?: string | null
|
|
49
|
+
subtitle?: string | null
|
|
50
|
+
/** Small round overlay on the avatar's bottom-right corner (e.g. MSP icon). */
|
|
51
|
+
iconOverlayUrl?: string | null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface EntityPortraitCardProps {
|
|
55
|
+
href: string
|
|
56
|
+
target?: '_blank'
|
|
57
|
+
rel?: string
|
|
58
|
+
/** Content-type chip ('Case Study', 'Podcast', …). OMIT to hide the chip —
|
|
59
|
+
* single-type rails don't need per-card type identification; only mixed
|
|
60
|
+
* rails pass it. */
|
|
61
|
+
typeLabel?: string
|
|
62
|
+
imageUrl?: string | null
|
|
63
|
+
/** Branded wide OG fallback — used when `imageUrl` is missing, errors, or
|
|
64
|
+
* isn't wide. */
|
|
65
|
+
placeholderUrl?: string | null
|
|
66
|
+
imageAlt: string
|
|
67
|
+
title: string
|
|
68
|
+
/** Typography override for the title (entity identity), zone box unchanged. */
|
|
69
|
+
titleClassName?: string
|
|
70
|
+
person?: EntityPortraitPerson | null
|
|
71
|
+
className?: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function EntityPortraitCard({
|
|
75
|
+
href,
|
|
76
|
+
target,
|
|
77
|
+
rel,
|
|
78
|
+
typeLabel,
|
|
79
|
+
imageUrl,
|
|
80
|
+
placeholderUrl,
|
|
81
|
+
imageAlt,
|
|
82
|
+
title,
|
|
83
|
+
titleClassName,
|
|
84
|
+
person,
|
|
85
|
+
className,
|
|
86
|
+
}: EntityPortraitCardProps) {
|
|
87
|
+
const { src, onError: onMediaError } = useCoverImageFallback(imageUrl, placeholderUrl)
|
|
88
|
+
// null = unknown (assume wide until the image reports its natural size).
|
|
89
|
+
const [isWide, setIsWide] = useState<boolean | null>(null)
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
// Re-detect whenever the resolved source changes (prop change OR the
|
|
92
|
+
// fallback chain advancing to the placeholder).
|
|
93
|
+
setIsWide(null)
|
|
94
|
+
}, [src])
|
|
95
|
+
|
|
96
|
+
const onMediaLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
|
|
97
|
+
const img = e.currentTarget
|
|
98
|
+
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
|
99
|
+
setIsWide(img.naturalWidth / img.naturalHeight >= MIN_WIDE_RATIO)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Edge-sampled slot fill for non-wide covers — the SAME hook/fallback the
|
|
104
|
+
// media/news cards use (og-link-preview.tsx).
|
|
105
|
+
const edgeColor = useImageEdgeColor(isWide === false ? src : null, 'var(--ods-bg-secondary)')
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<a href={href} target={target} rel={rel} className={cn('block h-full', className)} aria-label={`Open ${title}`}>
|
|
109
|
+
<Card className="bg-ods-card border border-ods-border hover:border-ods-accent hover:shadow-lg hover:shadow-ods-accent/[0.08] transition-all duration-200 p-6 flex flex-col gap-6 overflow-hidden h-full">
|
|
110
|
+
{/* Media zone — the real cover always wins: wide → cover-fill;
|
|
111
|
+
non-wide → contained on the edge-color fill (news-card treatment). */}
|
|
112
|
+
<div
|
|
113
|
+
className="relative w-full aspect-[1200/630] rounded-sm overflow-hidden bg-ods-bg shrink-0 flex items-center justify-center transition-colors duration-300"
|
|
114
|
+
style={isWide === false ? { backgroundColor: edgeColor } : undefined}
|
|
115
|
+
>
|
|
116
|
+
{src && (
|
|
117
|
+
<Image
|
|
118
|
+
src={src}
|
|
119
|
+
alt={imageAlt}
|
|
120
|
+
className={cn('w-full h-full', isWide === false ? 'object-contain' : 'object-cover')}
|
|
121
|
+
sizes="(min-width: 800px) 400px, 100vw"
|
|
122
|
+
fill
|
|
123
|
+
unoptimized
|
|
124
|
+
onLoad={onMediaLoad}
|
|
125
|
+
onError={onMediaError}
|
|
126
|
+
/>
|
|
127
|
+
)}
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
{/* Title zone — fixed box. Mixed rails: common StatusBadge eyebrow +
|
|
131
|
+
2-line title (chip lives here, never on the artwork). Single-type
|
|
132
|
+
rails (no typeLabel): centered 3-line title. */}
|
|
133
|
+
<div className="h-[72px] flex flex-col justify-center gap-1.5 shrink-0">
|
|
134
|
+
{typeLabel && (
|
|
135
|
+
<StatusBadge
|
|
136
|
+
variant="button"
|
|
137
|
+
colorScheme="accentBorder"
|
|
138
|
+
singleLine
|
|
139
|
+
text={typeLabel}
|
|
140
|
+
className="self-start"
|
|
141
|
+
/>
|
|
142
|
+
)}
|
|
143
|
+
<h3
|
|
144
|
+
className={cn(
|
|
145
|
+
'text-h4 leading-6 text-ods-text-primary break-words',
|
|
146
|
+
typeLabel ? 'line-clamp-2' : 'line-clamp-3',
|
|
147
|
+
titleClassName,
|
|
148
|
+
)}
|
|
149
|
+
>
|
|
150
|
+
{title}
|
|
151
|
+
</h3>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
{/* Person/footer zone — fixed box (kept even when empty so every card
|
|
155
|
+
in a rail shares baselines). */}
|
|
156
|
+
<div className="h-[60px] flex items-center shrink-0">
|
|
157
|
+
{person && (
|
|
158
|
+
<div className="flex items-center gap-3 min-w-0 w-full">
|
|
159
|
+
<div className="relative shrink-0 w-12 h-12">
|
|
160
|
+
{person.avatarUrl ? (
|
|
161
|
+
<Image
|
|
162
|
+
src={person.avatarUrl}
|
|
163
|
+
alt={person.name}
|
|
164
|
+
className="w-12 h-12 rounded-full object-cover bg-ods-bg border border-ods-border"
|
|
165
|
+
width={48}
|
|
166
|
+
height={48}
|
|
167
|
+
unoptimized
|
|
168
|
+
/>
|
|
169
|
+
) : (
|
|
170
|
+
<div className="w-12 h-12 rounded-full bg-ods-bg border border-ods-border flex items-center justify-center">
|
|
171
|
+
<span className="text-ods-text-secondary font-medium text-xl">
|
|
172
|
+
{person.name.charAt(0).toUpperCase()}
|
|
173
|
+
</span>
|
|
174
|
+
</div>
|
|
175
|
+
)}
|
|
176
|
+
{person.iconOverlayUrl && (
|
|
177
|
+
<div className="absolute -bottom-1 -right-1 w-6 h-6 rounded-full bg-ods-text-primary ring-1 ring-ods-bg overflow-hidden flex items-center justify-center">
|
|
178
|
+
<Image
|
|
179
|
+
src={person.iconOverlayUrl}
|
|
180
|
+
alt=""
|
|
181
|
+
className="w-full h-full object-cover"
|
|
182
|
+
width={24}
|
|
183
|
+
height={24}
|
|
184
|
+
unoptimized
|
|
185
|
+
/>
|
|
186
|
+
</div>
|
|
187
|
+
)}
|
|
188
|
+
</div>
|
|
189
|
+
<div className="flex flex-col min-w-0">
|
|
190
|
+
<span className="font-['DM_Sans'] font-bold text-ods-text-primary truncate">{person.name}</span>
|
|
191
|
+
{person.subtitle && (
|
|
192
|
+
<span className="font-['DM_Sans'] text-sm text-ods-text-secondary truncate">{person.subtitle}</span>
|
|
193
|
+
)}
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
)}
|
|
197
|
+
</div>
|
|
198
|
+
</Card>
|
|
199
|
+
</a>
|
|
200
|
+
)
|
|
201
|
+
}
|
|
@@ -16,6 +16,16 @@ export {
|
|
|
16
16
|
type EntityAuthorCardProps,
|
|
17
17
|
} from './entity-author-card'
|
|
18
18
|
export { BlogImagePlaceholder } from './blog-image-placeholder'
|
|
19
|
+
export {
|
|
20
|
+
EntityPortraitCard,
|
|
21
|
+
type EntityPortraitCardProps,
|
|
22
|
+
type EntityPortraitPerson,
|
|
23
|
+
} from './entity-portrait-card'
|
|
24
|
+
export {
|
|
25
|
+
useCoverImageFallback,
|
|
26
|
+
hideOnError,
|
|
27
|
+
type CoverImageFallback,
|
|
28
|
+
} from './use-cover-image-fallback'
|
|
19
29
|
export { AdminContentCard } from './admin-content-card'
|
|
20
30
|
export { WhatIShippedCard, WhatIShippedCardSkeleton } from './what-i-shipped-card'
|
|
21
31
|
export type { WhatIShippedCardData, WhatIShippedCardProps } from './what-i-shipped-card'
|
|
@@ -133,3 +143,11 @@ export {
|
|
|
133
143
|
type ChatCardRenderOptions,
|
|
134
144
|
type ChatCardDispatchExtras,
|
|
135
145
|
} from './dispatch'
|
|
146
|
+
|
|
147
|
+
// Program author-ref + video-bites profile adapter — re-exported through the
|
|
148
|
+
// PACKAGE-EXPORTED chat barrel so the hub can value-import them (deep paths
|
|
149
|
+
// into chat/types/* are not in the package exports map and 404 at runtime).
|
|
150
|
+
export {
|
|
151
|
+
programItemToStripProfile,
|
|
152
|
+
type ProgramAuthorRef,
|
|
153
|
+
} from '../types/entities/program-types'
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import React from 'react'
|
|
12
12
|
import { Calendar } from 'lucide-react'
|
|
13
13
|
import { AdminContentCard } from './admin-content-card'
|
|
14
|
+
import { EntityPortraitCard } from './entity-portrait-card'
|
|
14
15
|
import { formatInvestorUpdatePeriod, type InvestorUpdate } from '../types/entities/investor-update'
|
|
15
16
|
import { useEntityCardLink } from './use-entity-card-link'
|
|
16
17
|
import { useEntityCardPlaceholder } from './use-entity-card-placeholder'
|
|
@@ -38,11 +39,14 @@ export interface InvestorUpdateCardProps {
|
|
|
38
39
|
targetPlatform?: string | null
|
|
39
40
|
/** OG placeholder URL used when `update.featured_image` is missing. */
|
|
40
41
|
placeholderUrl?: string | null
|
|
41
|
-
size?: 'default' | 'sm'
|
|
42
|
+
size?: 'default' | 'sm' | 'portrait'
|
|
43
|
+
/** Portrait density: render the content-type chip. Mixed rails only; single-type rails pass false. Default true. */
|
|
44
|
+
showTypeBadge?: boolean
|
|
42
45
|
className?: string
|
|
43
46
|
}
|
|
44
47
|
|
|
45
|
-
|
|
48
|
+
/** `portrait` shares the default skeleton shape (same zone boxes). */
|
|
49
|
+
export function InvestorUpdateCardSkeleton({ size = 'default' }: { size?: 'default' | 'sm' | 'portrait' }) {
|
|
46
50
|
if (size === 'sm') {
|
|
47
51
|
return (
|
|
48
52
|
<span className={COMPACT_CARD_SKELETON_OUTER}>
|
|
@@ -83,6 +87,7 @@ export function InvestorUpdateCard({
|
|
|
83
87
|
targetPlatform,
|
|
84
88
|
placeholderUrl: placeholderUrlProp,
|
|
85
89
|
size = 'default',
|
|
90
|
+
showTypeBadge = true,
|
|
86
91
|
className,
|
|
87
92
|
}: InvestorUpdateCardProps) {
|
|
88
93
|
const { target, rel } = useEntityCardLink({
|
|
@@ -144,6 +149,27 @@ export function InvestorUpdateCard({
|
|
|
144
149
|
)
|
|
145
150
|
}
|
|
146
151
|
|
|
152
|
+
if (size === 'portrait') {
|
|
153
|
+
// Rail/strip density — shared <EntityPortraitCard> shell. The reporting
|
|
154
|
+
// period is the meaningful footer line (no fabricated author — no avatar
|
|
155
|
+
// renders an initial circle).
|
|
156
|
+
const periodText = formatInvestorUpdatePeriod(update.period_start, update.period_end)
|
|
157
|
+
return (
|
|
158
|
+
<EntityPortraitCard
|
|
159
|
+
href={href}
|
|
160
|
+
target={target}
|
|
161
|
+
rel={rel}
|
|
162
|
+
typeLabel={showTypeBadge ? 'Investor Update' : undefined}
|
|
163
|
+
imageUrl={update.featured_image}
|
|
164
|
+
placeholderUrl={placeholderUrl}
|
|
165
|
+
imageAlt={update.title ?? 'Investor update'}
|
|
166
|
+
title={update.title || `Update #${update.update_number ?? '?'}`}
|
|
167
|
+
person={periodText ? { name: periodText } : null}
|
|
168
|
+
className={className}
|
|
169
|
+
/>
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
|
|
147
173
|
return (
|
|
148
174
|
<a href={href} target={target} rel={rel} className={`block h-full ${className ?? ''}`}>
|
|
149
175
|
<AdminContentCard
|
|
@@ -24,6 +24,7 @@ import { Clock, ExternalLink, GraduationCap, Play } from 'lucide-react'
|
|
|
24
24
|
import { cn } from '../../../utils/cn'
|
|
25
25
|
import { BlogImagePlaceholder } from './blog-image-placeholder'
|
|
26
26
|
import { EntityAuthorCard } from './entity-author-card'
|
|
27
|
+
import { EntityPortraitCard } from './entity-portrait-card'
|
|
27
28
|
import { formatDurationMMSS } from '../../../utils/format'
|
|
28
29
|
import type { OnboardingGuide } from '../types/entities/onboarding-guide'
|
|
29
30
|
import { useEntityCardLink } from './use-entity-card-link'
|
|
@@ -52,7 +53,10 @@ export interface OnboardingGuideCardProps {
|
|
|
52
53
|
targetPlatform?: string | null
|
|
53
54
|
/** OG placeholder URL used by the catalog + sm variants when no cover. */
|
|
54
55
|
placeholderUrl?: string | null
|
|
55
|
-
size?: 'catalog' | 'default' | 'sm'
|
|
56
|
+
size?: 'catalog' | 'default' | 'sm' | 'portrait'
|
|
57
|
+
/** Portrait density: render the content-type chip. Mixed rails only; single-type rails pass false. Default true. */
|
|
58
|
+
showTypeBadge?: boolean
|
|
59
|
+
| 'default' | 'sm' | 'portrait'
|
|
56
60
|
className?: string
|
|
57
61
|
}
|
|
58
62
|
|
|
@@ -169,6 +173,7 @@ export function OnboardingGuideCard({
|
|
|
169
173
|
targetPlatform,
|
|
170
174
|
placeholderUrl: placeholderUrlProp,
|
|
171
175
|
size = 'default',
|
|
176
|
+
showTypeBadge = true,
|
|
172
177
|
className,
|
|
173
178
|
}: OnboardingGuideCardProps) {
|
|
174
179
|
const { target, rel } = useEntityCardLink({
|
|
@@ -183,6 +188,30 @@ export function OnboardingGuideCard({
|
|
|
183
188
|
aspect: size === 'sm' ? 'square' : 'wide',
|
|
184
189
|
})
|
|
185
190
|
|
|
191
|
+
if (size === 'portrait') {
|
|
192
|
+
// Rail/strip density — shared <EntityPortraitCard> shell. Same cover
|
|
193
|
+
// chain as the catalog variant.
|
|
194
|
+
const coverImage =
|
|
195
|
+
guide.featured_image ||
|
|
196
|
+
guide.main_video_thumbnail ||
|
|
197
|
+
guide.og_image_url ||
|
|
198
|
+
null
|
|
199
|
+
return (
|
|
200
|
+
<EntityPortraitCard
|
|
201
|
+
href={href}
|
|
202
|
+
target={target}
|
|
203
|
+
rel={rel}
|
|
204
|
+
typeLabel={showTypeBadge ? 'Guide' : undefined}
|
|
205
|
+
imageUrl={coverImage}
|
|
206
|
+
placeholderUrl={placeholderUrl}
|
|
207
|
+
imageAlt={guide.title}
|
|
208
|
+
title={guide.title}
|
|
209
|
+
person={guide.section ? { name: guide.section } : null}
|
|
210
|
+
className={className}
|
|
211
|
+
/>
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
186
215
|
if (size === 'catalog') {
|
|
187
216
|
const coverImage =
|
|
188
217
|
guide.featured_image ||
|
|
@@ -2,8 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* ProgramCard (pure presentation). Generic card for podcasts / webinars /
|
|
5
|
-
* events.
|
|
6
|
-
* horizontal for chat-inline)
|
|
5
|
+
* events. Three densities — `default` (wide horizontal detail, archive
|
|
6
|
+
* pages), `sm` (compact horizontal for chat-inline), and `portrait`
|
|
7
|
+
* (vertical rail/strip card).
|
|
8
|
+
*
|
|
9
|
+
* `portrait` exists because mixed-content rails MUST share ONE card anatomy
|
|
10
|
+
* (2026 card-UI practice: a rail mixes content types, never card layouts —
|
|
11
|
+
* mixing orientations/aspects in one scroller is the anti-pattern). It
|
|
12
|
+
* renders the SAME three zones as the other portrait entity cards
|
|
13
|
+
* (CaseStudyCard et al.): wide media slot → fixed 72px title zone → fixed
|
|
14
|
+
* 60px person/meta footer, p-6 / gap-6. The program's identity lives in the
|
|
15
|
+
* Azeret Mono title and the date · duration meta line — not in a different
|
|
16
|
+
* layout.
|
|
7
17
|
*
|
|
8
18
|
* The card writes NO click logic — callers wrap with their own anchor
|
|
9
19
|
* and pass the resolved detail URL via `href`.
|
|
@@ -34,16 +44,19 @@ import {
|
|
|
34
44
|
COMPACT_CARD_TITLE,
|
|
35
45
|
COMPACT_CARD_TITLE_ROW,
|
|
36
46
|
} from '../utils/compact-card-classes'
|
|
37
|
-
import
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
47
|
+
import {
|
|
48
|
+
programItemToStripProfile,
|
|
49
|
+
type BaseProgramItem,
|
|
50
|
+
type ProgramAuthorRef,
|
|
51
|
+
type ProgramConfig,
|
|
52
|
+
type ProgramMedia,
|
|
53
|
+
type ProgramHost,
|
|
42
54
|
} from '../types/entities/program-types'
|
|
55
|
+
import { EntityPortraitCard } from './entity-portrait-card'
|
|
43
56
|
import { useEntityCardLink } from './use-entity-card-link'
|
|
44
57
|
import { useEntityCardPlaceholder } from './use-entity-card-placeholder'
|
|
45
58
|
|
|
46
|
-
type CardSize = 'default' | 'sm'
|
|
59
|
+
type CardSize = 'default' | 'sm' | 'portrait'
|
|
47
60
|
|
|
48
61
|
/**
|
|
49
62
|
* Format a Date with date-fns pinned to UTC. `date-fns` `format()` reads the
|
|
@@ -115,6 +128,8 @@ export interface ProgramCardProps<T extends BaseProgramItem> {
|
|
|
115
128
|
media?: ProgramMedia[]
|
|
116
129
|
renderMeta?: (item: T) => React.ReactNode
|
|
117
130
|
size?: CardSize
|
|
131
|
+
/** Portrait density: render the content-type chip. Mixed rails only; single-type rails pass false. Default true. */
|
|
132
|
+
showTypeBadge?: boolean
|
|
118
133
|
/** Detail URL resolved by the caller. */
|
|
119
134
|
href: string
|
|
120
135
|
/** When `_blank`, opens in a new tab. Set by chat dispatch via
|
|
@@ -212,6 +227,7 @@ export function ProgramCard<T extends BaseProgramItem>({
|
|
|
212
227
|
media = [],
|
|
213
228
|
renderMeta,
|
|
214
229
|
size = 'default',
|
|
230
|
+
showTypeBadge = true,
|
|
215
231
|
href,
|
|
216
232
|
target: targetProp,
|
|
217
233
|
rel: relProp,
|
|
@@ -237,24 +253,60 @@ export function ProgramCard<T extends BaseProgramItem>({
|
|
|
237
253
|
const accentColor = 'var(--color-accent-primary)'
|
|
238
254
|
const isScheduled = 'status' in item && (item as any).status === 'scheduled'
|
|
239
255
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
})()
|
|
244
|
-
const compactCover = coverImage || placeholderUrl || null
|
|
245
|
-
let typeMeta: string | null = null
|
|
256
|
+
// Compact per-type meta (duration / location / start time) — shared by the
|
|
257
|
+
// `sm` and `portrait` densities.
|
|
258
|
+
const compactTypeMeta = (): string | null => {
|
|
246
259
|
if (config.type === 'podcast' && 'duration_seconds' in item && !isScheduled) {
|
|
247
260
|
const dur = (item as any).duration_seconds
|
|
248
|
-
if (typeof dur === 'number' && dur > 0)
|
|
261
|
+
if (typeof dur === 'number' && dur > 0) return formatDurationCompact(dur)
|
|
249
262
|
} else if (config.type === 'event' && 'location_name' in item) {
|
|
250
263
|
const loc = (item as any).location_name
|
|
251
|
-
if (typeof loc === 'string' && loc.trim().length > 0)
|
|
264
|
+
if (typeof loc === 'string' && loc.trim().length > 0) return loc
|
|
252
265
|
} else if (config.type === 'webinar' && 'start_at' in item) {
|
|
253
266
|
const w = item as any
|
|
254
267
|
const time = formatTimeWithTimezone(w.start_at, w.timezone ?? null)
|
|
255
268
|
const dur = formatDurationFromRange(w.start_at, w.end_at)
|
|
256
|
-
|
|
269
|
+
return dur ? `${time} · ${dur}` : time
|
|
257
270
|
}
|
|
271
|
+
return null
|
|
272
|
+
}
|
|
273
|
+
const compactDate = (() => {
|
|
274
|
+
try { return formatUtc(new Date(item.date), 'MMM d, yyyy') } catch { return '' }
|
|
275
|
+
})()
|
|
276
|
+
|
|
277
|
+
if (size === 'portrait') {
|
|
278
|
+
// Rail/strip density — mapped onto the shared <EntityPortraitCard> shell
|
|
279
|
+
// (media aspect-[1200/630] → h-[72px] title → h-[60px] footer, p-6/gap-6).
|
|
280
|
+
// Person = author-first via programItemToStripProfile (author → primary
|
|
281
|
+
// host); the date · duration meta line fills the subtitle when the
|
|
282
|
+
// profile has no job title.
|
|
283
|
+
const profile = programItemToStripProfile(item as { author?: ProgramAuthorRef | null; hosts?: ProgramHost[] | null })
|
|
284
|
+
const dateMeta = [compactDate, compactTypeMeta()].filter(Boolean).join(' · ')
|
|
285
|
+
return (
|
|
286
|
+
<EntityPortraitCard
|
|
287
|
+
href={href}
|
|
288
|
+
target={target}
|
|
289
|
+
rel={rel}
|
|
290
|
+
typeLabel={showTypeBadge ? config.labels.singular : undefined}
|
|
291
|
+
imageUrl={coverImage}
|
|
292
|
+
placeholderUrl={placeholderUrl}
|
|
293
|
+
imageAlt={item.title}
|
|
294
|
+
title={item.title}
|
|
295
|
+
titleClassName="font-['Azeret_Mono'] font-semibold text-lg leading-6"
|
|
296
|
+
person={
|
|
297
|
+
profile
|
|
298
|
+
? { name: profile.name, avatarUrl: profile.avatarUrl, subtitle: profile.subtitle || dateMeta }
|
|
299
|
+
: { name: config.labels.singular, subtitle: dateMeta }
|
|
300
|
+
}
|
|
301
|
+
className={className}
|
|
302
|
+
/>
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (size === 'sm') {
|
|
307
|
+
const itemDate = compactDate
|
|
308
|
+
const compactCover = coverImage || placeholderUrl || null
|
|
309
|
+
const typeMeta = compactTypeMeta()
|
|
258
310
|
const subtitleParts = [itemDate, typeMeta, config.labels?.singular].filter(
|
|
259
311
|
(s): s is string => typeof s === 'string' && s.length > 0,
|
|
260
312
|
)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* THE common cover-image fallback chain for entity cards:
|
|
5
|
+
* real cover → branded placeholder → nothing (hide the media element).
|
|
6
|
+
*
|
|
7
|
+
* One hook so no card hand-rolls its own `imageError` state / `displayImage`
|
|
8
|
+
* ternary (they drift: the pre-hook BlogCard chain couldn't recover from a
|
|
9
|
+
* FAILING placeholder, EntityPortraitCard duplicated the same logic with
|
|
10
|
+
* different state). Cards wire `src` + `onError` and render their own
|
|
11
|
+
* empty-state when `src` is null.
|
|
12
|
+
*
|
|
13
|
+
* Implementation note: state is the SET of candidate URLs that failed, and
|
|
14
|
+
* `src` is derived — so a prop change (new cover URL) re-resolves instantly
|
|
15
|
+
* with no reset effect, and a URL that failed once isn't retried in a loop.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { useCallback, useState, type SyntheticEvent } from 'react'
|
|
19
|
+
|
|
20
|
+
export interface CoverImageFallback {
|
|
21
|
+
/** Resolved source: first non-failed of [imageUrl, placeholderUrl], else null. */
|
|
22
|
+
src: string | null
|
|
23
|
+
/** Wire to the media element's `onError`. */
|
|
24
|
+
onError: () => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function useCoverImageFallback(
|
|
28
|
+
imageUrl?: string | null,
|
|
29
|
+
placeholderUrl?: string | null,
|
|
30
|
+
): CoverImageFallback {
|
|
31
|
+
const [failed, setFailed] = useState<ReadonlySet<string>>(() => new Set())
|
|
32
|
+
|
|
33
|
+
const src =
|
|
34
|
+
imageUrl && !failed.has(imageUrl) ? imageUrl
|
|
35
|
+
: placeholderUrl && !failed.has(placeholderUrl) ? placeholderUrl
|
|
36
|
+
: null
|
|
37
|
+
|
|
38
|
+
const onError = useCallback(() => {
|
|
39
|
+
if (!src) return
|
|
40
|
+
setFailed(prev => new Set(prev).add(src))
|
|
41
|
+
}, [src])
|
|
42
|
+
|
|
43
|
+
return { src, onError }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Fallback for cards with NO placeholder chain (compact/sm image slots):
|
|
47
|
+
* a broken cover simply disappears, leaving the slot's background. */
|
|
48
|
+
export const hideOnError = (e: SyntheticEvent<HTMLImageElement>) => {
|
|
49
|
+
;(e.currentTarget as HTMLImageElement).style.display = 'none'
|
|
50
|
+
}
|
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import type { ReactNode } from 'react';
|
|
20
|
+
import {
|
|
21
|
+
toStripProfile,
|
|
22
|
+
type VideoBiteStripProfile,
|
|
23
|
+
} from '../../../features/video-bites-shared';
|
|
20
24
|
|
|
21
25
|
// ============================================================================
|
|
22
26
|
// HOST TYPES
|
|
@@ -34,6 +38,36 @@ export interface ProgramHost {
|
|
|
34
38
|
role?: string; // e.g., "Host", "Speaker", "Guest"
|
|
35
39
|
}
|
|
36
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Client-safe author subset hydrated onto program items from the entity's
|
|
43
|
+
* `author_id → profiles` FK join (podcast episodes / webinars). Deliberately
|
|
44
|
+
* excludes `email` — this shape is serialized into public payloads.
|
|
45
|
+
*/
|
|
46
|
+
export interface ProgramAuthorRef {
|
|
47
|
+
id?: string | null;
|
|
48
|
+
full_name?: string | null;
|
|
49
|
+
avatar_url?: string | null;
|
|
50
|
+
job_title?: string | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Video-bites overlay profile for a program item: internal author first
|
|
55
|
+
* (`author_id → profiles`), then the provider-synced primary host (Livestorm
|
|
56
|
+
* speakers / Podbean hosts). Host `role` is a raw provider enum (e.g.
|
|
57
|
+
* `team_member`) — never display copy, so it is not used as the subtitle.
|
|
58
|
+
* Routes through `toStripProfile` (the SSoT — null on missing names).
|
|
59
|
+
*/
|
|
60
|
+
export function programItemToStripProfile(item: {
|
|
61
|
+
author?: ProgramAuthorRef | null;
|
|
62
|
+
hosts?: ProgramHost[] | null;
|
|
63
|
+
}): VideoBiteStripProfile | null {
|
|
64
|
+
const fromAuthor = toStripProfile(item.author);
|
|
65
|
+
if (fromAuthor) return fromAuthor;
|
|
66
|
+
const host = Array.isArray(item.hosts) ? item.hosts[0] : null;
|
|
67
|
+
if (!host?.name) return null;
|
|
68
|
+
return toStripProfile({ full_name: host.name, avatar_url: host.avatar_url ?? null });
|
|
69
|
+
}
|
|
70
|
+
|
|
37
71
|
// ============================================================================
|
|
38
72
|
// DATABASE ROW TYPES
|
|
39
73
|
// ============================================================================
|