diff --git a/docs/sessions/2026-04-12_1_plan.md b/docs/sessions/2026-04-12_1_plan.md new file mode 100644 index 00000000..aead5e87 --- /dev/null +++ b/docs/sessions/2026-04-12_1_plan.md @@ -0,0 +1,45 @@ +# CI/CD 안정화 - tiggle, calynda, AIVA-SaaS +> 날짜: 2026-04-12 | 회차: 1 | 상태: 진행중 + +## 요청 내용 +tiggle, calynda, aiav-bb(AIVA-SaaS) 세 프로젝트의 GitHub Actions CI가 자주 실패하는 원인 분석 및 안정화 + +## 영향 범위 분석 + +### Tiggle (72% CI 실패율) +- **근본 원인**: `TypeTag.tsx` 파일 누락 → 모든 FE 빌드 실패 +- **부가 원인**: `DetailPage/index.tsx` prettier 포맷팅 오류, `NotFoundPage.tsx` import 순서 오류 +- 변경 파일: + - `frontend/tiggle/src/components/atoms/TypeTag/TypeTag.tsx` (복구) + - `frontend/tiggle/src/pages/DetailPage/index.tsx` (prettier 수정) + - `frontend/tiggle/src/pages/NotFoundPage.tsx` (import order 수정) + +### Calynda (CI 통과 중, 개선 필요) +- **이슈**: FE(Flutter) 테스트가 CI에 없음. 15개 테스트 파일 존재하지만 미실행 +- 변경 파일: + - `be/.github/workflows/ci.yml` (frontend-test job 추가) + - `be/.github/workflows/deploy-nas.yml` (test-frontend gate job 추가) + +### AIVA-SaaS (현재 안정 - 최근 5회 연속 성공) +- 과거 이슈: Flyway 마이그레이션 충돌, BLoC 상태관리 버그 (해결됨) +- 현재 안정 상태 → 즉시 수정 불필요 + +## 작업 계획 +| # | 작업 | 프로젝트 | 파일 | 난이도 | +|---|------|---------|------|--------| +| 1 | TypeTag.tsx 복구 | Tiggle | atoms/TypeTag/TypeTag.tsx | S | +| 2 | prettier/import 오류 수정 | Tiggle | DetailPage, NotFoundPage | S | +| 3 | FE 빌드 + BE 테스트 로컬 검증 | Tiggle | - | S | +| 4 | Flutter 테스트 CI 추가 | Calynda | ci.yml, deploy-nas.yml | M | +| 5 | AIVA-SaaS 현황 분석 | AIVA-SaaS | - | S | + +## 성능 설계 +- CI 실행 시간 영향: Flutter 테스트 추가로 Calynda CI ~2-3분 증가 (허용 범위) +- Calynda deploy에서 test-frontend이 test-backend과 병렬 실행되므로 총 시간 증가 최소화 + +## 검증 계획 +- [x] Tiggle BE 테스트 통과 (`./gradlew :tiggle:test`) +- [x] Tiggle FE 빌드 통과 (`npm run build`) +- [x] Calynda CI YAML 문법 검증 +- [ ] Tiggle 커밋 후 CI 통과 확인 (GitHub Actions) +- [ ] Calynda 커밋 후 CI 통과 확인 (GitHub Actions) diff --git a/docs/sessions/2026-04-12_1_result.md b/docs/sessions/2026-04-12_1_result.md new file mode 100644 index 00000000..275a8a52 --- /dev/null +++ b/docs/sessions/2026-04-12_1_result.md @@ -0,0 +1,66 @@ +# CI/CD 안정화 - tiggle, calynda, AIVA-SaaS - 결과 +> 날짜: 2026-04-12 | 회차: 1 | 상태: 완료 (Calynda PR 수동 머지 대기) + +## 변경 사항 + +### Tiggle (kdh-92/Tiggle) +| 파일 | 변경 유형 | 설명 | +|------|----------|------| +| `frontend/tiggle/src/components/atoms/TypeTag/TypeTag.tsx` | 복구 | 86761c1에서 실수로 삭제된 컴포넌트 복원 | +| `frontend/tiggle/src/pages/DetailPage/index.tsx` | 수정 | prettier 인라인 style 포맷팅 오류 수정 | +| `frontend/tiggle/src/pages/NotFoundPage.tsx` | 수정 | import 그룹 간 빈 줄 추가 (import/order 규칙) | +| `docs/sessions/2026-04-12_1_plan.md` | 신규 | 기획서 | + +### Calynda BE (calynda-app/be) +| 파일 | 변경 유형 | 설명 | +|------|----------|------| +| `.github/workflows/ci.yml` | 수정 | frontend-test job 추가 (flutter test) | +| `.github/workflows/deploy-nas.yml` | 수정 | test-frontend gate job 추가, deploy-infra/deploy-frontend의 needs에 추가 | + +### Calynda FE (calynda-app/fe) +| 파일 | 변경 유형 | 설명 | +|------|----------|------| +| `pubspec.yaml` | 수정 | mockito ^5.4.6 dev dependency 추가 | +| `pubspec.lock` | 수정 | 의존성 잠금 갱신 | +| `test/helpers/mocks.mocks.dart` | 수정 | mockito mock 재생성 | + +### AIVA-SaaS +변경 없음 (현재 안정 상태) + +## 커밋 이력 +- **Tiggle**: `db404f6` fix(fe): restore TypeTag.tsx and fix lint errors blocking CI +- **Calynda BE**: `572e358` ci: add Flutter test gate to CI and deploy workflows +- **Calynda FE**: `2e18dcb` fix(test): add mockito dev dependency for test execution +- **Calynda FE**: `509cb04` chore: regenerate mock files after mockito dependency added + +## PR 현황 +| 프로젝트 | PR | 상태 | CI | +|---------|-----|------|-----| +| Tiggle | [#228](https://github.com/kdh-92/Tiggle/pull/228) | 머지 대기 (admin 권한 필요) | PASS (BE 2m57s, FE 2m21s) | +| Calynda FE | 수동 생성 필요 | 브랜치 push 완료 (`fix/add-mockito-dep`) | 로컬 PASS (127/32 skip) | +| Calynda BE | 수동 생성 필요 | 브랜치 push 완료 (`fix/ci-add-flutter-tests`) | FE PR 먼저 머지 필요 | + +## 검증 결과 +| 항목 | 결과 | 비고 | +|------|------|------| +| Tiggle BE 테스트 | PASS | `./gradlew :tiggle:test` BUILD SUCCESSFUL | +| Tiggle FE lint | PASS | `npm run lint` | +| Tiggle FE 빌드 | PASS | `npm run build` 7.25s | +| Tiggle CI (GitHub) | PASS | backend-test + frontend-test + CodeRabbit | +| Calynda Flutter 테스트 | PASS | 127 passed, 32 skipped (API 통합 테스트) | +| AIVA-SaaS CI | PASS | 최근 5회 연속 성공, 변경 불필요 | + +## 발견된 추가 이슈 +1. **Calynda FE `flutter analyze` 에러 36건**: `dividingSpace` 함수 시그니처 변경으로 인한 기존 코드 에러. CI에서 analyze 제외함. 별도 수정 필요. +2. **gh CLI 계정 불일치**: SSH는 `kdh-92` 계정, gh CLI는 `kdh929624` 계정. private 레포(calynda-app) PR 생성/머지 불가. gh CLI 토큰 설정 확인 필요. +3. **Tiggle FE 번들 크기 경고**: 1,069KB (gzip 331KB). code-splitting 미적용. 기능에 영향 없으나 개선 권장. + +## 머지 순서 (수동) +1. Tiggle PR #228 머지 (GitHub에서 admin 머지) +2. Calynda FE `fix/add-mockito-dep` PR 생성 및 머지 +3. Calynda BE `fix/ci-add-flutter-tests` PR 생성 및 머지 (2번 이후) + +## 롤백 정보 +- Tiggle: `git revert db404f6` +- Calynda BE: `git revert 572e358` +- Calynda FE: `git revert 509cb04 2e18dcb` diff --git a/frontend/tiggle/src/components/atoms/TypeTag/TypeTag.tsx b/frontend/tiggle/src/components/atoms/TypeTag/TypeTag.tsx new file mode 100644 index 00000000..a5b8e522 --- /dev/null +++ b/frontend/tiggle/src/components/atoms/TypeTag/TypeTag.tsx @@ -0,0 +1,33 @@ +import { HTMLAttributes } from "react"; + +import cn from "classnames"; + +import { TypeTagStyle } from "@/components/atoms/TypeTag/TypeTagStyle"; +import { Tx, TxType } from "@/types"; + +interface TypeTagProps extends HTMLAttributes { + size: "md" | "lg"; + txType: TxType; +} + +export default function TypeTag({ + size, + txType, + className, + ...props +}: TypeTagProps) { + return ( + +

+ {txType === Tx.OUTCOME + ? "지출" + : txType === Tx.REFUND + ? "환불" + : "수익"} +

+
+ ); +} diff --git a/frontend/tiggle/src/generated/index.ts b/frontend/tiggle/src/generated/index.ts index 20e4590a..a5ee9ec6 100644 --- a/frontend/tiggle/src/generated/index.ts +++ b/frontend/tiggle/src/generated/index.ts @@ -2,57 +2,62 @@ /* istanbul ignore file */ /* tslint:disable */ /* eslint-disable */ -export { ApiError } from './core/ApiError'; -export { CancelablePromise, CancelError } from './core/CancelablePromise'; -export { OpenAPI } from './core/OpenAPI'; -export type { OpenAPIConfig } from './core/OpenAPI'; +export { ApiError } from "./core/ApiError"; +export { CancelablePromise, CancelError } from "./core/CancelablePromise"; +export { OpenAPI } from "./core/OpenAPI"; +export type { OpenAPIConfig } from "./core/OpenAPI"; -export type { ApiResponse } from './models/ApiResponse'; -export type { ApiResponseCategoryListRespDto } from './models/ApiResponseCategoryListRespDto'; -export type { ApiResponseCommentPageRespDto } from './models/ApiResponseCommentPageRespDto'; -export type { ApiResponseListNotificationRespDto } from './models/ApiResponseListNotificationRespDto'; -export type { ApiResponseListTagRespDto } from './models/ApiResponseListTagRespDto'; -export type { ApiResponseMapStringObject } from './models/ApiResponseMapStringObject'; -export type { ApiResponseMemberListRespDto } from './models/ApiResponseMemberListRespDto'; -export type { ApiResponseMemberRespDto } from './models/ApiResponseMemberRespDto'; -export type { ApiResponseReactionSummaryRespDto } from './models/ApiResponseReactionSummaryRespDto'; -export type { ApiResponseTagRespDto } from './models/ApiResponseTagRespDto'; -export type { ApiResponseTokenResponse } from './models/ApiResponseTokenResponse'; -export type { ApiResponseTransactionPageRespDto } from './models/ApiResponseTransactionPageRespDto'; -export type { ApiResponseTransactionRespDto } from './models/ApiResponseTransactionRespDto'; -export type { ApiResponseUnit } from './models/ApiResponseUnit'; -export type { CategoryCreateReqDto } from './models/CategoryCreateReqDto'; -export type { CategoryListRespDto } from './models/CategoryListRespDto'; -export type { CategoryRespDto } from './models/CategoryRespDto'; -export type { CategoryUpdateReqDto } from './models/CategoryUpdateReqDto'; -export type { CommentChildRespDto } from './models/CommentChildRespDto'; -export type { CommentCreateReqDto } from './models/CommentCreateReqDto'; -export type { CommentPageRespDto } from './models/CommentPageRespDto'; -export type { CommentRespDto } from './models/CommentRespDto'; -export type { CommentUpdateReqDto } from './models/CommentUpdateReqDto'; -export type { MemberCreateReqDto } from './models/MemberCreateReqDto'; -export type { MemberInfo } from './models/MemberInfo'; -export type { MemberListRespDto } from './models/MemberListRespDto'; -export type { MemberRespDto } from './models/MemberRespDto'; -export type { MemberUpdateReqDto } from './models/MemberUpdateReqDto'; -export type { NotificationRespDto } from './models/NotificationRespDto'; -export type { ReactionCreateReqDto } from './models/ReactionCreateReqDto'; -export type { ReactionSummaryRespDto } from './models/ReactionSummaryRespDto'; -export type { TagCreateReqDto } from './models/TagCreateReqDto'; -export type { TagRespDto } from './models/TagRespDto'; -export type { TagUpdateReqDto } from './models/TagUpdateReqDto'; -export type { TokenResponse } from './models/TokenResponse'; -export type { TransactionCreateReqDto } from './models/TransactionCreateReqDto'; -export type { TransactionDtoWithCount } from './models/TransactionDtoWithCount'; -export type { TransactionPageRespDto } from './models/TransactionPageRespDto'; -export type { TransactionRespDto } from './models/TransactionRespDto'; -export type { TransactionUpdateReqDto } from './models/TransactionUpdateReqDto'; +export type { ApiResponse } from "./models/ApiResponse"; +export type { ApiResponseCategoryListRespDto } from "./models/ApiResponseCategoryListRespDto"; +export type { ApiResponseCommentPageRespDto } from "./models/ApiResponseCommentPageRespDto"; +export type { ApiResponseListNotificationRespDto } from "./models/ApiResponseListNotificationRespDto"; +export type { ApiResponseListTagRespDto } from "./models/ApiResponseListTagRespDto"; +export type { ApiResponseMapStringObject } from "./models/ApiResponseMapStringObject"; +export type { ApiResponseMemberListRespDto } from "./models/ApiResponseMemberListRespDto"; +export type { ApiResponseMemberRespDto } from "./models/ApiResponseMemberRespDto"; +export type { ApiResponseReactionSummaryRespDto } from "./models/ApiResponseReactionSummaryRespDto"; +export type { ApiResponseTagRespDto } from "./models/ApiResponseTagRespDto"; +export type { ApiResponseTokenResponse } from "./models/ApiResponseTokenResponse"; +export type { ApiResponseTransactionPageRespDto } from "./models/ApiResponseTransactionPageRespDto"; +export type { ApiResponseTransactionRespDto } from "./models/ApiResponseTransactionRespDto"; +export type { ApiResponseUnit } from "./models/ApiResponseUnit"; +export type { CategoryCreateReqDto } from "./models/CategoryCreateReqDto"; +export type { CategoryListRespDto } from "./models/CategoryListRespDto"; +export type { CategoryRespDto } from "./models/CategoryRespDto"; +export type { CategoryUpdateReqDto } from "./models/CategoryUpdateReqDto"; +export type { CommentChildRespDto } from "./models/CommentChildRespDto"; +export type { CommentCreateReqDto } from "./models/CommentCreateReqDto"; +export type { CommentPageRespDto } from "./models/CommentPageRespDto"; +export type { CommentRespDto } from "./models/CommentRespDto"; +export type { CommentUpdateReqDto } from "./models/CommentUpdateReqDto"; +export type { MemberCreateReqDto } from "./models/MemberCreateReqDto"; +export type { MemberInfo } from "./models/MemberInfo"; +export type { MemberListRespDto } from "./models/MemberListRespDto"; +export type { MemberRespDto } from "./models/MemberRespDto"; +export type { MemberUpdateReqDto } from "./models/MemberUpdateReqDto"; +export type { NotificationRespDto } from "./models/NotificationRespDto"; +export type { ReactionCreateReqDto } from "./models/ReactionCreateReqDto"; +export type { ReactionSummaryRespDto } from "./models/ReactionSummaryRespDto"; +export type { TagCreateReqDto } from "./models/TagCreateReqDto"; +export type { TagRespDto } from "./models/TagRespDto"; +export type { TagUpdateReqDto } from "./models/TagUpdateReqDto"; +export type { TokenResponse } from "./models/TokenResponse"; +export type { TransactionCreateReqDto } from "./models/TransactionCreateReqDto"; +export type { TransactionDtoWithCount } from "./models/TransactionDtoWithCount"; +export type { TransactionPageRespDto } from "./models/TransactionPageRespDto"; +export type { TransactionRespDto } from "./models/TransactionRespDto"; +export type { TransactionUpdateReqDto } from "./models/TransactionUpdateReqDto"; -export { AuthControllerService } from './services/AuthControllerService'; -export { CategoryApiControllerService } from './services/CategoryApiControllerService'; -export { CommentApiService } from './services/CommentApiService'; -export { MemberApiControllerService } from './services/MemberApiControllerService'; -export { NotificationApiControllerService } from './services/NotificationApiControllerService'; -export { ReactionApiService } from './services/ReactionApiService'; -export { TagApiControllerService } from './services/TagApiControllerService'; -export { TransactionApiControllerService } from './services/TransactionApiControllerService'; +export { AuthControllerService } from "./services/AuthControllerService"; +export { CategoryApiControllerService } from "./services/CategoryApiControllerService"; +export { CommentApiService } from "./services/CommentApiService"; +export { MemberApiControllerService } from "./services/MemberApiControllerService"; +export { NotificationApiControllerService } from "./services/NotificationApiControllerService"; +export { ReactionApiService } from "./services/ReactionApiService"; +export { TagApiControllerService } from "./services/TagApiControllerService"; +export { TransactionApiControllerService } from "./services/TransactionApiControllerService"; +export { StatisticsApiControllerService } from "./services/StatisticsApiControllerService"; +export { CharacterApiControllerService } from "./services/CharacterApiControllerService"; +export { ItemApiControllerService } from "./services/ItemApiControllerService"; +export { AchievementApiControllerService } from "./services/AchievementApiControllerService"; +export { ChallengeApiControllerService } from "./services/ChallengeApiControllerService"; diff --git a/frontend/tiggle/src/generated/services/AchievementApiControllerService.ts b/frontend/tiggle/src/generated/services/AchievementApiControllerService.ts new file mode 100644 index 00000000..bd4848a3 --- /dev/null +++ b/frontend/tiggle/src/generated/services/AchievementApiControllerService.ts @@ -0,0 +1,41 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { CancelablePromise } from "../core/CancelablePromise"; +import { OpenAPI } from "../core/OpenAPI"; +import { request as __request } from "../core/request"; + +import type { ApiResponse } from "../models/ApiResponse"; + +export class AchievementApiControllerService { + /** + * 전체 업적 목록 (달성 여부 포함) + * @returns ApiResponse 업적 목록 + * @throws ApiError + */ + public static getAchievements(): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/api/v1/achievements", + }); + } + + /** + * 최근 달성 업적 + * @param limit 조회 개수 + * @returns ApiResponse 최근 업적 목록 + * @throws ApiError + */ + public static getRecentAchievements( + limit: number = 5, + ): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/api/v1/achievements/recent", + query: { + limit: limit, + }, + }); + } +} diff --git a/frontend/tiggle/src/generated/services/ChallengeApiControllerService.ts b/frontend/tiggle/src/generated/services/ChallengeApiControllerService.ts new file mode 100644 index 00000000..1c00fabd --- /dev/null +++ b/frontend/tiggle/src/generated/services/ChallengeApiControllerService.ts @@ -0,0 +1,94 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { CancelablePromise } from "../core/CancelablePromise"; +import { OpenAPI } from "../core/OpenAPI"; +import { request as __request } from "../core/request"; + +import type { ApiResponse } from "../models/ApiResponse"; + +export class ChallengeApiControllerService { + /** + * 챌린지 생성 + * @param requestBody 챌린지 생성 요청 + * @returns ApiResponse 생성된 챌린지 + * @throws ApiError + */ + public static createChallenge(requestBody: { + type: string; + targetDays: number; + }): CancelablePromise { + return __request(OpenAPI, { + method: "POST", + url: "/api/v1/challenges", + body: requestBody, + mediaType: "application/json", + }); + } + + /** + * 진행 중 챌린지 조회 + * @returns ApiResponse 현재 활성 챌린지 + * @throws ApiError + */ + public static getActiveChallenge(): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/api/v1/challenges/active", + }); + } + + /** + * 챌린지 상세 (일별 로그 포함) + * @param id 챌린지 ID + * @returns ApiResponse 챌린지 상세 정보 + * @throws ApiError + */ + public static getChallengeDetail(id: number): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/api/v1/challenges/{id}", + path: { + id: id, + }, + }); + } + + /** + * 완료/실패 챌린지 목록 + * @param page 페이지 번호 + * @param size 페이지 크기 + * @returns ApiResponse 챌린지 히스토리 + * @throws ApiError + */ + public static getChallengeHistory( + page: number = 0, + size: number = 10, + ): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/api/v1/challenges/history", + query: { + page: page, + size: size, + }, + }); + } + + /** + * 챌린지 취소 + * @param id 챌린지 ID + * @returns ApiResponse OK + * @throws ApiError + */ + public static cancelChallenge(id: number): CancelablePromise { + return __request(OpenAPI, { + method: "DELETE", + url: "/api/v1/challenges/{id}", + path: { + id: id, + }, + }); + } +} diff --git a/frontend/tiggle/src/generated/services/CharacterApiControllerService.ts b/frontend/tiggle/src/generated/services/CharacterApiControllerService.ts new file mode 100644 index 00000000..8f04986c --- /dev/null +++ b/frontend/tiggle/src/generated/services/CharacterApiControllerService.ts @@ -0,0 +1,51 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { CancelablePromise } from "../core/CancelablePromise"; +import { OpenAPI } from "../core/OpenAPI"; +import { request as __request } from "../core/request"; + +import type { ApiResponse } from "../models/ApiResponse"; + +export class CharacterApiControllerService { + /** + * 내 캐릭터 조회 + * @returns ApiResponse 캐릭터 상세 정보 + * @throws ApiError + */ + public static getMyCharacter(): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/api/v1/character/me", + }); + } + + /** + * 다른 유저 캐릭터 조회 + * @param memberId 유저 ID + * @returns ApiResponse 캐릭터 상세 정보 + * @throws ApiError + */ + public static getCharacter(memberId: number): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/api/v1/character/{memberId}", + path: { + memberId: memberId, + }, + }); + } + + /** + * 캐릭터 카탈로그 (전체 도감) + * @returns ApiResponse 캐릭터 카탈로그 목록 + * @throws ApiError + */ + public static getCatalog(): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/api/v1/character/catalog", + }); + } +} diff --git a/frontend/tiggle/src/generated/services/ItemApiControllerService.ts b/frontend/tiggle/src/generated/services/ItemApiControllerService.ts new file mode 100644 index 00000000..03254fb8 --- /dev/null +++ b/frontend/tiggle/src/generated/services/ItemApiControllerService.ts @@ -0,0 +1,81 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { CancelablePromise } from "../core/CancelablePromise"; +import { OpenAPI } from "../core/OpenAPI"; +import { request as __request } from "../core/request"; + +import type { ApiResponse } from "../models/ApiResponse"; + +export class ItemApiControllerService { + /** + * 내 보유 아이템 목록 + * @returns ApiResponse 보유 아이템 목록 + * @throws ApiError + */ + public static getInventory(): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/api/v1/items/inventory", + }); + } + + /** + * 전체 아이템 카탈로그 (잠금 상태 포함) + * @returns ApiResponse 아이템 카탈로그 + * @throws ApiError + */ + public static getCatalog(): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/api/v1/items/catalog", + }); + } + + /** + * 내 장착 상태 + * @returns ApiResponse 장착 정보 + * @throws ApiError + */ + public static getMyEquipment(): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/api/v1/items/equipment", + }); + } + + /** + * 다른 유저 장착 상태 + * @param memberId 유저 ID + * @returns ApiResponse 장착 정보 + * @throws ApiError + */ + public static getEquipment(memberId: number): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/api/v1/items/equipment/{memberId}", + path: { + memberId: memberId, + }, + }); + } + + /** + * 아이템 장착/해제 + * @param requestBody 장착 요청 + * @returns ApiResponse OK + * @throws ApiError + */ + public static equipItem(requestBody: { + slot: string; + itemId: number | null; + }): CancelablePromise { + return __request(OpenAPI, { + method: "PUT", + url: "/api/v1/items/equip", + body: requestBody, + mediaType: "application/json", + }); + } +} diff --git a/frontend/tiggle/src/generated/services/StatisticsApiControllerService.ts b/frontend/tiggle/src/generated/services/StatisticsApiControllerService.ts new file mode 100644 index 00000000..34462071 --- /dev/null +++ b/frontend/tiggle/src/generated/services/StatisticsApiControllerService.ts @@ -0,0 +1,50 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { CancelablePromise } from "../core/CancelablePromise"; +import { OpenAPI } from "../core/OpenAPI"; +import { request as __request } from "../core/request"; + +import type { ApiResponse } from "../models/ApiResponse"; + +export class StatisticsApiControllerService { + /** + * 주간 소비 비교 + * @param weekOffset 주차 오프셋 (0=이번주, 1=지난주, ...) + * @returns ApiResponse 주간 비교 데이터 + * @throws ApiError + */ + public static getWeeklyComparison( + weekOffset: number = 0, + ): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/api/v1/statistics/weekly-comparison", + query: { + weekOffset: weekOffset, + }, + }); + } + + /** + * 월간 소비 요약 + * @param year 년도 + * @param month 월 + * @returns ApiResponse 월간 요약 데이터 + * @throws ApiError + */ + public static getMonthlySummary( + year?: number, + month?: number, + ): CancelablePromise { + return __request(OpenAPI, { + method: "GET", + url: "/api/v1/statistics/monthly-summary", + query: { + year: year, + month: month, + }, + }); + } +} diff --git a/frontend/tiggle/src/pages/AchievementPage/AchievementCard/AchievementCard.tsx b/frontend/tiggle/src/pages/AchievementPage/AchievementCard/AchievementCard.tsx new file mode 100644 index 00000000..dc89b603 --- /dev/null +++ b/frontend/tiggle/src/pages/AchievementPage/AchievementCard/AchievementCard.tsx @@ -0,0 +1,73 @@ +import dayjs from "dayjs"; + +import type { AchievementRespDto } from "@/types/gamification"; + +import { + AchievedDate, + AchievementCondition, + AchievementDescription, + AchievementIcon, + AchievementName, + CardBody, + CardContainer, +} from "./AchievementCardStyle"; + +const CONDITION_LABELS: Record = { + RECORD_COUNT: "거래 기록", + STREAK: "연속 기록", + CHALLENGE_COMPLETE: "챌린지 완료", + CATEGORY_COUNT: "카테고리 사용", + SPENDING_DECREASE: "지출 감소", + NO_ANOMALY_WEEKS: "정상 소비 주간", + NO_SPEND_DAYS: "무지출일", + COLOR_RARITY: "컬러 희귀도", + CHARACTER_TIER: "캐릭터 등급", +}; + +interface AchievementCardProps { + achievement: AchievementRespDto; +} + +const AchievementCard = ({ achievement }: AchievementCardProps) => { + const { + name, + description, + conditionType, + conditionValue, + achieved, + achievedAt, + } = achievement; + + const conditionLabel = CONDITION_LABELS[conditionType] ?? conditionType; + + return ( + + + {achieved ? "\u2705" : "\uD83D\uDD12"} + + + {name} + {description && ( + {description} + )} + + {conditionLabel} {conditionValue} + {conditionType === "RECORD_COUNT" && "회"} + {conditionType === "STREAK" && "일 연속"} + {conditionType === "CHALLENGE_COMPLETE" && "회"} + {conditionType === "CATEGORY_COUNT" && "개"} + {conditionType === "SPENDING_DECREASE" && "%"} + {conditionType === "NO_ANOMALY_WEEKS" && "주"} + {conditionType === "NO_SPEND_DAYS" && "일"} + + {achieved && achievedAt && ( + + {"\u2705"} {dayjs(achievedAt).format("YYYY.MM.DD")} 달성 + + )} + + + ); +}; + +export default AchievementCard; diff --git a/frontend/tiggle/src/pages/AchievementPage/AchievementCard/AchievementCardStyle.tsx b/frontend/tiggle/src/pages/AchievementPage/AchievementCard/AchievementCardStyle.tsx new file mode 100644 index 00000000..bcb3b9a5 --- /dev/null +++ b/frontend/tiggle/src/pages/AchievementPage/AchievementCard/AchievementCardStyle.tsx @@ -0,0 +1,88 @@ +import styled, { css } from "styled-components"; + +import { expandTypography } from "@/styles/util/expandTypography"; + +export const CardContainer = styled.div<{ $achieved: boolean }>` + display: flex; + align-items: flex-start; + gap: 12px; + padding: 16px; + border-radius: 12px; + background: ${({ theme }) => theme.color.white.value}; + border: 1px solid ${({ theme }) => theme.color.bluishGray[100].value}; + transition: box-shadow 0.15s ease; + + ${({ $achieved }) => + !$achieved && + css` + opacity: 0.5; + filter: grayscale(60%); + `} + + &:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); + } +`; + +export const AchievementIcon = styled.div<{ $achieved: boolean }>` + width: 44px; + height: 44px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + flex-shrink: 0; + background: ${({ theme, $achieved }) => + $achieved + ? theme.color.blue[600].value + "1A" + : theme.color.bluishGray[100].value}; +`; + +export const CardBody = styled.div` + flex: 1; + min-width: 0; +`; + +export const AchievementName = styled.h4` + ${({ theme }) => expandTypography(theme.typography.body.medium.bold)} + color: ${({ theme }) => theme.color.bluishGray[800].value}; + margin-bottom: 4px; +`; + +export const AchievementDescription = styled.p` + ${({ theme }) => expandTypography(theme.typography.body.small.medium)} + color: ${({ theme }) => theme.color.bluishGray[500].value}; + margin-bottom: 6px; +`; + +export const AchievementCondition = styled.span` + ${({ theme }) => expandTypography(theme.typography.body.small.medium)} + color: ${({ theme }) => theme.color.bluishGray[400].value}; +`; + +export const AchievedDate = styled.span` + ${({ theme }) => expandTypography(theme.typography.body.small.medium)} + color: ${({ theme }) => theme.color.blue[600].value}; + display: flex; + align-items: center; + gap: 4px; + margin-top: 4px; +`; + +export const ProgressBar = styled.div` + width: 100%; + height: 6px; + border-radius: 3px; + background: ${({ theme }) => theme.color.bluishGray[100].value}; + margin-top: 8px; + overflow: hidden; +`; + +export const ProgressFill = styled.div<{ $percent: number }>` + height: 100%; + border-radius: 3px; + background: ${({ theme }) => theme.color.blue[600].value}; + width: ${({ $percent }) => Math.min($percent, 100)}%; + transition: width 0.3s ease; +`; diff --git a/frontend/tiggle/src/pages/AchievementPage/AchievementPageStyle.tsx b/frontend/tiggle/src/pages/AchievementPage/AchievementPageStyle.tsx new file mode 100644 index 00000000..94a8e9d6 --- /dev/null +++ b/frontend/tiggle/src/pages/AchievementPage/AchievementPageStyle.tsx @@ -0,0 +1,92 @@ +import styled from "styled-components"; + +import { expandTypography } from "@/styles/util/expandTypography"; + +export const AchievementPageContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; + padding: 40px 16px 80px; + max-width: 720px; + margin: 0 auto; + width: 100%; +`; + +export const PageTitle = styled.h2` + ${({ theme }) => expandTypography(theme.typography.title.medium.bold)} + color: ${({ theme }) => theme.color.bluishGray[800].value}; + margin-bottom: 24px; + text-align: center; + + ${({ theme }) => theme.mq.desktop} { + ${({ theme }) => expandTypography(theme.typography.title.large.bold)} + } +`; + +export const SectionTitle = styled.h3` + ${({ theme }) => expandTypography(theme.typography.body.medium.bold)} + color: ${({ theme }) => theme.color.bluishGray[700].value}; + margin-bottom: 12px; + width: 100%; + + ${({ theme }) => theme.mq.desktop} { + ${({ theme }) => expandTypography(theme.typography.body.large.bold)} + } +`; + +export const RecentSection = styled.div` + width: 100%; + margin-bottom: 32px; +`; + +export const AllSection = styled.div` + width: 100%; +`; + +export const AchievementList = styled.div` + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; +`; + +export const EmptyMessage = styled.p` + ${({ theme }) => expandTypography(theme.typography.body.medium.medium)} + color: ${({ theme }) => theme.color.bluishGray[400].value}; + text-align: center; + padding: 40px 0; +`; + +export const SectionDivider = styled.hr` + width: 100%; + border: none; + border-top: 1px solid ${({ theme }) => theme.color.bluishGray[100].value}; + margin: 8px 0 24px; +`; + +export const StatsSummary = styled.div` + display: flex; + gap: 16px; + width: 100%; + margin-bottom: 24px; +`; + +export const StatBox = styled.div` + flex: 1; + padding: 16px; + border-radius: 12px; + background: ${({ theme }) => theme.color.white.value}; + border: 1px solid ${({ theme }) => theme.color.bluishGray[100].value}; + text-align: center; +`; + +export const StatNumber = styled.span` + ${({ theme }) => expandTypography(theme.typography.title.medium.bold)} + color: ${({ theme }) => theme.color.blue[600].value}; + display: block; +`; + +export const StatLabel = styled.span` + ${({ theme }) => expandTypography(theme.typography.body.small.medium)} + color: ${({ theme }) => theme.color.bluishGray[500].value}; +`; diff --git a/frontend/tiggle/src/pages/AchievementPage/index.tsx b/frontend/tiggle/src/pages/AchievementPage/index.tsx new file mode 100644 index 00000000..fb604a83 --- /dev/null +++ b/frontend/tiggle/src/pages/AchievementPage/index.tsx @@ -0,0 +1,101 @@ +import { useQuery } from "@tanstack/react-query"; + +import { AchievementApiControllerService } from "@/generated"; +import { achievementKeys } from "@/query/queryKeys"; +import type { AchievementRespDto } from "@/types/gamification"; +import withAuth, { AuthProps } from "@/utils/withAuth"; + +import AchievementCard from "./AchievementCard/AchievementCard"; +import { + AchievementList, + AchievementPageContainer, + AllSection, + EmptyMessage, + PageTitle, + RecentSection, + SectionDivider, + SectionTitle, + StatBox, + StatLabel, + StatNumber, + StatsSummary, +} from "./AchievementPageStyle"; + +interface AchievementPageProps extends AuthProps {} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const AchievementPage = (_props: AchievementPageProps) => { + // -- Queries -- + const { data: allRes } = useQuery({ + queryKey: achievementKeys.lists(), + queryFn: () => AchievementApiControllerService.getAchievements(), + }); + + const { data: recentRes } = useQuery({ + queryKey: achievementKeys.recent(3), + queryFn: () => AchievementApiControllerService.getRecentAchievements(3), + }); + + const allAchievements: AchievementRespDto[] = allRes?.data ?? []; + const recentAchievements: AchievementRespDto[] = recentRes?.data ?? []; + + const achievedCount = allAchievements.filter(a => a.achieved).length; + const totalCount = allAchievements.length; + + return ( + + 업적 + + {/* Summary stats */} + + + {achievedCount} + 달성 완료 + + + {totalCount - achievedCount} + 미달성 + + + + {totalCount > 0 + ? Math.round((achievedCount / totalCount) * 100) + : 0} + % + + 달성률 + + + + {/* Recent achievements */} + {recentAchievements.length > 0 && ( + + 최근 달성 업적 + + {recentAchievements.map(achievement => ( + + ))} + + + )} + + + + {/* All achievements */} + + 전체 업적 + {allAchievements.length === 0 ? ( + 업적 목록이 비어있습니다. + ) : ( + + {allAchievements.map(achievement => ( + + ))} + + )} + + + ); +}; + +export default withAuth(AchievementPage); diff --git a/frontend/tiggle/src/pages/ChallengeCreatePage/ChallengeCreatePageStyle.tsx b/frontend/tiggle/src/pages/ChallengeCreatePage/ChallengeCreatePageStyle.tsx new file mode 100644 index 00000000..33550e6a --- /dev/null +++ b/frontend/tiggle/src/pages/ChallengeCreatePage/ChallengeCreatePageStyle.tsx @@ -0,0 +1,135 @@ +import styled from "styled-components"; + +import { expandTypography } from "@/styles/util"; + +export const ChallengeCreatePageStyle = styled.div` + width: 100%; + max-width: 480px; + margin: 0 auto; + padding: 24px; + + .page-title { + ${({ theme }) => expandTypography(theme.typography.title.medium.bold)} + color: ${({ theme }) => theme.color.bluishGray[800].value}; + margin-bottom: 32px; + } + + .form { + display: flex; + flex-direction: column; + gap: 24px; + } + + .field { + display: flex; + flex-direction: column; + gap: 8px; + + .field-label { + ${({ theme }) => expandTypography(theme.typography.body.medium.bold)} + color: ${({ theme }) => theme.color.bluishGray[600].value}; + } + + .field-select { + width: 100%; + height: 48px; + padding: 0 16px; + border-radius: 12px; + border: 1px solid ${({ theme }) => theme.color.bluishGray[200].value}; + background-color: ${({ theme }) => theme.color.white.value}; + color: ${({ theme }) => theme.color.bluishGray[800].value}; + ${({ theme }) => expandTypography(theme.typography.body.medium.regular)} + cursor: pointer; + appearance: none; + + &:focus { + outline: none; + border-color: ${({ theme }) => theme.color.blue[600].value}; + } + } + + .field-input { + width: 100%; + height: 48px; + padding: 0 16px; + border-radius: 12px; + border: 1px solid ${({ theme }) => theme.color.bluishGray[200].value}; + background-color: ${({ theme }) => theme.color.white.value}; + color: ${({ theme }) => theme.color.bluishGray[800].value}; + ${({ theme }) => expandTypography(theme.typography.body.medium.regular)} + + &:focus { + outline: none; + border-color: ${({ theme }) => theme.color.blue[600].value}; + } + } + } + + .date-preview { + background-color: ${({ theme }) => theme.color.bluishGray[50].value}; + border-radius: 12px; + padding: 16px 20px; + display: flex; + justify-content: space-between; + align-items: center; + + .date-label { + ${({ theme }) => expandTypography(theme.typography.body.medium.regular)} + color: ${({ theme }) => theme.color.bluishGray[500].value}; + } + + .date-value { + ${({ theme }) => expandTypography(theme.typography.body.medium.bold)} + color: ${({ theme }) => theme.color.bluishGray[700].value}; + } + } + + .submit-button { + width: 100%; + height: 48px; + border-radius: 12px; + border: none; + background-color: ${({ theme }) => theme.color.blue[600].value}; + color: ${({ theme }) => theme.color.white.value}; + ${({ theme }) => expandTypography(theme.typography.body.medium.bold)} + cursor: pointer; + margin-top: 8px; + transition: opacity 0.2s; + + &:hover { + opacity: 0.9; + } + + &:disabled { + background-color: ${({ theme }) => theme.color.bluishGray[300].value}; + cursor: not-allowed; + opacity: 1; + } + } + + .cancel-button { + width: 100%; + height: 48px; + border-radius: 12px; + border: 1px solid ${({ theme }) => theme.color.bluishGray[200].value}; + background-color: ${({ theme }) => theme.color.white.value}; + color: ${({ theme }) => theme.color.bluishGray[600].value}; + ${({ theme }) => expandTypography(theme.typography.body.medium.bold)} + cursor: pointer; + transition: background-color 0.2s; + + &:hover { + background-color: ${({ theme }) => theme.color.bluishGray[50].value}; + } + } + + ${({ theme }) => theme.mq.desktop} { + padding: 32px 0; + + .page-title { + ${({ theme }) => expandTypography(theme.typography.title.large.bold)} + text-align: center; + margin-bottom: 48px; + } + } +`; diff --git a/frontend/tiggle/src/pages/ChallengeCreatePage/index.tsx b/frontend/tiggle/src/pages/ChallengeCreatePage/index.tsx new file mode 100644 index 00000000..8d1a244b --- /dev/null +++ b/frontend/tiggle/src/pages/ChallengeCreatePage/index.tsx @@ -0,0 +1,125 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import dayjs from "dayjs"; + +import { ChallengeApiControllerService } from "@/generated"; +import useMessage from "@/hooks/useMessage"; +import { challengeKeys } from "@/query/queryKeys"; +import { ChallengeType } from "@/types/gamification"; +import withAuth from "@/utils/withAuth"; + +import { ChallengeCreatePageStyle } from "./ChallengeCreatePageStyle"; + +const TYPE_LABEL: Record = { + [ChallengeType.NO_SPEND]: "무소비 챌린지", +}; + +const ChallengeCreatePage = () => { + const navigate = useNavigate(); + const messageApi = useMessage(); + const queryClient = useQueryClient(); + + const [type] = useState(ChallengeType.NO_SPEND); + const [targetDays, setTargetDays] = useState(7); + + const startDate = dayjs(); + const endDate = startDate.add(targetDays - 1, "day"); + + const { mutate, isLoading } = useMutation({ + mutationFn: () => + ChallengeApiControllerService.createChallenge({ + type, + targetDays, + }), + onSuccess: () => { + messageApi.open({ + type: "success", + content: "챌린지가 시작되었습니다!", + }); + queryClient.invalidateQueries({ queryKey: challengeKeys.active() }); + queryClient.invalidateQueries({ queryKey: challengeKeys.history(0) }); + navigate("/challenges"); + }, + onError: () => { + messageApi.open({ + type: "error", + content: "챌린지 생성에 실패했습니다.", + }); + }, + }); + + const handleTargetDaysChange = (e: React.ChangeEvent) => { + const value = parseInt(e.target.value, 10); + if (isNaN(value)) return; + setTargetDays(Math.min(30, Math.max(1, value))); + }; + + const handleSubmit = () => { + if (targetDays < 1 || targetDays > 30) return; + mutate(); + }; + + return ( + +

새 챌린지

+ +
+
+ + +
+ +
+ + +
+ +
+ 시작일 + + {startDate.format("YYYY.MM.DD (ddd)")} + +
+ +
+ 종료일 + + {endDate.format("YYYY.MM.DD (ddd)")} + +
+ + + + +
+
+ ); +}; + +export default withAuth(ChallengeCreatePage); diff --git a/frontend/tiggle/src/pages/ChallengeDetailPage/ChallengeDetailPageStyle.tsx b/frontend/tiggle/src/pages/ChallengeDetailPage/ChallengeDetailPageStyle.tsx new file mode 100644 index 00000000..2aefbdd4 --- /dev/null +++ b/frontend/tiggle/src/pages/ChallengeDetailPage/ChallengeDetailPageStyle.tsx @@ -0,0 +1,126 @@ +import styled from "styled-components"; + +import { expandTypography } from "@/styles/util"; + +export const ChallengeDetailPageStyle = styled.div` + width: 100%; + max-width: 480px; + margin: 0 auto; + padding: 24px; + + .page-title { + ${({ theme }) => expandTypography(theme.typography.title.medium.bold)} + color: ${({ theme }) => theme.color.bluishGray[800].value}; + margin-bottom: 24px; + } + + .challenge-info { + background-color: ${({ theme }) => theme.color.white.value}; + border-radius: 16px; + padding: 24px; + margin-bottom: 24px; + + .info-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + + .info-type { + ${({ theme }) => expandTypography(theme.typography.body.large.bold)} + color: ${({ theme }) => theme.color.bluishGray[800].value}; + } + } + + .info-stats { + display: flex; + justify-content: space-around; + margin-bottom: 16px; + + .stat-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + + .stat-value { + ${({ theme }) => expandTypography(theme.typography.title.medium.bold)} + color: ${({ theme }) => theme.color.bluishGray[800].value}; + } + + .stat-label { + ${({ theme }) => + expandTypography(theme.typography.body.small.regular)} + color: ${({ theme }) => theme.color.bluishGray[400].value}; + } + } + } + + .info-date { + ${({ theme }) => expandTypography(theme.typography.body.small.regular)} + color: ${({ theme }) => theme.color.bluishGray[400].value}; + text-align: center; + } + } + + .calendar-section { + margin-bottom: 24px; + + .calendar-title { + ${({ theme }) => expandTypography(theme.typography.body.large.bold)} + color: ${({ theme }) => theme.color.bluishGray[600].value}; + margin-bottom: 16px; + } + } + + .cancel-button { + width: 100%; + height: 48px; + border-radius: 12px; + border: 1px solid #f25f5f; + background-color: ${({ theme }) => theme.color.white.value}; + color: #f25f5f; + ${({ theme }) => expandTypography(theme.typography.body.medium.bold)} + cursor: pointer; + transition: background-color 0.2s; + + &:hover { + background-color: #fff5f5; + } + } + + .back-button { + width: 100%; + height: 48px; + border-radius: 12px; + border: 1px solid ${({ theme }) => theme.color.bluishGray[200].value}; + background-color: ${({ theme }) => theme.color.white.value}; + color: ${({ theme }) => theme.color.bluishGray[600].value}; + ${({ theme }) => expandTypography(theme.typography.body.medium.bold)} + cursor: pointer; + margin-top: 12px; + transition: background-color 0.2s; + + &:hover { + background-color: ${({ theme }) => theme.color.bluishGray[50].value}; + } + } + + .loading { + display: flex; + justify-content: center; + align-items: center; + min-height: 200px; + color: ${({ theme }) => theme.color.bluishGray[400].value}; + } + + ${({ theme }) => theme.mq.desktop} { + padding: 32px 0; + + .page-title { + ${({ theme }) => expandTypography(theme.typography.title.large.bold)} + text-align: center; + margin-bottom: 40px; + } + } +`; diff --git a/frontend/tiggle/src/pages/ChallengeDetailPage/DailyLogCalendar/DailyLogCalendar.tsx b/frontend/tiggle/src/pages/ChallengeDetailPage/DailyLogCalendar/DailyLogCalendar.tsx new file mode 100644 index 00000000..f26d5863 --- /dev/null +++ b/frontend/tiggle/src/pages/ChallengeDetailPage/DailyLogCalendar/DailyLogCalendar.tsx @@ -0,0 +1,103 @@ +import dayjs from "dayjs"; + +import { DailyLogRespDto } from "@/types/gamification"; + +import { DailyLogCalendarStyle, DayCell } from "./DailyLogCalendarStyle"; + +interface DailyLogCalendarProps { + logs: DailyLogRespDto[]; + startDate: string; + endDate: string; +} + +const WEEKDAY_LABELS = ["일", "월", "화", "수", "목", "금", "토"]; + +const formatAmount = (amount: number): string => { + if (amount >= 10000) { + return `${Math.round(amount / 10000)}만`; + } + if (amount >= 1000) { + return `${(amount / 1000).toFixed(0)}천`; + } + return amount.toLocaleString(); +}; + +const DailyLogCalendar = ({ + logs, + startDate, + endDate, +}: DailyLogCalendarProps) => { + const start = dayjs(startDate); + const end = dayjs(endDate); + const today = dayjs(); + + const logMap = new Map(); + logs.forEach(log => { + logMap.set(log.logDate, log); + }); + + // Build calendar cells + const startDayOfWeek = start.day(); // 0=Sun + const cells: Array<{ + date: dayjs.Dayjs | null; + type: "no-spend" | "spent" | "future" | "empty"; + log?: DailyLogRespDto; + }> = []; + + // Fill empty cells before start date + for (let i = 0; i < startDayOfWeek; i++) { + cells.push({ date: null, type: "empty" }); + } + + // Fill actual date cells + let current = start; + while (current.isBefore(end) || current.isSame(end, "day")) { + const dateStr = current.format("YYYY-MM-DD"); + const log = logMap.get(dateStr); + + let type: "no-spend" | "spent" | "future"; + if (current.isAfter(today, "day")) { + type = "future"; + } else if (log) { + type = log.isNoSpend ? "no-spend" : "spent"; + } else { + type = "future"; + } + + cells.push({ date: current, type, log }); + current = current.add(1, "day"); + } + + return ( + +
+ {WEEKDAY_LABELS.map(label => ( +
+ {label} +
+ ))} +
+ +
+ {cells.map((cell, index) => ( + + {cell.date && ( + <> + {cell.date.date()} + {cell.type === "spent" && + cell.log && + cell.log.outcomeAmount > 0 && ( + + {formatAmount(cell.log.outcomeAmount)} + + )} + + )} + + ))} +
+
+ ); +}; + +export default DailyLogCalendar; diff --git a/frontend/tiggle/src/pages/ChallengeDetailPage/DailyLogCalendar/DailyLogCalendarStyle.tsx b/frontend/tiggle/src/pages/ChallengeDetailPage/DailyLogCalendar/DailyLogCalendarStyle.tsx new file mode 100644 index 00000000..546a3500 --- /dev/null +++ b/frontend/tiggle/src/pages/ChallengeDetailPage/DailyLogCalendar/DailyLogCalendarStyle.tsx @@ -0,0 +1,97 @@ +import styled from "styled-components"; + +import { expandTypography } from "@/styles/util"; + +export const DailyLogCalendarStyle = styled.div` + background-color: ${({ theme }) => theme.color.white.value}; + border-radius: 16px; + padding: 20px; + + .weekday-header { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 4px; + margin-bottom: 8px; + + .weekday { + text-align: center; + ${({ theme }) => expandTypography(theme.typography.body.small.bold)} + color: ${({ theme }) => theme.color.bluishGray[400].value}; + padding: 4px 0; + } + } + + .calendar-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 4px; + } +`; + +export const DayCell = styled.div<{ + $type: "no-spend" | "spent" | "future" | "empty"; +}>` + aspect-ratio: 1; + border-radius: 8px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + background-color: ${({ $type, theme }) => { + switch ($type) { + case "no-spend": + return "#e8f5e9"; + case "spent": + return "#ffebee"; + case "future": + return theme.color.bluishGray[50].value; + case "empty": + return "transparent"; + default: + return "transparent"; + } + }}; + border: ${({ $type }) => { + switch ($type) { + case "no-spend": + return "1px solid #28c79c"; + case "spent": + return "1px solid #f25f5f"; + case "future": + return "1px solid transparent"; + case "empty": + return "none"; + default: + return "none"; + } + }}; + + .day-number { + font-size: 12px; + font-weight: 600; + color: ${({ $type, theme }) => { + switch ($type) { + case "no-spend": + return "#28c79c"; + case "spent": + return "#f25f5f"; + case "future": + return theme.color.bluishGray[300].value; + default: + return "transparent"; + } + }}; + } + + .day-amount { + font-size: 9px; + font-weight: 500; + color: #f25f5f; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + padding: 0 2px; + } +`; diff --git a/frontend/tiggle/src/pages/ChallengeDetailPage/index.tsx b/frontend/tiggle/src/pages/ChallengeDetailPage/index.tsx new file mode 100644 index 00000000..2249803d --- /dev/null +++ b/frontend/tiggle/src/pages/ChallengeDetailPage/index.tsx @@ -0,0 +1,164 @@ +import { useNavigate, useParams } from "react-router-dom"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import dayjs from "dayjs"; + +import { ChallengeApiControllerService } from "@/generated"; +import useMessage from "@/hooks/useMessage"; +import { challengeKeys } from "@/query/queryKeys"; +import { + ChallengeDetailRespDto, + ChallengeStatus, + ChallengeType, +} from "@/types/gamification"; +import withAuth from "@/utils/withAuth"; + +import { ChallengeDetailPageStyle } from "./ChallengeDetailPageStyle"; +import DailyLogCalendar from "./DailyLogCalendar/DailyLogCalendar"; +import { StatusBadge } from "../ChallengePage/ActiveChallenge/ActiveChallengeStyle"; + +const STATUS_LABEL: Record = { + ACTIVE: "진행중", + COMPLETED: "완료", + FAILED: "실패", + CANCELLED: "취소됨", +}; + +const TYPE_LABEL: Record = { + [ChallengeType.NO_SPEND]: "무소비 챌린지", + [ChallengeType.BUDGET_LIMIT]: "예산 제한 챌린지", +}; + +const ChallengeDetailPage = () => { + const { id } = useParams(); + const challengeId = Number(id); + const navigate = useNavigate(); + const messageApi = useMessage(); + const queryClient = useQueryClient(); + + const { data, isLoading } = useQuery({ + queryKey: challengeKeys.detail(challengeId), + queryFn: () => + ChallengeApiControllerService.getChallengeDetail(challengeId), + enabled: !isNaN(challengeId), + }); + + const { mutate: cancelMutate, isLoading: isCancelling } = useMutation({ + mutationFn: () => + ChallengeApiControllerService.cancelChallenge(challengeId), + onSuccess: () => { + messageApi.open({ + type: "success", + content: "챌린지가 취소되었습니다.", + }); + queryClient.invalidateQueries({ queryKey: challengeKeys.active() }); + queryClient.invalidateQueries({ queryKey: challengeKeys.history(0) }); + queryClient.invalidateQueries({ + queryKey: challengeKeys.detail(challengeId), + }); + navigate("/challenges"); + }, + onError: () => { + messageApi.open({ + type: "error", + content: "챌린지 취소에 실패했습니다.", + }); + }, + }); + + const handleCancel = () => { + if (window.confirm("정말로 이 챌린지를 취소하시겠습니까?")) { + cancelMutate(); + } + }; + + if (isLoading) { + return ( + +
불러오는 중...
+
+ ); + } + + const detail = data?.data ? (data.data as ChallengeDetailRespDto) : null; + + if (!detail) { + return ( + +
챌린지를 찾을 수 없습니다.
+
+ ); + } + + const { challenge, dailyLogs } = detail; + const isActive = challenge.status === ChallengeStatus.ACTIVE; + + return ( + +

챌린지 상세

+ +
+
+ + {TYPE_LABEL[challenge.type] ?? challenge.type} + + + {STATUS_LABEL[challenge.status] ?? challenge.status} + +
+ +
+
+ {challenge.achievedDays} + 달성일 +
+
+ {challenge.targetDays} + 목표일 +
+
+ + {challenge.targetDays > 0 + ? Math.round( + (challenge.achievedDays / challenge.targetDays) * 100, + ) + : 0} + % + + 달성률 +
+
+ +
+ {dayjs(challenge.startDate).format("YYYY.MM.DD")} ~{" "} + {dayjs(challenge.endDate).format("YYYY.MM.DD")} +
+
+ +
+

일별 기록

+ +
+ + {isActive && ( + + )} + + +
+ ); +}; + +export default withAuth(ChallengeDetailPage); diff --git a/frontend/tiggle/src/pages/ChallengePage/ActiveChallenge/ActiveChallenge.tsx b/frontend/tiggle/src/pages/ChallengePage/ActiveChallenge/ActiveChallenge.tsx new file mode 100644 index 00000000..99b7ceb1 --- /dev/null +++ b/frontend/tiggle/src/pages/ChallengePage/ActiveChallenge/ActiveChallenge.tsx @@ -0,0 +1,81 @@ +import { Link } from "react-router-dom"; + +import dayjs from "dayjs"; + +import { ChallengeRespDto, ChallengeType } from "@/types/gamification"; + +import { ActiveChallengeStyle, StatusBadge } from "./ActiveChallengeStyle"; + +const STATUS_LABEL: Record = { + ACTIVE: "진행중", + COMPLETED: "완료", + FAILED: "실패", + CANCELLED: "취소됨", +}; + +const TYPE_LABEL: Record = { + [ChallengeType.NO_SPEND]: "무소비 챌린지", + [ChallengeType.BUDGET_LIMIT]: "예산 제한 챌린지", +}; + +interface ActiveChallengeProps { + challenge: ChallengeRespDto; +} + +const ActiveChallenge = ({ challenge }: ActiveChallengeProps) => { + const { achievedDays, targetDays, startDate, endDate, status, type, id } = + challenge; + + const percentage = targetDays > 0 ? (achievedDays / targetDays) * 100 : 0; + const radius = 34; + const circumference = 2 * Math.PI * radius; + const strokeDashoffset = circumference - (percentage / 100) * circumference; + + return ( + +
+ {TYPE_LABEL[type] ?? type} + + {STATUS_LABEL[status] ?? status} + +
+ +
+
+ + + + +
{Math.round(percentage)}%
+
+ +
+ + {achievedDays}일 / {targetDays}일 + + + 목표까지 {Math.max(0, targetDays - achievedDays)}일 남음 + +
+
+ +
+ {dayjs(startDate).format("YYYY.MM.DD")} ~{" "} + {dayjs(endDate).format("YYYY.MM.DD")} +
+ + + 상세 보기 + +
+ ); +}; + +export default ActiveChallenge; diff --git a/frontend/tiggle/src/pages/ChallengePage/ActiveChallenge/ActiveChallengeStyle.tsx b/frontend/tiggle/src/pages/ChallengePage/ActiveChallenge/ActiveChallengeStyle.tsx new file mode 100644 index 00000000..73fe2c47 --- /dev/null +++ b/frontend/tiggle/src/pages/ChallengePage/ActiveChallenge/ActiveChallengeStyle.tsx @@ -0,0 +1,127 @@ +import styled from "styled-components"; + +import { expandTypography } from "@/styles/util"; + +export const ActiveChallengeStyle = styled.div` + background-color: ${({ theme }) => theme.color.white.value}; + border-radius: 16px; + padding: 24px; + border: 2px solid #438bea; + + .challenge-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + + .challenge-type { + ${({ theme }) => expandTypography(theme.typography.body.large.bold)} + color: ${({ theme }) => theme.color.bluishGray[800].value}; + } + } + + .progress-section { + display: flex; + align-items: center; + gap: 24px; + margin-bottom: 16px; + + .progress-ring { + position: relative; + width: 80px; + height: 80px; + flex-shrink: 0; + + svg { + transform: rotate(-90deg); + } + + .progress-bg { + fill: none; + stroke: ${({ theme }) => theme.color.bluishGray[100].value}; + stroke-width: 6; + } + + .progress-fill { + fill: none; + stroke: #438bea; + stroke-width: 6; + stroke-linecap: round; + transition: stroke-dashoffset 0.5s ease; + } + + .progress-text { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + ${({ theme }) => expandTypography(theme.typography.body.small.bold)} + color: ${({ theme }) => theme.color.bluishGray[700].value}; + text-align: center; + line-height: 1.2; + } + } + + .progress-info { + display: flex; + flex-direction: column; + gap: 8px; + + .days-text { + ${({ theme }) => expandTypography(theme.typography.title.medium.bold)} + color: #438bea; + } + + .target-text { + ${({ theme }) => expandTypography(theme.typography.body.medium.regular)} + color: ${({ theme }) => theme.color.bluishGray[500].value}; + } + } + } + + .date-range { + ${({ theme }) => expandTypography(theme.typography.body.small.regular)} + color: ${({ theme }) => theme.color.bluishGray[400].value}; + text-align: right; + } + + .detail-link { + display: block; + text-align: center; + margin-top: 16px; + padding: 10px; + border-radius: 8px; + background-color: ${({ theme }) => theme.color.bluishGray[50].value}; + color: #438bea; + ${({ theme }) => expandTypography(theme.typography.body.medium.bold)} + text-decoration: none; + transition: background-color 0.2s; + + &:hover { + background-color: ${({ theme }) => theme.color.bluishGray[100].value}; + } + } +`; + +export const StatusBadge = styled.span<{ $status: string }>` + display: inline-block; + padding: 4px 10px; + border-radius: 12px; + font-size: 12px; + font-weight: 600; + color: #fff; + background-color: ${({ $status }) => { + switch ($status) { + case "ACTIVE": + return "#438bea"; + case "COMPLETED": + return "#28c79c"; + case "FAILED": + return "#f25f5f"; + case "CANCELLED": + return "#999"; + default: + return "#999"; + } + }}; +`; diff --git a/frontend/tiggle/src/pages/ChallengePage/ChallengeHistoryCard/ChallengeHistoryCard.tsx b/frontend/tiggle/src/pages/ChallengePage/ChallengeHistoryCard/ChallengeHistoryCard.tsx new file mode 100644 index 00000000..72527d09 --- /dev/null +++ b/frontend/tiggle/src/pages/ChallengePage/ChallengeHistoryCard/ChallengeHistoryCard.tsx @@ -0,0 +1,57 @@ +import { useNavigate } from "react-router-dom"; + +import dayjs from "dayjs"; + +import { ChallengeRespDto, ChallengeType } from "@/types/gamification"; + +import { ChallengeHistoryCardStyle } from "./ChallengeHistoryCardStyle"; +import { StatusBadge } from "../ActiveChallenge/ActiveChallengeStyle"; + +const STATUS_LABEL: Record = { + ACTIVE: "진행중", + COMPLETED: "완료", + FAILED: "실패", + CANCELLED: "취소됨", +}; + +const TYPE_LABEL: Record = { + [ChallengeType.NO_SPEND]: "무소비 챌린지", + [ChallengeType.BUDGET_LIMIT]: "예산 제한 챌린지", +}; + +interface ChallengeHistoryCardProps { + challenge: ChallengeRespDto; +} + +const ChallengeHistoryCard = ({ challenge }: ChallengeHistoryCardProps) => { + const navigate = useNavigate(); + const { id, type, status, startDate, endDate, targetDays, achievedDays } = + challenge; + + return ( + navigate(`/challenges/${id}`)} + > +
+ {TYPE_LABEL[type] ?? type} + + {dayjs(startDate).format("MM.DD")} ~ {dayjs(endDate).format("MM.DD")} + +
+ +
+ + {achievedDays} / {targetDays}일 + + + {STATUS_LABEL[status] ?? status} + +
+
+ ); +}; + +export default ChallengeHistoryCard; diff --git a/frontend/tiggle/src/pages/ChallengePage/ChallengeHistoryCard/ChallengeHistoryCardStyle.tsx b/frontend/tiggle/src/pages/ChallengePage/ChallengeHistoryCard/ChallengeHistoryCardStyle.tsx new file mode 100644 index 00000000..eb4ceefe --- /dev/null +++ b/frontend/tiggle/src/pages/ChallengePage/ChallengeHistoryCard/ChallengeHistoryCardStyle.tsx @@ -0,0 +1,59 @@ +import styled from "styled-components"; + +import { expandTypography } from "@/styles/util"; + +export const ChallengeHistoryCardStyle = styled.div<{ $status: string }>` + background-color: ${({ theme }) => theme.color.white.value}; + border-radius: 12px; + padding: 16px 20px; + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + transition: background-color 0.2s; + border-left: 4px solid + ${({ $status }) => { + switch ($status) { + case "COMPLETED": + return "#28c79c"; + case "FAILED": + return "#f25f5f"; + case "CANCELLED": + return "#999"; + default: + return "#438bea"; + } + }}; + + &:hover { + background-color: ${({ theme }) => theme.color.bluishGray[50].value}; + } + + .card-left { + display: flex; + flex-direction: column; + gap: 4px; + + .card-type { + ${({ theme }) => expandTypography(theme.typography.body.medium.bold)} + color: ${({ theme }) => theme.color.bluishGray[800].value}; + } + + .card-date { + ${({ theme }) => expandTypography(theme.typography.body.small.regular)} + color: ${({ theme }) => theme.color.bluishGray[400].value}; + } + } + + .card-right { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; + + .card-days { + ${({ theme }) => expandTypography(theme.typography.body.medium.bold)} + color: ${({ theme }) => theme.color.bluishGray[700].value}; + } + } +`; diff --git a/frontend/tiggle/src/pages/ChallengePage/ChallengePageStyle.tsx b/frontend/tiggle/src/pages/ChallengePage/ChallengePageStyle.tsx new file mode 100644 index 00000000..61eaee6c --- /dev/null +++ b/frontend/tiggle/src/pages/ChallengePage/ChallengePageStyle.tsx @@ -0,0 +1,83 @@ +import styled from "styled-components"; + +import { expandTypography } from "@/styles/util"; + +export const ChallengePageStyle = styled.div` + width: 100%; + max-width: 480px; + box-sizing: border-box; + margin: 0 auto; + padding: 24px; + + .page-title { + ${({ theme }) => expandTypography(theme.typography.title.medium.bold)} + color: ${({ theme }) => theme.color.bluishGray[800].value}; + margin-bottom: 24px; + } + + .active-section { + margin-bottom: 32px; + } + + .create-button { + width: 100%; + height: 48px; + border-radius: 12px; + border: none; + background-color: ${({ theme }) => theme.color.blue[600].value}; + color: ${({ theme }) => theme.color.white.value}; + ${({ theme }) => expandTypography(theme.typography.body.medium.bold)} + cursor: pointer; + margin-bottom: 40px; + transition: opacity 0.2s; + + &:hover { + opacity: 0.9; + } + + &:disabled { + background-color: ${({ theme }) => theme.color.bluishGray[300].value}; + cursor: not-allowed; + opacity: 1; + } + } + + .history-section { + .history-title { + ${({ theme }) => expandTypography(theme.typography.body.large.bold)} + color: ${({ theme }) => theme.color.bluishGray[600].value}; + margin-bottom: 16px; + } + + .history-list { + display: flex; + flex-direction: column; + gap: 12px; + } + + .empty-history { + text-align: center; + padding: 40px 0; + color: ${({ theme }) => theme.color.bluishGray[400].value}; + ${({ theme }) => expandTypography(theme.typography.body.medium.regular)} + } + } + + .loading { + display: flex; + justify-content: center; + align-items: center; + min-height: 200px; + color: ${({ theme }) => theme.color.bluishGray[400].value}; + } + + ${({ theme }) => theme.mq.desktop} { + padding: 32px 0; + + .page-title { + ${({ theme }) => expandTypography(theme.typography.title.large.bold)} + text-align: center; + margin-bottom: 40px; + } + } +`; diff --git a/frontend/tiggle/src/pages/ChallengePage/index.tsx b/frontend/tiggle/src/pages/ChallengePage/index.tsx new file mode 100644 index 00000000..cab8ec4c --- /dev/null +++ b/frontend/tiggle/src/pages/ChallengePage/index.tsx @@ -0,0 +1,79 @@ +import { Link } from "react-router-dom"; + +import { useQuery } from "@tanstack/react-query"; + +import { ChallengeApiControllerService } from "@/generated"; +import { challengeKeys } from "@/query/queryKeys"; +import { ChallengeRespDto, PageRespDto } from "@/types/gamification"; +import withAuth from "@/utils/withAuth"; + +import ActiveChallenge from "./ActiveChallenge/ActiveChallenge"; +import ChallengeHistoryCard from "./ChallengeHistoryCard/ChallengeHistoryCard"; +import { ChallengePageStyle } from "./ChallengePageStyle"; + +const ChallengePage = () => { + const { data: activeData, isLoading: isActiveLoading } = useQuery({ + queryKey: challengeKeys.active(), + queryFn: () => ChallengeApiControllerService.getActiveChallenge(), + }); + + const { data: historyData, isLoading: isHistoryLoading } = useQuery({ + queryKey: challengeKeys.history(0), + queryFn: () => ChallengeApiControllerService.getChallengeHistory(0, 10), + }); + + const activeChallenge = activeData?.data + ? (activeData.data as ChallengeRespDto) + : null; + const historyPage = historyData?.data + ? (historyData.data as PageRespDto) + : null; + + const hasActiveChallenge = !!activeChallenge; + + if (isActiveLoading && isHistoryLoading) { + return ( + +
불러오는 중...
+
+ ); + } + + return ( + +

챌린지

+ + {activeChallenge && ( +
+ +
+ )} + + {hasActiveChallenge ? ( + + ) : ( + + + + )} + +
+

히스토리

+ + {historyPage && historyPage.content.length > 0 ? ( +
+ {historyPage.content.map(challenge => ( + + ))} +
+ ) : ( +
아직 완료된 챌린지가 없습니다
+ )} +
+
+ ); +}; + +export default withAuth(ChallengePage); diff --git a/frontend/tiggle/src/pages/CharacterCatalogPage/CharacterCatalogPageStyle.tsx b/frontend/tiggle/src/pages/CharacterCatalogPage/CharacterCatalogPageStyle.tsx new file mode 100644 index 00000000..ee57d74f --- /dev/null +++ b/frontend/tiggle/src/pages/CharacterCatalogPage/CharacterCatalogPageStyle.tsx @@ -0,0 +1,156 @@ +import { Link } from "react-router-dom"; + +import styled from "styled-components"; + +import { expandTypography } from "@/styles/util/expandTypography"; + +export const CatalogPageStyle = styled.div` + display: flex; + flex-direction: column; + align-items: center; + padding: 40px 20px 80px; + gap: 32px; + width: 100%; + + ${({ theme }) => theme.mq.desktop} { + padding: 60px 20px 120px; + gap: 40px; + } +`; + +export const CatalogTitle = styled.h1` + ${({ theme }) => expandTypography(theme.typography.title.medium.bold)} + color: ${({ theme }) => theme.color.bluishGray[800].value}; + text-align: center; + + ${({ theme }) => theme.mq.desktop} { + ${({ theme }) => expandTypography(theme.typography.title.large.bold)} + } +`; + +export const PathSection = styled.section` + width: 100%; + max-width: 720px; +`; + +export const PathHeader = styled.button<{ $expanded: boolean }>` + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 16px 20px; + background: ${({ theme }) => theme.color.white.value}; + border-radius: ${({ $expanded }) => ($expanded ? "16px 16px 0 0" : "16px")}; + transition: border-radius 0.2s ease; + + &:hover { + background: ${({ theme }) => theme.color.bluishGray[50].value}; + } +`; + +export const PathLabel = styled.span` + ${({ theme }) => expandTypography(theme.typography.title.small2x.bold)} + color: ${({ theme }) => theme.color.bluishGray[800].value}; +`; + +export const PathCount = styled.span` + ${({ theme }) => expandTypography(theme.typography.body.medium.medium)} + color: ${({ theme }) => theme.color.bluishGray[400].value}; +`; + +export const ChevronIcon = styled.span<{ $expanded: boolean }>` + display: flex; + align-items: center; + color: ${({ theme }) => theme.color.bluishGray[400].value}; + transform: rotate(${({ $expanded }) => ($expanded ? "90deg" : "0deg")}); + transition: transform 0.2s ease; +`; + +export const PathHeaderLeft = styled.div` + display: flex; + align-items: center; + gap: 8px; +`; + +export const FormGrid = styled.div` + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1px; + background: ${({ theme }) => theme.color.bluishGray[100].value}; + border-radius: 0 0 16px 16px; + overflow: hidden; + + ${({ theme }) => theme.mq.desktop} { + grid-template-columns: repeat(3, 1fr); + } +`; + +export const FormCard = styled.div<{ $tierColor: string }>` + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 20px 12px; + background: ${({ theme }) => theme.color.white.value}; + text-align: center; +`; + +export const FormImagePlaceholder = styled.div<{ $tierColor: string }>` + width: 64px; + height: 64px; + border-radius: 50%; + background: ${({ $tierColor }) => $tierColor}22; + border: 2px solid ${({ $tierColor }) => $tierColor}; + display: flex; + align-items: center; + justify-content: center; + + ${({ theme }) => theme.mq.desktop} { + width: 80px; + height: 80px; + } +`; + +export const FormLevel = styled.span<{ $tierColor: string }>` + ${({ theme }) => expandTypography(theme.typography.body.small2x.bold)} + color: ${({ $tierColor }) => $tierColor}; +`; + +export const FormName = styled.span` + ${({ theme }) => expandTypography(theme.typography.body.medium.bold)} + color: ${({ theme }) => theme.color.bluishGray[800].value}; +`; + +export const FormExp = styled.span` + ${({ theme }) => expandTypography(theme.typography.body.small.medium)} + color: ${({ theme }) => theme.color.bluishGray[400].value}; +`; + +export const TierDot = styled.span<{ $color: string }>` + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: ${({ $color }) => $color}; +`; + +export const EmptyState = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 60px 20px; + color: ${({ theme }) => theme.color.bluishGray[400].value}; + ${({ theme }) => expandTypography(theme.typography.body.large.medium)} + text-align: center; +`; + +export const BackLink = styled(Link)` + color: ${({ theme }) => theme.color.blue[600].value}; + ${({ theme }) => expandTypography(theme.typography.body.medium.bold)} + text-decoration: none; + + &:hover { + text-decoration: underline; + } +`; diff --git a/frontend/tiggle/src/pages/CharacterCatalogPage/index.tsx b/frontend/tiggle/src/pages/CharacterCatalogPage/index.tsx new file mode 100644 index 00000000..db229ff3 --- /dev/null +++ b/frontend/tiggle/src/pages/CharacterCatalogPage/index.tsx @@ -0,0 +1,175 @@ +import { useMemo, useState } from "react"; +import { ChevronRight } from "react-feather"; + +import { useQuery } from "@tanstack/react-query"; + +import { CharacterApiControllerService } from "@/generated/services/CharacterApiControllerService"; +import { characterKeys } from "@/query/queryKeys"; +import { + type CharacterCatalogDto, + type CharacterPathType, + type CharacterTierType, +} from "@/types/gamification"; +import withAuth from "@/utils/withAuth"; + +import { + CatalogPageStyle, + CatalogTitle, + PathSection, + PathHeader, + PathLabel, + PathCount, + PathHeaderLeft, + ChevronIcon, + FormGrid, + FormCard, + FormImagePlaceholder, + FormLevel, + FormName, + FormExp, + TierDot, + EmptyState, + BackLink, +} from "./CharacterCatalogPageStyle"; + +const PATH_ORDER: CharacterPathType[] = [ + "GOLD", + "NATURE", + "OCEAN", + "STAR", + "DRAGON", +]; + +const PATH_LABELS: Record = { + GOLD: "재물의 길", + NATURE: "자연의 길", + OCEAN: "바다의 길", + STAR: "별의 길", + DRAGON: "용의 길", +}; + +const TIER_COLORS: Record = { + COMMON: "#999999", + RARE: "#438bea", + EPIC: "#9333ea", + LEGENDARY: "#eab308", + UNIQUE: "#ec4899", +}; + +const CharacterCatalogPage = () => { + const { data, isLoading, isError } = useQuery({ + queryKey: characterKeys.catalog(), + queryFn: () => CharacterApiControllerService.getCatalog(), + staleTime: 1000 * 60 * 10, + }); + + const catalog = (data?.data as CharacterCatalogDto[] | undefined) ?? []; + + const grouped = useMemo(() => { + const map = new Map(); + for (const path of PATH_ORDER) { + map.set(path, []); + } + for (const item of catalog) { + const list = map.get(item.path); + if (list) { + list.push(item); + } + } + // Sort each group by level + for (const [, items] of map) { + items.sort((a, b) => a.level - b.level); + } + return map; + }, [catalog]); + + const [expanded, setExpanded] = useState>( + () => new Set(PATH_ORDER), + ); + + const togglePath = (path: CharacterPathType) => { + setExpanded(prev => { + const next = new Set(prev); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }; + + if (isLoading) { + return ( + + 불러오는 중... + + ); + } + + if (isError) { + return ( + + 도감을 불러올 수 없습니다. + + ); + } + + return ( + + ← 캐릭터로 돌아가기 + 캐릭터 도감 + + {PATH_ORDER.map(path => { + const items = grouped.get(path) ?? []; + const isExpanded = expanded.has(path); + + return ( + + togglePath(path)}> + + {PATH_LABELS[path]} + {items.length}종 + + + + + + + {isExpanded && items.length > 0 && ( + + {items.map(form => { + const tierColor = TIER_COLORS[form.tier]; + return ( + + + + Lv.{form.level} + + + {form.name} + + 필요 경험치: {form.requiredExp.toLocaleString()} + + + + ); + })} + + )} + + {isExpanded && items.length === 0 && ( + + + 아직 등록된 캐릭터가 없습니다. + + + )} + + ); + })} + + ); +}; + +export default withAuth(CharacterCatalogPage); diff --git a/frontend/tiggle/src/pages/CharacterPage/CharacterDisplay/CharacterDisplay.tsx b/frontend/tiggle/src/pages/CharacterPage/CharacterDisplay/CharacterDisplay.tsx new file mode 100644 index 00000000..0549c85c --- /dev/null +++ b/frontend/tiggle/src/pages/CharacterPage/CharacterDisplay/CharacterDisplay.tsx @@ -0,0 +1,94 @@ +import { + type CharacterDetailRespDto, + type CharacterTierType, + type ColorRarityType, + CharacterStage, +} from "@/types/gamification"; + +import { + CharacterDisplayWrapper, + CharacterOrb, + EggPhaseText, + EggProgressText, + FormName, + TierBadge, + RarityBadge, + CharacterInfoRow, +} from "./CharacterDisplayStyle"; + +const TIER_COLORS: Record = { + COMMON: "#999999", + RARE: "#438bea", + EPIC: "#9333ea", + LEGENDARY: "#eab308", + UNIQUE: "#ec4899", +}; + +const TIER_LABELS: Record = { + COMMON: "일반", + RARE: "희귀", + EPIC: "영웅", + LEGENDARY: "전설", + UNIQUE: "유일", +}; + +const EGG_PHASE_LABELS: Record = { + 0: "알", + 1: "흔들리는 알", + 2: "금 간 알", + 3: "부화 직전", +}; + +const RARITY_LABELS: Record = { + NORMAL: "일반", + SHINE: "빛나는", + HOLOGRAPHIC: "홀로그램", + LEGENDARY: "전설", +}; + +interface CharacterDisplayProps { + character: CharacterDetailRespDto; +} + +const CharacterDisplay = ({ character }: CharacterDisplayProps) => { + const { stage, tier, color, egg, currentForm } = character; + const isEgg = stage === CharacterStage.EGG; + + return ( + + + {isEgg && egg ? ( + <> + {EGG_PHASE_LABELS[egg.phase] ?? "알"} + + {egg.records} / {egg.nextPhaseAt} + + + ) : ( + <> + {currentForm && {currentForm.name}} + {tier && ( + + {TIER_LABELS[tier]} + + )} + + )} + + + + + {RARITY_LABELS[color.rarity]} + + + + ); +}; + +export default CharacterDisplay; diff --git a/frontend/tiggle/src/pages/CharacterPage/CharacterDisplay/CharacterDisplayStyle.tsx b/frontend/tiggle/src/pages/CharacterPage/CharacterDisplay/CharacterDisplayStyle.tsx new file mode 100644 index 00000000..b2919ccc --- /dev/null +++ b/frontend/tiggle/src/pages/CharacterPage/CharacterDisplay/CharacterDisplayStyle.tsx @@ -0,0 +1,176 @@ +import styled, { css, keyframes } from "styled-components"; + +import { expandTypography } from "@/styles/util/expandTypography"; + +const pulseGlow = keyframes` + 0%, 100% { + filter: drop-shadow(0 0 12px var(--char-color)) drop-shadow(0 0 24px var(--char-color)); + } + 50% { + filter: drop-shadow(0 0 20px var(--char-color)) drop-shadow(0 0 40px var(--char-color)); + } +`; + +const holoGradient = keyframes` + 0% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + 100% { + background-position: 0% 50%; + } +`; + +export const CharacterDisplayWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; +`; + +export const CharacterOrb = styled.div<{ + $hue: number; + $saturation: number; + $lightness: number; + $rarity: string; + $isEgg: boolean; +}>` + --char-color: hsl( + ${({ $hue }) => $hue}, + ${({ $saturation }) => $saturation}%, + ${({ $lightness }) => $lightness}% + ); + + width: 160px; + height: 160px; + border-radius: ${({ $isEgg }) => + $isEgg ? "50% 50% 50% 50% / 40% 40% 60% 60%" : "50%"}; + background: var(--char-color); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + position: relative; + transition: all 0.3s ease; + + ${({ theme }) => theme.mq.desktop} { + width: 200px; + height: 200px; + } + + ${({ $rarity, $hue }) => + $rarity === "SHINE" && + css` + box-shadow: + 0 0 20px hsla(${$hue}, 70%, 60%, 0.5), + 0 0 40px hsla(${$hue}, 70%, 60%, 0.2); + `} + + ${({ $rarity, $hue }) => + $rarity === "HOLOGRAPHIC" && + css` + background: linear-gradient( + 135deg, + hsl(${$hue}, 70%, 60%), + hsl(${$hue + 15}, 70%, 55%), + hsl(${$hue + 30}, 70%, 60%) + ); + background-size: 200% 200%; + animation: ${holoGradient} 3s ease infinite; + `} + + ${({ $rarity, $hue }) => + $rarity === "LEGENDARY" && + css` + background: linear-gradient( + 135deg, + hsl(${$hue}, 80%, 55%), + hsl(${$hue + 15}, 80%, 50%), + hsl(${$hue + 30}, 80%, 55%) + ); + background-size: 200% 200%; + animation: + ${holoGradient} 3s ease infinite, + ${pulseGlow} 2s ease-in-out infinite; + `} + + @media (prefers-reduced-motion: reduce) { + animation: none !important; + transition: none; + } +`; + +export const EggPhaseText = styled.span` + color: rgba(255, 255, 255, 0.9); + ${({ theme }) => expandTypography(theme.typography.body.large.bold)} + text-align: center; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); +`; + +export const EggProgressText = styled.span` + color: rgba(255, 255, 255, 0.7); + ${({ theme }) => expandTypography(theme.typography.body.small.medium)} + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); +`; + +export const FormName = styled.span` + color: rgba(255, 255, 255, 0.95); + ${({ theme }) => expandTypography(theme.typography.title.small2x.bold)} + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + text-align: center; +`; + +export const TierBadge = styled.span<{ $tierColor: string }>` + display: inline-block; + padding: 2px 10px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.25); + color: ${({ $tierColor }) => $tierColor}; + ${({ theme }) => expandTypography(theme.typography.body.small2x.bold)} + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); + backdrop-filter: blur(4px); +`; + +export const RarityBadge = styled.div<{ $rarity: string }>` + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 12px; + border-radius: 16px; + ${({ theme }) => expandTypography(theme.typography.body.small.bold)} + + ${({ $rarity, theme }) => { + switch ($rarity) { + case "SHINE": + return css` + background: ${theme.color.blue[50].value}; + color: ${theme.color.blue[600].value}; + `; + case "HOLOGRAPHIC": + return css` + background: linear-gradient(135deg, #e0e7ff, #fce7f3); + color: #7c3aed; + `; + case "LEGENDARY": + return css` + background: linear-gradient(135deg, #fef3c7, #fde68a); + color: #b45309; + `; + default: + return css` + background: ${theme.color.bluishGray[100].value}; + color: ${theme.color.bluishGray[600].value}; + `; + } + }} +`; + +export const CharacterInfoRow = styled.div` + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + justify-content: center; +`; diff --git a/frontend/tiggle/src/pages/CharacterPage/CharacterPageStyle.tsx b/frontend/tiggle/src/pages/CharacterPage/CharacterPageStyle.tsx new file mode 100644 index 00000000..91868cbd --- /dev/null +++ b/frontend/tiggle/src/pages/CharacterPage/CharacterPageStyle.tsx @@ -0,0 +1,103 @@ +import { Link } from "react-router-dom"; + +import styled from "styled-components"; + +import { expandTypography } from "@/styles/util/expandTypography"; + +export const CharacterPageStyle = styled.div` + display: flex; + flex-direction: column; + align-items: center; + padding: 40px 20px 80px; + gap: 32px; + + ${({ theme }) => theme.mq.desktop} { + padding: 60px 20px 120px; + gap: 40px; + } +`; + +export const PageTitle = styled.h1` + ${({ theme }) => expandTypography(theme.typography.title.medium.bold)} + color: ${({ theme }) => theme.color.bluishGray[800].value}; + text-align: center; + + ${({ theme }) => theme.mq.desktop} { + ${({ theme }) => expandTypography(theme.typography.title.large.bold)} + } +`; + +export const CharacterSection = styled.section` + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; + width: 100%; + max-width: 327px; + + ${({ theme }) => theme.mq.desktop} { + max-width: 480px; + } +`; + +export const InfoCard = styled.div` + width: 100%; + padding: 20px; + background: ${({ theme }) => theme.color.white.value}; + border-radius: 16px; + display: flex; + flex-direction: column; + gap: 12px; +`; + +export const InfoRow = styled.div` + display: flex; + justify-content: space-between; + align-items: center; +`; + +export const InfoLabel = styled.span` + color: ${({ theme }) => theme.color.bluishGray[500].value}; + ${({ theme }) => expandTypography(theme.typography.body.medium.medium)} +`; + +export const InfoValue = styled.span` + color: ${({ theme }) => theme.color.bluishGray[800].value}; + ${({ theme }) => expandTypography(theme.typography.body.medium.bold)} +`; + +export const CatalogLink = styled(Link)` + display: flex; + align-items: center; + justify-content: center; + width: 100%; + max-width: 327px; + padding: 16px; + border-radius: 12px; + background: ${({ theme }) => theme.color.white.value}; + color: ${({ theme }) => theme.color.blue[600].value}; + ${({ theme }) => expandTypography(theme.typography.body.medium.bold)} + text-decoration: none; + transition: background 0.2s ease; + gap: 4px; + + &:hover { + background: ${({ theme }) => theme.color.blue[50].value}; + } + + ${({ theme }) => theme.mq.desktop} { + max-width: 480px; + ${({ theme }) => expandTypography(theme.typography.body.large.bold)} + } +`; + +export const EmptyState = styled.div` + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 60px 20px; + color: ${({ theme }) => theme.color.bluishGray[400].value}; + ${({ theme }) => expandTypography(theme.typography.body.large.medium)} + text-align: center; +`; diff --git a/frontend/tiggle/src/pages/CharacterPage/ExpProgressBar/ExpProgressBar.tsx b/frontend/tiggle/src/pages/CharacterPage/ExpProgressBar/ExpProgressBar.tsx new file mode 100644 index 00000000..8c9221f8 --- /dev/null +++ b/frontend/tiggle/src/pages/CharacterPage/ExpProgressBar/ExpProgressBar.tsx @@ -0,0 +1,45 @@ +import { + ExpBarWrapper, + ExpBarHeader, + LevelBadge, + ExpText, + BarTrack, + BarFill, +} from "./ExpProgressBarStyle"; + +interface ExpProgressBarProps { + experience: number; + nextLevelExp: number | null; + level: number; +} + +const ExpProgressBar = ({ + experience, + nextLevelExp, + level, +}: ExpProgressBarProps) => { + const isMaxLevel = nextLevelExp === null; + const percent = isMaxLevel + ? 100 + : nextLevelExp > 0 + ? Math.min(Math.round((experience / nextLevelExp) * 100), 100) + : 0; + + return ( + + + Lv. {level} + + {isMaxLevel + ? "MAX" + : `${experience.toLocaleString()} / ${nextLevelExp.toLocaleString()}`} + + + + + + + ); +}; + +export default ExpProgressBar; diff --git a/frontend/tiggle/src/pages/CharacterPage/ExpProgressBar/ExpProgressBarStyle.tsx b/frontend/tiggle/src/pages/CharacterPage/ExpProgressBar/ExpProgressBarStyle.tsx new file mode 100644 index 00000000..6b109f6c --- /dev/null +++ b/frontend/tiggle/src/pages/CharacterPage/ExpProgressBar/ExpProgressBarStyle.tsx @@ -0,0 +1,56 @@ +import styled from "styled-components"; + +import { expandTypography } from "@/styles/util/expandTypography"; + +export const ExpBarWrapper = styled.div` + width: 100%; + max-width: 327px; + + ${({ theme }) => theme.mq.desktop} { + max-width: 480px; + } +`; + +export const ExpBarHeader = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +`; + +export const LevelBadge = styled.span` + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px 10px; + border-radius: 12px; + background: ${({ theme }) => theme.color.blue[600].value}; + color: ${({ theme }) => theme.color.white.value}; + ${({ theme }) => expandTypography(theme.typography.body.small.bold)} +`; + +export const ExpText = styled.span` + color: ${({ theme }) => theme.color.bluishGray[500].value}; + ${({ theme }) => expandTypography(theme.typography.body.small.medium)} +`; + +export const BarTrack = styled.div` + width: 100%; + height: 12px; + border-radius: 6px; + background: ${({ theme }) => theme.color.bluishGray[100].value}; + overflow: hidden; +`; + +export const BarFill = styled.div<{ $percent: number }>` + height: 100%; + border-radius: 6px; + background: linear-gradient( + 90deg, + ${({ theme }) => theme.color.blue[400].value}, + ${({ theme }) => theme.color.blue[600].value} + ); + width: ${({ $percent }) => $percent}%; + transition: width 0.5s ease; + min-width: ${({ $percent }) => ($percent > 0 ? "4px" : "0")}; +`; diff --git a/frontend/tiggle/src/pages/CharacterPage/index.tsx b/frontend/tiggle/src/pages/CharacterPage/index.tsx new file mode 100644 index 00000000..7944b9b5 --- /dev/null +++ b/frontend/tiggle/src/pages/CharacterPage/index.tsx @@ -0,0 +1,142 @@ +import { ChevronRight } from "react-feather"; + +import { useQuery } from "@tanstack/react-query"; + +import { CharacterApiControllerService } from "@/generated/services/CharacterApiControllerService"; +import { characterKeys } from "@/query/queryKeys"; +import { + type CharacterDetailRespDto, + CharacterStage, +} from "@/types/gamification"; +import withAuth, { type AuthProps } from "@/utils/withAuth"; + +import CharacterDisplay from "./CharacterDisplay/CharacterDisplay"; +import { + CharacterPageStyle, + PageTitle, + CharacterSection, + InfoCard, + InfoRow, + InfoLabel, + InfoValue, + CatalogLink, + EmptyState, +} from "./CharacterPageStyle"; +import ExpProgressBar from "./ExpProgressBar/ExpProgressBar"; + +const PATH_LABELS: Record = { + GOLD: "재물의 길", + NATURE: "자연의 길", + OCEAN: "바다의 길", + STAR: "별의 길", + DRAGON: "용의 길", +}; + +interface CharacterPageProps extends AuthProps {} + +const CharacterPage = ({ profile }: CharacterPageProps) => { + const { data, isLoading, isError } = useQuery({ + queryKey: characterKeys.me(), + queryFn: () => CharacterApiControllerService.getMyCharacter(), + staleTime: 1000 * 60 * 5, + }); + + const character = data?.data as CharacterDetailRespDto | undefined; + + if (isLoading) { + return ( + + 불러오는 중... + + ); + } + + if (isError || !character) { + return ( + + 캐릭터 정보를 불러올 수 없습니다. + + ); + } + + const isEgg = character.stage === CharacterStage.EGG; + + return ( + + {profile.nickname}의 캐릭터 + + + + + + + {!isEgg && character.currentForm && ( + + + 형태 + {character.currentForm.name} + + {character.tier && ( + + 등급 + + { + { + COMMON: "일반", + RARE: "희귀", + EPIC: "영웅", + LEGENDARY: "전설", + UNIQUE: "유일", + }[character.tier] + } + + + )} + {character.path && ( + + 성장 경로 + + {PATH_LABELS[character.path] ?? character.path} + + + )} + + 레벨 + Lv. {character.level} + + + )} + + {isEgg && character.egg && ( + + + 기록 진행 + {character.egg.records} / 30 + + + 현재 단계 + + {{ + 0: "알", + 1: "흔들리는 알", + 2: "금 간 알", + 3: "부화 직전", + }[character.egg.phase] ?? "알"} + + + + )} + + + + 캐릭터 도감 보기 + + + ); +}; + +export default withAuth(CharacterPage); diff --git a/frontend/tiggle/src/pages/CreatePage/TransactionPreviewCell/TransactionPreviewCell.tsx b/frontend/tiggle/src/pages/CreatePage/TransactionPreviewCell/TransactionPreviewCell.tsx index 16c9551a..7356df78 100644 --- a/frontend/tiggle/src/pages/CreatePage/TransactionPreviewCell/TransactionPreviewCell.tsx +++ b/frontend/tiggle/src/pages/CreatePage/TransactionPreviewCell/TransactionPreviewCell.tsx @@ -1,8 +1,8 @@ import cn from "classnames"; import { TypeTag } from "@/components/atoms"; -import { TxType } from "@/types"; import { TransactionRespDto } from "@/generated"; +import { TxType } from "@/types"; import { convertTxTypeToWord } from "@/utils/txType"; import { TransactionPreviewCellStyle } from "./TransactionPreviewCellStyle"; diff --git a/frontend/tiggle/src/pages/DetailPage/ReplyCell/ReplyCell.tsx b/frontend/tiggle/src/pages/DetailPage/ReplyCell/ReplyCell.tsx index 25a24714..ae275337 100644 --- a/frontend/tiggle/src/pages/DetailPage/ReplyCell/ReplyCell.tsx +++ b/frontend/tiggle/src/pages/DetailPage/ReplyCell/ReplyCell.tsx @@ -1,9 +1,9 @@ import { CommentRespDto } from "@/generated"; import { calculateDateTimeDiff } from "@/utils/date"; +import { getProfileImageUrl } from "@/utils/imageUrl"; import { ReplyCellStyle } from "./ReplyCellStyle"; import { CommentSenderStyle } from "../CommentCell/CommentCellStyle"; -import { getProfileImageUrl } from "@/utils/imageUrl"; interface ReplyCellProps extends Pick {} diff --git a/frontend/tiggle/src/pages/DetailPage/index.tsx b/frontend/tiggle/src/pages/DetailPage/index.tsx index 34a62741..68c3ba31 100644 --- a/frontend/tiggle/src/pages/DetailPage/index.tsx +++ b/frontend/tiggle/src/pages/DetailPage/index.tsx @@ -55,7 +55,14 @@ const DetailPage = () => { if (!transactionData?.data) { return ( -
+
불러오는 중...
diff --git a/frontend/tiggle/src/pages/InventoryPage/EquipmentSlots/EquipmentSlots.tsx b/frontend/tiggle/src/pages/InventoryPage/EquipmentSlots/EquipmentSlots.tsx new file mode 100644 index 00000000..f1a24199 --- /dev/null +++ b/frontend/tiggle/src/pages/InventoryPage/EquipmentSlots/EquipmentSlots.tsx @@ -0,0 +1,109 @@ +import { ItemSlot } from "@/types/gamification"; +import type { + EquipmentRespDto, + ItemSlotType, + MemberItemRespDto, +} from "@/types/gamification"; + +import { + EquipmentContainer, + EquipmentTitle, + EquippedItemName, + SlotBox, + SlotIcon, + SlotLabel, + SlotsGrid, +} from "./EquipmentSlotsStyle"; + +const SLOT_CONFIG: { + slot: ItemSlotType; + label: string; + icon: string; + equipKey: keyof EquipmentRespDto; +}[] = [ + { + slot: ItemSlot.HAT, + label: "모자", + icon: "\uD83E\uDDE2", + equipKey: "hatItemId", + }, + { + slot: ItemSlot.OUTFIT, + label: "의상", + icon: "\uD83D\uDC55", + equipKey: "outfitItemId", + }, + { + slot: ItemSlot.ACCESSORY, + label: "악세서리", + icon: "\uD83D\uDC8D", + equipKey: "accessoryItemId", + }, + { + slot: ItemSlot.BACKGROUND, + label: "배경", + icon: "\uD83C\uDF04", + equipKey: "backgroundItemId", + }, + { + slot: ItemSlot.EFFECT, + label: "이펙트", + icon: "\u2728", + equipKey: "effectItemId", + }, + { + slot: ItemSlot.TITLE, + label: "칭호", + icon: "\uD83C\uDFC5", + equipKey: "titleItemId", + }, +]; + +interface EquipmentSlotsProps { + equipment: EquipmentRespDto; + items: MemberItemRespDto[]; + onSlotClick: (slot: ItemSlotType) => void; +} + +const EquipmentSlots = ({ + equipment, + items, + onSlotClick, +}: EquipmentSlotsProps) => { + const findItemName = (itemId: number | null): string | null => { + if (itemId == null) return null; + const found = items.find(i => i.itemId === itemId); + return found?.name ?? null; + }; + + return ( + + 장착 장비 + + {SLOT_CONFIG.map(({ slot, label, icon, equipKey }) => { + const equippedId = equipment[equipKey]; + const equippedName = findItemName(equippedId); + const hasItem = equippedId != null; + + return ( + onSlotClick(slot)} + type="button" + > + {icon} + {equippedName ? ( + {equippedName} + ) : ( + {label} + )} + + ); + })} + + + ); +}; + +export default EquipmentSlots; diff --git a/frontend/tiggle/src/pages/InventoryPage/EquipmentSlots/EquipmentSlotsStyle.tsx b/frontend/tiggle/src/pages/InventoryPage/EquipmentSlots/EquipmentSlotsStyle.tsx new file mode 100644 index 00000000..6c4ab04c --- /dev/null +++ b/frontend/tiggle/src/pages/InventoryPage/EquipmentSlots/EquipmentSlotsStyle.tsx @@ -0,0 +1,67 @@ +import styled from "styled-components"; + +import { expandTypography } from "@/styles/util/expandTypography"; + +export const EquipmentContainer = styled.div` + width: 100%; + margin-bottom: 24px; +`; + +export const EquipmentTitle = styled.h3` + ${({ theme }) => expandTypography(theme.typography.body.medium.bold)} + color: ${({ theme }) => theme.color.bluishGray[700].value}; + margin-bottom: 12px; +`; + +export const SlotsGrid = styled.div` + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + + ${({ theme }) => theme.mq.desktop} { + grid-template-columns: repeat(6, 1fr); + } +`; + +export const SlotBox = styled.button<{ $hasItem: boolean }>` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + padding: 12px 8px; + border-radius: 12px; + border: 2px dashed + ${({ theme, $hasItem }) => + $hasItem + ? theme.color.blue[600].value + : theme.color.bluishGray[200].value}; + background: ${({ theme, $hasItem }) => + $hasItem ? theme.color.blue[600].value + "0A" : theme.color.white.value}; + cursor: pointer; + transition: + border-color 0.15s ease, + background 0.15s ease; + min-height: 88px; + + &:hover { + border-color: ${({ theme }) => theme.color.blue[600].value}; + background: ${({ theme }) => theme.color.blue[600].value}0A; + } +`; + +export const SlotIcon = styled.span` + font-size: 24px; +`; + +export const SlotLabel = styled.span` + ${({ theme }) => expandTypography(theme.typography.body.small.medium)} + color: ${({ theme }) => theme.color.bluishGray[500].value}; +`; + +export const EquippedItemName = styled.span` + ${({ theme }) => expandTypography(theme.typography.body.small.bold)} + color: ${({ theme }) => theme.color.blue[600].value}; + text-align: center; + word-break: keep-all; +`; diff --git a/frontend/tiggle/src/pages/InventoryPage/InventoryPageStyle.tsx b/frontend/tiggle/src/pages/InventoryPage/InventoryPageStyle.tsx new file mode 100644 index 00000000..63b22cd8 --- /dev/null +++ b/frontend/tiggle/src/pages/InventoryPage/InventoryPageStyle.tsx @@ -0,0 +1,166 @@ +import styled from "styled-components"; + +import { expandTypography } from "@/styles/util/expandTypography"; + +export const InventoryPageContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; + padding: 40px 16px 80px; + max-width: 720px; + margin: 0 auto; + width: 100%; +`; + +export const PageTitle = styled.h2` + ${({ theme }) => expandTypography(theme.typography.title.medium.bold)} + color: ${({ theme }) => theme.color.bluishGray[800].value}; + margin-bottom: 24px; + text-align: center; + + ${({ theme }) => theme.mq.desktop} { + ${({ theme }) => expandTypography(theme.typography.title.large.bold)} + } +`; + +export const TabContainer = styled.div` + display: flex; + gap: 0; + margin-bottom: 24px; + width: 100%; + border-bottom: 1px solid ${({ theme }) => theme.color.bluishGray[200].value}; +`; + +export const TabButton = styled.button<{ $active: boolean }>` + flex: 1; + padding: 12px 0; + border: none; + ${({ theme }) => expandTypography(theme.typography.body.medium.bold)} + color: ${({ theme, $active }) => + $active ? theme.color.blue[600].value : theme.color.bluishGray[400].value}; + border-bottom: 2px solid + ${({ theme, $active }) => + $active ? theme.color.blue[600].value : "transparent"}; + background: none; + cursor: pointer; + transition: + color 0.15s ease, + border-color 0.15s ease; + + &:hover { + color: ${({ theme }) => theme.color.blue[600].value}; + } + + &:focus-visible { + outline: 2px solid ${({ theme }) => theme.color.blue[400].value}; + outline-offset: -2px; + } +`; + +export const ItemGrid = styled.div` + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + width: 100%; + + ${({ theme }) => theme.mq.desktop} { + grid-template-columns: repeat(4, 1fr); + gap: 16px; + } +`; + +export const EmptyMessage = styled.p` + ${({ theme }) => expandTypography(theme.typography.body.medium.medium)} + color: ${({ theme }) => theme.color.bluishGray[400].value}; + text-align: center; + padding: 40px 0; +`; + +export const SectionDivider = styled.hr` + width: 100%; + border: none; + border-top: 1px solid ${({ theme }) => theme.color.bluishGray[100].value}; + margin: 24px 0; +`; + +export const EquipModalOverlay = styled.div` + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +`; + +export const EquipModalContent = styled.div` + background: ${({ theme }) => theme.color.white.value}; + border-radius: 16px; + padding: 24px; + width: 90%; + max-width: 400px; + max-height: 70vh; + overflow-y: auto; +`; + +export const EquipModalTitle = styled.h3` + ${({ theme }) => expandTypography(theme.typography.title.small2x.bold)} + color: ${({ theme }) => theme.color.bluishGray[800].value}; + margin-bottom: 16px; +`; + +export const EquipModalItemList = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +`; + +export const EquipModalItem = styled.button<{ $selected: boolean }>` + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + border-radius: 8px; + border: 2px solid + ${({ theme, $selected }) => + $selected + ? theme.color.blue[600].value + : theme.color.bluishGray[200].value}; + background: ${({ theme, $selected }) => + $selected ? theme.color.blue[600].value + "0A" : theme.color.white.value}; + cursor: pointer; + width: 100%; + text-align: left; + + &:hover { + border-color: ${({ theme }) => theme.color.blue[600].value}; + } +`; + +export const EquipModalItemName = styled.span` + ${({ theme }) => expandTypography(theme.typography.body.medium.bold)} + color: ${({ theme }) => theme.color.bluishGray[800].value}; +`; + +export const EquipModalActions = styled.div` + display: flex; + gap: 8px; + margin-top: 16px; +`; + +export const EquipModalButton = styled.button<{ $primary?: boolean }>` + flex: 1; + padding: 10px 16px; + border-radius: 8px; + ${({ theme }) => expandTypography(theme.typography.body.medium.bold)} + background: ${({ theme, $primary }) => + $primary ? theme.color.blue[600].value : theme.color.bluishGray[100].value}; + color: ${({ theme, $primary }) => + $primary ? theme.color.white.value : theme.color.bluishGray[600].value}; + cursor: pointer; + transition: opacity 0.15s ease; + + &:hover { + opacity: 0.85; + } +`; diff --git a/frontend/tiggle/src/pages/InventoryPage/ItemCard/ItemCard.tsx b/frontend/tiggle/src/pages/InventoryPage/ItemCard/ItemCard.tsx new file mode 100644 index 00000000..410ad4cd --- /dev/null +++ b/frontend/tiggle/src/pages/InventoryPage/ItemCard/ItemCard.tsx @@ -0,0 +1,59 @@ +import type { + ItemCatalogRespDto, + ItemSlotType, + MemberItemRespDto, +} from "@/types/gamification"; + +import { + ItemCardContainer, + ItemImage, + ItemName, + ItemSlotLabel, + LockOverlay, + TierBadge, +} from "./ItemCardStyle"; + +const SLOT_LABELS: Record = { + HAT: "모자", + OUTFIT: "의상", + ACCESSORY: "악세서리", + BACKGROUND: "배경", + EFFECT: "이펙트", + TITLE: "칭호", +}; + +const SLOT_ICONS: Record = { + HAT: "\uD83E\uDDE2", + OUTFIT: "\uD83D\uDC55", + ACCESSORY: "\uD83D\uDC8D", + BACKGROUND: "\uD83C\uDF04", + EFFECT: "\u2728", + TITLE: "\uD83C\uDFC5", +}; + +interface ItemCardProps { + item: MemberItemRespDto | ItemCatalogRespDto; + locked?: boolean; + onClick?: () => void; +} + +const ItemCard = ({ item, locked = false, onClick }: ItemCardProps) => { + const slot: ItemSlotType = item.slot; + const tier = item.tier; + + return ( + + {locked && {"\uD83D\uDD12"}} + {SLOT_ICONS[slot] ?? "\uD83D\uDCE6"} + {item.name} + {SLOT_LABELS[slot] ?? slot} + {tier} + + ); +}; + +export default ItemCard; diff --git a/frontend/tiggle/src/pages/InventoryPage/ItemCard/ItemCardStyle.tsx b/frontend/tiggle/src/pages/InventoryPage/ItemCard/ItemCardStyle.tsx new file mode 100644 index 00000000..1b18eb45 --- /dev/null +++ b/frontend/tiggle/src/pages/InventoryPage/ItemCard/ItemCardStyle.tsx @@ -0,0 +1,106 @@ +import styled, { css, keyframes } from "styled-components"; + +import { expandTypography } from "@/styles/util/expandTypography"; + +const rainbowBorder = keyframes` + 0% { border-color: #ff0000; } + 17% { border-color: #ff8800; } + 33% { border-color: #ffff00; } + 50% { border-color: #00ff00; } + 67% { border-color: #0088ff; } + 83% { border-color: #8800ff; } + 100% { border-color: #ff0000; } +`; + +export const tierBorderColor: Record = { + COMMON: "#999999", + RARE: "#438bea", + EPIC: "#9333ea", + LEGENDARY: "#eab308", + UNIQUE: "#ff0000", +}; + +export const ItemCardContainer = styled.div<{ + $tier: string; + $locked?: boolean; +}>` + display: flex; + flex-direction: column; + align-items: center; + padding: 12px; + border-radius: 12px; + border: 2px solid ${({ $tier }) => tierBorderColor[$tier] ?? "#999999"}; + background: ${({ theme }) => theme.color.white.value}; + cursor: pointer; + transition: + transform 0.15s ease, + box-shadow 0.15s ease; + position: relative; + overflow: hidden; + + ${({ $tier }) => + $tier === "UNIQUE" && + css` + animation: ${rainbowBorder} 3s linear infinite; + `} + + ${({ $locked }) => + $locked && + css` + opacity: 0.45; + filter: grayscale(100%); + cursor: default; + `} + + &:hover { + ${({ $locked }) => + !$locked && + css` + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + `} + } +`; + +export const ItemImage = styled.div` + width: 56px; + height: 56px; + border-radius: 8px; + background: ${({ theme }) => theme.color.bluishGray[100].value}; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 8px; + font-size: 24px; + + ${({ theme }) => theme.mq.desktop} { + width: 72px; + height: 72px; + } +`; + +export const ItemName = styled.span` + ${({ theme }) => expandTypography(theme.typography.body.small.bold)} + color: ${({ theme }) => theme.color.bluishGray[800].value}; + text-align: center; + word-break: keep-all; +`; + +export const ItemSlotLabel = styled.span` + ${({ theme }) => expandTypography(theme.typography.body.small.medium)} + color: ${({ theme }) => theme.color.bluishGray[400].value}; + margin-top: 2px; +`; + +export const LockOverlay = styled.div` + position: absolute; + top: 6px; + right: 6px; + font-size: 14px; +`; + +export const TierBadge = styled.span<{ $tier: string }>` + ${({ theme }) => expandTypography(theme.typography.body.small.medium)} + color: ${({ $tier }) => tierBorderColor[$tier] ?? "#999999"}; + margin-top: 4px; +`; diff --git a/frontend/tiggle/src/pages/InventoryPage/index.tsx b/frontend/tiggle/src/pages/InventoryPage/index.tsx new file mode 100644 index 00000000..68f1f511 --- /dev/null +++ b/frontend/tiggle/src/pages/InventoryPage/index.tsx @@ -0,0 +1,243 @@ +import { useCallback, useState } from "react"; + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { ItemApiControllerService } from "@/generated"; +import { itemKeys } from "@/query/queryKeys"; +import type { + EquipmentRespDto, + ItemCatalogRespDto, + ItemSlotType, + MemberItemRespDto, +} from "@/types/gamification"; +import { ItemSlot } from "@/types/gamification"; +import withAuth, { AuthProps } from "@/utils/withAuth"; + +import EquipmentSlots from "./EquipmentSlots/EquipmentSlots"; +import { + EmptyMessage, + EquipModalActions, + EquipModalButton, + EquipModalContent, + EquipModalItem, + EquipModalItemList, + EquipModalItemName, + EquipModalOverlay, + EquipModalTitle, + InventoryPageContainer, + ItemGrid, + PageTitle, + SectionDivider, + TabButton, + TabContainer, +} from "./InventoryPageStyle"; +import ItemCard from "./ItemCard/ItemCard"; + +type Tab = "inventory" | "catalog"; + +const EQUIPMENT_KEY_MAP: Record = { + [ItemSlot.HAT]: "hatItemId", + [ItemSlot.OUTFIT]: "outfitItemId", + [ItemSlot.ACCESSORY]: "accessoryItemId", + [ItemSlot.BACKGROUND]: "backgroundItemId", + [ItemSlot.EFFECT]: "effectItemId", + [ItemSlot.TITLE]: "titleItemId", +}; + +interface InventoryPageProps extends AuthProps {} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const InventoryPage = (_props: InventoryPageProps) => { + const queryClient = useQueryClient(); + const [activeTab, setActiveTab] = useState("inventory"); + const [equipSlot, setEquipSlot] = useState(null); + const [selectedItemId, setSelectedItemId] = useState(null); + + // -- Queries -- + const { data: inventoryRes } = useQuery({ + queryKey: itemKeys.inventory(), + queryFn: () => ItemApiControllerService.getInventory(), + }); + + const { data: catalogRes } = useQuery({ + queryKey: itemKeys.catalog(), + queryFn: () => ItemApiControllerService.getCatalog(), + }); + + const { data: equipmentRes } = useQuery({ + queryKey: itemKeys.equipment(), + queryFn: () => ItemApiControllerService.getMyEquipment(), + }); + + const inventory: MemberItemRespDto[] = inventoryRes?.data ?? []; + const catalog: ItemCatalogRespDto[] = catalogRes?.data ?? []; + const equipment: EquipmentRespDto = equipmentRes?.data ?? { + hatItemId: null, + outfitItemId: null, + accessoryItemId: null, + backgroundItemId: null, + effectItemId: null, + titleItemId: null, + }; + + // -- Mutation -- + const equipMutation = useMutation({ + mutationFn: (body: { slot: string; itemId: number | null }) => + ItemApiControllerService.equipItem(body), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: itemKeys.equipment() }); + queryClient.invalidateQueries({ queryKey: itemKeys.inventory() }); + closeModal(); + }, + }); + + // -- Equip modal logic -- + const openEquipModal = useCallback( + (slot: ItemSlotType) => { + const currentEquippedId = equipment[EQUIPMENT_KEY_MAP[slot]]; + setEquipSlot(slot); + setSelectedItemId(currentEquippedId); + }, + [equipment], + ); + + const closeModal = useCallback(() => { + setEquipSlot(null); + setSelectedItemId(null); + }, []); + + const handleEquipConfirm = useCallback(() => { + if (!equipSlot) return; + equipMutation.mutate({ slot: equipSlot, itemId: selectedItemId }); + }, [equipSlot, selectedItemId, equipMutation]); + + const slotItems = equipSlot + ? inventory.filter(i => i.slot === equipSlot) + : []; + + return ( + + 아이템 + + {/* Equipment section */} + + + + + {/* Tabs */} + + setActiveTab("inventory")} + type="button" + > + 보유 아이템 + + setActiveTab("catalog")} + type="button" + > + 아이템 도감 + + + + {/* Inventory tab */} + {activeTab === "inventory" && ( + <> + {inventory.length === 0 ? ( + 보유한 아이템이 없습니다. + ) : ( + + {inventory.map(item => ( + openEquipModal(item.slot)} + /> + ))} + + )} + + )} + + {/* Catalog tab */} + {activeTab === "catalog" && ( + <> + {catalog.length === 0 ? ( + 아이템 도감이 비어있습니다. + ) : ( + + {catalog.map(item => ( + + ))} + + )} + + )} + + {/* Equip modal */} + {equipSlot && ( + + e.stopPropagation()}> + + {equipSlot === "HAT" && "모자"} + {equipSlot === "OUTFIT" && "의상"} + {equipSlot === "ACCESSORY" && "악세서리"} + {equipSlot === "BACKGROUND" && "배경"} + {equipSlot === "EFFECT" && "이펙트"} + {equipSlot === "TITLE" && "칭호"} 장착 + + + + {/* Unequip option */} + setSelectedItemId(null)} + type="button" + > + 장착 해제 + + + {slotItems.map(item => ( + setSelectedItemId(item.itemId)} + type="button" + > + {item.name} + + ))} + + {slotItems.length === 0 && ( + + 해당 슬롯에 장착할 수 있는 아이템이 없습니다. + + )} + + + + + 취소 + + + 확인 + + + + + )} + + ); +}; + +export default withAuth(InventoryPage); diff --git a/frontend/tiggle/src/pages/NotFoundPage.tsx b/frontend/tiggle/src/pages/NotFoundPage.tsx index c3b8c4ad..26e3f6fa 100644 --- a/frontend/tiggle/src/pages/NotFoundPage.tsx +++ b/frontend/tiggle/src/pages/NotFoundPage.tsx @@ -1,4 +1,5 @@ import { useNavigate } from "react-router-dom"; + import styled from "styled-components"; const NotFoundPageStyle = styled.div` diff --git a/frontend/tiggle/src/pages/StatisticsPage/CategoryBreakdown/CategoryBreakdown.tsx b/frontend/tiggle/src/pages/StatisticsPage/CategoryBreakdown/CategoryBreakdown.tsx new file mode 100644 index 00000000..274e2295 --- /dev/null +++ b/frontend/tiggle/src/pages/StatisticsPage/CategoryBreakdown/CategoryBreakdown.tsx @@ -0,0 +1,50 @@ +import type { CategoryBreakdownDto } from "@/types/gamification"; + +import { CategoryBreakdownWrapper } from "./CategoryBreakdownStyle"; + +interface CategoryBreakdownProps { + data: CategoryBreakdownDto[]; +} + +const formatAmount = (amount: number): string => + `${amount.toLocaleString("ko-KR")}원`; + +const CategoryBreakdown = ({ data }: CategoryBreakdownProps) => { + const sorted = [...data].sort((a, b) => b.ratio - a.ratio); + + return ( + +

카테고리별 지출

+ + {sorted.length > 0 ? ( +
+ {sorted.map(item => ( +
+
+ {item.categoryName} +
+ + {formatAmount(item.amount)} + + + {item.ratio.toFixed(1)}% + +
+
+
+
+
+
+ ))} +
+ ) : ( +

이번 달 지출 데이터가 없습니다.

+ )} + + ); +}; + +export default CategoryBreakdown; diff --git a/frontend/tiggle/src/pages/StatisticsPage/CategoryBreakdown/CategoryBreakdownStyle.tsx b/frontend/tiggle/src/pages/StatisticsPage/CategoryBreakdown/CategoryBreakdownStyle.tsx new file mode 100644 index 00000000..099bdda5 --- /dev/null +++ b/frontend/tiggle/src/pages/StatisticsPage/CategoryBreakdown/CategoryBreakdownStyle.tsx @@ -0,0 +1,97 @@ +import styled from "styled-components"; + +import { expandTypography } from "@/styles/util/expandTypography"; + +export const CategoryBreakdownWrapper = styled.div` + width: 100%; + max-width: 327px; + box-sizing: border-box; + background: ${({ theme }) => theme.color.white.value}; + border-radius: 16px; + padding: 24px; + margin-bottom: 24px; + + ${({ theme }) => theme.mq.desktop} { + max-width: 480px; + padding: 32px; + } + + .section-title { + ${({ theme }) => expandTypography(theme.typography.body.medium.bold)} + color: ${({ theme }) => theme.color.bluishGray[600].value}; + margin-bottom: 20px; + + ${({ theme }) => theme.mq.desktop} { + ${({ theme }) => expandTypography(theme.typography.body.large.bold)} + } + } + + .category-list { + display: flex; + flex-direction: column; + gap: 14px; + } + + .category-item { + display: flex; + flex-direction: column; + gap: 6px; + + .category-header { + display: flex; + justify-content: space-between; + align-items: center; + + .category-name { + ${({ theme }) => expandTypography(theme.typography.body.medium.medium)} + color: ${({ theme }) => theme.color.bluishGray[700].value}; + + ${({ theme }) => theme.mq.desktop} { + ${({ theme }) => expandTypography(theme.typography.body.large.medium)} + } + } + + .category-info { + display: flex; + align-items: center; + gap: 8px; + + .category-amount { + ${({ theme }) => expandTypography(theme.typography.body.medium.bold)} + color: ${({ theme }) => theme.color.bluishGray[800].value}; + + ${({ theme }) => theme.mq.desktop} { + ${({ theme }) => expandTypography(theme.typography.body.large.bold)} + } + } + + .category-ratio { + ${({ theme }) => expandTypography(theme.typography.body.small.medium)} + color: ${({ theme }) => theme.color.bluishGray[400].value}; + } + } + } + + .bar-track { + width: 100%; + height: 8px; + border-radius: 4px; + background: ${({ theme }) => theme.color.bluishGray[100].value}; + overflow: hidden; + + .bar-fill { + height: 100%; + border-radius: 4px; + background: ${({ theme }) => theme.color.blue[500].value}; + transition: width 0.3s ease; + } + } + } + + .empty-message { + text-align: center; + padding: 24px 0; + ${({ theme }) => expandTypography(theme.typography.body.medium.regular)} + color: ${({ theme }) => theme.color.bluishGray[400].value}; + } +`; diff --git a/frontend/tiggle/src/pages/StatisticsPage/StatisticsPageStyle.tsx b/frontend/tiggle/src/pages/StatisticsPage/StatisticsPageStyle.tsx new file mode 100644 index 00000000..41f68efe --- /dev/null +++ b/frontend/tiggle/src/pages/StatisticsPage/StatisticsPageStyle.tsx @@ -0,0 +1,89 @@ +import styled from "styled-components"; + +import { expandTypography } from "@/styles/util/expandTypography"; + +export const StatisticsPageWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: center; + padding: 40px 0 80px; + + ${({ theme }) => theme.mq.desktop} { + padding: 60px 0 128px; + } + + .page-title { + ${({ theme }) => expandTypography(theme.typography.title.medium.bold)} + color: ${({ theme }) => theme.color.bluishGray[800].value}; + margin-bottom: 32px; + + ${({ theme }) => theme.mq.desktop} { + ${({ theme }) => expandTypography(theme.typography.title.large.bold)} + margin-bottom: 40px; + } + } + + .summary-cards { + display: flex; + gap: 12px; + width: 327px; + margin-bottom: 24px; + + ${({ theme }) => theme.mq.desktop} { + width: 480px; + } + + .summary-card { + flex: 1; + padding: 16px; + border-radius: 12px; + background: ${({ theme }) => theme.color.white.value}; + text-align: center; + + .summary-label { + ${({ theme }) => expandTypography(theme.typography.body.small.medium)} + color: ${({ theme }) => theme.color.bluishGray[500].value}; + margin-bottom: 8px; + } + + .summary-amount { + ${({ theme }) => expandTypography(theme.typography.body.medium.bold)} + color: ${({ theme }) => theme.color.bluishGray[800].value}; + + ${({ theme }) => theme.mq.desktop} { + ${({ theme }) => expandTypography(theme.typography.body.large.bold)} + } + } + + &.outcome .summary-amount { + color: ${({ theme }) => theme.color.peach[600].value}; + } + + &.income .summary-amount { + color: ${({ theme }) => theme.color.green[600].value}; + } + + &.refund .summary-amount { + color: ${({ theme }) => theme.color.blue[600].value}; + } + } + } + + .section-divider { + width: 327px; + height: 1px; + background: ${({ theme }) => theme.color.bluishGray[100].value}; + margin: 8px 0 24px; + + ${({ theme }) => theme.mq.desktop} { + width: 480px; + } + } + + .loading-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 200px; + } +`; diff --git a/frontend/tiggle/src/pages/StatisticsPage/WeeklyComparison/WeeklyComparison.tsx b/frontend/tiggle/src/pages/StatisticsPage/WeeklyComparison/WeeklyComparison.tsx new file mode 100644 index 00000000..50c5ea4a --- /dev/null +++ b/frontend/tiggle/src/pages/StatisticsPage/WeeklyComparison/WeeklyComparison.tsx @@ -0,0 +1,91 @@ +import { ArrowDown, ArrowUp, AlertTriangle } from "react-feather"; + +import type { WeeklyComparisonRespDto } from "@/types/gamification"; + +import { WeeklyComparisonWrapper } from "./WeeklyComparisonStyle"; + +interface WeeklyComparisonProps { + data: WeeklyComparisonRespDto; +} + +const formatAmount = (amount: number): string => + `${amount.toLocaleString("ko-KR")}원`; + +const formatPeriod = (start: string, end: string): string => + `${start.slice(5)} ~ ${end.slice(5)}`; + +const WeeklyComparison = ({ data }: WeeklyComparisonProps) => { + const { currentWeek, previousWeek, changeRate, isImproved, isAnomaly } = data; + + const getChangeClass = (): string => { + if (isImproved === null) return "neutral"; + return isImproved ? "improved" : "worsened"; + }; + + const getChangeLabel = (): string => { + if (changeRate === null || isImproved === null) return "비교 데이터 없음"; + const absRate = Math.abs(changeRate).toFixed(1); + if (isImproved) { + return `지난주 대비 ${absRate}% 절약`; + } + return `지난주 대비 ${absRate}% 증가`; + }; + + return ( + +

주간 소비 비교

+ +
+
+

이번 주

+

+ {formatAmount(currentWeek.totalOutcome)} +

+

+ {formatPeriod(currentWeek.weekStartDate, currentWeek.weekEndDate)} +

+
+ + {previousWeek && ( +
+

지난 주

+

+ {formatAmount(previousWeek.totalOutcome)} +

+

+ {formatPeriod( + previousWeek.weekStartDate, + previousWeek.weekEndDate, + )} +

+
+ )} +
+ + {previousWeek ? ( +
+ {isImproved !== null && + (isImproved ? ( + + ) : ( + + ))} + {getChangeLabel()} +
+ ) : ( +

+ 첫 주 사용 중이에요. 다음 주부터 비교 데이터가 제공됩니다. +

+ )} + + {isAnomaly && ( +
+ + 이상 소비 감지 +
+ )} +
+ ); +}; + +export default WeeklyComparison; diff --git a/frontend/tiggle/src/pages/StatisticsPage/WeeklyComparison/WeeklyComparisonStyle.tsx b/frontend/tiggle/src/pages/StatisticsPage/WeeklyComparison/WeeklyComparisonStyle.tsx new file mode 100644 index 00000000..5544cea7 --- /dev/null +++ b/frontend/tiggle/src/pages/StatisticsPage/WeeklyComparison/WeeklyComparisonStyle.tsx @@ -0,0 +1,130 @@ +import styled from "styled-components"; + +import { expandTypography } from "@/styles/util/expandTypography"; + +export const WeeklyComparisonWrapper = styled.div` + width: 327px; + background: ${({ theme }) => theme.color.white.value}; + border-radius: 16px; + padding: 24px; + margin-bottom: 24px; + + ${({ theme }) => theme.mq.desktop} { + width: 480px; + padding: 32px; + } + + .section-title { + ${({ theme }) => expandTypography(theme.typography.body.medium.bold)} + color: ${({ theme }) => theme.color.bluishGray[600].value}; + margin-bottom: 20px; + + ${({ theme }) => theme.mq.desktop} { + ${({ theme }) => expandTypography(theme.typography.body.large.bold)} + } + } + + .comparison-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + } + + .week-card { + flex: 1; + padding: 16px; + border-radius: 12px; + background: ${({ theme }) => theme.color.bluishGray[50].value}; + text-align: center; + + .week-label { + ${({ theme }) => expandTypography(theme.typography.body.small.medium)} + color: ${({ theme }) => theme.color.bluishGray[500].value}; + margin-bottom: 8px; + } + + .week-amount { + ${({ theme }) => expandTypography(theme.typography.title.small2x.bold)} + color: ${({ theme }) => theme.color.bluishGray[800].value}; + + ${({ theme }) => theme.mq.desktop} { + ${({ theme }) => expandTypography(theme.typography.title.small.bold)} + } + } + + .week-period { + ${({ theme }) => expandTypography(theme.typography.body.small2x.regular)} + color: ${({ theme }) => theme.color.bluishGray[400].value}; + margin-top: 4px; + } + } + + .change-indicator { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + margin-top: 16px; + padding: 10px 16px; + border-radius: 8px; + background: ${({ theme }) => theme.color.bluishGray[50].value}; + + .change-text { + ${({ theme }) => expandTypography(theme.typography.body.medium.bold)} + + ${({ theme }) => theme.mq.desktop} { + ${({ theme }) => expandTypography(theme.typography.body.large.bold)} + } + } + + &.improved { + background: ${({ theme }) => theme.color.green[50].value}; + + .change-text { + color: ${({ theme }) => theme.color.green[600].value}; + } + + .arrow-icon { + color: ${({ theme }) => theme.color.green[600].value}; + } + } + + &.worsened { + background: ${({ theme }) => theme.color.peach[50].value}; + + .change-text { + color: ${({ theme }) => theme.color.peach[600].value}; + } + + .arrow-icon { + color: ${({ theme }) => theme.color.peach[600].value}; + } + } + + &.neutral { + .change-text { + color: ${({ theme }) => theme.color.bluishGray[500].value}; + } + } + } + + .anomaly-badge { + display: inline-flex; + align-items: center; + gap: 4px; + margin-top: 12px; + padding: 6px 12px; + border-radius: 6px; + background: ${({ theme }) => theme.color.peach[50].value}; + color: ${({ theme }) => theme.color.peach[600].value}; + ${({ theme }) => expandTypography(theme.typography.body.small.bold)} + } + + .no-previous { + margin-top: 12px; + text-align: center; + ${({ theme }) => expandTypography(theme.typography.body.small.regular)} + color: ${({ theme }) => theme.color.bluishGray[400].value}; + } +`; diff --git a/frontend/tiggle/src/pages/StatisticsPage/index.tsx b/frontend/tiggle/src/pages/StatisticsPage/index.tsx new file mode 100644 index 00000000..97a14df8 --- /dev/null +++ b/frontend/tiggle/src/pages/StatisticsPage/index.tsx @@ -0,0 +1,112 @@ +import { useQuery } from "@tanstack/react-query"; + +import { StatisticsApiControllerService } from "@/generated"; +import { statisticsKeys } from "@/query/queryKeys"; +import type { + WeeklyComparisonRespDto, + MonthlySummaryRespDto, +} from "@/types/gamification"; +import withAuth from "@/utils/withAuth"; + +import CategoryBreakdown from "./CategoryBreakdown/CategoryBreakdown"; +import { StatisticsPageWrapper } from "./StatisticsPageStyle"; +import WeeklyComparison from "./WeeklyComparison/WeeklyComparison"; + +const formatAmount = (amount: number): string => + `${amount.toLocaleString("ko-KR")}원`; + +const StatisticsPage = () => { + const { + data: weeklyResp, + isLoading: isWeeklyLoading, + isError: isWeeklyError, + } = useQuery({ + queryKey: statisticsKeys.weekly(0), + queryFn: () => StatisticsApiControllerService.getWeeklyComparison(0), + }); + + const now = new Date(); + const { + data: monthlyResp, + isLoading: isMonthlyLoading, + isError: isMonthlyError, + } = useQuery({ + queryKey: statisticsKeys.monthly(now.getFullYear(), now.getMonth() + 1), + queryFn: () => + StatisticsApiControllerService.getMonthlySummary( + now.getFullYear(), + now.getMonth() + 1, + ), + }); + + const isLoading = isWeeklyLoading || isMonthlyLoading; + + const weeklyData = weeklyResp?.data + ? (weeklyResp.data as WeeklyComparisonRespDto) + : null; + + const monthlyData = monthlyResp?.data + ? (monthlyResp.data as MonthlySummaryRespDto) + : null; + + if (isLoading) { + return ( + +

소비 통계

+
+

불러오는 중...

+
+
+ ); + } + + if (isWeeklyError && isMonthlyError) { + return ( + +

소비 통계

+
+

통계를 불러올 수 없습니다. 잠시 후 다시 시도해 주세요.

+
+
+ ); + } + + return ( + +

소비 통계

+ + {weeklyData && } + + {monthlyData && ( + <> +
+
+

이번 달 지출

+

+ {formatAmount(monthlyData.totalOutcome)} +

+
+
+

이번 달 수입

+

+ {formatAmount(monthlyData.totalIncome)} +

+
+
+

환불

+

+ {formatAmount(monthlyData.totalRefund)} +

+
+
+ +
+ + + + )} + + ); +}; + +export default withAuth(StatisticsPage); diff --git a/frontend/tiggle/src/query/queryKeys.ts b/frontend/tiggle/src/query/queryKeys.ts index 62d88c5d..d5657c04 100644 --- a/frontend/tiggle/src/query/queryKeys.ts +++ b/frontend/tiggle/src/query/queryKeys.ts @@ -44,3 +44,41 @@ export const memberKeys = { details: () => [memberKeys.key, "detail"] as const, detail: (id: number | "me") => [...memberKeys.details(), id] as const, }; + +export const statisticsKeys = { + key: "statistics" as const, + weekly: (weekOffset: number) => + [statisticsKeys.key, "weekly", weekOffset] as const, + monthly: (year?: number, month?: number) => + [statisticsKeys.key, "monthly", { year, month }] as const, +}; + +export const characterKeys = { + key: "character" as const, + me: () => [characterKeys.key, "me"] as const, + detail: (memberId: number) => + [characterKeys.key, "detail", memberId] as const, + catalog: () => [characterKeys.key, "catalog"] as const, +}; + +export const itemKeys = { + key: "item" as const, + inventory: () => [itemKeys.key, "inventory"] as const, + catalog: () => [itemKeys.key, "catalog"] as const, + equipment: () => [itemKeys.key, "equipment", "me"] as const, + memberEquipment: (memberId: number) => + [itemKeys.key, "equipment", memberId] as const, +}; + +export const achievementKeys = { + key: "achievement" as const, + lists: () => [achievementKeys.key, "list"] as const, + recent: (limit: number) => [achievementKeys.key, "recent", limit] as const, +}; + +export const challengeKeys = { + key: "challenge" as const, + active: () => [challengeKeys.key, "active"] as const, + detail: (id: number) => [challengeKeys.key, "detail", id] as const, + history: (page: number) => [challengeKeys.key, "history", page] as const, +}; diff --git a/frontend/tiggle/src/router.tsx b/frontend/tiggle/src/router.tsx index 8117b147..ccf7980a 100644 --- a/frontend/tiggle/src/router.tsx +++ b/frontend/tiggle/src/router.tsx @@ -1,3 +1,4 @@ +import { lazy, Suspense } from "react"; import { createBrowserRouter } from "react-router-dom"; import CreatePage, { createPageLoader } from "@/pages/CreatePage"; @@ -13,6 +14,22 @@ import CategorySettingPage from "@/pages/SettingPage/CategorySettingPage"; import queryClient from "@/query/queryClient"; import GeneralTemplate from "@/templates/GeneralTemplate"; +const StatisticsPage = lazy(() => import("@/pages/StatisticsPage")); +const CharacterPage = lazy(() => import("@/pages/CharacterPage")); +const CharacterCatalogPage = lazy(() => import("@/pages/CharacterCatalogPage")); +const InventoryPage = lazy(() => import("@/pages/InventoryPage")); +const AchievementPage = lazy(() => import("@/pages/AchievementPage")); +const ChallengePage = lazy(() => import("@/pages/ChallengePage")); +const ChallengeCreatePage = lazy(() => import("@/pages/ChallengeCreatePage")); +const ChallengeDetailPage = lazy(() => import("@/pages/ChallengeDetailPage")); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const withSuspense = (Component: React.ComponentType) => ( + }> + + +); + const router = createBrowserRouter([ { path: "", @@ -54,6 +71,39 @@ const router = createBrowserRouter([ path: "/mypage/setting/category", element: , }, + // Gamification routes + { + path: "/statistics", + element: withSuspense(StatisticsPage), + }, + { + path: "/mypage/character", + element: withSuspense(CharacterPage), + }, + { + path: "/mypage/character/catalog", + element: withSuspense(CharacterCatalogPage), + }, + { + path: "/mypage/inventory", + element: withSuspense(InventoryPage), + }, + { + path: "/mypage/achievements", + element: withSuspense(AchievementPage), + }, + { + path: "/challenges", + element: withSuspense(ChallengePage), + }, + { + path: "/challenges/create", + element: withSuspense(ChallengeCreatePage), + }, + { + path: "/challenges/:id", + element: withSuspense(ChallengeDetailPage), + }, ], }, { diff --git a/frontend/tiggle/src/types/gamification.ts b/frontend/tiggle/src/types/gamification.ts new file mode 100644 index 00000000..1468594a --- /dev/null +++ b/frontend/tiggle/src/types/gamification.ts @@ -0,0 +1,265 @@ +// === Enums === + +export const CharacterStage = { + EGG: "EGG", + HATCHING: "HATCHING", + ACTIVE: "ACTIVE", +} as const; +export type CharacterStageType = + (typeof CharacterStage)[keyof typeof CharacterStage]; + +export const CharacterTier = { + COMMON: "COMMON", + RARE: "RARE", + EPIC: "EPIC", + LEGENDARY: "LEGENDARY", + UNIQUE: "UNIQUE", +} as const; +export type CharacterTierType = + (typeof CharacterTier)[keyof typeof CharacterTier]; + +export const CharacterPath = { + GOLD: "GOLD", + NATURE: "NATURE", + OCEAN: "OCEAN", + STAR: "STAR", + DRAGON: "DRAGON", +} as const; +export type CharacterPathType = + (typeof CharacterPath)[keyof typeof CharacterPath]; + +export const ColorRarity = { + NORMAL: "NORMAL", + SHINE: "SHINE", + HOLOGRAPHIC: "HOLOGRAPHIC", + LEGENDARY: "LEGENDARY", +} as const; +export type ColorRarityType = (typeof ColorRarity)[keyof typeof ColorRarity]; + +export const ItemSlot = { + HAT: "HAT", + OUTFIT: "OUTFIT", + ACCESSORY: "ACCESSORY", + BACKGROUND: "BACKGROUND", + EFFECT: "EFFECT", + TITLE: "TITLE", +} as const; +export type ItemSlotType = (typeof ItemSlot)[keyof typeof ItemSlot]; + +export const ItemTier = { + COMMON: "COMMON", + RARE: "RARE", + EPIC: "EPIC", + LEGENDARY: "LEGENDARY", + UNIQUE: "UNIQUE", +} as const; +export type ItemTierType = (typeof ItemTier)[keyof typeof ItemTier]; + +export const ChallengeType = { + NO_SPEND: "NO_SPEND", + BUDGET_LIMIT: "BUDGET_LIMIT", +} as const; +export type ChallengeTypeValue = + (typeof ChallengeType)[keyof typeof ChallengeType]; + +export const ChallengeStatus = { + ACTIVE: "ACTIVE", + COMPLETED: "COMPLETED", + FAILED: "FAILED", + CANCELLED: "CANCELLED", +} as const; +export type ChallengeStatusType = + (typeof ChallengeStatus)[keyof typeof ChallengeStatus]; + +export const AchievementConditionType = { + RECORD_COUNT: "RECORD_COUNT", + STREAK: "STREAK", + CHALLENGE_COMPLETE: "CHALLENGE_COMPLETE", + CATEGORY_COUNT: "CATEGORY_COUNT", + SPENDING_DECREASE: "SPENDING_DECREASE", + NO_ANOMALY_WEEKS: "NO_ANOMALY_WEEKS", + NO_SPEND_DAYS: "NO_SPEND_DAYS", + COLOR_RARITY: "COLOR_RARITY", + CHARACTER_TIER: "CHARACTER_TIER", +} as const; +export type AchievementConditionTypeValue = + (typeof AchievementConditionType)[keyof typeof AchievementConditionType]; + +// === Statistics DTOs === + +export interface WeeklyStatRespDto { + weekStartDate: string; + weekEndDate: string; + totalOutcome: number; + totalIncome: number; + totalRefund: number; + transactionCount: number; + avgDailyOutcome: number; + topCategoryId: number | null; + topCategoryName: string | null; +} + +export interface WeeklyComparisonRespDto { + currentWeek: WeeklyStatRespDto; + previousWeek: WeeklyStatRespDto | null; + changeRate: number | null; + isImproved: boolean | null; + isAnomaly: boolean; + anomalyRatio: number | null; +} + +export interface CategoryBreakdownDto { + categoryId: number; + categoryName: string; + amount: number; + ratio: number; +} + +export interface MonthlySummaryRespDto { + year: number; + month: number; + totalOutcome: number; + totalIncome: number; + totalRefund: number; + transactionCount: number; + categoryBreakdown: CategoryBreakdownDto[]; +} + +// === Character DTOs === + +export interface CharacterFormDto { + name: string; + nameEn: string; + description: string | null; + imageKey: string; +} + +export interface ColorRespDto { + hue: number; + saturation: number; + lightness: number; + rarity: ColorRarityType; + cssValue: string; +} + +export interface EggStatusDto { + phase: number; + records: number; + nextPhaseAt: number; +} + +export interface CharacterDetailRespDto { + stage: CharacterStageType; + tier: CharacterTierType | null; + path: CharacterPathType | null; + level: number; + experience: number; + nextLevelExp: number | null; + currentForm: CharacterFormDto | null; + color: ColorRespDto; + egg: EggStatusDto | null; +} + +export interface CharacterCatalogDto { + id: number; + tier: CharacterTierType; + path: CharacterPathType; + level: number; + name: string; + nameEn: string; + description: string | null; + requiredExp: number; + imageKey: string; +} + +// === Item DTOs === + +export interface MemberItemRespDto { + itemId: number; + name: string; + nameEn: string; + slot: ItemSlotType; + tier: ItemTierType; + imageKey: string; + acquiredAt: string; +} + +export interface ItemCatalogRespDto { + id: number; + name: string; + nameEn: string; + description: string | null; + slot: ItemSlotType; + tier: ItemTierType; + imageKey: string; + requiredCharacterLevel: number; + unlocked: boolean; +} + +export interface EquipmentRespDto { + hatItemId: number | null; + outfitItemId: number | null; + accessoryItemId: number | null; + backgroundItemId: number | null; + effectItemId: number | null; + titleItemId: number | null; +} + +export interface EquipItemReqDto { + slot: ItemSlotType; + itemId: number | null; +} + +// === Achievement DTOs === + +export interface AchievementRespDto { + id: number; + code: string; + name: string; + description: string | null; + conditionType: AchievementConditionTypeValue; + conditionValue: number; + achieved: boolean; + achievedAt: string | null; +} + +// === Challenge DTOs === + +export interface ChallengeRespDto { + id: number; + type: ChallengeTypeValue; + status: ChallengeStatusType; + startDate: string; + endDate: string; + targetDays: number; + achievedDays: number; + createdAt: string; +} + +export interface DailyLogRespDto { + logDate: string; + isNoSpend: boolean; + outcomeAmount: number; +} + +export interface ChallengeDetailRespDto { + challenge: ChallengeRespDto; + dailyLogs: DailyLogRespDto[]; +} + +export interface ChallengeCreateReqDto { + type: ChallengeTypeValue; + targetDays: number; +} + +export interface PageRespDto { + content: T[]; + totalElements: number; + totalPages: number; + size: number; + number: number; + first: boolean; + last: boolean; + numberOfElements: number; + empty: boolean; +}