- (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>
174 lines
6.4 KiB
TypeScript
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 },
|
|
});
|