@flamingo-stack/openframe-frontend-core 0.0.396 → 0.0.397-snapshot.20260710042522

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 (142) hide show
  1. package/dist/{chunk-EKYE2VJU.js → chunk-3MMYFUWX.js} +4 -4
  2. package/dist/{chunk-HWJUWOXK.cjs → chunk-5IZDYGPU.cjs} +12 -12
  3. package/dist/{chunk-HWJUWOXK.cjs.map → chunk-5IZDYGPU.cjs.map} +1 -1
  4. package/dist/{chunk-OP3H7VB2.js → chunk-5SGIFYSA.js} +2 -2
  5. package/dist/{chunk-AODSKL5I.cjs → chunk-BIQH2W35.cjs} +9 -9
  6. package/dist/{chunk-AODSKL5I.cjs.map → chunk-BIQH2W35.cjs.map} +1 -1
  7. package/dist/{chunk-H4WIS6RJ.js → chunk-D3EAVXAQ.js} +2 -2
  8. package/dist/{chunk-XQILJ7FF.cjs → chunk-I4D6KJLI.cjs} +5 -5
  9. package/dist/{chunk-XQILJ7FF.cjs.map → chunk-I4D6KJLI.cjs.map} +1 -1
  10. package/dist/{chunk-TIC3UE6L.js → chunk-JAO2PKOU.js} +2 -2
  11. package/dist/{chunk-2PTEK3D6.js → chunk-KOIH637Y.js} +2 -2
  12. package/dist/{chunk-GA3R77TB.js → chunk-LHICE5MK.js} +2 -2
  13. package/dist/{chunk-CCV6Z3CL.cjs → chunk-MBWSBKSG.cjs} +26 -26
  14. package/dist/{chunk-CCV6Z3CL.cjs.map → chunk-MBWSBKSG.cjs.map} +1 -1
  15. package/dist/{chunk-PQXUY6SB.js → chunk-MH5U4ZMV.js} +2 -2
  16. package/dist/{chunk-SV7IJPIH.js → chunk-NLYOIYUB.js} +5456 -5015
  17. package/dist/chunk-NLYOIYUB.js.map +1 -0
  18. package/dist/{chunk-STEM5CPV.js → chunk-PKRHMZBN.js} +4 -4
  19. package/dist/{chunk-Y36KVLBX.cjs → chunk-PZIZOFPY.cjs} +1260 -819
  20. package/dist/chunk-PZIZOFPY.cjs.map +1 -0
  21. package/dist/{chunk-J2UHW4UM.js → chunk-QCUHIPG3.js} +2 -2
  22. package/dist/{chunk-KRMGVMIO.cjs → chunk-QOMEGWOM.cjs} +31 -31
  23. package/dist/{chunk-KRMGVMIO.cjs.map → chunk-QOMEGWOM.cjs.map} +1 -1
  24. package/dist/{chunk-HNXZFKDQ.cjs → chunk-THE2AFUN.cjs} +62 -62
  25. package/dist/{chunk-HNXZFKDQ.cjs.map → chunk-THE2AFUN.cjs.map} +1 -1
  26. package/dist/{chunk-NFRHBR7W.js → chunk-WR74UUVM.js} +4 -4
  27. package/dist/{chunk-RUYZ5QMK.cjs → chunk-WRS7OVIT.cjs} +37 -37
  28. package/dist/{chunk-RUYZ5QMK.cjs.map → chunk-WRS7OVIT.cjs.map} +1 -1
  29. package/dist/{chunk-FAUC7EA3.js → chunk-WX3MHQRH.js} +4 -4
  30. package/dist/{chunk-6DGQPKVA.cjs → chunk-XFOSQQKD.cjs} +26 -26
  31. package/dist/{chunk-6DGQPKVA.cjs.map → chunk-XFOSQQKD.cjs.map} +1 -1
  32. package/dist/{chunk-HEH47UTC.cjs → chunk-XQ35TKEM.cjs} +15 -15
  33. package/dist/{chunk-HEH47UTC.cjs.map → chunk-XQ35TKEM.cjs.map} +1 -1
  34. package/dist/{chunk-NAFW3LKI.cjs → chunk-Y2QAEMGF.cjs} +7 -7
  35. package/dist/{chunk-NAFW3LKI.cjs.map → chunk-Y2QAEMGF.cjs.map} +1 -1
  36. package/dist/{chunk-VSPNWVIE.cjs → chunk-YSI6FXZH.cjs} +59 -59
  37. package/dist/{chunk-VSPNWVIE.cjs.map → chunk-YSI6FXZH.cjs.map} +1 -1
  38. package/dist/components/case-studies/index.cjs +8 -8
  39. package/dist/components/case-studies/index.js +2 -2
  40. package/dist/components/chat/entity-cards/blog-card.d.ts +6 -3
  41. package/dist/components/chat/entity-cards/blog-card.d.ts.map +1 -1
  42. package/dist/components/chat/entity-cards/case-study-card.d.ts +7 -3
  43. package/dist/components/chat/entity-cards/case-study-card.d.ts.map +1 -1
  44. package/dist/components/chat/entity-cards/customer-interview-card.d.ts +6 -3
  45. package/dist/components/chat/entity-cards/customer-interview-card.d.ts.map +1 -1
  46. package/dist/components/chat/entity-cards/entity-portrait-card.d.ts +59 -0
  47. package/dist/components/chat/entity-cards/entity-portrait-card.d.ts.map +1 -0
  48. package/dist/components/chat/entity-cards/index.d.ts +3 -0
  49. package/dist/components/chat/entity-cards/index.d.ts.map +1 -1
  50. package/dist/components/chat/entity-cards/investor-update-card.d.ts +6 -3
  51. package/dist/components/chat/entity-cards/investor-update-card.d.ts.map +1 -1
  52. package/dist/components/chat/entity-cards/onboarding-guide-card.d.ts +4 -2
  53. package/dist/components/chat/entity-cards/onboarding-guide-card.d.ts.map +1 -1
  54. package/dist/components/chat/entity-cards/program-card.d.ts +17 -5
  55. package/dist/components/chat/entity-cards/program-card.d.ts.map +1 -1
  56. package/dist/components/chat/entity-cards/use-cover-image-fallback.d.ts +26 -0
  57. package/dist/components/chat/entity-cards/use-cover-image-fallback.d.ts.map +1 -0
  58. package/dist/components/chat/index.cjs +10 -2
  59. package/dist/components/chat/index.cjs.map +1 -1
  60. package/dist/components/chat/index.js +9 -1
  61. package/dist/components/chat/types/entities/program-types.d.ts +23 -0
  62. package/dist/components/chat/types/entities/program-types.d.ts.map +1 -1
  63. package/dist/components/contact/index.cjs +3 -3
  64. package/dist/components/contact/index.js +2 -2
  65. package/dist/components/docs/index.cjs +5 -5
  66. package/dist/components/docs/index.js +4 -4
  67. package/dist/components/embeds/index.cjs +3 -3
  68. package/dist/components/embeds/index.js +2 -2
  69. package/dist/components/faq/index.cjs +3 -3
  70. package/dist/components/faq/index.js +2 -2
  71. package/dist/components/features/cards-strip.d.ts.map +1 -1
  72. package/dist/components/features/index.cjs +6 -2
  73. package/dist/components/features/index.cjs.map +1 -1
  74. package/dist/components/features/index.d.ts +1 -0
  75. package/dist/components/features/index.d.ts.map +1 -1
  76. package/dist/components/features/index.js +5 -1
  77. package/dist/components/features/video-bites-strip.d.ts.map +1 -1
  78. package/dist/components/features/video-center-badge.d.ts +36 -0
  79. package/dist/components/features/video-center-badge.d.ts.map +1 -0
  80. package/dist/components/features/video.d.ts +7 -0
  81. package/dist/components/features/video.d.ts.map +1 -1
  82. package/dist/components/help-center-pages/index.cjs +20 -20
  83. package/dist/components/help-center-pages/index.js +11 -11
  84. package/dist/components/index.cjs +134 -120
  85. package/dist/components/index.cjs.map +1 -1
  86. package/dist/components/index.js +23 -9
  87. package/dist/components/index.js.map +1 -1
  88. package/dist/components/media-carousel.d.ts.map +1 -1
  89. package/dist/components/navigation/index.cjs +2 -2
  90. package/dist/components/navigation/index.js +1 -1
  91. package/dist/components/onboarding-guides/index.cjs +5 -5
  92. package/dist/components/onboarding-guides/index.js +4 -4
  93. package/dist/components/related-content/index.cjs +3 -3
  94. package/dist/components/related-content/index.js +2 -2
  95. package/dist/components/tickets/index.cjs +5 -5
  96. package/dist/components/tickets/index.js +4 -4
  97. package/dist/components/ui/crossfade-panels.d.ts +52 -0
  98. package/dist/components/ui/crossfade-panels.d.ts.map +1 -0
  99. package/dist/components/ui/index.cjs +12 -2
  100. package/dist/components/ui/index.cjs.map +1 -1
  101. package/dist/components/ui/index.d.ts +1 -0
  102. package/dist/components/ui/index.d.ts.map +1 -1
  103. package/dist/components/ui/index.js +11 -1
  104. package/dist/components/ui/tab-navigation.d.ts +6 -2
  105. package/dist/components/ui/tab-navigation.d.ts.map +1 -1
  106. package/dist/index.cjs +16 -2
  107. package/dist/index.cjs.map +1 -1
  108. package/dist/index.js +15 -1
  109. package/package.json +4 -4
  110. package/src/components/chat/entity-cards/blog-card.tsx +40 -7
  111. package/src/components/chat/entity-cards/case-study-card.tsx +32 -6
  112. package/src/components/chat/entity-cards/customer-interview-card.tsx +31 -6
  113. package/src/components/chat/entity-cards/entity-portrait-card.tsx +201 -0
  114. package/src/components/chat/entity-cards/index.ts +18 -0
  115. package/src/components/chat/entity-cards/investor-update-card.tsx +28 -2
  116. package/src/components/chat/entity-cards/onboarding-guide-card.tsx +30 -1
  117. package/src/components/chat/entity-cards/program-card.tsx +69 -17
  118. package/src/components/chat/entity-cards/use-cover-image-fallback.ts +50 -0
  119. package/src/components/chat/types/entities/program-types.ts +34 -0
  120. package/src/components/features/cards-strip.tsx +122 -8
  121. package/src/components/features/index.ts +1 -0
  122. package/src/components/features/video-bites-strip.tsx +77 -15
  123. package/src/components/features/video-center-badge.tsx +76 -0
  124. package/src/components/features/video.tsx +51 -7
  125. package/src/components/media-carousel.tsx +2 -4
  126. package/src/components/ui/crossfade-panels.tsx +88 -0
  127. package/src/components/ui/index.ts +1 -0
  128. package/src/components/ui/tab-navigation.tsx +31 -14
  129. package/src/styles/app-globals.css +23 -0
  130. package/dist/chunk-SV7IJPIH.js.map +0 -1
  131. package/dist/chunk-Y36KVLBX.cjs.map +0 -1
  132. /package/dist/{chunk-EKYE2VJU.js.map → chunk-3MMYFUWX.js.map} +0 -0
  133. /package/dist/{chunk-OP3H7VB2.js.map → chunk-5SGIFYSA.js.map} +0 -0
  134. /package/dist/{chunk-H4WIS6RJ.js.map → chunk-D3EAVXAQ.js.map} +0 -0
  135. /package/dist/{chunk-TIC3UE6L.js.map → chunk-JAO2PKOU.js.map} +0 -0
  136. /package/dist/{chunk-2PTEK3D6.js.map → chunk-KOIH637Y.js.map} +0 -0
  137. /package/dist/{chunk-GA3R77TB.js.map → chunk-LHICE5MK.js.map} +0 -0
  138. /package/dist/{chunk-PQXUY6SB.js.map → chunk-MH5U4ZMV.js.map} +0 -0
  139. /package/dist/{chunk-STEM5CPV.js.map → chunk-PKRHMZBN.js.map} +0 -0
  140. /package/dist/{chunk-J2UHW4UM.js.map → chunk-QCUHIPG3.js.map} +0 -0
  141. /package/dist/{chunk-NFRHBR7W.js.map → chunk-WR74UUVM.js.map} +0 -0
  142. /package/dist/{chunk-FAUC7EA3.js.map → chunk-WX3MHQRH.js.map} +0 -0
@@ -18,14 +18,16 @@
18
18
  * `bg-ods-bg` (via the slot's background).
19
19
  */
20
20
 
21
- import React, { useState } from 'react'
21
+ import React from 'react'
22
22
  import { Eye } from 'lucide-react'
23
23
  import Image from '../../../embed-shims/next-image'
24
24
  import { StatusBadge } from '../../ui/status-badge'
25
25
  import { cn } from '../../../utils/cn'
26
26
  import type { BlogPostSummary } from '../../../types/blog'
27
+ import { EntityPortraitCard } from './entity-portrait-card'
27
28
  import { useEntityCardLink } from './use-entity-card-link'
28
29
  import { useEntityCardPlaceholder } from './use-entity-card-placeholder'
30
+ import { useCoverImageFallback } from './use-cover-image-fallback'
29
31
  import {
30
32
  COMPACT_CARD_IMAGE_SLOT,
31
33
  COMPACT_CARD_META_ROW_BOX,
@@ -55,7 +57,9 @@ export interface BlogCardProps {
55
57
  /** Placeholder URL when `post.featured_image` is missing. Caller
56
58
  * resolves via `useOgPlaceholderUrl` (hub) or a static asset. */
57
59
  placeholderUrl?: string | null
58
- size?: 'default' | 'sm'
60
+ size?: 'default' | 'sm' | 'portrait'
61
+ /** Portrait density: render the content-type chip. Mixed rails only; single-type rails pass false. Default true. */
62
+ showTypeBadge?: boolean
59
63
  className?: string
60
64
  /** Surfaces a "Video" badge in compact mode. */
61
65
  hasEmbeddedVideo?: boolean
@@ -65,7 +69,8 @@ export interface BlogCardProps {
65
69
  priority?: boolean
66
70
  }
67
71
 
68
- export function BlogCardSkeleton({ size = 'default' }: { size?: 'default' | 'sm' }) {
72
+ /** `portrait` shares the default skeleton shape (same zone boxes). */
73
+ export function BlogCardSkeleton({ size = 'default' }: { size?: 'default' | 'sm' | 'portrait' }) {
69
74
  if (size === 'sm') {
70
75
  return (
71
76
  <span className={COMPACT_CARD_SKELETON_OUTER}>
@@ -109,6 +114,7 @@ export function BlogCard({
109
114
  targetPlatform,
110
115
  placeholderUrl: placeholderUrlProp,
111
116
  size = 'default',
117
+ showTypeBadge = true,
112
118
  className,
113
119
  hasEmbeddedVideo = false,
114
120
  priority = false,
@@ -124,8 +130,8 @@ export function BlogCard({
124
130
  placeholderUrl: placeholderUrlProp,
125
131
  aspect: size === 'sm' ? 'square' : 'wide',
126
132
  })
127
- const [imageError, setImageError] = useState(false)
128
- const displayImage = (post.featured_image && !imageError) ? post.featured_image : placeholderUrl
133
+ // Shared cover placeholder → hide chain (ONE fallback logic for all cards).
134
+ const { src: displayImage, onError: onImageError } = useCoverImageFallback(post.featured_image, placeholderUrl)
129
135
 
130
136
  if (size === 'sm') {
131
137
  const dateStr = post.published_at
@@ -149,7 +155,7 @@ export function BlogCard({
149
155
  sizes="56px"
150
156
  className="object-contain"
151
157
  unoptimized
152
- onError={() => setImageError(true)}
158
+ onError={onImageError}
153
159
  />
154
160
  ) : null}
155
161
  </span>
@@ -177,6 +183,33 @@ export function BlogCard({
177
183
  )
178
184
  }
179
185
 
186
+ if (size === 'portrait') {
187
+ // Rail/strip density — shared <EntityPortraitCard> shell. Raw cover +
188
+ // placeholder go in separately; the shell runs the SAME shared
189
+ // useCoverImageFallback chain internally (so its error recovery works).
190
+ const dateStr = post.published_at
191
+ ? new Date(post.published_at).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' })
192
+ : ''
193
+ return (
194
+ <EntityPortraitCard
195
+ href={href}
196
+ target={target}
197
+ rel={rel}
198
+ typeLabel={showTypeBadge ? 'Blog Post' : undefined}
199
+ imageUrl={post.featured_image}
200
+ placeholderUrl={placeholderUrl}
201
+ imageAlt={post.title}
202
+ title={post.title}
203
+ person={{
204
+ name: post.author_name || 'Anonymous',
205
+ avatarUrl: post.author_avatar,
206
+ subtitle: dateStr || null,
207
+ }}
208
+ className={className}
209
+ />
210
+ )
211
+ }
212
+
180
213
  // Default: full vertical card.
181
214
  const dateStr = post.published_at
182
215
  ? new Date(post.published_at).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' })
@@ -215,7 +248,7 @@ export function BlogCard({
215
248
  )}
216
249
  sizes="(max-width: 768px) 100vw, (max-width: 1519px) 50vw, 33vw"
217
250
  unoptimized
218
- onError={() => setImageError(true)}
251
+ onError={onImageError}
219
252
  />
220
253
  ) : null}
221
254
  </div>
@@ -17,6 +17,7 @@ import Image from '../../../embed-shims/next-image'
17
17
  import { Card } from '../../ui/card'
18
18
  import { cn } from '../../../utils/cn'
19
19
  import type { CaseStudy } from '../../../types/case-study'
20
+ import { EntityPortraitCard } from './entity-portrait-card'
20
21
  import { useEntityCardLink } from './use-entity-card-link'
21
22
  import { useEntityCardPlaceholder } from './use-entity-card-placeholder'
22
23
  import {
@@ -32,10 +33,7 @@ import {
32
33
  COMPACT_CARD_TITLE,
33
34
  COMPACT_CARD_TITLE_ROW,
34
35
  } from '../utils/compact-card-classes'
35
-
36
- const hideOnError = (e: React.SyntheticEvent<HTMLImageElement>) => {
37
- ;(e.currentTarget as HTMLImageElement).style.display = 'none'
38
- }
36
+ import { hideOnError } from './use-cover-image-fallback'
39
37
 
40
38
  export interface CaseStudyCardProps {
41
39
  study: CaseStudy
@@ -48,11 +46,15 @@ export interface CaseStudyCardProps {
48
46
  targetPlatform?: string | null
49
47
  /** OG placeholder URL, used when `study.featured_image` is missing. */
50
48
  placeholderUrl?: string | null
51
- size?: 'default' | 'sm'
49
+ size?: 'default' | 'sm' | 'portrait'
50
+ /** Portrait density: render the content-type chip. Mixed rails only; single-type rails pass false. Default true. */
51
+ showTypeBadge?: boolean
52
52
  className?: string
53
53
  }
54
54
 
55
- export function CaseStudyCardSkeleton({ size = 'default' }: { size?: 'default' | 'sm' }) {
55
+ /** `portrait` shares the default skeleton shape the portrait anatomy uses the
56
+ * same zone boxes (media aspect → 72px title → 60px person footer). */
57
+ export function CaseStudyCardSkeleton({ size = 'default' }: { size?: 'default' | 'sm' | 'portrait' }) {
56
58
  if (size === 'sm') {
57
59
  return (
58
60
  <span className={COMPACT_CARD_SKELETON_OUTER}>
@@ -98,6 +100,7 @@ export function CaseStudyCard({
98
100
  targetPlatform,
99
101
  placeholderUrl: placeholderUrlProp,
100
102
  size = 'default',
103
+ showTypeBadge = true,
101
104
  className,
102
105
  }: CaseStudyCardProps) {
103
106
  const { target, rel } = useEntityCardLink({
@@ -145,6 +148,29 @@ export function CaseStudyCard({
145
148
  )
146
149
  }
147
150
 
151
+ if (size === 'portrait') {
152
+ // Rail/strip density — shared <EntityPortraitCard> shell.
153
+ return (
154
+ <EntityPortraitCard
155
+ href={href}
156
+ target={target}
157
+ rel={rel}
158
+ typeLabel={showTypeBadge ? 'Case Study' : undefined}
159
+ imageUrl={study.featured_image}
160
+ placeholderUrl={placeholderUrl}
161
+ imageAlt={study.msp?.name || study.title}
162
+ title={study.title}
163
+ person={{
164
+ name: study.user?.full_name || 'Anonymous',
165
+ avatarUrl: study.user?.avatar_url,
166
+ subtitle: study.msp?.name ?? null,
167
+ iconOverlayUrl: study.msp?.icon_url ?? null,
168
+ }}
169
+ className={className}
170
+ />
171
+ )
172
+ }
173
+
148
174
  return (
149
175
  <a href={href} target={target} rel={rel} className={cn('block h-full', className)}>
150
176
  <Card className="bg-ods-card border border-ods-border hover:border-ods-accent transition-colors p-6 flex flex-col gap-6 overflow-hidden">
@@ -13,6 +13,7 @@ import { Card } from '../../ui/card'
13
13
  import { cn } from '../../../utils/cn'
14
14
  import { Video } from 'lucide-react'
15
15
  import type { CustomerInterview } from '../../../types/customer-interview'
16
+ import { EntityPortraitCard } from './entity-portrait-card'
16
17
  import { useEntityCardLink } from './use-entity-card-link'
17
18
  import { useEntityCardPlaceholder } from './use-entity-card-placeholder'
18
19
  import {
@@ -28,10 +29,7 @@ import {
28
29
  COMPACT_CARD_TITLE,
29
30
  COMPACT_CARD_TITLE_ROW,
30
31
  } from '../utils/compact-card-classes'
31
-
32
- const hideOnError = (e: React.SyntheticEvent<HTMLImageElement>) => {
33
- ;(e.currentTarget as HTMLImageElement).style.display = 'none'
34
- }
32
+ import { hideOnError } from './use-cover-image-fallback'
35
33
 
36
34
  export interface CustomerInterviewCardProps {
37
35
  interview: CustomerInterview
@@ -43,11 +41,14 @@ export interface CustomerInterviewCardProps {
43
41
  targetPlatform?: string | null
44
42
  /** OG placeholder URL fallback when `interview.featured_image` is missing. */
45
43
  placeholderUrl?: string | null
46
- size?: 'default' | 'sm'
44
+ size?: 'default' | 'sm' | 'portrait'
45
+ /** Portrait density: render the content-type chip. Mixed rails only; single-type rails pass false. Default true. */
46
+ showTypeBadge?: boolean
47
47
  className?: string
48
48
  }
49
49
 
50
- export function CustomerInterviewCardSkeleton({ size = 'default' }: { size?: 'default' | 'sm' }) {
50
+ /** `portrait` shares the default skeleton shape (same zone boxes). */
51
+ export function CustomerInterviewCardSkeleton({ size = 'default' }: { size?: 'default' | 'sm' | 'portrait' }) {
51
52
  if (size === 'sm') {
52
53
  return (
53
54
  <span className={COMPACT_CARD_SKELETON_OUTER}>
@@ -98,6 +99,7 @@ export function CustomerInterviewCard({
98
99
  targetPlatform,
99
100
  placeholderUrl: placeholderUrlProp,
100
101
  size = 'default',
102
+ showTypeBadge = true,
101
103
  className,
102
104
  }: CustomerInterviewCardProps) {
103
105
  const { target, rel } = useEntityCardLink({
@@ -150,6 +152,29 @@ export function CustomerInterviewCard({
150
152
  )
151
153
  }
152
154
 
155
+ if (size === 'portrait') {
156
+ // Rail/strip density — shared <EntityPortraitCard> shell.
157
+ return (
158
+ <EntityPortraitCard
159
+ href={href}
160
+ target={target}
161
+ rel={rel}
162
+ typeLabel={showTypeBadge ? 'Customer Interview' : undefined}
163
+ imageUrl={interview.featured_image}
164
+ placeholderUrl={placeholderUrl}
165
+ imageAlt={interview.title}
166
+ title={interview.title}
167
+ person={{
168
+ name: interview.user?.full_name || 'Customer',
169
+ avatarUrl: interview.user?.avatar_url,
170
+ subtitle: interview.msp?.name ?? null,
171
+ iconOverlayUrl: interview.msp?.icon_url ?? null,
172
+ }}
173
+ className={className}
174
+ />
175
+ )
176
+ }
177
+
153
178
  return (
154
179
  <a href={href} target={target} rel={rel} className={cn('block h-full', className)}>
155
180
  <Card className="bg-ods-card border border-ods-border hover:border-ods-accent transition-colors p-6 flex flex-col gap-6 overflow-hidden">
@@ -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 ||