@flamingo-stack/openframe-frontend-core 0.0.395 → 0.0.396-snapshot.20260709212121
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-NJPMHDFG.js → chunk-2OOZASQE.js} +2 -2
- package/dist/{chunk-PJ2YFSMY.js → chunk-3E4BDAHJ.js} +2 -2
- package/dist/{chunk-M7WTWBCY.cjs → chunk-43C5ECNT.cjs} +37 -37
- package/dist/{chunk-M7WTWBCY.cjs.map → chunk-43C5ECNT.cjs.map} +1 -1
- package/dist/{chunk-OSOKWP5R.cjs → chunk-55SNCMFS.cjs} +31 -31
- package/dist/{chunk-OSOKWP5R.cjs.map → chunk-55SNCMFS.cjs.map} +1 -1
- package/dist/{chunk-BEPZ5YAN.cjs → chunk-BB7TZ6KS.cjs} +59 -59
- package/dist/{chunk-BEPZ5YAN.cjs.map → chunk-BB7TZ6KS.cjs.map} +1 -1
- package/dist/{chunk-RWBTSF2E.js → chunk-CK5UZLIN.js} +5316 -5003
- package/dist/chunk-CK5UZLIN.js.map +1 -0
- package/dist/{chunk-YJE3HL6Q.js → chunk-D4F7ZLJG.js} +4 -4
- package/dist/{chunk-ZCKVP4SU.cjs → chunk-DB6CUWTL.cjs} +26 -26
- package/dist/{chunk-ZCKVP4SU.cjs.map → chunk-DB6CUWTL.cjs.map} +1 -1
- package/dist/{chunk-Y4I4462K.cjs → chunk-HPQKX5IW.cjs} +15 -15
- package/dist/{chunk-Y4I4462K.cjs.map → chunk-HPQKX5IW.cjs.map} +1 -1
- package/dist/{chunk-NN73KMIK.js → chunk-IVXOR2IA.js} +2 -2
- package/dist/{chunk-WHJEU7V7.js → chunk-J6TAK6HE.js} +4 -4
- package/dist/{chunk-45Z6B6CO.js → chunk-JNU3YPLP.js} +4 -4
- package/dist/{chunk-TWLPG5IQ.js → chunk-K5FRWPWF.js} +2 -2
- package/dist/{chunk-RNWLVI3V.cjs → chunk-M6NPDNVB.cjs} +26 -26
- package/dist/{chunk-RNWLVI3V.cjs.map → chunk-M6NPDNVB.cjs.map} +1 -1
- package/dist/{chunk-5U72NXGG.cjs → chunk-OAENYOKC.cjs} +9 -9
- package/dist/{chunk-5U72NXGG.cjs.map → chunk-OAENYOKC.cjs.map} +1 -1
- package/dist/{chunk-T6FEE3R2.cjs → chunk-SBWTMJPQ.cjs} +5 -5
- package/dist/{chunk-T6FEE3R2.cjs.map → chunk-SBWTMJPQ.cjs.map} +1 -1
- package/dist/{chunk-4YVUL4T7.cjs → chunk-T5F54MN4.cjs} +12 -12
- package/dist/{chunk-4YVUL4T7.cjs.map → chunk-T5F54MN4.cjs.map} +1 -1
- package/dist/{chunk-MPN3MV5H.js → chunk-TTHTO5H5.js} +4 -4
- package/dist/{chunk-PFF6Q6N5.js → chunk-VTR4QDL2.js} +2 -2
- package/dist/{chunk-TTEAYVLT.js → chunk-WCDM226S.js} +2 -2
- package/dist/{chunk-J2UDV3FH.cjs → chunk-XNAYZHPK.cjs} +62 -62
- package/dist/{chunk-J2UDV3FH.cjs.map → chunk-XNAYZHPK.cjs.map} +1 -1
- package/dist/{chunk-3VJPMRNT.cjs → chunk-Z6PAD2UN.cjs} +7 -7
- package/dist/{chunk-3VJPMRNT.cjs.map → chunk-Z6PAD2UN.cjs.map} +1 -1
- package/dist/{chunk-ZB5HSWX4.js → chunk-ZFK4HHRX.js} +2 -2
- package/dist/{chunk-PNMX75W4.cjs → chunk-ZUKBC2ZX.cjs} +1114 -801
- package/dist/chunk-ZUKBC2ZX.cjs.map +1 -0
- 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/auth/create-organization-form.d.ts +1 -1
- package/dist/components/features/auth/create-organization-form.d.ts.map +1 -1
- package/dist/components/features/auth/login-form.d.ts +1 -1
- package/dist/components/features/auth/login-form.d.ts.map +1 -1
- package/dist/components/features/auth/sso-providers.d.ts +1 -1
- package/dist/components/features/auth/sso-providers.d.ts.map +1 -1
- package/dist/components/features/cards-strip.d.ts.map +1 -1
- package/dist/components/features/index.cjs +2 -2
- package/dist/components/features/index.js +1 -1
- package/dist/components/features/video-bites-strip.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 +128 -120
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +17 -9
- package/dist/components/index.js.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/index.cjs +10 -2
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.js +9 -1
- package/dist/index.cjs +10 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +9 -1
- package/package.json +1 -1
- 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/auth/create-organization-form.tsx +2 -2
- package/src/components/features/auth/login-form.tsx +2 -2
- package/src/components/features/auth/sso-providers.tsx +2 -2
- package/src/components/features/cards-strip.tsx +122 -8
- package/src/components/features/video-bites-strip.tsx +4 -0
- package/src/stories/auth/CreateOrganization.stories.tsx +0 -1
- package/src/stories/auth/Login.stories.tsx +1 -1
- package/dist/chunk-PNMX75W4.cjs.map +0 -1
- package/dist/chunk-RWBTSF2E.js.map +0 -1
- /package/dist/{chunk-NJPMHDFG.js.map → chunk-2OOZASQE.js.map} +0 -0
- /package/dist/{chunk-PJ2YFSMY.js.map → chunk-3E4BDAHJ.js.map} +0 -0
- /package/dist/{chunk-YJE3HL6Q.js.map → chunk-D4F7ZLJG.js.map} +0 -0
- /package/dist/{chunk-NN73KMIK.js.map → chunk-IVXOR2IA.js.map} +0 -0
- /package/dist/{chunk-WHJEU7V7.js.map → chunk-J6TAK6HE.js.map} +0 -0
- /package/dist/{chunk-45Z6B6CO.js.map → chunk-JNU3YPLP.js.map} +0 -0
- /package/dist/{chunk-TWLPG5IQ.js.map → chunk-K5FRWPWF.js.map} +0 -0
- /package/dist/{chunk-MPN3MV5H.js.map → chunk-TTHTO5H5.js.map} +0 -0
- /package/dist/{chunk-PFF6Q6N5.js.map → chunk-VTR4QDL2.js.map} +0 -0
- /package/dist/{chunk-TTEAYVLT.js.map → chunk-WCDM226S.js.map} +0 -0
- /package/dist/{chunk-ZB5HSWX4.js.map → chunk-ZFK4HHRX.js.map} +0 -0
|
@@ -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 ||
|
|
@@ -2,8 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* ProgramCard (pure presentation). Generic card for podcasts / webinars /
|
|
5
|
-
* events.
|
|
6
|
-
* horizontal for chat-inline)
|
|
5
|
+
* events. Three densities — `default` (wide horizontal detail, archive
|
|
6
|
+
* pages), `sm` (compact horizontal for chat-inline), and `portrait`
|
|
7
|
+
* (vertical rail/strip card).
|
|
8
|
+
*
|
|
9
|
+
* `portrait` exists because mixed-content rails MUST share ONE card anatomy
|
|
10
|
+
* (2026 card-UI practice: a rail mixes content types, never card layouts —
|
|
11
|
+
* mixing orientations/aspects in one scroller is the anti-pattern). It
|
|
12
|
+
* renders the SAME three zones as the other portrait entity cards
|
|
13
|
+
* (CaseStudyCard et al.): wide media slot → fixed 72px title zone → fixed
|
|
14
|
+
* 60px person/meta footer, p-6 / gap-6. The program's identity lives in the
|
|
15
|
+
* Azeret Mono title and the date · duration meta line — not in a different
|
|
16
|
+
* layout.
|
|
7
17
|
*
|
|
8
18
|
* The card writes NO click logic — callers wrap with their own anchor
|
|
9
19
|
* and pass the resolved detail URL via `href`.
|
|
@@ -34,16 +44,19 @@ import {
|
|
|
34
44
|
COMPACT_CARD_TITLE,
|
|
35
45
|
COMPACT_CARD_TITLE_ROW,
|
|
36
46
|
} from '../utils/compact-card-classes'
|
|
37
|
-
import
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
47
|
+
import {
|
|
48
|
+
programItemToStripProfile,
|
|
49
|
+
type BaseProgramItem,
|
|
50
|
+
type ProgramAuthorRef,
|
|
51
|
+
type ProgramConfig,
|
|
52
|
+
type ProgramMedia,
|
|
53
|
+
type ProgramHost,
|
|
42
54
|
} from '../types/entities/program-types'
|
|
55
|
+
import { EntityPortraitCard } from './entity-portrait-card'
|
|
43
56
|
import { useEntityCardLink } from './use-entity-card-link'
|
|
44
57
|
import { useEntityCardPlaceholder } from './use-entity-card-placeholder'
|
|
45
58
|
|
|
46
|
-
type CardSize = 'default' | 'sm'
|
|
59
|
+
type CardSize = 'default' | 'sm' | 'portrait'
|
|
47
60
|
|
|
48
61
|
/**
|
|
49
62
|
* Format a Date with date-fns pinned to UTC. `date-fns` `format()` reads the
|
|
@@ -115,6 +128,8 @@ export interface ProgramCardProps<T extends BaseProgramItem> {
|
|
|
115
128
|
media?: ProgramMedia[]
|
|
116
129
|
renderMeta?: (item: T) => React.ReactNode
|
|
117
130
|
size?: CardSize
|
|
131
|
+
/** Portrait density: render the content-type chip. Mixed rails only; single-type rails pass false. Default true. */
|
|
132
|
+
showTypeBadge?: boolean
|
|
118
133
|
/** Detail URL resolved by the caller. */
|
|
119
134
|
href: string
|
|
120
135
|
/** When `_blank`, opens in a new tab. Set by chat dispatch via
|
|
@@ -212,6 +227,7 @@ export function ProgramCard<T extends BaseProgramItem>({
|
|
|
212
227
|
media = [],
|
|
213
228
|
renderMeta,
|
|
214
229
|
size = 'default',
|
|
230
|
+
showTypeBadge = true,
|
|
215
231
|
href,
|
|
216
232
|
target: targetProp,
|
|
217
233
|
rel: relProp,
|
|
@@ -237,24 +253,60 @@ export function ProgramCard<T extends BaseProgramItem>({
|
|
|
237
253
|
const accentColor = 'var(--color-accent-primary)'
|
|
238
254
|
const isScheduled = 'status' in item && (item as any).status === 'scheduled'
|
|
239
255
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
})()
|
|
244
|
-
const compactCover = coverImage || placeholderUrl || null
|
|
245
|
-
let typeMeta: string | null = null
|
|
256
|
+
// Compact per-type meta (duration / location / start time) — shared by the
|
|
257
|
+
// `sm` and `portrait` densities.
|
|
258
|
+
const compactTypeMeta = (): string | null => {
|
|
246
259
|
if (config.type === 'podcast' && 'duration_seconds' in item && !isScheduled) {
|
|
247
260
|
const dur = (item as any).duration_seconds
|
|
248
|
-
if (typeof dur === 'number' && dur > 0)
|
|
261
|
+
if (typeof dur === 'number' && dur > 0) return formatDurationCompact(dur)
|
|
249
262
|
} else if (config.type === 'event' && 'location_name' in item) {
|
|
250
263
|
const loc = (item as any).location_name
|
|
251
|
-
if (typeof loc === 'string' && loc.trim().length > 0)
|
|
264
|
+
if (typeof loc === 'string' && loc.trim().length > 0) return loc
|
|
252
265
|
} else if (config.type === 'webinar' && 'start_at' in item) {
|
|
253
266
|
const w = item as any
|
|
254
267
|
const time = formatTimeWithTimezone(w.start_at, w.timezone ?? null)
|
|
255
268
|
const dur = formatDurationFromRange(w.start_at, w.end_at)
|
|
256
|
-
|
|
269
|
+
return dur ? `${time} · ${dur}` : time
|
|
257
270
|
}
|
|
271
|
+
return null
|
|
272
|
+
}
|
|
273
|
+
const compactDate = (() => {
|
|
274
|
+
try { return formatUtc(new Date(item.date), 'MMM d, yyyy') } catch { return '' }
|
|
275
|
+
})()
|
|
276
|
+
|
|
277
|
+
if (size === 'portrait') {
|
|
278
|
+
// Rail/strip density — mapped onto the shared <EntityPortraitCard> shell
|
|
279
|
+
// (media aspect-[1200/630] → h-[72px] title → h-[60px] footer, p-6/gap-6).
|
|
280
|
+
// Person = author-first via programItemToStripProfile (author → primary
|
|
281
|
+
// host); the date · duration meta line fills the subtitle when the
|
|
282
|
+
// profile has no job title.
|
|
283
|
+
const profile = programItemToStripProfile(item as { author?: ProgramAuthorRef | null; hosts?: ProgramHost[] | null })
|
|
284
|
+
const dateMeta = [compactDate, compactTypeMeta()].filter(Boolean).join(' · ')
|
|
285
|
+
return (
|
|
286
|
+
<EntityPortraitCard
|
|
287
|
+
href={href}
|
|
288
|
+
target={target}
|
|
289
|
+
rel={rel}
|
|
290
|
+
typeLabel={showTypeBadge ? config.labels.singular : undefined}
|
|
291
|
+
imageUrl={coverImage}
|
|
292
|
+
placeholderUrl={placeholderUrl}
|
|
293
|
+
imageAlt={item.title}
|
|
294
|
+
title={item.title}
|
|
295
|
+
titleClassName="font-['Azeret_Mono'] font-semibold text-lg leading-6"
|
|
296
|
+
person={
|
|
297
|
+
profile
|
|
298
|
+
? { name: profile.name, avatarUrl: profile.avatarUrl, subtitle: profile.subtitle || dateMeta }
|
|
299
|
+
: { name: config.labels.singular, subtitle: dateMeta }
|
|
300
|
+
}
|
|
301
|
+
className={className}
|
|
302
|
+
/>
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (size === 'sm') {
|
|
307
|
+
const itemDate = compactDate
|
|
308
|
+
const compactCover = coverImage || placeholderUrl || null
|
|
309
|
+
const typeMeta = compactTypeMeta()
|
|
258
310
|
const subtitleParts = [itemDate, typeMeta, config.labels?.singular].filter(
|
|
259
311
|
(s): s is string => typeof s === 'string' && s.length > 0,
|
|
260
312
|
)
|