@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.
- package/dist/{chunk-EKYE2VJU.js → chunk-3MMYFUWX.js} +4 -4
- package/dist/{chunk-HWJUWOXK.cjs → chunk-5IZDYGPU.cjs} +12 -12
- package/dist/{chunk-HWJUWOXK.cjs.map → chunk-5IZDYGPU.cjs.map} +1 -1
- package/dist/{chunk-OP3H7VB2.js → chunk-5SGIFYSA.js} +2 -2
- package/dist/{chunk-AODSKL5I.cjs → chunk-BIQH2W35.cjs} +9 -9
- package/dist/{chunk-AODSKL5I.cjs.map → chunk-BIQH2W35.cjs.map} +1 -1
- package/dist/{chunk-H4WIS6RJ.js → chunk-D3EAVXAQ.js} +2 -2
- package/dist/{chunk-XQILJ7FF.cjs → chunk-I4D6KJLI.cjs} +5 -5
- package/dist/{chunk-XQILJ7FF.cjs.map → chunk-I4D6KJLI.cjs.map} +1 -1
- package/dist/{chunk-TIC3UE6L.js → chunk-JAO2PKOU.js} +2 -2
- package/dist/{chunk-2PTEK3D6.js → chunk-KOIH637Y.js} +2 -2
- package/dist/{chunk-GA3R77TB.js → chunk-LHICE5MK.js} +2 -2
- package/dist/{chunk-CCV6Z3CL.cjs → chunk-MBWSBKSG.cjs} +26 -26
- package/dist/{chunk-CCV6Z3CL.cjs.map → chunk-MBWSBKSG.cjs.map} +1 -1
- package/dist/{chunk-PQXUY6SB.js → chunk-MH5U4ZMV.js} +2 -2
- package/dist/{chunk-SV7IJPIH.js → chunk-NLYOIYUB.js} +5456 -5015
- package/dist/chunk-NLYOIYUB.js.map +1 -0
- package/dist/{chunk-STEM5CPV.js → chunk-PKRHMZBN.js} +4 -4
- package/dist/{chunk-Y36KVLBX.cjs → chunk-PZIZOFPY.cjs} +1260 -819
- package/dist/chunk-PZIZOFPY.cjs.map +1 -0
- package/dist/{chunk-J2UHW4UM.js → chunk-QCUHIPG3.js} +2 -2
- package/dist/{chunk-KRMGVMIO.cjs → chunk-QOMEGWOM.cjs} +31 -31
- package/dist/{chunk-KRMGVMIO.cjs.map → chunk-QOMEGWOM.cjs.map} +1 -1
- package/dist/{chunk-HNXZFKDQ.cjs → chunk-THE2AFUN.cjs} +62 -62
- package/dist/{chunk-HNXZFKDQ.cjs.map → chunk-THE2AFUN.cjs.map} +1 -1
- package/dist/{chunk-NFRHBR7W.js → chunk-WR74UUVM.js} +4 -4
- package/dist/{chunk-RUYZ5QMK.cjs → chunk-WRS7OVIT.cjs} +37 -37
- package/dist/{chunk-RUYZ5QMK.cjs.map → chunk-WRS7OVIT.cjs.map} +1 -1
- package/dist/{chunk-FAUC7EA3.js → chunk-WX3MHQRH.js} +4 -4
- package/dist/{chunk-6DGQPKVA.cjs → chunk-XFOSQQKD.cjs} +26 -26
- package/dist/{chunk-6DGQPKVA.cjs.map → chunk-XFOSQQKD.cjs.map} +1 -1
- package/dist/{chunk-HEH47UTC.cjs → chunk-XQ35TKEM.cjs} +15 -15
- package/dist/{chunk-HEH47UTC.cjs.map → chunk-XQ35TKEM.cjs.map} +1 -1
- package/dist/{chunk-NAFW3LKI.cjs → chunk-Y2QAEMGF.cjs} +7 -7
- package/dist/{chunk-NAFW3LKI.cjs.map → chunk-Y2QAEMGF.cjs.map} +1 -1
- package/dist/{chunk-VSPNWVIE.cjs → chunk-YSI6FXZH.cjs} +59 -59
- package/dist/{chunk-VSPNWVIE.cjs.map → chunk-YSI6FXZH.cjs.map} +1 -1
- package/dist/components/case-studies/index.cjs +8 -8
- package/dist/components/case-studies/index.js +2 -2
- 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 +10 -2
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.js +9 -1
- 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 +3 -3
- package/dist/components/contact/index.js +2 -2
- package/dist/components/docs/index.cjs +5 -5
- package/dist/components/docs/index.js +4 -4
- package/dist/components/embeds/index.cjs +3 -3
- package/dist/components/embeds/index.js +2 -2
- package/dist/components/faq/index.cjs +3 -3
- package/dist/components/faq/index.js +2 -2
- package/dist/components/features/cards-strip.d.ts.map +1 -1
- package/dist/components/features/index.cjs +6 -2
- 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 +5 -1
- package/dist/components/features/video-bites-strip.d.ts.map +1 -1
- package/dist/components/features/video-center-badge.d.ts +36 -0
- package/dist/components/features/video-center-badge.d.ts.map +1 -0
- package/dist/components/features/video.d.ts +7 -0
- package/dist/components/features/video.d.ts.map +1 -1
- package/dist/components/help-center-pages/index.cjs +20 -20
- package/dist/components/help-center-pages/index.js +11 -11
- package/dist/components/index.cjs +134 -120
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +23 -9
- package/dist/components/index.js.map +1 -1
- package/dist/components/media-carousel.d.ts.map +1 -1
- package/dist/components/navigation/index.cjs +2 -2
- package/dist/components/navigation/index.js +1 -1
- package/dist/components/onboarding-guides/index.cjs +5 -5
- package/dist/components/onboarding-guides/index.js +4 -4
- package/dist/components/related-content/index.cjs +3 -3
- package/dist/components/related-content/index.js +2 -2
- package/dist/components/tickets/index.cjs +5 -5
- package/dist/components/tickets/index.js +4 -4
- 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 +12 -2
- 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 +11 -1
- package/dist/components/ui/tab-navigation.d.ts +6 -2
- package/dist/components/ui/tab-navigation.d.ts.map +1 -1
- package/dist/index.cjs +16 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +15 -1
- 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 +76 -0
- package/src/components/features/video.tsx +51 -7
- 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-SV7IJPIH.js.map +0 -1
- package/dist/chunk-Y36KVLBX.cjs.map +0 -1
- /package/dist/{chunk-EKYE2VJU.js.map → chunk-3MMYFUWX.js.map} +0 -0
- /package/dist/{chunk-OP3H7VB2.js.map → chunk-5SGIFYSA.js.map} +0 -0
- /package/dist/{chunk-H4WIS6RJ.js.map → chunk-D3EAVXAQ.js.map} +0 -0
- /package/dist/{chunk-TIC3UE6L.js.map → chunk-JAO2PKOU.js.map} +0 -0
- /package/dist/{chunk-2PTEK3D6.js.map → chunk-KOIH637Y.js.map} +0 -0
- /package/dist/{chunk-GA3R77TB.js.map → chunk-LHICE5MK.js.map} +0 -0
- /package/dist/{chunk-PQXUY6SB.js.map → chunk-MH5U4ZMV.js.map} +0 -0
- /package/dist/{chunk-STEM5CPV.js.map → chunk-PKRHMZBN.js.map} +0 -0
- /package/dist/{chunk-J2UHW4UM.js.map → chunk-QCUHIPG3.js.map} +0 -0
- /package/dist/{chunk-NFRHBR7W.js.map → chunk-WR74UUVM.js.map} +0 -0
- /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
|
|
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
|
-
|
|
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
|
-
|
|
128
|
-
const displayImage
|
|
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={
|
|
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={
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|