feat: 다국어 지원 (프랑스, 독일) 및 항해 화면 크루 포함
This commit is contained in:
@@ -9,7 +9,7 @@ import { getHistory } from '@/shared/lib/store';
|
||||
import { Voyage } from '@/shared/types';
|
||||
|
||||
export default function LogDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
// Next.js 15: params is a Promise
|
||||
const resolvedParams = use(params);
|
||||
const [log, setLog] = useState<Voyage | null>(null);
|
||||
@@ -27,6 +27,13 @@ export default function LogDetailPage({ params }: { params: Promise<{ id: string
|
||||
const route = findRouteById(log.routeId);
|
||||
const routeName = route ? t(route.nameKey, undefined, log.routeName) : log.routeName;
|
||||
const statusLabel = t(VOYAGE_STATUS_LABEL_KEYS[log.status], undefined, t('status.in_progress'));
|
||||
const missionLabel = (() => {
|
||||
const trimmed = log.missionText.trim();
|
||||
if (!trimmed || trimmed === '미입력') {
|
||||
return t('log.mission.empty');
|
||||
}
|
||||
return log.missionText;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 p-6 space-y-8 animate-in fade-in duration-300">
|
||||
@@ -39,9 +46,9 @@ export default function LogDetailPage({ params }: { params: Promise<{ id: string
|
||||
← Lobby
|
||||
</Link>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white">{log.missionText}</h1>
|
||||
<h1 className="text-2xl font-bold text-white">{missionLabel}</h1>
|
||||
<div className="flex gap-3 mt-2 text-sm text-slate-500">
|
||||
<span>{new Date(log.startedAt).toLocaleString()}</span>
|
||||
<span>{new Date(log.startedAt).toLocaleString(locale)}</span>
|
||||
<span>•</span>
|
||||
<span>{routeName} ({log.durationMinutes}{t('common.minuteShort')})</span>
|
||||
</div>
|
||||
@@ -53,16 +60,11 @@ export default function LogDetailPage({ params }: { params: Promise<{ id: string
|
||||
<p className="text-lg text-indigo-100">{statusLabel}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<div>
|
||||
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">{t('log.detail.progressTitle')}</h3>
|
||||
<p className="text-slate-300 leading-relaxed bg-slate-900/20 p-3 rounded">{log.debriefProgress || '-'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">{t('log.detail.nextActionTitle')}</h3>
|
||||
<p className="text-slate-300 leading-relaxed bg-slate-900/20 p-3 rounded">{log.nextAction || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{log.notes && (
|
||||
|
||||
@@ -9,7 +9,7 @@ import { getHistory } from '@/shared/lib/store';
|
||||
import { Voyage, VoyageStatus } from '@/shared/types';
|
||||
|
||||
export default function LogListPage() {
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const [logs, setLogs] = useState<Voyage[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -20,6 +20,14 @@ export default function LogListPage() {
|
||||
return t(VOYAGE_STATUS_LABEL_KEYS[s], undefined, t('status.in_progress'));
|
||||
};
|
||||
|
||||
const getMissionLabel = (missionText: string) => {
|
||||
const trimmed = missionText.trim();
|
||||
if (!trimmed || trimmed === '미입력') {
|
||||
return t('log.mission.empty');
|
||||
}
|
||||
return missionText;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 p-6">
|
||||
<div className="mb-6 flex items-center justify-between gap-3">
|
||||
@@ -55,14 +63,14 @@ export default function LogListPage() {
|
||||
>
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<span className="text-xs text-slate-500">
|
||||
{new Date(log.startedAt).toLocaleDateString()}
|
||||
{new Date(log.startedAt).toLocaleDateString(locale)}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-slate-400 bg-slate-800 px-1.5 py-0.5 rounded">
|
||||
{getStatusLabel(log.status)}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="font-semibold text-slate-200 truncate mb-1">
|
||||
{log.missionText}
|
||||
{getMissionLabel(log.missionText)}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500">
|
||||
{routeName} · {log.durationMinutes}
|
||||
|
||||
@@ -16,9 +16,7 @@ export const startVoyage = ({
|
||||
routeName: string;
|
||||
}) => {
|
||||
const missionText = mission.trim();
|
||||
if (!missionText) {
|
||||
return false;
|
||||
}
|
||||
const normalizedMissionText = missionText || '미입력';
|
||||
|
||||
const newVoyage: Voyage = {
|
||||
id: createVoyageId(),
|
||||
@@ -27,7 +25,7 @@ export const startVoyage = ({
|
||||
durationMinutes: route.durationMinutes,
|
||||
startedAt: Date.now(),
|
||||
status: 'in_progress',
|
||||
missionText,
|
||||
missionText: normalizedMissionText,
|
||||
};
|
||||
|
||||
saveCurrentVoyage(newVoyage);
|
||||
|
||||
@@ -3,6 +3,24 @@
|
||||
import { FormEvent, useState } from 'react';
|
||||
import { useI18n } from '@/features/i18n/model/useI18n';
|
||||
|
||||
const OPTIONAL_PREFIX_PATTERN = /^(?:선택|optional|任意|facultatif)\s*[::]\s*/i;
|
||||
const EXAMPLE_PREFIX_PATTERN =
|
||||
/^(?:예|例|e\.?\s?g\.?|eg\.?|example|exemple|beispiel|p\.?\s?ex\.?|z\.?\s?b\.?)\s*[::)\.]\s*/i;
|
||||
|
||||
const buildMissionPlaceholder = (raw: string, optionalLabel: string) => {
|
||||
const sanitized = raw
|
||||
.trim()
|
||||
.replace(OPTIONAL_PREFIX_PATTERN, '')
|
||||
.replace(EXAMPLE_PREFIX_PATTERN, '')
|
||||
.trim();
|
||||
|
||||
if (!sanitized) return '';
|
||||
const normalizedOptionalLabel = optionalLabel.trim();
|
||||
if (!normalizedOptionalLabel) return sanitized;
|
||||
|
||||
return `${sanitized} (${normalizedOptionalLabel})`;
|
||||
};
|
||||
|
||||
export function BoardingMissionForm({
|
||||
onDock,
|
||||
onCancel,
|
||||
@@ -17,11 +35,13 @@ export function BoardingMissionForm({
|
||||
const { t } = useI18n();
|
||||
const [mission, setMission] = useState('');
|
||||
const trimmedMission = mission.trim();
|
||||
const canSubmit = Boolean(trimmedMission);
|
||||
const missionPlaceholder = buildMissionPlaceholder(
|
||||
t('boarding.missionPlaceholder'),
|
||||
t('boarding.optionalLabel'),
|
||||
);
|
||||
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
onDock(trimmedMission);
|
||||
};
|
||||
|
||||
@@ -38,7 +58,7 @@ export function BoardingMissionForm({
|
||||
type="text"
|
||||
value={mission}
|
||||
onChange={(event) => setMission(event.target.value)}
|
||||
placeholder={t('boarding.missionPlaceholder')}
|
||||
placeholder={missionPlaceholder}
|
||||
className="w-full border-b-2 border-slate-700 bg-slate-900/50 px-0 py-3 text-lg outline-none transition-colors placeholder:text-slate-600 focus:border-indigo-500"
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
@@ -56,8 +76,7 @@ export function BoardingMissionForm({
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className={`rounded-xl bg-indigo-600 font-bold text-white transition-all shadow-lg shadow-indigo-900/30 hover:bg-indigo-500 disabled:bg-slate-800 disabled:text-slate-500 ${compact ? 'px-6 py-2' : 'w-full py-4 text-lg'}`}
|
||||
className={`rounded-xl bg-indigo-600 font-bold text-white transition-all shadow-lg shadow-indigo-900/30 hover:bg-indigo-500 ${compact ? 'px-6 py-2' : 'w-full py-4 text-lg'}`}
|
||||
>
|
||||
{t('boarding.submit')}
|
||||
</button>
|
||||
|
||||
@@ -9,10 +9,11 @@ export const getFlightStarCount = (width: number, height: number) => {
|
||||
? FLIGHT_STARFIELD_TUNING.starCount.mobile.min
|
||||
: FLIGHT_STARFIELD_TUNING.starCount.desktop.min;
|
||||
const max = isMobile
|
||||
? FLIGHT_STARFIELD_TUNING.starCount.mobile.max
|
||||
: FLIGHT_STARFIELD_TUNING.starCount.desktop.max;
|
||||
? FLIGHT_STARFIELD_TUNING.maxStars.mobile
|
||||
: FLIGHT_STARFIELD_TUNING.maxStars.desktop;
|
||||
const byArea = Math.round(
|
||||
(width * height) / FLIGHT_STARFIELD_TUNING.densityDivisor,
|
||||
((width * height) / FLIGHT_STARFIELD_TUNING.densityDivisor) *
|
||||
FLIGHT_STARFIELD_TUNING.densityMultiplier,
|
||||
);
|
||||
|
||||
return clamp(byArea, min, max);
|
||||
|
||||
@@ -223,7 +223,7 @@ export function FlightStarfieldCanvas({
|
||||
const from = projectFlightStar(star, vp, star.z);
|
||||
|
||||
if (moveStars) {
|
||||
star.z -= star.speed;
|
||||
star.z -= star.speed * FLIGHT_STARFIELD_TUNING.speedScale;
|
||||
}
|
||||
|
||||
const to = projectFlightStar(star, vp, star.z);
|
||||
|
||||
62
src/shared/config/featureFlags.ts
Normal file
62
src/shared/config/featureFlags.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
export const FEATURE_FLAGS = {
|
||||
crewPresencePanelEnabled: false,
|
||||
crewPresenceNotificationsEnabled: false,
|
||||
} as const;
|
||||
|
||||
export const CREW_PRESENCE_ROLLOUT_GUARDRAILS = {
|
||||
forcePanelOff: false,
|
||||
forceNotificationsOff: false,
|
||||
reduceRenderCap: false,
|
||||
relaxUpdateInterval: false,
|
||||
reducedRenderCap: 30,
|
||||
defaultRenderCap: 50,
|
||||
defaultUpdateIntervalMs: 30_000,
|
||||
relaxedUpdateIntervalMs: 60_000,
|
||||
} as const;
|
||||
|
||||
export const CREW_PRESENCE_ROLLBACK_TOGGLE_BINDINGS = {
|
||||
forcePanelOff: "rollback.panel_off",
|
||||
forceNotificationsOff: "rollback.notifications_off",
|
||||
reduceRenderCap: "rollback.cap_reduce",
|
||||
relaxUpdateInterval: "rollback.interval_relax",
|
||||
} as const;
|
||||
|
||||
export type CrewPresenceRuntimeConfig = {
|
||||
panelEnabled: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
renderCap: number;
|
||||
updateIntervalMs: number;
|
||||
};
|
||||
|
||||
export const resolveCrewPresenceRuntimeConfig = (): CrewPresenceRuntimeConfig => {
|
||||
const panelEnabled =
|
||||
FEATURE_FLAGS.crewPresencePanelEnabled &&
|
||||
!CREW_PRESENCE_ROLLOUT_GUARDRAILS.forcePanelOff;
|
||||
const notificationsEnabled =
|
||||
FEATURE_FLAGS.crewPresenceNotificationsEnabled &&
|
||||
!CREW_PRESENCE_ROLLOUT_GUARDRAILS.forceNotificationsOff;
|
||||
const renderCap = CREW_PRESENCE_ROLLOUT_GUARDRAILS.reduceRenderCap
|
||||
? CREW_PRESENCE_ROLLOUT_GUARDRAILS.reducedRenderCap
|
||||
: CREW_PRESENCE_ROLLOUT_GUARDRAILS.defaultRenderCap;
|
||||
const updateIntervalMs = CREW_PRESENCE_ROLLOUT_GUARDRAILS.relaxUpdateInterval
|
||||
? CREW_PRESENCE_ROLLOUT_GUARDRAILS.relaxedUpdateIntervalMs
|
||||
: CREW_PRESENCE_ROLLOUT_GUARDRAILS.defaultUpdateIntervalMs;
|
||||
|
||||
return {
|
||||
panelEnabled,
|
||||
notificationsEnabled,
|
||||
renderCap,
|
||||
updateIntervalMs,
|
||||
};
|
||||
};
|
||||
|
||||
export const isCrewPresenceRuntimeConfigSafe = (
|
||||
config: CrewPresenceRuntimeConfig,
|
||||
): boolean => config.renderCap > 0 && config.updateIntervalMs >= 1_000;
|
||||
|
||||
export const CREW_PRESENCE_QA_GUARDRAIL_CHECKS = [
|
||||
"panel_toggle_guard",
|
||||
"notifications_toggle_guard",
|
||||
"render_cap_guard",
|
||||
"update_interval_guard",
|
||||
] as const;
|
||||
@@ -1,6 +1,6 @@
|
||||
import { VoyageStatus } from "@/shared/types";
|
||||
|
||||
export const SUPPORTED_LOCALES = ["ko", "en", "ja"] as const;
|
||||
export const SUPPORTED_LOCALES = ["ko", "en", "ja", "fr", "de"] as const;
|
||||
|
||||
export type Locale = (typeof SUPPORTED_LOCALES)[number];
|
||||
|
||||
@@ -12,6 +12,8 @@ export const LOCALE_LABELS: Record<Locale, string> = {
|
||||
ko: "한국어",
|
||||
en: "English",
|
||||
ja: "日本語",
|
||||
fr: "Français",
|
||||
de: "Deutsch",
|
||||
};
|
||||
|
||||
const enMessages = {
|
||||
@@ -39,7 +41,8 @@ const enMessages = {
|
||||
"boarding.check": "Boarding Check",
|
||||
"boarding.routeBoarding": "{routeName} Route Boarding",
|
||||
"boarding.missionLabel": "Core mission for this voyage",
|
||||
"boarding.missionPlaceholder": "e.g. Finish 3 intro paragraphs",
|
||||
"boarding.missionPlaceholder": "Optional: e.g. Finish 3 intro paragraphs",
|
||||
"boarding.optionalLabel": "Optional",
|
||||
"boarding.cancel": "Cancel",
|
||||
"boarding.submit": "Dock Complete (Launch)",
|
||||
"flight.badge.paused": "Paused",
|
||||
@@ -47,6 +50,23 @@ const enMessages = {
|
||||
"flight.missionLabel": "Voyage Mission",
|
||||
"flight.pause": "Pause",
|
||||
"flight.resume": "Resume",
|
||||
"flight.crewPresence.header": "Sailing together now: {count}",
|
||||
"flight.crewPresence.expand": "Expand",
|
||||
"flight.crewPresence.collapse": "Collapse",
|
||||
"flight.crewPresence.listLimit": "Showing up to {limit} crew",
|
||||
"flight.crewPresence.overflow": "+{count} more",
|
||||
"flight.crewPresence.status.online": "Online",
|
||||
"flight.crewPresence.status.idle": "Idle",
|
||||
"flight.crewPresence.status.offline": "Offline",
|
||||
"flight.crewPresence.activityHint": "Based on recent activity",
|
||||
"flight.crewPresence.elapsedMinutes": "{minutes}m ago",
|
||||
"flight.crewPresence.goal.private": "Goal private",
|
||||
"flight.crewPresence.goal.unset": "Goal not set",
|
||||
"flight.crewPresence.notifications.policyOn": "Alerts ON",
|
||||
"flight.crewPresence.notifications.filterHint":
|
||||
"Showing friend/my-crew events only",
|
||||
"flight.crewPresence.notifications.target.friend": "Friend",
|
||||
"flight.crewPresence.notifications.target.myCrew": "My crew",
|
||||
"flight.finish.debrief": "Arrived (Debrief)",
|
||||
"flight.finish.end": "End Voyage",
|
||||
"flight.debrief.title": "Wrap up this voyage",
|
||||
@@ -65,6 +85,7 @@ const enMessages = {
|
||||
"e.g. What worked, what did not, and what I learned",
|
||||
"debrief.save": "Save to Logbook",
|
||||
"log.title": "My Voyage Logs",
|
||||
"log.mission.empty": "No mission entered",
|
||||
"log.empty": "No voyages recorded yet.",
|
||||
"log.firstVoyage": "Start your first voyage",
|
||||
"log.detail.back": "Back to list",
|
||||
@@ -112,7 +133,8 @@ const koMessages = {
|
||||
"boarding.check": "Boarding Check",
|
||||
"boarding.routeBoarding": "{routeName} 항로 탑승",
|
||||
"boarding.missionLabel": "이번 항해의 핵심 목표",
|
||||
"boarding.missionPlaceholder": "예: 서론 3문단 완성하기",
|
||||
"boarding.missionPlaceholder": "선택: 예) 서론 3문단 완성하기",
|
||||
"boarding.optionalLabel": "선택",
|
||||
"boarding.cancel": "취소",
|
||||
"boarding.submit": "도킹 완료 (출항)",
|
||||
"flight.badge.paused": "일시정지",
|
||||
@@ -120,6 +142,23 @@ const koMessages = {
|
||||
"flight.missionLabel": "이번 항해 목표",
|
||||
"flight.pause": "일시정지",
|
||||
"flight.resume": "다시 시작",
|
||||
"flight.crewPresence.header": "현재 함께 항해 중 {count}명",
|
||||
"flight.crewPresence.expand": "펼치기",
|
||||
"flight.crewPresence.collapse": "접기",
|
||||
"flight.crewPresence.listLimit": "최대 {limit}명까지 표시",
|
||||
"flight.crewPresence.overflow": "+{count}명",
|
||||
"flight.crewPresence.status.online": "온라인",
|
||||
"flight.crewPresence.status.idle": "자리비움",
|
||||
"flight.crewPresence.status.offline": "오프라인",
|
||||
"flight.crewPresence.activityHint": "최근 활동 기반",
|
||||
"flight.crewPresence.elapsedMinutes": "{minutes}분 전",
|
||||
"flight.crewPresence.goal.private": "목표 비공개",
|
||||
"flight.crewPresence.goal.unset": "목표 미설정",
|
||||
"flight.crewPresence.notifications.policyOn": "알림 ON",
|
||||
"flight.crewPresence.notifications.filterHint":
|
||||
"friend/my-crew 이벤트만 표시",
|
||||
"flight.crewPresence.notifications.target.friend": "친구",
|
||||
"flight.crewPresence.notifications.target.myCrew": "내 크루",
|
||||
"flight.finish.debrief": "도착 (회고)",
|
||||
"flight.finish.end": "항해 종료",
|
||||
"flight.debrief.title": "이번 항해를 정리하세요",
|
||||
@@ -139,6 +178,7 @@ const koMessages = {
|
||||
"예: 잘한 점, 아쉬운 점, 느낀 점을 짧게 남겨보세요",
|
||||
"debrief.save": "항해일지 저장",
|
||||
"log.title": "나의 항해 기록",
|
||||
"log.mission.empty": "미입력",
|
||||
"log.empty": "아직 기록된 항해가 없습니다.",
|
||||
"log.firstVoyage": "첫 항해 떠나기",
|
||||
"log.detail.back": "목록으로",
|
||||
@@ -183,7 +223,8 @@ const jaMessages = {
|
||||
"boarding.check": "搭乗チェック",
|
||||
"boarding.routeBoarding": "{routeName} 航路に搭乗",
|
||||
"boarding.missionLabel": "今回の航海のコア目標",
|
||||
"boarding.missionPlaceholder": "例: 導入の3段落を完成する",
|
||||
"boarding.missionPlaceholder": "任意: 例) 導入の3段落を完成する",
|
||||
"boarding.optionalLabel": "任意",
|
||||
"boarding.cancel": "キャンセル",
|
||||
"boarding.submit": "ドッキング完了(出航)",
|
||||
"flight.badge.paused": "一時停止",
|
||||
@@ -191,6 +232,23 @@ const jaMessages = {
|
||||
"flight.missionLabel": "今回の航海目標",
|
||||
"flight.pause": "一時停止",
|
||||
"flight.resume": "再開",
|
||||
"flight.crewPresence.header": "現在一緒に航海中 {count}人",
|
||||
"flight.crewPresence.expand": "展開",
|
||||
"flight.crewPresence.collapse": "折りたたむ",
|
||||
"flight.crewPresence.listLimit": "最大{limit}人まで表示",
|
||||
"flight.crewPresence.overflow": "+{count}人",
|
||||
"flight.crewPresence.status.online": "オンライン",
|
||||
"flight.crewPresence.status.idle": "離席",
|
||||
"flight.crewPresence.status.offline": "オフライン",
|
||||
"flight.crewPresence.activityHint": "最近のアクティビティに基づく",
|
||||
"flight.crewPresence.elapsedMinutes": "{minutes}分前",
|
||||
"flight.crewPresence.goal.private": "目標は非公開",
|
||||
"flight.crewPresence.goal.unset": "目標未設定",
|
||||
"flight.crewPresence.notifications.policyOn": "通知 ON",
|
||||
"flight.crewPresence.notifications.filterHint":
|
||||
"friend/my-crew イベントのみ表示",
|
||||
"flight.crewPresence.notifications.target.friend": "フレンド",
|
||||
"flight.crewPresence.notifications.target.myCrew": "マイクルー",
|
||||
"flight.finish.debrief": "到着(振り返り)",
|
||||
"flight.finish.end": "航海終了",
|
||||
"flight.debrief.title": "今回の航海を整理しましょう",
|
||||
@@ -210,6 +268,7 @@ const jaMessages = {
|
||||
"例: 良かった点・難しかった点・気づきを短く残してください",
|
||||
"debrief.save": "航海ログに保存",
|
||||
"log.title": "私の航海記録",
|
||||
"log.mission.empty": "未入力",
|
||||
"log.empty": "まだ記録された航海がありません。",
|
||||
"log.firstVoyage": "最初の航海を始める",
|
||||
"log.detail.back": "一覧へ戻る",
|
||||
@@ -230,11 +289,207 @@ const jaMessages = {
|
||||
} satisfies I18nMessages;
|
||||
|
||||
const normalizedEnMessages: I18nMessages = enMessages;
|
||||
const frMessages: I18nMessages = {
|
||||
"layout.nav.log": "Journal de bord",
|
||||
"layout.nav.settings": "Parametres",
|
||||
"layout.nav.language": "Langue",
|
||||
"common.loading": "Chargement...",
|
||||
"common.minuteShort": "min",
|
||||
"routes.station.name": "Station spatiale",
|
||||
"routes.station.tag": "Attente/Flexible",
|
||||
"routes.station.description":
|
||||
"Une zone sure ou vous pouvez rester sans limite de temps",
|
||||
"routes.orion.name": "Orion",
|
||||
"routes.orion.tag": "Travail profond",
|
||||
"routes.orion.description": "Voyage de concentration de 60 minutes",
|
||||
"routes.gemini.name": "Gemini",
|
||||
"routes.gemini.tag": "Sprint court",
|
||||
"routes.gemini.description": "Voyage de concentration de 30 minutes",
|
||||
"lobby.title": "Vers quelle constellation voulez-vous naviguer ?",
|
||||
"lobby.subtitle": "Choisissez une orbite qui favorise votre concentration.",
|
||||
"lobby.cta.station": "Entrer dans la station (Attente)",
|
||||
"lobby.cta.launch": "Decoller maintenant",
|
||||
"lobby.modal.boardingCheck": "Verification d'embarquement",
|
||||
"lobby.modal.routeBoarding": "Embarquement route {routeName}",
|
||||
"lobby.modal.description":
|
||||
"Definissez votre mission avant de commencer ce voyage.",
|
||||
"boarding.check": "Verification d'embarquement",
|
||||
"boarding.routeBoarding": "Embarquement route {routeName}",
|
||||
"boarding.missionLabel": "Mission principale pour ce voyage",
|
||||
"boarding.missionPlaceholder":
|
||||
"Facultatif: terminer 3 paragraphes d'introduction",
|
||||
"boarding.optionalLabel": "Facultatif",
|
||||
"boarding.cancel": "Annuler",
|
||||
"boarding.submit": "Amarrage termine (Decoller)",
|
||||
"flight.badge.paused": "En pause",
|
||||
"flight.badge.cruising": "En croisiere",
|
||||
"flight.missionLabel": "Mission du voyage",
|
||||
"flight.pause": "Pause",
|
||||
"flight.resume": "Reprendre",
|
||||
"flight.crewPresence.header": "En navigation ensemble actuellement : {count}",
|
||||
"flight.crewPresence.expand": "Afficher",
|
||||
"flight.crewPresence.collapse": "Reduire",
|
||||
"flight.crewPresence.listLimit": "Afficher jusqu'a {limit} membres",
|
||||
"flight.crewPresence.overflow": "+{count} personnes",
|
||||
"flight.crewPresence.status.online": "En ligne",
|
||||
"flight.crewPresence.status.idle": "Inactif",
|
||||
"flight.crewPresence.status.offline": "Hors ligne",
|
||||
"flight.crewPresence.activityHint": "Base sur l'activite recente",
|
||||
"flight.crewPresence.elapsedMinutes": "il y a {minutes} min",
|
||||
"flight.crewPresence.goal.private": "Objectif prive",
|
||||
"flight.crewPresence.goal.unset": "Objectif non defini",
|
||||
"flight.crewPresence.notifications.policyOn": "Alertes ON",
|
||||
"flight.crewPresence.notifications.filterHint":
|
||||
"Afficher uniquement les evenements friend/my-crew",
|
||||
"flight.crewPresence.notifications.target.friend": "Ami",
|
||||
"flight.crewPresence.notifications.target.myCrew": "Mon crew",
|
||||
"flight.finish.debrief": "Arrivee (Debrief)",
|
||||
"flight.finish.end": "Terminer le voyage",
|
||||
"flight.debrief.title": "Concluez ce voyage",
|
||||
"flight.debrief.description":
|
||||
"Ecrivez une courte note et enregistrez-la dans votre journal.",
|
||||
"debrief.page.title": "Vous etes arrive en orbite en securite",
|
||||
"debrief.page.description":
|
||||
"Consignez brievement ce voyage puis terminez.",
|
||||
"debrief.status.label": "Resultat du voyage",
|
||||
"debrief.option.completed.label": "Mission accomplie",
|
||||
"debrief.option.completed.desc":
|
||||
"J'ai termine ce que j'avais prevu de faire.",
|
||||
"debrief.option.partial.label": "Progression partielle",
|
||||
"debrief.option.partial.desc":
|
||||
"J'ai avance les points cles et garde les prochaines etapes.",
|
||||
"debrief.option.reoriented.label": "Mission redefinie",
|
||||
"debrief.option.reoriented.desc":
|
||||
"J'ai reajuste le perimetre et les priorites pendant le travail.",
|
||||
"debrief.reflection.label": "Reflexion apres ce voyage",
|
||||
"debrief.reflection.placeholder":
|
||||
"Exemple: ce qui a fonctionne, ce qui n'a pas marche, et ce que j'ai appris",
|
||||
"debrief.save": "Enregistrer dans le journal",
|
||||
"log.title": "Mes journaux de voyage",
|
||||
"log.mission.empty": "Mission non renseignee",
|
||||
"log.empty": "Aucun voyage enregistre pour le moment.",
|
||||
"log.firstVoyage": "Commencer votre premier voyage",
|
||||
"log.detail.back": "Retour a la liste",
|
||||
"log.detail.loadingOrNotFound": "Chargement ou introuvable...",
|
||||
"log.detail.statusTitle": "Statut du resultat",
|
||||
"log.detail.progressTitle": "Ce que j'ai obtenu",
|
||||
"log.detail.nextActionTitle": "Action suivante",
|
||||
"log.detail.initialNoteTitle": "Note initiale",
|
||||
"settings.title": "Parametres",
|
||||
"settings.hideSeconds.title": "Masquer les secondes",
|
||||
"settings.hideSeconds.description":
|
||||
"Afficher uniquement les minutes sur le minuteur pour reduire la pression.",
|
||||
"status.completed": "Termine",
|
||||
"status.partial": "Partiel",
|
||||
"status.reoriented": "Reoriente",
|
||||
"status.aborted": "Interrompu tot",
|
||||
"status.in_progress": "En cours",
|
||||
};
|
||||
|
||||
const deMessages: I18nMessages = {
|
||||
"layout.nav.log": "Logbuch",
|
||||
"layout.nav.settings": "Einstellungen",
|
||||
"layout.nav.language": "Sprache",
|
||||
"common.loading": "Wird geladen...",
|
||||
"common.minuteShort": "Min",
|
||||
"routes.station.name": "Raumstation",
|
||||
"routes.station.tag": "Warten/Flexibel",
|
||||
"routes.station.description":
|
||||
"Ein sicherer Bereich, in dem Sie ohne Zeitlimit bleiben konnen",
|
||||
"routes.orion.name": "Orion",
|
||||
"routes.orion.tag": "Tiefe Arbeit",
|
||||
"routes.orion.description": "60-Minuten-Fokusreise",
|
||||
"routes.gemini.name": "Gemini",
|
||||
"routes.gemini.tag": "Kurzer Sprint",
|
||||
"routes.gemini.description": "30-Minuten-Fokusreise",
|
||||
"lobby.title": "Zu welcher Konstellation mochten Sie reisen?",
|
||||
"lobby.subtitle":
|
||||
"Wahlen Sie eine Umlaufbahn, die Ihre Konzentration unterstutzt.",
|
||||
"lobby.cta.station": "Station betreten (Warten)",
|
||||
"lobby.cta.launch": "Jetzt starten",
|
||||
"lobby.modal.boardingCheck": "Boarding-Check",
|
||||
"lobby.modal.routeBoarding": "Boarding fur Route {routeName}",
|
||||
"lobby.modal.description":
|
||||
"Legen Sie Ihre Mission fest, bevor diese Reise beginnt.",
|
||||
"boarding.check": "Boarding-Check",
|
||||
"boarding.routeBoarding": "Boarding fur Route {routeName}",
|
||||
"boarding.missionLabel": "Kernmission fur diese Reise",
|
||||
"boarding.missionPlaceholder":
|
||||
"Optional: 3 Einleitungsabsatze fertigstellen",
|
||||
"boarding.optionalLabel": "Optional",
|
||||
"boarding.cancel": "Abbrechen",
|
||||
"boarding.submit": "Andocken abgeschlossen (Start)",
|
||||
"flight.badge.paused": "Pausiert",
|
||||
"flight.badge.cruising": "Im Flug",
|
||||
"flight.missionLabel": "Reisemission",
|
||||
"flight.pause": "Pause",
|
||||
"flight.resume": "Fortsetzen",
|
||||
"flight.crewPresence.header": "Aktuell gemeinsam auf Reise: {count}",
|
||||
"flight.crewPresence.expand": "Ausklappen",
|
||||
"flight.crewPresence.collapse": "Einklappen",
|
||||
"flight.crewPresence.listLimit": "Bis zu {limit} Crewmitglieder anzeigen",
|
||||
"flight.crewPresence.overflow": "+{count} mehr",
|
||||
"flight.crewPresence.status.online": "Online",
|
||||
"flight.crewPresence.status.idle": "Inaktiv",
|
||||
"flight.crewPresence.status.offline": "Offline",
|
||||
"flight.crewPresence.activityHint": "Basierend auf letzter Aktivitat",
|
||||
"flight.crewPresence.elapsedMinutes": "vor {minutes} Min",
|
||||
"flight.crewPresence.goal.private": "Ziel privat",
|
||||
"flight.crewPresence.goal.unset": "Ziel nicht festgelegt",
|
||||
"flight.crewPresence.notifications.policyOn": "Hinweise ON",
|
||||
"flight.crewPresence.notifications.filterHint":
|
||||
"Nur friend/my-crew Ereignisse anzeigen",
|
||||
"flight.crewPresence.notifications.target.friend": "Freund",
|
||||
"flight.crewPresence.notifications.target.myCrew": "Meine Crew",
|
||||
"flight.finish.debrief": "Ankunft (Debrief)",
|
||||
"flight.finish.end": "Reise beenden",
|
||||
"flight.debrief.title": "Diese Reise abschliessen",
|
||||
"flight.debrief.description":
|
||||
"Schreiben Sie eine kurze Notiz und speichern Sie sie im Logbuch.",
|
||||
"debrief.page.title": "Sie haben die Umlaufbahn sicher erreicht",
|
||||
"debrief.page.description":
|
||||
"Dokumentieren Sie diese Reise kurz und schliessen Sie ab.",
|
||||
"debrief.status.label": "Reiseergebnis",
|
||||
"debrief.option.completed.label": "Mission abgeschlossen",
|
||||
"debrief.option.completed.desc":
|
||||
"Ich habe das erledigt, was ich mir vorgenommen hatte.",
|
||||
"debrief.option.partial.label": "Teilweise Fortschritte",
|
||||
"debrief.option.partial.desc":
|
||||
"Ich habe wichtige Teile vorangebracht und nachste Schritte offen gelassen.",
|
||||
"debrief.option.reoriented.label": "Mission neu ausgerichtet",
|
||||
"debrief.option.reoriented.desc":
|
||||
"Ich habe Umfang und Prioritaten wahrend der Arbeit neu festgelegt.",
|
||||
"debrief.reflection.label": "Reflexion nach dieser Reise",
|
||||
"debrief.reflection.placeholder":
|
||||
"Beispiel: was gut lief, was nicht gut lief, und was ich gelernt habe",
|
||||
"debrief.save": "Im Logbuch speichern",
|
||||
"log.title": "Meine Reiseprotokolle",
|
||||
"log.mission.empty": "Keine Mission eingetragen",
|
||||
"log.empty": "Noch keine Reisen aufgezeichnet.",
|
||||
"log.firstVoyage": "Ihre erste Reise starten",
|
||||
"log.detail.back": "Zuruck zur Liste",
|
||||
"log.detail.loadingOrNotFound": "Wird geladen oder nicht gefunden...",
|
||||
"log.detail.statusTitle": "Ergebnisstatus",
|
||||
"log.detail.progressTitle": "Was ich erreicht habe",
|
||||
"log.detail.nextActionTitle": "Nachste Aktion",
|
||||
"log.detail.initialNoteTitle": "Anfangsnotiz",
|
||||
"settings.title": "Einstellungen",
|
||||
"settings.hideSeconds.title": "Sekunden ausblenden",
|
||||
"settings.hideSeconds.description":
|
||||
"Nur Minuten auf dem Timer anzeigen, um Druck zu reduzieren.",
|
||||
"status.completed": "Abgeschlossen",
|
||||
"status.partial": "Teilweise",
|
||||
"status.reoriented": "Neu ausgerichtet",
|
||||
"status.aborted": "Fruh abgebrochen",
|
||||
"status.in_progress": "In Bearbeitung",
|
||||
};
|
||||
|
||||
export const I18N_MESSAGES: Record<Locale, I18nMessages> = {
|
||||
ko: koMessages,
|
||||
en: normalizedEnMessages,
|
||||
ja: jaMessages,
|
||||
fr: frMessages,
|
||||
de: deMessages,
|
||||
};
|
||||
|
||||
export type I18nKey = MessageKey;
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
export const FLIGHT_STARFIELD_TUNING = {
|
||||
mobileBreakpoint: 768,
|
||||
densityDivisor: 42000,
|
||||
densityMultiplier: 1.35,
|
||||
speedScale: 0.3,
|
||||
starCount: {
|
||||
mobile: { min: 12, max: 30 },
|
||||
desktop: { min: 18, max: 45 },
|
||||
mobile: { min: 16, max: 48 },
|
||||
desktop: { min: 24, max: 72 },
|
||||
},
|
||||
maxStars: {
|
||||
mobile: 48,
|
||||
desktop: 72,
|
||||
},
|
||||
vanishXJitter: { min: 10, max: 25 },
|
||||
speedTiers: {
|
||||
|
||||
71
src/shared/lib/analytics.ts
Normal file
71
src/shared/lib/analytics.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
export type CrewPresenceEventName =
|
||||
| "panel_visible"
|
||||
| "panel_collapsed"
|
||||
| "presence_event_click";
|
||||
|
||||
export type CrewPresenceNotificationTarget = "friend" | "my-crew";
|
||||
|
||||
export type CrewPresenceEventPayload = {
|
||||
decisionId: string;
|
||||
routeId: string;
|
||||
totalCount: number;
|
||||
visibleCount: number;
|
||||
renderCap?: number;
|
||||
updateIntervalMs?: number;
|
||||
collapsed?: boolean;
|
||||
targetType?: CrewPresenceNotificationTarget;
|
||||
};
|
||||
|
||||
type CrewPresenceDashboardBinding = {
|
||||
metricKey: string;
|
||||
chartKey: string;
|
||||
};
|
||||
|
||||
export const CREW_PRESENCE_DASHBOARD_BINDINGS: Record<
|
||||
CrewPresenceEventName,
|
||||
CrewPresenceDashboardBinding
|
||||
> = {
|
||||
panel_visible: {
|
||||
metricKey: "crew_presence.panel_visible.count",
|
||||
chartKey: "crew_presence_panel_visible_trend",
|
||||
},
|
||||
panel_collapsed: {
|
||||
metricKey: "crew_presence.panel_collapsed.count",
|
||||
chartKey: "crew_presence_panel_collapsed_trend",
|
||||
},
|
||||
presence_event_click: {
|
||||
metricKey: "crew_presence.presence_event_click.count",
|
||||
chartKey: "crew_presence_event_click_target_split",
|
||||
},
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__HUSHROOM_ANALYTICS__?: {
|
||||
track: (eventName: string, payload: Record<string, unknown>) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const trackCrewPresenceEvent = (
|
||||
eventName: CrewPresenceEventName,
|
||||
payload: CrewPresenceEventPayload,
|
||||
) => {
|
||||
try {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const dashboardBinding = CREW_PRESENCE_DASHBOARD_BINDINGS[eventName];
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("hushroom:crew-presence-analytics", {
|
||||
detail: { eventName, payload, dashboardBinding },
|
||||
}),
|
||||
);
|
||||
|
||||
window.__HUSHROOM_ANALYTICS__?.track(eventName, {
|
||||
...payload,
|
||||
...dashboardBinding,
|
||||
});
|
||||
} catch {
|
||||
// Keep analytics failures isolated from UX flow.
|
||||
}
|
||||
};
|
||||
@@ -9,14 +9,121 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useI18n } from "@/features/i18n/model/useI18n";
|
||||
import {
|
||||
isCrewPresenceRuntimeConfigSafe,
|
||||
resolveCrewPresenceRuntimeConfig,
|
||||
} from "@/shared/config/featureFlags";
|
||||
import { DEBRIEF_STATUS_OPTIONS } from "@/shared/config/i18n";
|
||||
import { findRouteById } from "@/shared/config/routes";
|
||||
import {
|
||||
CrewPresenceNotificationTarget,
|
||||
trackCrewPresenceEvent,
|
||||
} from "@/shared/lib/analytics";
|
||||
import { saveCurrentVoyage, saveToHistory } from "@/shared/lib/store";
|
||||
import { Voyage, VoyageStatus } from "@/shared/types";
|
||||
|
||||
const FINISH_HOLD_MS = 1000;
|
||||
const HOLD_STAGE_ONE_MS = 100;
|
||||
const HOLD_STAGE_ONE_PROGRESS = 0.2;
|
||||
const CREW_PRESENCE_GUARDRAIL_FALLBACK_CAP = 1;
|
||||
const CREW_PRESENCE_GUARDRAIL_FALLBACK_INTERVAL_MS = 60_000;
|
||||
const CREW_PRESENCE_DECISION_ID = "DEC-20260216-01";
|
||||
|
||||
type CrewPresenceStatus = "online" | "idle" | "offline";
|
||||
type CrewGoalVisibility = "public" | "private" | "unset";
|
||||
type CrewNotificationTarget = "friend" | "my-crew" | "global";
|
||||
|
||||
type CrewPresenceMember = {
|
||||
id: string;
|
||||
name: string;
|
||||
status: CrewPresenceStatus;
|
||||
elapsedMinutes: number;
|
||||
goalVisibility: CrewGoalVisibility;
|
||||
goalText: string;
|
||||
};
|
||||
|
||||
type CrewPresenceNotification = {
|
||||
id: string;
|
||||
targetType: CrewNotificationTarget;
|
||||
message: string;
|
||||
};
|
||||
|
||||
const CREW_PRESENCE_STATUS_CYCLE: CrewPresenceStatus[] = [
|
||||
"online",
|
||||
"idle",
|
||||
"offline",
|
||||
];
|
||||
const CREW_PRESENCE_GOAL_CYCLE: CrewGoalVisibility[] = [
|
||||
"public",
|
||||
"private",
|
||||
"unset",
|
||||
];
|
||||
const CREW_PRESENCE_MOCK_MEMBERS: CrewPresenceMember[] = Array.from(
|
||||
{ length: 57 },
|
||||
(_, index) => ({
|
||||
id: `crew-${index + 1}`,
|
||||
name: `Crew ${index + 1}`,
|
||||
status:
|
||||
CREW_PRESENCE_STATUS_CYCLE[index % CREW_PRESENCE_STATUS_CYCLE.length],
|
||||
elapsedMinutes: (index + 1) * 2,
|
||||
goalVisibility:
|
||||
CREW_PRESENCE_GOAL_CYCLE[index % CREW_PRESENCE_GOAL_CYCLE.length],
|
||||
goalText: `Mission ${index + 1}`,
|
||||
}),
|
||||
);
|
||||
|
||||
const CREW_STATUS_LABEL_KEYS: Record<CrewPresenceStatus, string> = {
|
||||
online: "flight.crewPresence.status.online",
|
||||
idle: "flight.crewPresence.status.idle",
|
||||
offline: "flight.crewPresence.status.offline",
|
||||
};
|
||||
|
||||
const CREW_GOAL_STATE_LABEL_KEYS: Record<
|
||||
Exclude<CrewGoalVisibility, "public">,
|
||||
string
|
||||
> = {
|
||||
private: "flight.crewPresence.goal.private",
|
||||
unset: "flight.crewPresence.goal.unset",
|
||||
};
|
||||
|
||||
const CREW_NOTIFICATION_ALLOWED_TARGETS = ["friend", "my-crew"] as const;
|
||||
|
||||
const CREW_PRESENCE_MOCK_NOTIFICATIONS: CrewPresenceNotification[] = [
|
||||
{
|
||||
id: "notif-1",
|
||||
targetType: "friend",
|
||||
message: "Crew 2 just joined this voyage",
|
||||
},
|
||||
{
|
||||
id: "notif-2",
|
||||
targetType: "my-crew",
|
||||
message: "Crew 7 switched to online",
|
||||
},
|
||||
{
|
||||
id: "notif-3",
|
||||
targetType: "global",
|
||||
message: "A public voyage wave started",
|
||||
},
|
||||
];
|
||||
|
||||
const CREW_NOTIFICATION_TARGET_LABEL_KEYS: Record<
|
||||
(typeof CREW_NOTIFICATION_ALLOWED_TARGETS)[number],
|
||||
string
|
||||
> = {
|
||||
friend: "flight.crewPresence.notifications.target.friend",
|
||||
"my-crew": "flight.crewPresence.notifications.target.myCrew",
|
||||
};
|
||||
|
||||
const isCrewNotificationTargetAllowed = (
|
||||
targetType: CrewNotificationTarget,
|
||||
): targetType is (typeof CREW_NOTIFICATION_ALLOWED_TARGETS)[number] =>
|
||||
CREW_NOTIFICATION_ALLOWED_TARGETS.includes(
|
||||
targetType as (typeof CREW_NOTIFICATION_ALLOWED_TARGETS)[number],
|
||||
);
|
||||
|
||||
type AllowedCrewPresenceNotification = CrewPresenceNotification & {
|
||||
targetType: CrewPresenceNotificationTarget;
|
||||
};
|
||||
|
||||
type FlightHudWidgetProps = {
|
||||
voyage: Voyage | null;
|
||||
@@ -42,6 +149,7 @@ export function FlightHudWidget({
|
||||
const [status, setStatus] = useState<VoyageStatus | null>(null);
|
||||
const [progress, setProgress] = useState("");
|
||||
const [holdProgress, setHoldProgress] = useState(0);
|
||||
const [isCrewPanelCollapsed, setIsCrewPanelCollapsed] = useState(true);
|
||||
const holdStartAtRef = useRef<number | null>(null);
|
||||
const holdRafRef = useRef<number | null>(null);
|
||||
const isHoldCompletedRef = useRef(false);
|
||||
@@ -110,7 +218,10 @@ export function FlightHudWidget({
|
||||
|
||||
const stageTwoElapsed = elapsed - HOLD_STAGE_ONE_MS;
|
||||
const stageTwoDuration = FINISH_HOLD_MS - HOLD_STAGE_ONE_MS;
|
||||
const stageTwoProgressRatio = Math.min(1, stageTwoElapsed / stageTwoDuration);
|
||||
const stageTwoProgressRatio = Math.min(
|
||||
1,
|
||||
stageTwoElapsed / stageTwoDuration,
|
||||
);
|
||||
|
||||
return Math.min(
|
||||
1,
|
||||
@@ -150,23 +261,107 @@ export function FlightHudWidget({
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!voyage) return null;
|
||||
|
||||
const route = findRouteById(voyage.routeId);
|
||||
const voyageRouteId = voyage?.routeId ?? "";
|
||||
const route = voyageRouteId ? findRouteById(voyageRouteId) : null;
|
||||
const routeName = route
|
||||
? t(route.nameKey, undefined, voyage.routeName)
|
||||
: voyage.routeName;
|
||||
? t(route.nameKey, undefined, voyage?.routeName ?? "")
|
||||
: (voyage?.routeName ?? "");
|
||||
const hasMissionText =
|
||||
Boolean(voyage?.missionText?.trim()) &&
|
||||
voyage?.missionText?.trim() !== "미입력";
|
||||
const statusOptions = DEBRIEF_STATUS_OPTIONS.map((option) => ({
|
||||
value: option.value as VoyageStatus,
|
||||
label: t(option.labelKey),
|
||||
desc: t(option.descKey),
|
||||
}));
|
||||
const crewRuntimeConfig = resolveCrewPresenceRuntimeConfig();
|
||||
const hasSafeCrewRuntimeConfig =
|
||||
isCrewPresenceRuntimeConfigSafe(crewRuntimeConfig);
|
||||
const crewRenderCap = hasSafeCrewRuntimeConfig
|
||||
? crewRuntimeConfig.renderCap
|
||||
: CREW_PRESENCE_GUARDRAIL_FALLBACK_CAP;
|
||||
const crewUpdateIntervalMs = hasSafeCrewRuntimeConfig
|
||||
? crewRuntimeConfig.updateIntervalMs
|
||||
: CREW_PRESENCE_GUARDRAIL_FALLBACK_INTERVAL_MS;
|
||||
const isCrewPresencePanelVisible =
|
||||
hasSafeCrewRuntimeConfig && crewRuntimeConfig.panelEnabled;
|
||||
const isCrewNotificationPolicyEnabled =
|
||||
hasSafeCrewRuntimeConfig && crewRuntimeConfig.notificationsEnabled;
|
||||
const crewTotalCount = CREW_PRESENCE_MOCK_MEMBERS.length;
|
||||
const visibleCrewMembers = CREW_PRESENCE_MOCK_MEMBERS.slice(0, crewRenderCap);
|
||||
const overflowCrewCount = Math.max(0, crewTotalCount - crewRenderCap);
|
||||
const filteredCrewNotifications: AllowedCrewPresenceNotification[] =
|
||||
CREW_PRESENCE_MOCK_NOTIFICATIONS.filter(
|
||||
(notification): notification is AllowedCrewPresenceNotification =>
|
||||
isCrewNotificationTargetAllowed(notification.targetType),
|
||||
);
|
||||
const buildCommonCrewAnalyticsPayload = () => ({
|
||||
decisionId: CREW_PRESENCE_DECISION_ID,
|
||||
routeId: voyageRouteId,
|
||||
totalCount: crewTotalCount,
|
||||
visibleCount: visibleCrewMembers.length,
|
||||
renderCap: crewRenderCap,
|
||||
updateIntervalMs: crewUpdateIntervalMs,
|
||||
});
|
||||
|
||||
const handleCrewPanelToggle = () => {
|
||||
setIsCrewPanelCollapsed((prev) => {
|
||||
const nextCollapsed = !prev;
|
||||
trackCrewPresenceEvent("panel_collapsed", {
|
||||
...buildCommonCrewAnalyticsPayload(),
|
||||
collapsed: nextCollapsed,
|
||||
});
|
||||
return nextCollapsed;
|
||||
});
|
||||
};
|
||||
|
||||
const handlePresenceEventClick = (
|
||||
targetType: CrewPresenceNotificationTarget,
|
||||
) => {
|
||||
trackCrewPresenceEvent("presence_event_click", {
|
||||
...buildCommonCrewAnalyticsPayload(),
|
||||
targetType,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCrewPresencePanelVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trackPanelVisible = () => {
|
||||
trackCrewPresenceEvent("panel_visible", {
|
||||
...buildCommonCrewAnalyticsPayload(),
|
||||
collapsed: isCrewPanelCollapsed,
|
||||
});
|
||||
};
|
||||
|
||||
trackPanelVisible();
|
||||
const intervalId = window.setInterval(
|
||||
trackPanelVisible,
|
||||
crewUpdateIntervalMs,
|
||||
);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [
|
||||
crewTotalCount,
|
||||
isCrewPanelCollapsed,
|
||||
isCrewPresencePanelVisible,
|
||||
crewUpdateIntervalMs,
|
||||
visibleCrewMembers.length,
|
||||
voyageRouteId,
|
||||
]);
|
||||
|
||||
if (!voyage) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="absolute top-8 z-10 text-center">
|
||||
<span className="rounded-full border border-indigo-500/30 bg-indigo-950/50 px-4 py-1.5 text-xs font-medium uppercase tracking-widest text-indigo-300 shadow-[0_0_15px_rgba(99,102,241,0.3)] backdrop-blur">
|
||||
{routeName} · {isPaused ? t("flight.badge.paused") : t("flight.badge.cruising")}
|
||||
{routeName} ·{" "}
|
||||
{isPaused ? t("flight.badge.paused") : t("flight.badge.cruising")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -176,6 +371,7 @@ export function FlightHudWidget({
|
||||
{formattedTime}
|
||||
</div>
|
||||
|
||||
{hasMissionText && (
|
||||
<div className="relative z-10 mb-24 w-full max-w-2xl px-4">
|
||||
<section className="rounded-2xl border border-slate-600/60 bg-slate-950/55 p-4 text-left shadow-[0_16px_40px_rgba(2,6,23,0.35)] backdrop-blur md:p-6">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.24em] text-slate-400 md:text-xs">
|
||||
@@ -186,6 +382,113 @@ export function FlightHudWidget({
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCrewPresencePanelVisible && (
|
||||
<div className="relative z-10 mb-8 w-full max-w-2xl px-4">
|
||||
<section className="rounded-2xl border border-slate-600/60 bg-slate-950/55 p-4 text-left shadow-[0_16px_40px_rgba(2,6,23,0.35)] backdrop-blur md:p-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h2 className="text-sm font-semibold text-slate-100 md:text-base">
|
||||
{t("flight.crewPresence.header", {
|
||||
count: crewTotalCount,
|
||||
})}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCrewPanelToggle}
|
||||
className="rounded-lg border border-slate-600/80 bg-slate-900/40 px-3 py-1.5 text-xs font-semibold text-slate-200 transition-colors hover:border-slate-400 hover:text-white"
|
||||
>
|
||||
{isCrewPanelCollapsed
|
||||
? t("flight.crewPresence.expand")
|
||||
: t("flight.crewPresence.collapse")}
|
||||
</button>
|
||||
</div>
|
||||
{!isCrewPanelCollapsed && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<p className="text-xs text-slate-400">
|
||||
{t("flight.crewPresence.listLimit", {
|
||||
limit: crewRenderCap,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
{t("flight.crewPresence.activityHint")}
|
||||
</p>
|
||||
<ul className="max-h-56 space-y-2 overflow-y-auto rounded-xl border border-slate-700/70 bg-slate-900/35 p-3">
|
||||
{visibleCrewMembers.map((member) => (
|
||||
<li
|
||||
key={member.id}
|
||||
className="space-y-1 rounded-lg border border-slate-700/70 bg-slate-900/60 px-3 py-2 text-sm text-slate-100"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="font-semibold">{member.name}</span>
|
||||
<span className="rounded-md border border-slate-600/80 px-2 py-0.5 text-[11px] text-slate-200">
|
||||
{t(CREW_STATUS_LABEL_KEYS[member.status])}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-300">
|
||||
{t("flight.crewPresence.elapsedMinutes", {
|
||||
minutes: member.elapsedMinutes,
|
||||
})}
|
||||
</div>
|
||||
<div className="text-xs text-slate-300">
|
||||
{member.goalVisibility === "public"
|
||||
? member.goalText
|
||||
: t(
|
||||
CREW_GOAL_STATE_LABEL_KEYS[member.goalVisibility],
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{overflowCrewCount > 0 && (
|
||||
<p className="text-xs font-semibold text-slate-300">
|
||||
{t("flight.crewPresence.overflow", {
|
||||
count: overflowCrewCount,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{isCrewNotificationPolicyEnabled &&
|
||||
filteredCrewNotifications.length > 0 && (
|
||||
<section className="rounded-xl border border-slate-700/70 bg-slate-900/35 p-3">
|
||||
<p className="text-xs font-semibold text-slate-200">
|
||||
{t("flight.crewPresence.notifications.policyOn")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
{t("flight.crewPresence.notifications.filterHint")}
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{filteredCrewNotifications.map((notification) => (
|
||||
<li key={notification.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handlePresenceEventClick(
|
||||
notification.targetType,
|
||||
)
|
||||
}
|
||||
className="w-full rounded-md border border-slate-700/70 bg-slate-900/60 px-2.5 py-1.5 text-left text-xs text-slate-200"
|
||||
>
|
||||
<span className="font-semibold text-slate-100">
|
||||
{t(
|
||||
CREW_NOTIFICATION_TARGET_LABEL_KEYS[
|
||||
notification.targetType
|
||||
],
|
||||
)}
|
||||
</span>{" "}
|
||||
<span className="text-slate-300">
|
||||
{notification.message}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute bottom-12 z-10 flex gap-6">
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user