@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.
Files changed (170) hide show
  1. package/dist/{chunk-HWJUWOXK.cjs → chunk-2DGA5LHT.cjs} +19 -19
  2. package/dist/{chunk-HWJUWOXK.cjs.map → chunk-2DGA5LHT.cjs.map} +1 -1
  3. package/dist/{chunk-J2UHW4UM.js → chunk-2TBXIW7L.js} +3 -3
  4. package/dist/{chunk-HNXZFKDQ.cjs → chunk-4BVRLCCA.cjs} +73 -73
  5. package/dist/{chunk-HNXZFKDQ.cjs.map → chunk-4BVRLCCA.cjs.map} +1 -1
  6. package/dist/{chunk-WWHPTP4B.js → chunk-4KWIQEZT.js} +2 -2
  7. package/dist/{chunk-6DGQPKVA.cjs → chunk-7Q2DZEUA.cjs} +32 -32
  8. package/dist/{chunk-6DGQPKVA.cjs.map → chunk-7Q2DZEUA.cjs.map} +1 -1
  9. package/dist/{chunk-SV7IJPIH.js → chunk-B2K5COVE.js} +5458 -5029
  10. package/dist/chunk-B2K5COVE.js.map +1 -0
  11. package/dist/{chunk-AODSKL5I.cjs → chunk-C4DHYDXB.cjs} +12 -12
  12. package/dist/{chunk-AODSKL5I.cjs.map → chunk-C4DHYDXB.cjs.map} +1 -1
  13. package/dist/{chunk-HEH47UTC.cjs → chunk-FZIMFLJG.cjs} +15 -15
  14. package/dist/{chunk-HEH47UTC.cjs.map → chunk-FZIMFLJG.cjs.map} +1 -1
  15. package/dist/{chunk-PQXUY6SB.js → chunk-G42EXM23.js} +2 -2
  16. package/dist/{chunk-TIC3UE6L.js → chunk-GKRJOS6S.js} +3 -3
  17. package/dist/{chunk-NAFW3LKI.cjs → chunk-HMTOQGYU.cjs} +7 -7
  18. package/dist/{chunk-NAFW3LKI.cjs.map → chunk-HMTOQGYU.cjs.map} +1 -1
  19. package/dist/{chunk-743FCO64.cjs → chunk-JWBRDNYH.cjs} +31 -31
  20. package/dist/{chunk-743FCO64.cjs.map → chunk-JWBRDNYH.cjs.map} +1 -1
  21. package/dist/{chunk-VSPNWVIE.cjs → chunk-LBDKAMOH.cjs} +75 -75
  22. package/dist/{chunk-VSPNWVIE.cjs.map → chunk-LBDKAMOH.cjs.map} +1 -1
  23. package/dist/{chunk-LWNPMLIH.cjs → chunk-LPH2JDOW.cjs} +56 -2
  24. package/dist/chunk-LPH2JDOW.cjs.map +1 -0
  25. package/dist/{chunk-OP3H7VB2.js → chunk-MGJZQ3EE.js} +2 -2
  26. package/dist/{chunk-GA3R77TB.js → chunk-MNQVBME4.js} +3 -3
  27. package/dist/{chunk-H4WIS6RJ.js → chunk-MQXUIYYN.js} +4 -4
  28. package/dist/{chunk-2PTEK3D6.js → chunk-N4R7TRF3.js} +2 -2
  29. package/dist/{chunk-LQHMXPOJ.js → chunk-N7YBNHVT.js} +563 -509
  30. package/dist/chunk-N7YBNHVT.js.map +1 -0
  31. package/dist/{chunk-KRMGVMIO.cjs → chunk-PE37UDPF.cjs} +40 -40
  32. package/dist/{chunk-KRMGVMIO.cjs.map → chunk-PE37UDPF.cjs.map} +1 -1
  33. package/dist/{chunk-FAUC7EA3.js → chunk-URODM26E.js} +6 -6
  34. package/dist/{chunk-EKYE2VJU.js → chunk-VDDNJ6XC.js} +5 -5
  35. package/dist/{chunk-Y36KVLBX.cjs → chunk-VVO7AZKZ.cjs} +1413 -984
  36. package/dist/chunk-VVO7AZKZ.cjs.map +1 -0
  37. package/dist/{chunk-2U3YPWHG.cjs → chunk-WBLRUO3F.cjs} +3 -3
  38. package/dist/{chunk-2U3YPWHG.cjs.map → chunk-WBLRUO3F.cjs.map} +1 -1
  39. package/dist/{chunk-RUYZ5QMK.cjs → chunk-WJSUUOPU.cjs} +40 -40
  40. package/dist/{chunk-RUYZ5QMK.cjs.map → chunk-WJSUUOPU.cjs.map} +1 -1
  41. package/dist/{chunk-STEM5CPV.js → chunk-WXOIEYXW.js} +5 -5
  42. package/dist/{chunk-XQILJ7FF.cjs → chunk-XYT5PIKZ.cjs} +5 -5
  43. package/dist/{chunk-XQILJ7FF.cjs.map → chunk-XYT5PIKZ.cjs.map} +1 -1
  44. package/dist/{chunk-NFRHBR7W.js → chunk-Y3FREU4B.js} +7 -7
  45. package/dist/{chunk-CCV6Z3CL.cjs → chunk-YEWGFWNM.cjs} +29 -29
  46. package/dist/{chunk-CCV6Z3CL.cjs.map → chunk-YEWGFWNM.cjs.map} +1 -1
  47. package/dist/{chunk-KQESCGVG.js → chunk-ZSZRJWY3.js} +2 -2
  48. package/dist/components/case-studies/index.cjs +14 -14
  49. package/dist/components/case-studies/index.js +4 -4
  50. package/dist/components/chat/entity-cards/blog-card.d.ts +6 -3
  51. package/dist/components/chat/entity-cards/blog-card.d.ts.map +1 -1
  52. package/dist/components/chat/entity-cards/case-study-card.d.ts +7 -3
  53. package/dist/components/chat/entity-cards/case-study-card.d.ts.map +1 -1
  54. package/dist/components/chat/entity-cards/customer-interview-card.d.ts +6 -3
  55. package/dist/components/chat/entity-cards/customer-interview-card.d.ts.map +1 -1
  56. package/dist/components/chat/entity-cards/entity-portrait-card.d.ts +59 -0
  57. package/dist/components/chat/entity-cards/entity-portrait-card.d.ts.map +1 -0
  58. package/dist/components/chat/entity-cards/index.d.ts +3 -0
  59. package/dist/components/chat/entity-cards/index.d.ts.map +1 -1
  60. package/dist/components/chat/entity-cards/investor-update-card.d.ts +6 -3
  61. package/dist/components/chat/entity-cards/investor-update-card.d.ts.map +1 -1
  62. package/dist/components/chat/entity-cards/onboarding-guide-card.d.ts +4 -2
  63. package/dist/components/chat/entity-cards/onboarding-guide-card.d.ts.map +1 -1
  64. package/dist/components/chat/entity-cards/program-card.d.ts +17 -5
  65. package/dist/components/chat/entity-cards/program-card.d.ts.map +1 -1
  66. package/dist/components/chat/entity-cards/use-cover-image-fallback.d.ts +26 -0
  67. package/dist/components/chat/entity-cards/use-cover-image-fallback.d.ts.map +1 -0
  68. package/dist/components/chat/index.cjs +12 -4
  69. package/dist/components/chat/index.cjs.map +1 -1
  70. package/dist/components/chat/index.js +11 -3
  71. package/dist/components/chat/types/entities/program-types.d.ts +23 -0
  72. package/dist/components/chat/types/entities/program-types.d.ts.map +1 -1
  73. package/dist/components/contact/index.cjs +5 -5
  74. package/dist/components/contact/index.js +4 -4
  75. package/dist/components/docs/index.cjs +7 -7
  76. package/dist/components/docs/index.js +6 -6
  77. package/dist/components/embeds/index.cjs +5 -5
  78. package/dist/components/embeds/index.js +4 -4
  79. package/dist/components/faq/index.cjs +6 -6
  80. package/dist/components/faq/index.js +5 -5
  81. package/dist/components/features/cards-strip.d.ts.map +1 -1
  82. package/dist/components/features/index.cjs +8 -4
  83. package/dist/components/features/index.cjs.map +1 -1
  84. package/dist/components/features/index.d.ts +1 -0
  85. package/dist/components/features/index.d.ts.map +1 -1
  86. package/dist/components/features/index.js +7 -3
  87. package/dist/components/features/video-bites-strip.d.ts.map +1 -1
  88. package/dist/components/features/video-center-badge.d.ts +40 -0
  89. package/dist/components/features/video-center-badge.d.ts.map +1 -0
  90. package/dist/components/features/video.d.ts +7 -4
  91. package/dist/components/features/video.d.ts.map +1 -1
  92. package/dist/components/help-center-pages/index.cjs +23 -23
  93. package/dist/components/help-center-pages/index.js +14 -14
  94. package/dist/components/icons/index.cjs +6 -2
  95. package/dist/components/icons/index.cjs.map +1 -1
  96. package/dist/components/icons/index.d.ts +2 -0
  97. package/dist/components/icons/index.d.ts.map +1 -1
  98. package/dist/components/icons/index.js +5 -1
  99. package/dist/components/icons/play-filled-icon.d.ts +15 -0
  100. package/dist/components/icons/play-filled-icon.d.ts.map +1 -0
  101. package/dist/components/icons/volume-off-filled-icon.d.ts +14 -0
  102. package/dist/components/icons/volume-off-filled-icon.d.ts.map +1 -0
  103. package/dist/components/index.cjs +160 -142
  104. package/dist/components/index.cjs.map +1 -1
  105. package/dist/components/index.js +30 -12
  106. package/dist/components/index.js.map +1 -1
  107. package/dist/components/media-carousel.d.ts.map +1 -1
  108. package/dist/components/navigation/index.cjs +4 -4
  109. package/dist/components/navigation/index.js +3 -3
  110. package/dist/components/onboarding-guides/index.cjs +8 -8
  111. package/dist/components/onboarding-guides/index.js +7 -7
  112. package/dist/components/related-content/index.cjs +6 -6
  113. package/dist/components/related-content/index.js +5 -5
  114. package/dist/components/tickets/index.cjs +7 -7
  115. package/dist/components/tickets/index.js +6 -6
  116. package/dist/components/ui/crossfade-panels.d.ts +52 -0
  117. package/dist/components/ui/crossfade-panels.d.ts.map +1 -0
  118. package/dist/components/ui/index.cjs +14 -4
  119. package/dist/components/ui/index.cjs.map +1 -1
  120. package/dist/components/ui/index.d.ts +1 -0
  121. package/dist/components/ui/index.d.ts.map +1 -1
  122. package/dist/components/ui/index.js +13 -3
  123. package/dist/components/ui/tab-navigation.d.ts +6 -2
  124. package/dist/components/ui/tab-navigation.d.ts.map +1 -1
  125. package/dist/hooks/index.cjs +3 -3
  126. package/dist/hooks/index.js +2 -2
  127. package/dist/index.cjs +22 -4
  128. package/dist/index.cjs.map +1 -1
  129. package/dist/index.js +21 -3
  130. package/package.json +4 -4
  131. package/src/components/chat/entity-cards/blog-card.tsx +40 -7
  132. package/src/components/chat/entity-cards/case-study-card.tsx +32 -6
  133. package/src/components/chat/entity-cards/customer-interview-card.tsx +31 -6
  134. package/src/components/chat/entity-cards/entity-portrait-card.tsx +201 -0
  135. package/src/components/chat/entity-cards/index.ts +18 -0
  136. package/src/components/chat/entity-cards/investor-update-card.tsx +28 -2
  137. package/src/components/chat/entity-cards/onboarding-guide-card.tsx +30 -1
  138. package/src/components/chat/entity-cards/program-card.tsx +69 -17
  139. package/src/components/chat/entity-cards/use-cover-image-fallback.ts +50 -0
  140. package/src/components/chat/types/entities/program-types.ts +34 -0
  141. package/src/components/features/cards-strip.tsx +122 -8
  142. package/src/components/features/index.ts +1 -0
  143. package/src/components/features/video-bites-strip.tsx +77 -15
  144. package/src/components/features/video-center-badge.tsx +77 -0
  145. package/src/components/features/video.tsx +51 -28
  146. package/src/components/icons/index.ts +2 -0
  147. package/src/components/icons/play-filled-icon.tsx +36 -0
  148. package/src/components/icons/volume-off-filled-icon.tsx +38 -0
  149. package/src/components/media-carousel.tsx +2 -4
  150. package/src/components/ui/crossfade-panels.tsx +88 -0
  151. package/src/components/ui/index.ts +1 -0
  152. package/src/components/ui/tab-navigation.tsx +31 -14
  153. package/src/styles/app-globals.css +23 -0
  154. package/dist/chunk-LQHMXPOJ.js.map +0 -1
  155. package/dist/chunk-LWNPMLIH.cjs.map +0 -1
  156. package/dist/chunk-SV7IJPIH.js.map +0 -1
  157. package/dist/chunk-Y36KVLBX.cjs.map +0 -1
  158. /package/dist/{chunk-J2UHW4UM.js.map → chunk-2TBXIW7L.js.map} +0 -0
  159. /package/dist/{chunk-WWHPTP4B.js.map → chunk-4KWIQEZT.js.map} +0 -0
  160. /package/dist/{chunk-PQXUY6SB.js.map → chunk-G42EXM23.js.map} +0 -0
  161. /package/dist/{chunk-TIC3UE6L.js.map → chunk-GKRJOS6S.js.map} +0 -0
  162. /package/dist/{chunk-OP3H7VB2.js.map → chunk-MGJZQ3EE.js.map} +0 -0
  163. /package/dist/{chunk-GA3R77TB.js.map → chunk-MNQVBME4.js.map} +0 -0
  164. /package/dist/{chunk-H4WIS6RJ.js.map → chunk-MQXUIYYN.js.map} +0 -0
  165. /package/dist/{chunk-2PTEK3D6.js.map → chunk-N4R7TRF3.js.map} +0 -0
  166. /package/dist/{chunk-FAUC7EA3.js.map → chunk-URODM26E.js.map} +0 -0
  167. /package/dist/{chunk-EKYE2VJU.js.map → chunk-VDDNJ6XC.js.map} +0 -0
  168. /package/dist/{chunk-STEM5CPV.js.map → chunk-WXOIEYXW.js.map} +0 -0
  169. /package/dist/{chunk-NFRHBR7W.js.map → chunk-Y3FREU4B.js.map} +0 -0
  170. /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
- export function InvestorUpdateCardSkeleton({ size = 'default' }: { size?: 'default' | 'sm' }) {
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. Two densities — `default` (vertical detail) and `sm` (compact
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 type {
38
- BaseProgramItem,
39
- ProgramConfig,
40
- ProgramMedia,
41
- ProgramHost,
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
- if (size === 'sm') {
241
- const itemDate = (() => {
242
- try { return formatUtc(new Date(item.date), 'MMM d, yyyy') } catch { return '' }
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) typeMeta = formatDurationCompact(dur)
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) typeMeta = loc
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
- typeMeta = dur ? `${time} · ${dur}` : time
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
  // ============================================================================