kintex/mobile/components/Avatar.tsx
zio 214b6df1ef feat(public,security,data): visitor public site + AI guide, security hardening, V42-V45 migrations
- frontend: visitor public home (AiAssistant, AiPlanningBriefing, VisitorTrackPage),
  3-track public routing (PublicShell/App), i18n ko/en/zh/ja, favicon
- backend: public AI visitor-assistant + event calendar API (publicsite/*),
  profile avatar API (auth/profile/*), SecurityConfig CORS whitelist,
  SecretStartupValidator (B12 fail-fast, prod only), application-prod.yml,
  AppIntegrity, /api/auth/me expansion (MeResponse)
- db: V42 bulk demo seed, V43 visitor_guide/transport + event calendar view,
  V44 performance indexes, V45 app_user profile photo columns (all idempotent)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 19:05:00 +09:00

73 lines
2.4 KiB
TypeScript

/*
* 공통 Avatar — 프로필 사진(photoUrl) 표시, 실패/부재 시 이름 이니셜 폴백. WISE 모바일 Avatar 이식.
* 레퍼런스: guardia-messenger/app/uiws/_lib/components/Avatar.tsx.
* 사진 서빙 엔드포인트는 인증 필요 — <Image>는 api 인터셉터를 거치지 않으므로 Bearer 헤더를 직접 부여.
* 색은 theme 토큰만 사용(신규 토큰 발명 금지).
*/
import React, { useEffect, useState } from 'react';
import { Image, StyleSheet, Text, View } from 'react-native';
import { getAccessToken } from '../lib/api';
import { colors } from '../theme';
interface AvatarProps {
photoUrl?: string | null;
name?: string | null;
size?: number;
testID?: string;
}
function initials(name?: string | null): string {
if (!name) return '?';
const trimmed = name.trim();
if (!trimmed) return '?';
const isAscii = /^[\x00-\x7F]+$/.test(trimmed);
if (isAscii) {
const parts = trimmed.split(/\s+/);
return (parts.length >= 2 ? parts[0][0] + parts[1][0] : trimmed.slice(0, 2)).toUpperCase();
}
return trimmed.slice(0, 1);
}
export function Avatar({ photoUrl, name, size = 48, testID }: AvatarProps) {
const [failed, setFailed] = useState(false);
const token = getAccessToken();
// photoUrl/token 변화 시 실패 상태 초기화 후 재시도(업로드·캐시버스터 반영).
useEffect(() => {
setFailed(false);
}, [photoUrl, token]);
const dim = { width: size, height: size, borderRadius: size / 2 };
const showImage = !!photoUrl && !!token && !failed;
if (showImage) {
return (
<Image
testID={testID}
source={{
uri: photoUrl as string,
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
}}
style={[dim, { borderWidth: 1, borderColor: colors.neutral200, backgroundColor: colors.primary050 }]}
onError={() => setFailed(true)}
accessibilityIgnoresInvertColors
/>
);
}
return (
<View
testID={testID}
style={[dim, styles.fallback, { backgroundColor: colors.primary100, borderColor: colors.neutral200 }]}
accessibilityLabel={name ? `${name} 프로필` : '프로필'}
>
<Text style={[styles.initials, { color: colors.primary700, fontSize: size * 0.4 }]}>
{initials(name)}
</Text>
</View>
);
}
const styles = StyleSheet.create({
fallback: { alignItems: 'center', justifyContent: 'center', borderWidth: 1 },
initials: { fontWeight: '800' },
});