/* * 라이브 공지 피드 — 공개 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 = { 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(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 ( {t('notices.title')} {t('notices.live')} {items == null ? ( ) : visible.length === 0 ? ( {degraded ? t('notices.degraded') : t('notices.empty')} ) : ( {degraded ? {t('notices.degraded')} : null} {visible.map((n) => ( ))} )} ); } 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 ( {catLabel(notice.category)} {notice.pinned ? ( {t('notices.pinned')} ) : null} {notice.title} {notice.body ? ( {notice.body} ) : null} {notice.authorName ? {notice.authorName} : null} ); } 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 }, });