feat(ui/product/social): ChannelBadge, PostCard, CampaignCard (8.0.2)

Extract the pure Hanzo Social display components into @hanzo/ui on the @hanzo/gui
idiom (Card/XStack/YStack/Text, $-tokens), data + handlers injected via props,
reusing the shared StatusTag. Additive: new product/social/* + one barrel line;
no existing export changed. Published @hanzo/ui 8.0.2.
This commit is contained in:
hanzo-dev
2026-07-15 00:03:38 -07:00
parent a3c5f5905e
commit 52fdb2b876
6 changed files with 213 additions and 2 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
{
"name": "@hanzo/ui",
"version": "8.0.1",
"version": "8.0.2",
"type": "module",
"description": "Hanzo UI \u2014 the one cross-platform component library on @hanzo/gui. The product/app layer (charts, metrics, page headers, status tags, rich empty states, combobox, slide-over, toasts, drag-reorder, labeled field rows, provider/product marks) + the metadata-driven record layer (@hanzo/data: RecordsView, DataTable, board, typed field editors) + the calm dark-first tokens and motion. Presentational, host-agnostic (data/effects injected), clean-room. Web + native (iOS) + desktop.",
"description": "Hanzo UI the one cross-platform component library on @hanzo/gui. The product/app layer (charts, metrics, page headers, status tags, rich empty states, combobox, slide-over, toasts, drag-reorder, labeled field rows, provider/product marks) + the metadata-driven record layer (@hanzo/data: RecordsView, DataTable, board, typed field editors) + the calm dark-first tokens and motion. Presentational, host-agnostic (data/effects injected), clean-room. Web + native (iOS) + desktop.",
"exports": {
".": {
"types": "./types/index.d.ts",
+3
View File
@@ -58,3 +58,6 @@ export * from './color'
// Usage — the shared AI-quota surface (meter bar → provider card → dashboard).
export * from './usage'
// Social — ChannelBadge, PostCard, CampaignCard (extracted from Hanzo Social).
export * from './social'
@@ -0,0 +1,62 @@
'use client'
/**
* CampaignCard — one marketing campaign (name, channel, lifecycle, budget/spend
* bar). Pure: all data via props. Reuses the shared StatusTag for the lifecycle
* pill (one status vocabulary). Extracted from Hanzo Social.
*/
import { Card, Text, XStack, YStack } from '@hanzo/gui'
import { StatusTag } from '../StatusTag'
export type Campaign = {
id: string
name: string
channel: string
status: string
objective?: string
/** minor units (cents) */
budget: number
spend: number
}
const money = (cents: number) => `$${Math.round((cents || 0) / 100).toLocaleString()}`
export function CampaignCard({ campaign }: { campaign: Campaign }) {
const pct =
campaign.budget > 0 ? Math.min(100, (campaign.spend / campaign.budget) * 100) : 0
return (
<Card p="$4" gap="$3" borderWidth={1} borderColor="$borderColor" width="100%">
<XStack items="flex-start" justify="space-between" gap="$2">
<YStack gap="$1">
<Text fontSize="$4" fontWeight="700">
{campaign.name}
</Text>
<Text fontSize="$2" color="$color11">
{campaign.channel}
</Text>
</YStack>
<StatusTag status={campaign.status} />
</XStack>
{campaign.objective ? (
<Text fontSize="$2" color="$color11">
{campaign.objective}
</Text>
) : null}
<YStack gap="$1.5">
<XStack justify="space-between">
<Text fontSize="$1" color="$color11">
{money(campaign.spend)} spent
</Text>
<Text fontSize="$1" color="$color11">
{money(campaign.budget)} budget
</Text>
</XStack>
<YStack height={6} rounded="$2" bg="$color3" overflow="hidden">
<YStack height={6} rounded="$2" bg="$color9" width={`${pct}%` as never} />
</YStack>
</YStack>
</Card>
)
}
@@ -0,0 +1,60 @@
'use client'
/**
* ChannelBadge — the ONE way a social network is marked (brand color + glyph +
* optional label), on @hanzo/gui primitives so it renders on web, native, and
* desktop. Data-free / host-agnostic. Extracted from Hanzo Social (social.hanzo.ai).
*/
import { Text, XStack, YStack } from '@hanzo/gui'
export type Channel =
| 'x'
| 'facebook'
| 'instagram'
| 'linkedin'
| 'tiktok'
| 'youtube'
| 'threads'
const META: Record<Channel, { label: string; bg: string; fg: string; mark: string }> = {
x: { label: 'X', bg: '#0f0f12', fg: '#e6e6ea', mark: '𝕏' },
facebook: { label: 'Facebook', bg: '#1877f2', fg: '#ffffff', mark: 'f' },
instagram: { label: 'Instagram', bg: '#e1306c', fg: '#ffffff', mark: 'IG' },
linkedin: { label: 'LinkedIn', bg: '#0a66c2', fg: '#ffffff', mark: 'in' },
tiktok: { label: 'TikTok', bg: '#111114', fg: '#25f4ee', mark: 'TT' },
youtube: { label: 'YouTube', bg: '#ff0000', fg: '#ffffff', mark: '▶' },
threads: { label: 'Threads', bg: '#111114', fg: '#e6e6ea', mark: '@' },
}
export function ChannelBadge({
channel,
showLabel = false,
size = 22,
}: {
channel: Channel
showLabel?: boolean
size?: number
}) {
const m = META[channel] ?? META.x
return (
<XStack items="center" gap="$2">
<YStack
width={size}
height={size}
items="center"
justify="center"
rounded="$2"
bg={m.bg as never}
>
<Text fontSize="$1" fontWeight="800" color={m.fg as never}>
{m.mark}
</Text>
</YStack>
{showLabel ? (
<Text fontSize="$3" color="$color12">
{m.label}
</Text>
) : null}
</XStack>
)
}
+80
View File
@@ -0,0 +1,80 @@
'use client'
/**
* PostCard — one social post: channel, lifecycle, body, schedule time, a media
* count, and injected edit/delete affordances. Pure (data + handlers via props);
* composes ChannelBadge + the shared StatusTag. Extracted from Hanzo Social.
*/
import { Card, Text, XStack, YStack, Button } from '@hanzo/gui'
import { Pencil, Trash2 } from '@hanzogui/lucide-icons-2'
import { StatusTag } from '../StatusTag'
import { ChannelBadge, type Channel } from './ChannelBadge'
export type Post = {
id: string
content: string
channel: Channel
status: string
/** unix seconds; 0/undefined = not scheduled */
scheduleAt?: number
media?: string[]
}
const fmt = (unix?: number) =>
unix
? new Date(unix * 1000).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
: ''
export function PostCard({
post,
onEdit,
onDelete,
}: {
post: Post
onEdit?: (p: Post) => void
onDelete?: (p: Post) => void
}) {
const mediaCount = post.media?.length ?? 0
return (
<Card p="$3" gap="$2.5" borderWidth={1} borderColor="$borderColor" width="100%">
<XStack items="center" justify="space-between" gap="$2">
<XStack items="center" gap="$2">
<ChannelBadge channel={post.channel} />
<StatusTag status={post.status} />
</XStack>
<XStack gap="$1">
{onEdit ? (
<Button size="$2" chromeless icon={<Pencil size={15} />} onPress={() => onEdit(post)} />
) : null}
{onDelete ? (
<Button size="$2" chromeless icon={<Trash2 size={15} />} onPress={() => onDelete(post)} />
) : null}
</XStack>
</XStack>
<Text fontSize="$3" color="$color12">
{post.content}
</Text>
<XStack items="center" justify="space-between" gap="$2">
{post.scheduleAt ? (
<Text fontSize="$1" color="$color11">
{fmt(post.scheduleAt)}
</Text>
) : (
<YStack />
)}
{mediaCount ? (
<Text fontSize="$1" color="$color10">
{mediaCount} media
</Text>
) : null}
</XStack>
</Card>
)
}
+6
View File
@@ -0,0 +1,6 @@
// @hanzo/ui/product/social — pure, host-agnostic social-media components
// extracted from Hanzo Social (social.hanzo.ai). Data + handlers injected via
// props; built only on @hanzo/gui primitives + the shared StatusTag.
export * from './ChannelBadge'
export * from './CampaignCard'
export * from './PostCard'