diff --git a/app/components/HomeMissionCard.vue b/app/components/HomeMissionCard.vue index f533aa7..e31066e 100644 --- a/app/components/HomeMissionCard.vue +++ b/app/components/HomeMissionCard.vue @@ -152,9 +152,12 @@ function rowFillFrac(row: number[]): number { // 레벨별 색상은 공용 유틸(levelTheme)에서 — 미션카드·마이페이지 공유 const theme = computed(() => levelTheme(missionLevel.value)) -// 주간 미션 요일 스탬프 — weeklyStatus가 없어도 기본 요일로 항상 노출 -const DEFAULT_WEEK_DAYS = ['월', '화', '수', '목', '금', '토', '일'].map((day) => ({ day, isCompleted: false })) -const weekDays = computed(() => props.data.weeklyStatus?.weekDays ?? DEFAULT_WEEK_DAYS) +// 주간 미션 요일 스탬프 — 백엔드 days(월~일 boolean[])를 요일 라벨과 zip. 데이터 없으면 전부 미완료 +const WEEKDAY_LABELS = ['월', '화', '수', '목', '금', '토', '일'] +const weekDays = computed(() => { + const days = props.data.weeklyStatus?.days ?? [] + return WEEKDAY_LABELS.map((day, i) => ({ day, isCompleted: days[i] ?? false })) +}) // 진행 바 채움: 해당 주차의 흰 점을 완전히 덮는 지점까지 (figma 실측: 바 306px 기준 1/2 진행 = 163px) const barWidth = computed(() => { diff --git a/app/components/HomeMissionMaxCard.vue b/app/components/HomeMissionMaxCard.vue new file mode 100644 index 0000000..521a2ac --- /dev/null +++ b/app/components/HomeMissionMaxCard.vue @@ -0,0 +1,29 @@ + + + diff --git a/app/composables/useBadges.ts b/app/composables/useBadges.ts index 63c001e..f791cb6 100644 --- a/app/composables/useBadges.ts +++ b/app/composables/useBadges.ts @@ -96,10 +96,9 @@ export function useBadges() { // throwOnError=true면 에러를 던진다(useLoadState.run과 함께 에러 화면 분기용) async function load(throwOnError = false) { try { - const [badgeRes, retroCount] = await Promise.all([ - $api.get>('/api/v1/badges'), - fetchRetroCount(), - ]) + // 배지 획득 상태·이미지는 /api/v1/badges 만으로 결정 → 먼저 채워 배지 이미지가 바로 뜨게 한다. + // (진행 카운트용 전체 회고 조회는 느리므로 묶지 않고 뒤에서 비동기로 갱신) + const badgeRes = await $api.get>('/api/v1/badges') // 백엔드는 conditionType + threshold 로 식별 const byKey = new Map() for (const item of badgeRes.data.data ?? []) { @@ -112,10 +111,17 @@ export function useBadges() { image: `/badges/${def.code}.svg`, acquired: api?.acquired ?? false, acquiredAt: api?.acquiredAt ?? null, - // 회고 수 기준 배지만 진행 카운트 표기 (목표 초과 시 목표값으로 캡) - current: def.countable ? Math.min(retroCount, def.goal ?? retroCount) : 0, + current: 0, } }) + // 진행 카운트(countable 배지)는 별도로 조회 후 갱신 — 배지 이미지 노출을 막지 않음 + void fetchRetroCount().then((retroCount) => { + badges.value = badges.value.map((b) => ({ + ...b, + // 회고 수 기준 배지만 진행 카운트 표기 (목표 초과 시 목표값으로 캡) + current: b.countable ? Math.min(retroCount, b.goal ?? retroCount) : 0, + })) + }) } catch (e) { badges.value = buildDefault() if (throwOnError) throw e diff --git a/app/composables/usePushNotifications.ts b/app/composables/usePushNotifications.ts index c5c5584..af8cd7b 100644 --- a/app/composables/usePushNotifications.ts +++ b/app/composables/usePushNotifications.ts @@ -9,6 +9,10 @@ import { getMessaging, getToken, onMessage, isSupported } from 'firebase/messagi // 리스너 중복 바인딩 방지 — 컴포저블이 여러 곳(app.vue·알림 설정)에서 호출돼도 앱 전체에서 1회만 let nativeListenersBound = false +// 알림 탭으로 앱이 콜드 스타트되면 딥링크가 스플래시(index.vue)의 홈 이동에 덮여 사라진다. +// 이 경우 링크를 여기 담아두고, index.vue가 인증 확인 후 consumePendingDeepLink()로 소비한다. +let pendingDeepLink: string | null = null + export function usePushNotifications() { const config = useRuntimeConfig().public.firebase const { $api } = useNuxtApp() @@ -47,10 +51,17 @@ export function usePushNotifications() { const body = notification.body ?? notification.data?.body ?? '' show(body || title) }) - // 알림 탭 시 백엔드가 data.link로 내려준 화면으로 이동 ('/'는 index가 로그인 상태 보고 홈으로 분기) + // 알림 탭 시 백엔드가 data.link로 내려준 화면으로 이동. + // 콜드 스타트(아직 스플래시 '/')면 pendingDeepLink에 담아 index.vue가 인증 확인 후 이동하고, + // 앱 실행 중(웜)이면 현재 라우터로 바로 이동한다. PushNotifications.addListener('pushNotificationActionPerformed', (action) => { const link = action.notification.data?.link - if (typeof link === 'string' && link.startsWith('/')) router.push(link) + if (typeof link !== 'string' || !link.startsWith('/')) return + if (router.currentRoute.value.path === '/') { + pendingDeepLink = link + } else { + router.push(link) + } }) } @@ -177,5 +188,12 @@ export function usePushNotifications() { } } - return { register, listenForeground, syncIfConsented, deviceType, bindNativeListeners } + // 스플래시(index.vue)가 인증 확인 후 호출 — 알림 탭으로 콜드 스타트된 경우 대기 중인 딥링크를 꺼내 소비한다 + function consumePendingDeepLink(): string | null { + const link = pendingDeepLink + pendingDeepLink = null + return link + } + + return { register, listenForeground, syncIfConsented, deviceType, bindNativeListeners, consumePendingDeepLink } } diff --git a/app/pages/home.vue b/app/pages/home.vue index 8672a0a..5eb48f1 100644 --- a/app/pages/home.vue +++ b/app/pages/home.vue @@ -29,69 +29,28 @@ - -
- -
- - + - -
- - - -
- - +
@@ -111,8 +70,15 @@ {{ greetingMessage }} + +
- +
@@ -229,6 +195,11 @@ const FIRST_MISSION: CurrentMissionResponse = { weeklyStatus: null, popup: { exists: false, type: null }, } +// 최고 레벨: 백엔드가 mission=null + currentLevel=10으로 내려줌 (진행할 미션 없음) +const MAX_LEVEL = 10 +const isMaxLevel = computed( + () => mission.value != null && mission.value.mission == null && mission.value.currentLevel >= MAX_LEVEL, +) // 미션이 비어 있으면 기본(첫 회고) 카드로 대체 — 카드가 항상 레벨에 맞게 뜨도록 const displayMission = computed(() => mission.value?.mission ? mission.value : FIRST_MISSION, diff --git a/app/pages/index.vue b/app/pages/index.vue index 69f6a24..a50d9cd 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -13,6 +13,12 @@ definePageMeta({ layout: false }) const { $api } = useNuxtApp() const runtimeConfig = useRuntimeConfig() +const push = usePushNotifications() + +// 인증·온보딩 완료 사용자의 최종 목적지 — 알림 탭 딥링크가 있으면 그쪽 우선, 없으면 홈 +function authedDestination(): string { + return push.consumePendingDeepLink() ?? '/home' +} onMounted(async () => { // 이미 로그인된 경우 스플래시 없이 즉시 이동 @@ -23,7 +29,7 @@ onMounted(async () => { navigateTo('/login', { replace: true }) return } - navigateTo(isOnboardingCompleted === 'true' ? '/home' : '/onboarding', { replace: true }) + navigateTo(isOnboardingCompleted === 'true' ? authedDestination() : '/onboarding', { replace: true }) return } @@ -75,7 +81,7 @@ onMounted(async () => { navigateTo('/login', { replace: true }) return } - navigateTo(isOnboardingCompleted === 'true' ? '/home' : '/onboarding', { replace: true }) + navigateTo(isOnboardingCompleted === 'true' ? authedDestination() : '/onboarding', { replace: true }) } catch { navigateTo('/login', { replace: true }) } diff --git a/app/pages/login.vue b/app/pages/login.vue index 499a7f4..1130581 100644 --- a/app/pages/login.vue +++ b/app/pages/login.vue @@ -112,6 +112,8 @@ const isLoading = ref(false) // 로그인 실패·안내 토스트는 notice 아이콘 표시 (피그마 8088:31255) const { show } = useToast() const showToast = (msg: string) => show(msg, { icon: true }) +// 로그인 성공 직후 푸시 기기 토큰을 등록하기 위해 사용 +const push = usePushNotifications() onMounted(async () => { // accessToken과 refreshToken이 모두 있을 때만 자동 로그인 @@ -177,6 +179,11 @@ async function submitLogin(provider: 'KAKAO' | 'GOOGLE' | 'APPLE', oauthToken: s const { data } = await $api.post>('/api/v1/auth/login', { provider, oauthToken }) localStorage.setItem('accessToken', data.data.accessToken) localStorage.setItem('refreshToken', data.data.refreshToken) + // 온보딩 완료 여부를 저장해야 다음 앱 실행 시 스플래시(index.vue)가 세션을 유지한다. + // (이 값이 없으면 스플래시가 미로그인으로 간주해 localStorage를 비우고 로그인 화면으로 보냄 → 매번 재로그인) + localStorage.setItem('isOnboardingCompleted', String(data.data.isOnboardingCompleted)) + // 로그인 직후 푸시 동의 상태면 기기 토큰을 즉시 등록한다 (앱 재실행 전까지 알림이 누락되던 문제 방지) + push.syncIfConsented() identify(data.data.accessToken, { provider: provider.toLowerCase() }) diff --git a/app/pages/my/index.vue b/app/pages/my/index.vue index 553d698..e5207d5 100644 --- a/app/pages/my/index.vue +++ b/app/pages/my/index.vue @@ -13,8 +13,9 @@

{{ jobLabel }}

{{ profile?.nickname ?? '' }}

- + Lv.{{ displayLevel }} @@ -142,8 +143,10 @@ const jobLabels: Record = { const jobLabel = computed(() => (profile.value?.job ? jobLabels[profile.value.job] : '')) -// 백엔드 currentLevel은 '달성한 레벨'이라 신규 유저는 0 — 표시 레벨은 최소 1 (모든 유저 Lv.1부터) -const displayLevel = computed(() => Math.max(profile.value?.currentLevel ?? 1, 1)) +// 백엔드 currentLevel은 '달성한 레벨'이라 신규·회고X 유저는 0 +// → 레벨 1 이상(회고 O)부터 배지 노출, 그 전엔 배지 자체를 숨김 +const showLevelBadge = computed(() => (profile.value?.currentLevel ?? 0) >= 1) +const displayLevel = computed(() => profile.value?.currentLevel ?? 1) const { badges, load: loadBadges } = useBadges() const acquiredBadges = computed(() => diff --git a/app/pages/retrospect/start.vue b/app/pages/retrospect/start.vue index 0775609..2dea536 100644 --- a/app/pages/retrospect/start.vue +++ b/app/pages/retrospect/start.vue @@ -311,7 +311,9 @@ const hasAnswered = computed(() => messages.value.some((m) => m.role === 'user') const voiceSupported = computed( () => isNative.value || (import.meta.client && !!navigator.mediaDevices?.getUserMedia), ) -const showVoice = computed(() => voiceSupported.value && inputText.value.trim().length === 0) +// 한글 IME 조합 중에는 v-model(inputText)이 갱신되지 않아, DOM 값을 직접 추적해 즉시 반영 +const hasInput = ref(false) +const showVoice = computed(() => voiceSupported.value && !hasInput.value) const exitDescription = computed(() => hasAnswered.value ? '지금까지 작성한 내용은 저장되지 않으며, 오늘 회고 횟수 1회가 차감돼요.' @@ -357,6 +359,8 @@ function autoGrow() { el.style.height = 'auto' el.style.height = `${Math.min(el.scrollHeight, 120)}px` isMultiline.value = el.scrollHeight > 40 + // 조합 중 글자를 포함해 실제 입력 유무 판단 (IME 대응) + hasInput.value = el.value.trim().length > 0 } // 질문 순번 계산: questionType('Q1'..)에서 숫자 추출, 없으면 카운터 증가 @@ -642,6 +646,7 @@ async function onConfirmRestart() { retrospectiveId.value = res.retrospectiveId messages.value = [] inputText.value = '' + hasInput.value = false questionNo.value = 0 deepAsked.value = false lastDeepId.value = null diff --git a/app/stores/auth.ts b/app/stores/auth.ts index 0334020..c711887 100644 --- a/app/stores/auth.ts +++ b/app/stores/auth.ts @@ -5,19 +5,17 @@ export const useAuthStore = defineStore('auth', () => { const user = ref(null) const isLoggedIn = computed(() => !!user.value) - // useProfile()과 동일한 전역 캐시 키 — 로그아웃 시 함께 비워 - // 다른 계정으로 재로그인했을 때 이전 계정 프로필(닉네임 등)이 남는 것을 방지 - const profileCache = useState('user:profile', () => null) - const profileInflight = useState | null>('user:profile:inflight', () => null) - function setUser(profile: UserProfile) { user.value = profile } function logout() { user.value = null - profileCache.value = null - profileInflight.value = null + // 로그아웃은 navigateTo로 SPA 이동이라 페이지가 리로드되지 않는다. + // → 전역 useState 캐시(프로필·프로젝트·회고·홈·알림 등)를 모두 비워 + // 다른 계정으로 재로그인했을 때 이전 계정 데이터가 남는 것을 방지한다. + // (user 스코프가 아닌 키는 다음 접근 시 기본값 팩토리로 재생성됨) + clearNuxtState() localStorage.removeItem('accessToken') localStorage.removeItem('refreshToken') } diff --git a/app/types/api.ts b/app/types/api.ts index 3c75407..b24c50c 100644 --- a/app/types/api.ts +++ b/app/types/api.ts @@ -131,14 +131,11 @@ export interface RecentRetrospective { // Mission / Level (게이미피케이션) — GET /api/v1/missions/current export type MissionType = 'FIRST_RETRO' | 'TIME_LIMITED' | 'CONSECUTIVE_WEEK' | 'CUMULATIVE_RETRO' -// 연속 주 미션에서만 내려옴 (월~일 7요소) -export interface WeekDayStatus { - day: string - isCompleted: boolean -} - +// 연속 주 미션에서만 내려옴. 백엔드 WeeklyStatus 계약: days는 이번 주 월~일 7개 boolean export interface WeeklyRetroStatus { - weekDays: WeekDayStatus[] + show: boolean + weekStart: string + days: boolean[] } // 미션 상세 (최고 레벨 등에서는 mission = null) diff --git a/package.json b/package.json index d807876..5f276cb 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,9 @@ "scripts": { "build": "nuxt build", "dev": "nuxt dev", + "dev:host": "nuxt dev --host 0.0.0.0", + "cap:live:android": "cap run android --live-reload --external --port 3000", + "cap:live:ios": "cap run ios --live-reload --external --port 3000", "generate": "nuxt generate", "cap:sync": "nuxt generate && cap sync", "cap:sync:prod": "nuxt generate --dotenv .env.production && cap sync",