Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions app/components/HomeMissionCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
29 changes: 29 additions & 0 deletions app/components/HomeMissionMaxCard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<template>
<!-- 최고 레벨(Lv.10): 진행할 미션이 없어 '이번 주 회고 현황'만 노출 (Figma 8173-30195) -->
<div class="bg-white rounded-2xl p-[22px] flex flex-col gap-3">
<p class="text-[14px] font-semibold text-grey-9 leading-[1.4] tracking-[-0.02em]">이번 주 회고 현황</p>
<div class="flex gap-2">
<div
v-for="d in weekDays"
:key="d.day"
class="flex-1 aspect-square max-w-11 rounded-xl flex items-center justify-center text-[15px] font-semibold"
:class="d.isCompleted ? 'bg-green-light-hover text-green-hover' : 'bg-grey-4 text-grey-6'"
>
{{ d.day }}
</div>
</div>
</div>
</template>

<script setup lang="ts">
import type { WeeklyRetroStatus } from '~/types/api'

const props = defineProps<{ weeklyStatus?: WeeklyRetroStatus | null }>()

// 이번 주 요일 스탬프 — 백엔드 days(월~일 boolean[])를 요일 라벨과 zip. 데이터 없으면 전부 미완료
const WEEKDAY_LABELS = ['월', '화', '수', '목', '금', '토', '일']
const weekDays = computed(() => {
const days = props.weeklyStatus?.days ?? []
return WEEKDAY_LABELS.map((day, i) => ({ day, isCompleted: days[i] ?? false }))
})
</script>
18 changes: 12 additions & 6 deletions app/composables/useBadges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ApiResponse<BadgeApiItem[]>>('/api/v1/badges'),
fetchRetroCount(),
])
// 배지 획득 상태·이미지는 /api/v1/badges 만으로 결정 → 먼저 채워 배지 이미지가 바로 뜨게 한다.
// (진행 카운트용 전체 회고 조회는 느리므로 묶지 않고 뒤에서 비동기로 갱신)
const badgeRes = await $api.get<ApiResponse<BadgeApiItem[]>>('/api/v1/badges')
// 백엔드는 conditionType + threshold 로 식별
const byKey = new Map<string, BadgeApiItem>()
for (const item of badgeRes.data.data ?? []) {
Expand All @@ -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
Expand Down
24 changes: 21 additions & 3 deletions app/composables/usePushNotifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Comment on lines +60 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle cold-start links after splash redirects

On an authenticated cold start, index.vue consumes pendingDeepLink and calls navigateTo(... ?? '/home') in its mounted hook, while the native listener is bound later from app.vue's mounted hook. If Capacitor delivers pushNotificationActionPerformed in that window and currentRoute is still '/', this branch only stores the link; because index.vue is the only caller of consumePendingDeepLink() and it has already returned, nothing ever opens the notification target and the scheduled /home navigation wins. This affects notification taps that launch an already logged-in app.

Useful? React with 👍 / 👎.

} else {
router.push(link)
}
})
}

Expand Down Expand Up @@ -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 }
}
79 changes: 25 additions & 54 deletions app/pages/home.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,69 +29,28 @@
</div>
</Teleport>

<!-- 헤더: H:50, 벨 아이콘만 우측 (빈 화면에서만 고정 노출 — 로딩 중엔 숨김) -->
<header
v-if="!isLoading && recentRetrospectives.length === 0"
class="flex items-center justify-end px-5 h-[50px] shrink-0"
>
<button @click="goToNotifications">
<img
:src="hasUnread ? '/icons/bell-on.svg' : '/icons/bell-off.svg'"
alt="알림"
class="w-6 h-6"
/>
</button>
</header>

<!-- 로드 실패: 헤더 아래~탭바 위를 덮는 전체 화면 에러 -->
<!-- 로드 실패: 전체 화면 에러 -->
<UiErrorState
v-if="loadError"
:variant="loadError"
class="absolute inset-x-0 bottom-0 top-[50px] bg-background"
class="absolute inset-0 bg-background"
@action="loadError === 'network' ? loadHome() : navigateTo('/my/inquiry')"
/>

<!-- 인사말 (로딩/빈 상태에서만 고정; 회고 있으면 아래 스크롤 영역 안에 포함) -->
<div v-if="isLoading || recentRetrospectives.length === 0" class="px-5 shrink-0">
<template v-if="isLoading">
<!-- 로딩 스켈레톤: 헤더(벨) + 인사말 -->
<template v-else-if="isLoading">
<header class="flex items-center justify-end px-5 h-[50px] shrink-0">
<span class="w-6 h-6 rounded-full bg-grey-4 animate-pulse" />
</header>
<div class="px-5 shrink-0">
<span class="inline-block w-24 h-6 bg-grey-4 rounded animate-pulse mb-1 block" />
<span class="inline-block w-52 h-6 bg-grey-4 rounded animate-pulse block" />
</template>
<h1 v-else class="text-title3 font-semibold text-grey-13 leading-[1.4]">
{{ nickname }}님,<br />
{{ greetingMessage }}
</h1>
</div>

<!-- 빈 상태: 세로 중앙 정렬 -->
<div
v-if="!isLoading && recentRetrospectives.length === 0"
class="flex-1 flex flex-col items-center justify-center gap-[40px] pb-16"
>
<!-- 아이콘 + 텍스트 그룹 -->
<div class="flex flex-col items-center gap-3">
<img src="/icons/empty-home.svg" alt="" class="w-[70px] h-[70px] rounded-[12px]" />
<div class="flex flex-col items-center gap-[6px]">
<p class="text-heading2 font-semibold text-grey-13">아직 작성한 회고가 없어요</p>
<p class="text-label1-reading font-normal text-grey-9 text-center">
회고를 시작하고<br />오늘의 일을 기록해 보세요!
</p>
</div>
</div>
</template>

<!-- 회고 시작하기 버튼 -->
<button
class="flex items-center gap-1 pl-[12px] pr-[18px] py-[9px] bg-primary rounded-xl"
@click="startRetrospect"
>
<img src="/icons/add.svg" alt="" class="w-6 h-6" />
<span class="text-body2 font-semibold text-grey-13">회고 시작하기</span>
</button>
</div>

<!-- 회고 있음: 인사말 + 피드백 슬라이더 + 나의 최근 회고 (전체 스크롤) -->
<!-- 콘텐츠: 회고 유무와 무관하게 항상 인사말 + 미션 카드 노출 (회고 없으면 Lv.1 첫 회고 카드) -->
<div
v-else-if="!isLoading && recentRetrospectives.length > 0"
v-else
class="flex-1 min-h-0 overflow-y-auto scrollbar-hide pb-24"
>
<!-- 헤더: 알림벨 (콘텐츠와 함께 스크롤) -->
Expand All @@ -111,8 +70,15 @@
{{ greetingMessage }}
</h1>

<!-- 최고 레벨: 진행할 미션이 없어 이번 주 회고 현황만 노출 -->
<HomeMissionMaxCard
v-if="isMaxLevel"
:weekly-status="mission?.weeklyStatus"
class="mt-5 mx-5"
/>
<!-- 미션/레벨 카드 — 비면 첫 회고(Lv.1 달성) 기본 카드 -->
<HomeMissionCard
v-else
:data="displayMission"
:disabled="isCompleted"
class="mt-5 mx-5"
Expand Down Expand Up @@ -148,9 +114,9 @@
</div>
</div>

<!-- FAB + 툴팁: 회고 있을 때만 표시 -->
<!-- FAB + 툴팁: 로딩/에러가 아니면 항상 표시 -->
<div
v-if="!isLoading && recentRetrospectives.length > 0"
v-if="!isLoading && !loadError"
class="absolute right-5 flex items-center gap-[14px]"
style="bottom: 16px;"
>
Expand Down Expand Up @@ -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<CurrentMissionResponse>(() =>
mission.value?.mission ? mission.value : FIRST_MISSION,
Expand Down
10 changes: 8 additions & 2 deletions app/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
// 이미 로그인된 경우 스플래시 없이 즉시 이동
Expand All @@ -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
}

Expand Down Expand Up @@ -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 })
}
Expand Down
7 changes: 7 additions & 0 deletions app/pages/login.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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이 모두 있을 때만 자동 로그인
Expand Down Expand Up @@ -177,6 +179,11 @@ async function submitLogin(provider: 'KAKAO' | 'GOOGLE' | 'APPLE', oauthToken: s
const { data } = await $api.post<ApiResponse<TokenResponse>>('/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() })

Expand Down
9 changes: 6 additions & 3 deletions app/pages/my/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@
<p class="text-label2 font-semibold text-grey-7">{{ jobLabel }}</p>
<div class="flex items-center gap-[7px]">
<p class="text-heading1 font-semibold text-grey-13 truncate">{{ profile?.nickname ?? '' }}</p>
<!-- 레벨 배지 -->
<!-- 레벨 배지: 레벨 1 이상 달성(회고 O)한 유저만 노출 -->
<span
v-if="showLevelBadge"
class="shrink-0 inline-flex items-center px-[6px] py-[3px] rounded-[6px] text-[11px] font-semibold leading-[1.3] tracking-[-0.02em]"
:style="{ backgroundColor: levelTheme(displayLevel).light, color: levelTheme(displayLevel).accent }"
>Lv.{{ displayLevel }}</span>
Expand Down Expand Up @@ -142,8 +143,10 @@ const jobLabels: Record<JobType, string> = {

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(() =>
Expand Down
7 changes: 6 additions & 1 deletion app/pages/retrospect/start.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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회가 차감돼요.'
Expand Down Expand Up @@ -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'..)에서 숫자 추출, 없으면 카운터 증가
Expand Down Expand Up @@ -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
Expand Down
12 changes: 5 additions & 7 deletions app/stores/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,17 @@ export const useAuthStore = defineStore('auth', () => {
const user = ref<UserProfile | null>(null)
const isLoggedIn = computed(() => !!user.value)

// useProfile()과 동일한 전역 캐시 키 — 로그아웃 시 함께 비워
// 다른 계정으로 재로그인했을 때 이전 계정 프로필(닉네임 등)이 남는 것을 방지
const profileCache = useState<UserProfile | null>('user:profile', () => null)
const profileInflight = useState<Promise<UserProfile | null> | 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')
}
Expand Down
11 changes: 4 additions & 7 deletions app/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading