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
4 changes: 4 additions & 0 deletions apps/mac/src/renderer/src/app/layouts/main/MainLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,12 @@ export const MainLayout = () => {
</S.ContentWrapper>
<AttendanceDialog
weeklyAttendance={attendanceDialog.weeklyAttendance}
optimisticAttendedDate={attendanceDialog.optimisticAttendedDate}
animatedAttendanceDate={attendanceDialog.animatedAttendanceDate}
isOpen={attendanceDialog.isOpen}
isSubmitting={attendanceDialog.isSubmitting}
isTodayAttended={attendanceDialog.isTodayAttended}
isCompletingAttendance={attendanceDialog.isCompletingAttendance}
errorMessage={attendanceDialog.errorMessage}
onClose={attendanceDialog.close}
onConfirm={attendanceDialog.confirm}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ export const useMarkAttendanceMutation = () => {
const response = await attendanceApi.markAttendance();
return response.data;
},
onSettled: async (_data, error) => {
await queryClient.invalidateQueries({ queryKey: attendanceQueryKeys.all });
onSettled: (_data, error) => {
void queryClient.invalidateQueries({ queryKey: attendanceQueryKeys.all });

if (!error) {
await queryClient.invalidateQueries({ queryKey: ["user"] });
void queryClient.invalidateQueries({ queryKey: ["user"] });
}
},
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export const useWeeklyAttendanceQuery = (enabled = true) => {
return response.data;
},
enabled,
placeholderData: previousData => previousData,
staleTime: 0,
refetchOnMount: "always",
retry: false,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
isAttended,
useMarkAttendanceMutation,
Expand All @@ -9,6 +9,7 @@ import { getErrorMessage } from "@/shared/lib";

const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
const ATTENDANCE_RESET_HOUR_MS = 6 * 60 * 60 * 1000;
const ATTENDANCE_STAMP_DURATION_MS = 1000;

const getCurrentAttendanceDate = (nowMs = Date.now()) => {
const shiftedDate = new Date(nowMs + KST_OFFSET_MS - ATTENDANCE_RESET_HOUR_MS);
Expand Down Expand Up @@ -36,15 +37,40 @@ export const useAttendanceDialog = () => {
const { mutateAsync: markAttendance, isPending: isSubmitting } = useMarkAttendanceMutation();
const [isManuallyOpen, setIsManuallyOpen] = useState(false);
const [dismissedAttendanceDate, setDismissedAttendanceDate] = useState<string | null>(null);
const [optimisticAttendedDate, setOptimisticAttendedDate] = useState<string | null>(null);
const [animatedAttendanceDate, setAnimatedAttendanceDate] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState("");
const closeTimeoutRef = useRef<number | null>(null);

const clearCloseTimeout = () => {
if (closeTimeoutRef.current === null) {
return;
}

window.clearTimeout(closeTimeoutRef.current);
closeTimeoutRef.current = null;
};

useEffect(() => {
return () => {
clearCloseTimeout();
};
}, []);

const isTodayAttended = useMemo(() => {
return getIsTodayAttended(weeklyAttendance, currentAttendanceDate);
}, [currentAttendanceDate, weeklyAttendance]);
return (
getIsTodayAttended(weeklyAttendance, currentAttendanceDate) ||
optimisticAttendedDate === currentAttendanceDate
);
}, [currentAttendanceDate, optimisticAttendedDate, weeklyAttendance]);

const isCompletingAttendance = animatedAttendanceDate !== null;

const isOpen = Boolean(
weeklyAttendance &&
(isManuallyOpen || (!isTodayAttended && dismissedAttendanceDate !== currentAttendanceDate))
(isCompletingAttendance ||
isManuallyOpen ||
(!isTodayAttended && dismissedAttendanceDate !== currentAttendanceDate))
);

const open = () => {
Expand All @@ -54,6 +80,10 @@ export const useAttendanceDialog = () => {
};

const close = () => {
if (isSubmitting || isCompletingAttendance) {
return;
}

setErrorMessage("");
setIsManuallyOpen(false);

Expand All @@ -76,8 +106,15 @@ export const useAttendanceDialog = () => {

try {
await markAttendance();
clearCloseTimeout();
setOptimisticAttendedDate(currentAttendanceDate);
setAnimatedAttendanceDate(currentAttendanceDate);
setDismissedAttendanceDate(currentAttendanceDate);
setIsManuallyOpen(false);
closeTimeoutRef.current = window.setTimeout(() => {
setAnimatedAttendanceDate(null);
setIsManuallyOpen(false);
closeTimeoutRef.current = null;
}, ATTENDANCE_STAMP_DURATION_MS);
} catch (error) {
setErrorMessage(getErrorMessage(error, "출석에 실패했습니다."));
}
Expand All @@ -89,6 +126,9 @@ export const useAttendanceDialog = () => {
isSubmitting,
isTodayAttended,
isAttendancePending: Boolean(weeklyAttendance && !isTodayAttended),
optimisticAttendedDate,
animatedAttendanceDate,
isCompletingAttendance,
errorMessage,
open,
close,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,43 @@
import styled, { css } from "styled-components";
import styled, { css, keyframes } from "styled-components";
import { font } from "@clash/design-tokens/font";
import { palette } from "@clash/design-tokens/theme";
import AttendedSvg from "./assets/attended.svg";
import NotAttendedSvg from "./assets/not-attended.svg";
import CalendarSvg from "@/shared/ui/assets/calendar.svg";

const stampIn = keyframes`
0% {
opacity: 0;
transform: scale(0.7) rotate(-12deg);
}

58% {
opacity: 1;
transform: scale(1.08) rotate(4deg);
}

100% {
opacity: 1;
transform: scale(1) rotate(0deg);
}
`;

const stampFlash = keyframes`
0% {
opacity: 0;
transform: scale(0.7);
}

45% {
opacity: 0.18;
}

100% {
opacity: 0;
transform: scale(1.2);
}
`;

export const Body = styled.div`
display: flex;
flex-direction: column;
Expand Down Expand Up @@ -66,6 +99,8 @@ export const DayItem = styled.div`
const dayStatusIconStyle = css`
width: 3.85rem;
height: 3.85rem;
position: relative;
z-index: 1;
`;

export const AttendedIcon = styled(AttendedSvg)`
Expand All @@ -76,6 +111,40 @@ export const NotAttendedIcon = styled(NotAttendedSvg)`
${dayStatusIconStyle}
`;

export const DayIconFrame = styled.div<{ $isAnimated: boolean }>`
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 3.85rem;
height: 3.85rem;

${({ $isAnimated, theme }) =>
$isAnimated &&
css`
animation: ${stampIn} 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;

&::after {
content: "";
position: absolute;
inset: 0.35rem;
z-index: 0;
border-radius: 999px;
background-color: ${theme.primary.normal};
animation: ${stampFlash} 0.5s ease-out both;
}
`}

@media (prefers-reduced-motion: reduce) {
animation: none;

&::after {
animation: none;
opacity: 0;
}
}
`;

export const DayLabel = styled.span<{ $isAttended: boolean }>`
${font.label.medium}
text-align: center;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,37 @@ import { Button, Dialog } from "@/shared/ui";
import { isAttended, type WeeklyAttendanceResponse } from "@/entities/attendance";
import * as S from "./AttendanceDialog.style";

const WEEKDAY_LABELS = ["일요일", "월요일", "화요일", "수요일", "목요일", "금요일", "토요일"] as const;
const WEEKDAY_LABELS = [
"일요일",
"월요일",
"화요일",
"수요일",
"목요일",
"금요일",
"토요일",
] as const;

interface AttendanceDialogProps {
weeklyAttendance: WeeklyAttendanceResponse | null;
optimisticAttendedDate: string | null;
animatedAttendanceDate: string | null;
isOpen: boolean;
isSubmitting: boolean;
isTodayAttended: boolean;
isCompletingAttendance: boolean;
errorMessage: string;
onClose: () => void;
onConfirm: () => Promise<void>;
}

export const AttendanceDialog = ({
weeklyAttendance,
optimisticAttendedDate,
animatedAttendanceDate,
isOpen,
isSubmitting,
isCompletingAttendance,
isTodayAttended,
errorMessage,
onClose,
onConfirm,
Expand All @@ -25,15 +41,27 @@ export const AttendanceDialog = ({
return null;
}

const headline =
weeklyAttendance.currentStreak > 0
const isDialogLocked = isSubmitting || isCompletingAttendance;
const headline = isCompletingAttendance
? "출석 완료!"
: weeklyAttendance.currentStreak > 0
? `${weeklyAttendance.currentStreak}일 연속 공부 중`
: "오늘도 공부해 볼까요?";

const description = `${weeklyAttendance.weekNumber}주째 출석 중이에요!`;
const description = isCompletingAttendance
? "오늘의 출석을 완료했어요."
: `${weeklyAttendance.weekNumber}주째 출석 중이에요!`;

return (
<Dialog title="출석" width={26} height={33} isOpen={isOpen} onClose={onClose}>
<Dialog
title="출석"
width={26}
height={33}
isOpen={isOpen}
onClose={onClose}
closeOnOverlayClick={!isDialogLocked}
showClose={!isDialogLocked}
>
<S.Body>
<S.Hero>
<S.GiftIcon />
Expand All @@ -43,14 +71,21 @@ export const AttendanceDialog = ({

<S.Board>
<S.DayGrid>
{weeklyAttendance.days.map((day, index) => (
<S.DayItem key={day.date}>
{isAttended(day.attendanceStatus) ? <S.AttendedIcon /> : <S.NotAttendedIcon />}
<S.DayLabel $isAttended={isAttended(day.attendanceStatus)}>
{WEEKDAY_LABELS[index] ?? ""}
</S.DayLabel>
</S.DayItem>
))}
{weeklyAttendance.days.map((day, index) => {
const isDisplayedAttended =
isAttended(day.attendanceStatus) || day.date === optimisticAttendedDate;

return (
<S.DayItem key={day.date}>
<S.DayIconFrame $isAnimated={day.date === animatedAttendanceDate}>
{isDisplayedAttended ? <S.AttendedIcon /> : <S.NotAttendedIcon />}
</S.DayIconFrame>
<S.DayLabel $isAttended={isDisplayedAttended}>
{WEEKDAY_LABELS[index] ?? ""}
</S.DayLabel>
</S.DayItem>
);
})}
</S.DayGrid>
</S.Board>

Expand All @@ -61,9 +96,9 @@ export const AttendanceDialog = ({
size="lg"
fullWidth
onClick={() => void onConfirm()}
isLoading={isSubmitting}
isLoading={isDialogLocked}
>
확인
{isSubmitting ? "출석 중..." : isTodayAttended ? "출석 완료" : "출석"}
</Button>
</S.Body>
</Dialog>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export const rankingRewardTooltipContent = `Aura: 100,000 EXP & 상위 1등
Master: 75,000 EXP & 상위 3등
export const rankingRewardTooltipContent = `Aura: 150,000 EXP & 상위 1등
Master: 100,000 EXP & 상위 3등
Diamond: 50,000 EXP
Gold: 30,000 EXP
Silver: 10,000 EXP
Expand Down
15 changes: 9 additions & 6 deletions apps/mac/src/renderer/src/widgets/topbar/Topbar.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,18 @@ PR: 1개당 100점 (최대 5개)

1분당 10점 (최대 10시간)
오전 6시 갱신

출석

하루: 100점
7일 연속 출석: 500 * 연속 출석 주
`;

export const cookieTooltipContent = `쿠키는 상점에서 사용 할 수 있어요!

로드맵 챕터 클리어: 100 쿠키
로드맵 섹션 클리어: 1000 쿠키
로드맵 섹션 클리어: 1,000 쿠키

EXP 하루 랭킹
1위: 1,000쿠키
2위: 500쿠키
3위: 300쿠키

출석
하루 출석: 300쿠키 (한 주 전체 출석 시: +700쿠키)
`;
Loading