kintex/mobile/components/LiveNoticeFeed.tsx
zio 0933dbea41 feat(mobile): B2C visitor tab, congestion pill, live notice feed, ticket wallet, brand logo (v0.2.2 prep)
- (visitor) route group, visitor lib, tickets wallet
- CongestionPill + LiveNoticeFeed components
- login/tickets screens, i18n 4 locales, wordmark assets, splash

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 00:41:57 +09:00

174 lines
6.4 KiB
TypeScript

/*
* 라이브 공지 피드 — 공개 GET /api/public/events/{id}/live-notices.
* 고정(pinned) 상단 우선 → 최신순(서버 정렬 신뢰). 긴급(URGENT) 강조. 30초 폴링.
* 상태: 로딩·빈·degraded(마지막 데이터 유지 + 안내). 홈/행사 상세 공용.
* 근거: src/backend/_workspace/cms_backlog_contract.md (공개 라이브 공지).
*/
import { Ionicons } from '@expo/vector-icons';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
import { isDegraded } from '../lib/api';
import { getLiveNotices } from '../lib/visitor';
import type { LiveNoticeCategory, LiveNoticeDto } from '../lib/types';
import { colors, radius, spacing, type } from '../theme';
const POLL_MS = 30_000;
/** 카테고리 → 배지 색(계약 권장 매핑). */
const CATEGORY_STYLE: Record<LiveNoticeCategory, { color: string; bg: string }> = {
URGENT: { color: colors.error, bg: '#FEF3F2' },
PROGRAM: { color: colors.warning, bg: '#FFF7ED' },
INFO: { color: colors.primary700, bg: colors.primary050 },
GENERAL: { color: colors.neutral700, bg: colors.neutral050 },
};
interface Props {
eventId: string;
/** 노출 최대 건수(홈=3 요약, 행사=전체). */
max?: number;
}
export function LiveNoticeFeed({ eventId, max }: Props) {
const { t } = useTranslation();
const [items, setItems] = useState<LiveNoticeDto[] | null>(null);
const [degraded, setDegraded] = useState(false);
const mounted = useRef(true);
const catLabel = useCallback(
(c: LiveNoticeCategory): string => t(`notices.cat.${c}` as const),
[t],
);
const load = useCallback(async () => {
try {
const res = await getLiveNotices(eventId, 100);
if (!mounted.current) return;
// 서버가 pinned 우선→최신 정렬. 방어적으로 pinned 재정렬(안정 정렬).
const sorted = [...(res ?? [])].sort((a, b) => Number(b.pinned) - Number(a.pinned));
setItems(sorted);
setDegraded(false);
} catch (e) {
if (!mounted.current) return;
setDegraded(isDegraded(e));
setItems((prev) => prev ?? []); // 최초 실패는 빈 배열로(무한 로딩 방지)
}
}, [eventId]);
useEffect(() => {
mounted.current = true;
load();
const timer = setInterval(load, POLL_MS);
return () => {
mounted.current = false;
clearInterval(timer);
};
}, [load]);
const visible = max != null ? (items ?? []).slice(0, max) : items ?? [];
return (
<View style={styles.wrap}>
<View style={styles.header}>
<View style={styles.headerLeft}>
<Ionicons name="megaphone-outline" size={18} color={colors.primary600} />
<Text style={styles.title}>{t('notices.title')}</Text>
</View>
<View style={styles.liveDot}>
<View style={styles.liveDotInner} />
<Text style={styles.liveText}>{t('notices.live')}</Text>
</View>
</View>
{items == null ? (
<View style={styles.loading}>
<ActivityIndicator color={colors.primary600} />
</View>
) : visible.length === 0 ? (
<Text style={styles.empty}>
{degraded ? t('notices.degraded') : t('notices.empty')}
</Text>
) : (
<View style={styles.list}>
{degraded ? <Text style={styles.degradedNote}>{t('notices.degraded')}</Text> : null}
{visible.map((n) => (
<NoticeRow key={n.id} notice={n} catLabel={catLabel} />
))}
</View>
)}
</View>
);
}
function NoticeRow({
notice,
catLabel,
}: {
notice: LiveNoticeDto;
catLabel: (c: LiveNoticeCategory) => string;
}) {
const { t } = useTranslation();
const urgent = notice.category === 'URGENT';
const cat = CATEGORY_STYLE[notice.category] ?? CATEGORY_STYLE.GENERAL;
return (
<View style={[styles.row, urgent && styles.rowUrgent]}>
<View style={styles.rowTop}>
<View style={[styles.catBadge, { backgroundColor: cat.bg }]}>
<Text style={[styles.catText, { color: cat.color }]}>{catLabel(notice.category)}</Text>
</View>
{notice.pinned ? (
<View style={styles.pinBadge}>
<Ionicons name="bookmark" size={10} color={colors.primary700} />
<Text style={styles.pinText}>{t('notices.pinned')}</Text>
</View>
) : null}
</View>
<Text style={[styles.rowTitle, urgent && { color: colors.error }]} numberOfLines={2}>
{notice.title}
</Text>
{notice.body ? (
<Text style={styles.rowBody} numberOfLines={2}>
{notice.body}
</Text>
) : null}
{notice.authorName ? <Text style={styles.rowMeta}>{notice.authorName}</Text> : null}
</View>
);
}
const styles = StyleSheet.create({
wrap: {
backgroundColor: colors.white,
borderWidth: 1,
borderColor: colors.neutral200,
borderRadius: radius.md,
padding: spacing.md,
gap: spacing.sm,
},
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
headerLeft: { flexDirection: 'row', alignItems: 'center', gap: 6 },
title: { fontSize: type.h3.fontSize, fontWeight: '700', color: colors.neutral900 },
liveDot: { flexDirection: 'row', alignItems: 'center', gap: 5 },
liveDotInner: { width: 6, height: 6, borderRadius: 3, backgroundColor: colors.error },
liveText: { fontSize: 11, fontWeight: '700', color: colors.error },
loading: { paddingVertical: spacing.md, alignItems: 'center' },
empty: { fontSize: type.caption.fontSize, color: colors.neutral500, paddingVertical: spacing.sm },
degradedNote: { fontSize: 11, color: colors.neutral500 },
list: { gap: spacing.sm },
row: {
borderTopWidth: 1,
borderTopColor: colors.neutral200,
paddingTop: spacing.sm,
gap: 4,
},
rowUrgent: { borderLeftWidth: 3, borderLeftColor: colors.error, paddingLeft: 8 },
rowTop: { flexDirection: 'row', alignItems: 'center', gap: 6 },
catBadge: { borderRadius: radius.sm, paddingHorizontal: 6, paddingVertical: 2 },
catText: { fontSize: 10, fontWeight: '700' },
pinBadge: { flexDirection: 'row', alignItems: 'center', gap: 3 },
pinText: { fontSize: 10, fontWeight: '600', color: colors.primary700 },
rowTitle: { fontSize: type.body.fontSize, fontWeight: '600', color: colors.neutral900 },
rowBody: { fontSize: type.caption.fontSize, color: colors.neutral700, lineHeight: 18 },
rowMeta: { fontSize: 11, color: colors.neutral500 },
});