diff --git a/admin/src/apis/flyway/flyway.api.ts b/admin/src/apis/flyway/flyway.api.ts new file mode 100644 index 000000000..88a28d1ae --- /dev/null +++ b/admin/src/apis/flyway/flyway.api.ts @@ -0,0 +1,88 @@ +import { fetcher } from '@bombom/shared/apis'; + +export type MigrationStatus = + | 'LOCAL_WIP' + | 'PR_REVIEW' + | 'MERGE_PENDING' + | 'DB_APPLIED'; + +export type WorkKind = 'NEW_TABLE' | 'EXISTING_TABLE'; + +export type ConflictSeverity = 'TABLE' | 'COLUMN'; + +export type MigrationItem = { + version: string; + description: string; + fileName: string; + status: MigrationStatus; + createsNewTable: boolean; + tables: string[]; + sourceLabel: string; + sourceUrl: string; + author: string; +}; + +export type FlywayConflict = { + version: string; + sources: string[]; + suggestedVersion: string; +}; + +export type FlywayLeapfrog = { + mineVersion: string; + aheadVersion: string; + sharedTables: string[]; + severity: ConflictSeverity; +}; + +export type FlywayOverview = { + deployBranch: string; + integrationBranch: string; + latestVersion: string; + appliedCount: number; + pendingCount: number; + nextSafeMinor: string; + nextSafeMajor: string; + migrations: MigrationItem[]; + conflicts: FlywayConflict[]; + leapfrogWarnings: FlywayLeapfrog[]; +}; + +export type MigrationScript = { + fileName: string; + content: string; + sourceUrl: string; +}; + +export type CreateWipIssuePayload = { + workKind: WorkKind; + targetTable?: string; + plannedVersion: string; + description: string; + assignee: string; +}; + +export type CreateWipIssueResponse = { + issueNumber: number; + issueUrl: string; +}; + +export const getFlywayOverview = async () => { + return fetcher.get({ + path: '/flyway/overview', + }); +}; + +export const getMigrationScript = async (fileName: string) => { + return fetcher.get({ + path: '/flyway/script', + query: { fileName }, + }); +}; + +export const createWipIssue = async (payload: CreateWipIssuePayload) => { + return fetcher.post({ + path: '/flyway/wip', + body: payload, + }); +}; diff --git a/admin/src/apis/flyway/flyway.query.ts b/admin/src/apis/flyway/flyway.query.ts new file mode 100644 index 000000000..aa16d13ca --- /dev/null +++ b/admin/src/apis/flyway/flyway.query.ts @@ -0,0 +1,34 @@ +import { queryOptions } from '@tanstack/react-query'; +import { + getFlywayOverview, + getMigrationScript, + createWipIssue, +} from './flyway.api'; + +const OVERVIEW_STALE_TIME = 1000 * 30; // 30s +const SCRIPT_STALE_TIME = 1000 * 60 * 10; // 10m + +export const flywayQueries = { + all: ['flyway'] as const, + + overview: () => + queryOptions({ + queryKey: ['flyway', 'overview'] as const, + queryFn: getFlywayOverview, + staleTime: OVERVIEW_STALE_TIME, + }), + + script: (fileName: string) => + queryOptions({ + queryKey: ['flyway', 'script', fileName] as const, + queryFn: () => getMigrationScript(fileName), + staleTime: SCRIPT_STALE_TIME, + enabled: fileName.length > 0, + }), + + mutation: { + createWip: () => ({ + mutationFn: createWipIssue, + }), + }, +}; diff --git a/admin/src/components/Sidebar.tsx b/admin/src/components/Sidebar.tsx index eebfb5789..fa29515b0 100644 --- a/admin/src/components/Sidebar.tsx +++ b/admin/src/components/Sidebar.tsx @@ -4,6 +4,7 @@ import { FiBell, FiCalendar, FiCode, + FiDatabase, FiEdit, FiFlag, FiHome, @@ -40,6 +41,10 @@ export const Sidebar = () => { 공지사항 + + + Flyway 형상 + = { + LOCAL_WIP: { label: '로컬 작업중', dot: '🟣', order: 0 }, + PR_REVIEW: { label: 'PR 리뷰중', dot: '🟠', order: 1 }, + MERGE_PENDING: { label: 'dev 반영', dot: '🔵', order: 2 }, + DB_APPLIED: { label: 'prod 반영', dot: '🟢', order: 3 }, +}; + +const STATUS_ORDER: MigrationStatus[] = [ + 'LOCAL_WIP', + 'PR_REVIEW', + 'MERGE_PENDING', + 'DB_APPLIED', +]; + +const compareVersionDesc = (left: string, right: string) => { + const leftParts = parseVersion(left); + const rightParts = parseVersion(right); + const size = Math.max(leftParts.length, rightParts.length); + for (let index = 0; index < size; index += 1) { + const diff = (rightParts[index] ?? 0) - (leftParts[index] ?? 0); + if (diff !== 0) { + return diff; + } + } + return 0; +}; + +const parseVersion = (version: string) => + version + .replace(/^V/, '') + .split('.') + .map((token) => Number(token) || 0); + +export const FlywayViewer = () => { + const { data, isLoading, isError } = useQuery(flywayQueries.overview()); + const [keyword, setKeyword] = useState(''); + const [statusFilter, setStatusFilter] = useState<'ALL' | MigrationStatus>( + 'ALL', + ); + const [selectedFileName, setSelectedFileName] = useState(''); + const [showForm, setShowForm] = useState(false); + + const migrations = useMemo(() => data?.migrations ?? [], [data]); + const selected = migrations.find( + (item) => item.fileName === selectedFileName, + ); + const conflictVersions = new Set( + (data?.conflicts ?? []).map((conflict) => conflict.version), + ); + + // version → 가장 심각한 severity (COLUMN > TABLE) + const leapfrogSeverityMap = useMemo(() => { + const map = new Map(); + for (const warning of data?.leapfrogWarnings ?? []) { + const current = map.get(warning.mineVersion); + if (!current || warning.severity === 'COLUMN') { + map.set(warning.mineVersion, warning.severity); + } + } + return map; + }, [data]); + + const selectedLeapfrogs = useMemo( + () => + (data?.leapfrogWarnings ?? []).filter( + (w) => w.mineVersion === selected?.version, + ), + [data, selected], + ); + + const filtered = useMemo( + () => filterMigrations(migrations, keyword, statusFilter), + [migrations, keyword, statusFilter], + ); + + const existingTables = useMemo(() => collectTables(migrations), [migrations]); + + if (isLoading) { + return ( + + 로딩 중... + + ); + } + + if (isError || !data) { + return ( + + 형상 정보를 불러오지 못했습니다. + + ); + } + + return ( + +
+ + 적용기준 {data.deployBranch} · 통합{' '} + {data.integrationBranch} + + + +
+ + {showForm ? ( + setShowForm(false)} + /> + ) : null} + + + {STATUS_ORDER.map((status) => ( + + + {STATUS_META[status].dot} {STATUS_META[status].label} + + {countByStatus(migrations, status)} + + ))} + + + {data.conflicts.length > 0 ? ( + + ⛔ 버전 번호 충돌{' '} + {data.conflicts + .map( + (conflict) => + `${conflict.version} (${conflict.sources.join(', ')}) → ${conflict.suggestedVersion} 권장`, + ) + .join(' · ')} + + ) : null} + + {data.leapfrogWarnings.length > 0 ? ( + + 🔀 순서 역전{' '} + {data.leapfrogWarnings + .map( + (warning) => + `${warning.mineVersion} ↔ ${warning.aheadVersion} (${warning.sharedTables.join(', ')}, ${warning.severity === 'COLUMN' ? '컬럼' : '테이블'})`, + ) + .join(' · ')} + + ) : null} + + + setKeyword(event.target.value)} + /> + + + 다음 안전 {data.nextSafeMinor} /{' '} + {data.nextSafeMajor} + + + + + + {filtered.map((item) => { + const leapfrogSeverity = + leapfrogSeverityMap.get(item.version) ?? null; + return ( + setSelectedFileName(item.fileName)} + > + {item.version} + {item.description} + {leapfrogSeverity ? ( + + {leapfrogSeverity === 'COLUMN' + ? '⚠ 컬럼 역전' + : '🔀 순서 역전'} + + ) : null} + {item.tables.slice(0, 1).map((table) => ( + + {item.createsNewTable ? '🆕' : '🗂'} {table} + + ))} + {item.sourceLabel ? {item.sourceLabel} : null} + + {STATUS_META[item.status].label} + + + ); + })} + {filtered.length === 0 ? ( + 조건에 맞는 마이그레이션이 없습니다. + ) : null} + + + + {selected ? ( + + ) : ( + 왼쪽에서 마이그레이션을 선택하세요. + )} + + +
+ ); +}; + +interface DetailContentProps { + item: MigrationItem; + leapfrogs: FlywayLeapfrog[]; +} + +const DetailContent = ({ item, leapfrogs }: DetailContentProps) => { + const hasFile = + item.status === 'DB_APPLIED' || item.status === 'MERGE_PENDING'; + const { data, isLoading, isError } = useQuery({ + ...flywayQueries.script(hasFile ? item.fileName : ''), + }); + + return ( + <> + + {item.fileName} + + {item.sourceUrl ? ( + + ↗ 열기 + + ) : null} + + + {STATUS_META[item.status].dot} {STATUS_META[item.status].label} + {item.author ? ` · 담당 ${item.author}` : ''} + {item.tables.length > 0 ? ` · ${item.tables.join(', ')}` : ''} + + + {leapfrogs.map((leapfrog) => ( + + + {leapfrog.severity === 'COLUMN' + ? '⚠ 순서 역전 — 같은 컬럼 접근 (강한 경고)' + : '🔀 순서 역전 — 같은 테이블 접근'} + + + 이 버전({leapfrog.mineVersion})보다 높은{' '} + {leapfrog.aheadVersion}이 이미 반영되어 있습니다. + {leapfrog.severity === 'COLUMN' + ? ' out-of-order로 끼워 넣으면 같은 컬럼이 덮어씌워질 수 있습니다.' + : ' out-of-order로 끼워 넣어도 컬럼 충돌은 없지만, 같은 테이블을 다룹니다.'} + + + 공유 {leapfrog.severity === 'COLUMN' ? '컬럼' : '테이블'}:{' '} + {leapfrog.sharedTables.map((name) => ( + + {name} + + ))} + + + ))} + + {hasFile ? ( + + {isLoading ? 스크립트 로딩 중... : null} + {isError ? ( + 스크립트를 불러오지 못했습니다. + ) : null} + {data ? ( + + ) : null} + + ) : ( + + 아직 dev/prod에 없는 작업중 항목입니다. 상세 SQL은{' '} + {item.sourceUrl ? 'PR/이슈' : '소스'}에서 확인하세요. + + )} + + ); +}; + +const filterMigrations = ( + migrations: MigrationItem[], + keyword: string, + statusFilter: 'ALL' | MigrationStatus, +) => { + const normalized = keyword.trim().toLowerCase(); + return migrations + .filter((item) => statusFilter === 'ALL' || item.status === statusFilter) + .filter((item) => matchesKeyword(item, normalized)) + .sort((left, right) => compareVersionDesc(left.version, right.version)); +}; + +const matchesKeyword = (item: MigrationItem, normalized: string) => { + if (normalized.length === 0) { + return true; + } + const haystack = [ + item.version, + item.description, + item.sourceLabel, + item.author, + ...item.tables, + ] + .join(' ') + .toLowerCase(); + return haystack.includes(normalized); +}; + +const countByStatus = (migrations: MigrationItem[], status: MigrationStatus) => + migrations.filter((item) => item.status === status).length; + +const collectTables = (migrations: MigrationItem[]) => { + const tables = new Set(); + migrations.forEach((item) => + item.tables.forEach((table) => tables.add(table)), + ); + return [...tables].sort(); +}; + +const Header = styled.div` + margin-bottom: ${({ theme }) => theme.spacing.md}; + display: flex; + gap: ${({ theme }) => theme.spacing.md}; + align-items: center; +`; + +const Chip = styled.span` + padding: ${({ theme }) => theme.spacing.xs} ${({ theme }) => theme.spacing.md}; + border: 1px solid ${({ theme }) => theme.colors.gray200}; + border-radius: ${({ theme }) => theme.borderRadius.full}; + color: ${({ theme }) => theme.colors.gray600}; + font-size: ${({ theme }) => theme.fontSize.sm}; +`; + +const Spacer = styled.div` + flex: 1; +`; + +const Pipeline = styled.div` + margin: ${({ theme }) => theme.spacing.md} 0; + display: flex; + gap: ${({ theme }) => theme.spacing.md}; +`; + +const Stage = styled.div` + flex: 1; + padding: ${({ theme }) => theme.spacing.md}; + border: 1px solid ${({ theme }) => theme.colors.gray200}; + border-radius: ${({ theme }) => theme.borderRadius.lg}; + background-color: ${({ theme }) => theme.colors.white}; +`; + +const StageTop = styled.div` + font-size: ${({ theme }) => theme.fontSize.sm}; + font-weight: ${({ theme }) => theme.fontWeight.semibold}; +`; + +const StageCount = styled.div` + margin-top: ${({ theme }) => theme.spacing.xs}; + font-size: ${({ theme }) => theme.fontSize['2xl']}; + font-weight: ${({ theme }) => theme.fontWeight.bold}; +`; + +const Banner = styled.div<{ $variant: 'error' | 'warning' }>` + margin-bottom: ${({ theme }) => theme.spacing.md}; + padding: ${({ theme }) => theme.spacing.md}; + border-radius: ${({ theme }) => theme.borderRadius.md}; + font-size: ${({ theme }) => theme.fontSize.sm}; + background-color: ${({ $variant }) => + $variant === 'error' ? '#FDEAEA' : '#FFF4E5'}; + color: ${({ theme, $variant }) => + $variant === 'error' ? theme.colors.error : '#B45309'}; +`; + +const Toolbar = styled.div` + margin-bottom: ${({ theme }) => theme.spacing.md}; + display: flex; + gap: ${({ theme }) => theme.spacing.md}; + align-items: center; +`; + +const SearchInput = styled.input` + flex: 1; + padding: ${({ theme }) => theme.spacing.sm}; + border: 1px solid ${({ theme }) => theme.colors.gray300}; + border-radius: ${({ theme }) => theme.borderRadius.md}; + font-size: ${({ theme }) => theme.fontSize.sm}; +`; + +const Select = styled.select` + padding: ${({ theme }) => theme.spacing.sm}; + border: 1px solid ${({ theme }) => theme.colors.gray300}; + border-radius: ${({ theme }) => theme.borderRadius.md}; + font-size: ${({ theme }) => theme.fontSize.sm}; +`; + +const NextSafe = styled.span` + color: ${({ theme }) => theme.colors.gray500}; + font-size: ${({ theme }) => theme.fontSize.sm}; + + code { + color: ${({ theme }) => theme.colors.primary}; + font-weight: ${({ theme }) => theme.fontWeight.semibold}; + } +`; + +const Panes = styled.div` + display: flex; + gap: ${({ theme }) => theme.spacing.md}; + height: calc(100vh - 400px); + min-height: 400px; +`; + +const List = styled.div` + width: 46%; + overflow: auto; + border: 1px solid ${({ theme }) => theme.colors.gray200}; + border-radius: ${({ theme }) => theme.borderRadius.lg}; + background-color: ${({ theme }) => theme.colors.white}; +`; + +const leapfrogBorderColor = (severity: ConflictSeverity | null) => { + if (severity === 'COLUMN') return '#EF4444'; + if (severity === 'TABLE') return '#F59E0B'; + return 'transparent'; +}; + +const leapfrogBackground = ( + severity: ConflictSeverity | null, + active: boolean, +) => { + if (severity === 'COLUMN') return active ? '#FFE4E4' : '#FFF5F5'; + if (severity === 'TABLE') return active ? '#FFFBEB' : '#FFFEF5'; + return null; +}; + +const RowButton = styled.button<{ + $active: boolean; + $conflict: boolean; + $leapfrogSeverity: ConflictSeverity | null; +}>` + width: 100%; + padding: ${({ theme }) => theme.spacing.sm} ${({ theme }) => theme.spacing.md}; + border-bottom: 1px solid ${({ theme }) => theme.colors.gray100}; + + display: flex; + gap: ${({ theme }) => theme.spacing.sm}; + align-items: center; + + cursor: pointer; + text-align: left; + background-color: ${({ theme, $active, $conflict, $leapfrogSeverity }) => { + if ($conflict) return '#FDEAEA'; + const leap = leapfrogBackground($leapfrogSeverity, $active); + if (leap) return leap; + return $active ? theme.colors.gray50 : theme.colors.white; + }}; + box-shadow: ${({ theme, $active, $conflict, $leapfrogSeverity }) => + $conflict + ? `inset 3px 0 0 ${theme.colors.error}` + : $leapfrogSeverity + ? `inset 3px 0 0 ${leapfrogBorderColor($leapfrogSeverity)}` + : $active + ? `inset 3px 0 0 ${theme.colors.primary}` + : 'none'}; + + &:hover { + background-color: ${({ theme }) => theme.colors.gray50}; + } +`; + +const Version = styled.span` + min-width: 64px; + font-family: monospace; + font-weight: ${({ theme }) => theme.fontWeight.semibold}; + font-size: ${({ theme }) => theme.fontSize.sm}; +`; + +const Desc = styled.span` + flex: 1; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + color: ${({ theme }) => theme.colors.gray500}; + font-size: ${({ theme }) => theme.fontSize.sm}; +`; + +const LeapfrogBadge = styled.span<{ $severity: ConflictSeverity }>` + flex-shrink: 0; + padding: 1px ${({ theme }) => theme.spacing.xs}; + border-radius: ${({ theme }) => theme.borderRadius.sm}; + font-size: ${({ theme }) => theme.fontSize.xs}; + font-weight: ${({ theme }) => theme.fontWeight.semibold}; + white-space: nowrap; + color: ${({ $severity }) => ($severity === 'COLUMN' ? '#B91C1C' : '#92400E')}; + background-color: ${({ $severity }) => + $severity === 'COLUMN' ? '#FEE2E2' : '#FEF3C7'}; +`; + +const Tag = styled.span` + padding: 0 ${({ theme }) => theme.spacing.xs}; + border-radius: ${({ theme }) => theme.borderRadius.sm}; + background-color: ${({ theme }) => theme.colors.gray100}; + color: ${({ theme }) => theme.colors.gray600}; + font-size: ${({ theme }) => theme.fontSize.xs}; +`; + +const Source = styled.span` + font-family: monospace; + font-size: ${({ theme }) => theme.fontSize.xs}; + color: ${({ theme }) => theme.colors.gray500}; +`; + +const StatusBadge = styled.span<{ $status: MigrationStatus }>` + padding: 0 ${({ theme }) => theme.spacing.sm}; + border-radius: ${({ theme }) => theme.borderRadius.sm}; + font-size: ${({ theme }) => theme.fontSize.xs}; + font-weight: ${({ theme }) => theme.fontWeight.semibold}; + white-space: nowrap; + color: ${({ theme, $status }) => statusColor(theme, $status)}; + background-color: ${({ $status }) => statusBackground($status)}; +`; + +const statusColor = ( + theme: { colors: Record }, + status: MigrationStatus, +) => { + if (status === 'DB_APPLIED') return theme.colors.success; + if (status === 'PR_REVIEW') return '#B45309'; + if (status === 'MERGE_PENDING') return theme.colors.primary; + return theme.colors.secondary; +}; + +const statusBackground = (status: MigrationStatus) => { + if (status === 'DB_APPLIED') return '#E7F7EF'; + if (status === 'PR_REVIEW') return '#FFF4E5'; + if (status === 'MERGE_PENDING') return '#E8F1FF'; + return '#F0EDFF'; +}; + +const Detail = styled.div` + flex: 1; + overflow: auto; + border: 1px solid ${({ theme }) => theme.colors.gray200}; + border-radius: ${({ theme }) => theme.borderRadius.lg}; + background-color: ${({ theme }) => theme.colors.white}; +`; + +const DetailBar = styled.div` + padding: ${({ theme }) => theme.spacing.md}; + border-bottom: 1px solid ${({ theme }) => theme.colors.gray200}; + display: flex; + align-items: center; + gap: ${({ theme }) => theme.spacing.md}; +`; + +const FileName = styled.span` + font-family: monospace; + font-size: ${({ theme }) => theme.fontSize.sm}; + font-weight: ${({ theme }) => theme.fontWeight.semibold}; +`; + +const DetailMeta = styled.div` + padding: ${({ theme }) => theme.spacing.md}; + border-bottom: 1px solid ${({ theme }) => theme.colors.gray100}; + color: ${({ theme }) => theme.colors.gray600}; + font-size: ${({ theme }) => theme.fontSize.sm}; +`; + +const LeapfrogWarning = styled.div<{ $severity: ConflictSeverity }>` + margin: ${({ theme }) => theme.spacing.md} ${({ theme }) => theme.spacing.md} + 0; + padding: ${({ theme }) => theme.spacing.md}; + border-radius: ${({ theme }) => theme.borderRadius.md}; + border: 1px solid + ${({ $severity }) => ($severity === 'COLUMN' ? '#FCA5A5' : '#FCD34D')}; + background-color: ${({ $severity }) => + $severity === 'COLUMN' ? '#FFF5F5' : '#FFFBEB'}; +`; + +const LeapfrogWarningTitle = styled.div` + font-size: ${({ theme }) => theme.fontSize.sm}; + font-weight: ${({ theme }) => theme.fontWeight.semibold}; + margin-bottom: ${({ theme }) => theme.spacing.xs}; +`; + +const LeapfrogWarningBody = styled.p` + font-size: ${({ theme }) => theme.fontSize.sm}; + color: ${({ theme }) => theme.colors.gray700}; + margin-bottom: ${({ theme }) => theme.spacing.xs}; + line-height: 1.5; +`; + +const LeapfrogWarningDetail = styled.div` + font-size: ${({ theme }) => theme.fontSize.xs}; + color: ${({ theme }) => theme.colors.gray600}; + display: flex; + align-items: center; + gap: ${({ theme }) => theme.spacing.xs}; + flex-wrap: wrap; +`; + +const LeapfrogTag = styled.code<{ $severity: ConflictSeverity }>` + padding: 1px 5px; + border-radius: ${({ theme }) => theme.borderRadius.sm}; + background-color: ${({ $severity }) => + $severity === 'COLUMN' ? '#FEE2E2' : '#FEF3C7'}; + font-size: ${({ theme }) => theme.fontSize.xs}; +`; + +const ScriptArea = styled.div` + padding: ${({ theme }) => theme.spacing.sm}; +`; + +const PendingNote = styled.p` + padding: ${({ theme }) => theme.spacing.lg}; + color: ${({ theme }) => theme.colors.gray500}; + font-size: ${({ theme }) => theme.fontSize.sm}; +`; + +const StateText = styled.p` + padding: ${({ theme }) => theme.spacing.lg}; + color: ${({ theme }) => theme.colors.gray500}; +`; + +const Empty = styled.p` + padding: ${({ theme }) => theme.spacing.lg}; + color: ${({ theme }) => theme.colors.gray400}; + font-size: ${({ theme }) => theme.fontSize.sm}; +`; diff --git a/admin/src/pages/flyway/WipRegisterForm.tsx b/admin/src/pages/flyway/WipRegisterForm.tsx new file mode 100644 index 000000000..d4c08d2d9 --- /dev/null +++ b/admin/src/pages/flyway/WipRegisterForm.tsx @@ -0,0 +1,223 @@ +import styled from '@emotion/styled'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; +import { flywayQueries } from '@/apis/flyway/flyway.query'; +import { Button } from '@/components/Button'; +import type { WorkKind } from '@/apis/flyway/flyway.api'; + +interface WipRegisterFormProps { + existingTables: string[]; + defaultVersion: string; + onClose: () => void; +} + +export const WipRegisterForm = ({ + existingTables, + defaultVersion, + onClose, +}: WipRegisterFormProps) => { + const queryClient = useQueryClient(); + const [workKind, setWorkKind] = useState('EXISTING_TABLE'); + const [targetTable, setTargetTable] = useState(''); + const [plannedVersion, setPlannedVersion] = useState(defaultVersion); + const [description, setDescription] = useState(''); + const [assignee, setAssignee] = useState(''); + + const { mutate, isPending, isError, error, data } = useMutation({ + ...flywayQueries.mutation.createWip(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: flywayQueries.all }); + }, + }); + + const isExistingTable = workKind === 'EXISTING_TABLE'; + const canSubmit = + plannedVersion.trim().length > 0 && + description.trim().length > 0 && + assignee.trim().length > 0 && + (isExistingTable === false || targetTable.trim().length > 0); + + const handleSubmit = () => { + mutate({ + workKind, + targetTable: isExistingTable ? targetTable.trim() : undefined, + plannedVersion: plannedVersion.trim(), + description: description.trim(), + assignee: assignee.trim(), + }); + }; + + return ( + + + 작업중 등록 + 새 마이그레이션을 미리 예약해 번호 충돌을 막아요 + + ✕ + + + + + + + + setWorkKind('EXISTING_TABLE')} + > + 기존 테이블 + + setWorkKind('NEW_TABLE')} + > + 새로운 테이블 + + + + + {isExistingTable ? ( + + + setTargetTable(event.target.value)} + /> + + {existingTables.map((table) => ( + + + ) : null} + + + + setPlannedVersion(event.target.value)} + /> + + + + setDescription(event.target.value)} + /> + + + + setAssignee(event.target.value)} + /> + + + + + {isError ? ( + + 등록 실패: {(error as Error)?.message ?? '알 수 없는 오류'} + + ) : null} + {data ? ( + + 이슈 #{data.issueNumber} 생성됨 —{' '} + + GitHub에서 보기 + + + ) : null} + + ); +}; + +const Panel = styled.div` + padding: ${({ theme }) => theme.spacing.lg}; + border: 1px solid ${({ theme }) => theme.colors.warning}; + border-radius: ${({ theme }) => theme.borderRadius.lg}; + background-color: #fffdf7; +`; + +const PanelHeader = styled.div` + margin-bottom: ${({ theme }) => theme.spacing.md}; + display: flex; + gap: ${({ theme }) => theme.spacing.md}; + align-items: center; + + span { + color: ${({ theme }) => theme.colors.gray500}; + font-size: ${({ theme }) => theme.fontSize.sm}; + } +`; + +const CloseButton = styled.button` + margin-left: auto; + color: ${({ theme }) => theme.colors.gray500}; + cursor: pointer; +`; + +const Row = styled.div` + display: flex; + gap: ${({ theme }) => theme.spacing.md}; + align-items: flex-end; + flex-wrap: wrap; +`; + +const Field = styled.div<{ $grow?: boolean }>` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing.xs}; + flex: ${({ $grow }) => ($grow ? 1 : 'none')}; + min-width: 140px; +`; + +const Label = styled.span` + color: ${({ theme }) => theme.colors.gray500}; + font-size: ${({ theme }) => theme.fontSize.xs}; + font-weight: ${({ theme }) => theme.fontWeight.semibold}; +`; + +const Input = styled.input` + padding: ${({ theme }) => theme.spacing.sm}; + border: 1px solid ${({ theme }) => theme.colors.gray300}; + border-radius: ${({ theme }) => theme.borderRadius.md}; + font-size: ${({ theme }) => theme.fontSize.sm}; +`; + +const Segment = styled.div` + display: inline-flex; + border: 1px solid ${({ theme }) => theme.colors.gray300}; + border-radius: ${({ theme }) => theme.borderRadius.md}; + overflow: hidden; +`; + +const SegmentButton = styled.button<{ $active: boolean }>` + padding: ${({ theme }) => theme.spacing.sm} ${({ theme }) => theme.spacing.md}; + font-size: ${({ theme }) => theme.fontSize.sm}; + cursor: pointer; + background-color: ${({ theme, $active }) => + $active ? theme.colors.primary : theme.colors.white}; + color: ${({ theme, $active }) => + $active ? theme.colors.white : theme.colors.gray600}; +`; + +const Notice = styled.p<{ $error?: boolean }>` + margin-top: ${({ theme }) => theme.spacing.md}; + font-size: ${({ theme }) => theme.fontSize.sm}; + color: ${({ theme, $error }) => + $error ? theme.colors.error : theme.colors.success}; +`; diff --git a/admin/src/routeTree.gen.ts b/admin/src/routeTree.gen.ts index cdf9b4478..c9dbe19bf 100644 --- a/admin/src/routeTree.gen.ts +++ b/admin/src/routeTree.gen.ts @@ -15,6 +15,7 @@ import { Route as AdminIndexRouteImport } from './routes/_admin/index'; import { Route as AdminResourcesRouteImport } from './routes/_admin/resources'; import { Route as AdminNoticesRouteImport } from './routes/_admin/notices'; import { Route as AdminMembersRouteImport } from './routes/_admin/members'; +import { Route as AdminFlywayRouteImport } from './routes/_admin/flyway'; import { Route as AdminEventsRouteImport } from './routes/_admin/events'; import { Route as AdminChallengesRouteImport } from './routes/_admin/challenges'; import { Route as AdminBlogRouteImport } from './routes/_admin/blog'; @@ -77,6 +78,11 @@ const AdminMembersRoute = AdminMembersRouteImport.update({ path: '/members', getParentRoute: () => AdminRoute, } as any); +const AdminFlywayRoute = AdminFlywayRouteImport.update({ + id: '/flyway', + path: '/flyway', + getParentRoute: () => AdminRoute, +} as any); const AdminEventsRoute = AdminEventsRouteImport.update({ id: '/events', path: '/events', @@ -261,6 +267,7 @@ export interface FileRoutesByFullPath { '/blog': typeof AdminBlogRouteWithChildren; '/challenges': typeof AdminChallengesRouteWithChildren; '/events': typeof AdminEventsRouteWithChildren; + '/flyway': typeof AdminFlywayRoute; '/members': typeof AdminMembersRoute; '/notices': typeof AdminNoticesRouteWithChildren; '/resources': typeof AdminResourcesRouteWithChildren; @@ -297,6 +304,7 @@ export interface FileRoutesByFullPath { } export interface FileRoutesByTo { '/403': typeof R403Route; + '/flyway': typeof AdminFlywayRoute; '/members': typeof AdminMembersRoute; '/': typeof AdminIndexRoute; '/blog/$postId': typeof AdminBlogPostIdRoute; @@ -331,6 +339,7 @@ export interface FileRoutesById { '/_admin/blog': typeof AdminBlogRouteWithChildren; '/_admin/challenges': typeof AdminChallengesRouteWithChildren; '/_admin/events': typeof AdminEventsRouteWithChildren; + '/_admin/flyway': typeof AdminFlywayRoute; '/_admin/members': typeof AdminMembersRoute; '/_admin/notices': typeof AdminNoticesRouteWithChildren; '/_admin/resources': typeof AdminResourcesRouteWithChildren; @@ -372,6 +381,7 @@ export interface FileRouteTypes { | '/blog' | '/challenges' | '/events' + | '/flyway' | '/members' | '/notices' | '/resources' @@ -408,6 +418,7 @@ export interface FileRouteTypes { fileRoutesByTo: FileRoutesByTo; to: | '/403' + | '/flyway' | '/members' | '/' | '/blog/$postId' @@ -441,6 +452,7 @@ export interface FileRouteTypes { | '/_admin/blog' | '/_admin/challenges' | '/_admin/events' + | '/_admin/flyway' | '/_admin/members' | '/_admin/notices' | '/_admin/resources' @@ -525,6 +537,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminMembersRouteImport; parentRoute: typeof AdminRoute; }; + '/_admin/flyway': { + id: '/_admin/flyway'; + path: '/flyway'; + fullPath: '/flyway'; + preLoaderRoute: typeof AdminFlywayRouteImport; + parentRoute: typeof AdminRoute; + }; '/_admin/events': { id: '/_admin/events'; path: '/events'; @@ -922,6 +941,7 @@ interface AdminRouteChildren { AdminBlogRoute: typeof AdminBlogRouteWithChildren; AdminChallengesRoute: typeof AdminChallengesRouteWithChildren; AdminEventsRoute: typeof AdminEventsRouteWithChildren; + AdminFlywayRoute: typeof AdminFlywayRoute; AdminMembersRoute: typeof AdminMembersRoute; AdminNoticesRoute: typeof AdminNoticesRouteWithChildren; AdminResourcesRoute: typeof AdminResourcesRouteWithChildren; @@ -936,6 +956,7 @@ const AdminRouteChildren: AdminRouteChildren = { AdminBlogRoute: AdminBlogRouteWithChildren, AdminChallengesRoute: AdminChallengesRouteWithChildren, AdminEventsRoute: AdminEventsRouteWithChildren, + AdminFlywayRoute: AdminFlywayRoute, AdminMembersRoute: AdminMembersRoute, AdminNoticesRoute: AdminNoticesRouteWithChildren, AdminResourcesRoute: AdminResourcesRouteWithChildren, diff --git a/admin/src/routes/_admin/flyway.tsx b/admin/src/routes/_admin/flyway.tsx new file mode 100644 index 000000000..3ae9399ab --- /dev/null +++ b/admin/src/routes/_admin/flyway.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { FlywayViewer } from '@/pages/flyway/FlywayViewer'; + +export const Route = createFileRoute('/_admin/flyway')({ + component: FlywayViewer, +}); diff --git a/codex-review-output.json b/codex-review-output.json new file mode 100644 index 000000000..7d83aac0d --- /dev/null +++ b/codex-review-output.json @@ -0,0 +1,6 @@ +{ + "overall_correctness": "patch is correct", + "overall_explanation": "랜딩 리다이렉트 제거 및 LandingIntroBanner 추가 변경이 의도한 SEO 전략과 일치합니다. 신규 컴포넌트의 반응형 처리와 캐러셀 통합이 올바르며, CarouselSlide의 min-width: 0 추가도 flex overflow 수정으로 적절합니다. canonical 설정(/landing → https://www.bombom.news)과 sitemap 정리도 SEO 정책에 부합합니다.", + "overall_confidence_score": 0.93, + "findings": [] +} diff --git a/shared/src/core/apis/generated/article/article.api.ts b/shared/src/core/apis/generated/article/article.api.ts deleted file mode 100644 index 4565c4f3f..000000000 --- a/shared/src/core/apis/generated/article/article.api.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* eslint-disable */ -/** - * @generated by @bombom/oas-gen - * Do not edit by hand — run `pnpm web:gen:api`. - */ -import { fetcher } from '../../fetcher'; -import type { operations } from '../types'; - -export type GetArticlesResponse = NonNullable< - operations['getArticles']['responses']['200']['content']['application/json'] ->; - -export type GetArticlesQuery = NonNullable< - operations['getArticles']['parameters'] ->['query']; - -export const getArticles = async ({ ...query }: GetArticlesQuery) => { - return fetcher.get({ path: '/articles', query }); -}; diff --git a/shared/src/core/apis/generated/article/article.query.ts b/shared/src/core/apis/generated/article/article.query.ts deleted file mode 100644 index 6f27179af..000000000 --- a/shared/src/core/apis/generated/article/article.query.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* eslint-disable */ -/** - * @generated by @bombom/oas-gen - * Do not edit by hand — run `pnpm web:gen:api`. - */ -import { queryOptions } from '@tanstack/react-query'; -import { getArticles } from './article.api'; - -export const articleQueries = { - getArticles: (params: Parameters[0]) => - queryOptions({ - queryKey: ['article', 'getArticles', params], - queryFn: () => getArticles(params), - }), -}; diff --git a/shared/src/core/apis/generated/article/index.ts b/shared/src/core/apis/generated/monthlyreport/index.ts similarity index 59% rename from shared/src/core/apis/generated/article/index.ts rename to shared/src/core/apis/generated/monthlyreport/index.ts index 9513d1bd5..8749e180d 100644 --- a/shared/src/core/apis/generated/article/index.ts +++ b/shared/src/core/apis/generated/monthlyreport/index.ts @@ -3,5 +3,5 @@ * @generated by @bombom/oas-gen * Do not edit by hand — run `pnpm web:gen:api`. */ -export * from './article.api'; -export * from './article.query'; +export * from './monthlyreport.api'; +export * from './monthlyreport.query'; diff --git a/shared/src/core/apis/generated/monthlyreport/monthlyreport.api.ts b/shared/src/core/apis/generated/monthlyreport/monthlyreport.api.ts new file mode 100644 index 000000000..6d5fdd62d --- /dev/null +++ b/shared/src/core/apis/generated/monthlyreport/monthlyreport.api.ts @@ -0,0 +1,41 @@ +/* eslint-disable */ +/** + * @generated by @bombom/oas-gen + * Do not edit by hand — run `pnpm web:gen:api`. + */ +import { fetcher } from '../../fetcher'; +import type { operations } from '../types'; + +export type GetReadingCalendarResponse = NonNullable< + operations['getReadingCalendar']['responses']['200']['content']['application/json'] +>; + +export type GetReadingCalendarQuery = NonNullable< + operations['getReadingCalendar']['parameters'] +>['query']; + +export type GetReadingDashboardResponse = NonNullable< + operations['getReadingDashboard']['responses']['200']['content']['application/json'] +>; + +export type GetReadingDashboardQuery = NonNullable< + operations['getReadingDashboard']['parameters'] +>['query']; + +export const getReadingCalendar = async ({ + ...query +}: GetReadingCalendarQuery) => { + return fetcher.get({ + path: '/members/me/reading/calendar', + query, + }); +}; + +export const getReadingDashboard = async ({ + ...query +}: GetReadingDashboardQuery) => { + return fetcher.get({ + path: '/members/me/reading/dashboard', + query, + }); +}; diff --git a/shared/src/core/apis/generated/monthlyreport/monthlyreport.query.ts b/shared/src/core/apis/generated/monthlyreport/monthlyreport.query.ts new file mode 100644 index 000000000..a040d54c4 --- /dev/null +++ b/shared/src/core/apis/generated/monthlyreport/monthlyreport.query.ts @@ -0,0 +1,20 @@ +/* eslint-disable */ +/** + * @generated by @bombom/oas-gen + * Do not edit by hand — run `pnpm web:gen:api`. + */ +import { queryOptions } from '@tanstack/react-query'; +import { getReadingCalendar, getReadingDashboard } from './monthlyreport.api'; + +export const monthlyReportQueries = { + getReadingCalendar: (params: Parameters[0]) => + queryOptions({ + queryKey: ['monthlyreport', 'getReadingCalendar', params], + queryFn: () => getReadingCalendar(params), + }), + getReadingDashboard: (params: Parameters[0]) => + queryOptions({ + queryKey: ['monthlyreport', 'getReadingDashboard', params], + queryFn: () => getReadingDashboard(params), + }), +}; diff --git a/shared/src/core/apis/generated/pet/index.ts b/shared/src/core/apis/generated/mypage/index.ts similarity index 64% rename from shared/src/core/apis/generated/pet/index.ts rename to shared/src/core/apis/generated/mypage/index.ts index 4a4b7289d..d1811883b 100644 --- a/shared/src/core/apis/generated/pet/index.ts +++ b/shared/src/core/apis/generated/mypage/index.ts @@ -3,5 +3,5 @@ * @generated by @bombom/oas-gen * Do not edit by hand — run `pnpm web:gen:api`. */ -export * from './pet.api'; -export * from './pet.query'; +export * from './mypage.api'; +export * from './mypage.query'; diff --git a/shared/src/core/apis/generated/mypage/mypage.api.ts b/shared/src/core/apis/generated/mypage/mypage.api.ts new file mode 100644 index 000000000..12ae03f9d --- /dev/null +++ b/shared/src/core/apis/generated/mypage/mypage.api.ts @@ -0,0 +1,32 @@ +/* eslint-disable */ +/** + * @generated by @bombom/oas-gen + * Do not edit by hand — run `pnpm web:gen:api`. + */ +import { fetcher } from '../../fetcher'; +import type { operations } from '../types'; + +export type GetMemberJoinDaysResponse = NonNullable< + operations['getMemberJoinDays']['responses']['200']['content']['application/json'] +>; + +export type GetRankSummaryResponse = NonNullable< + operations['getRankSummary']['responses']['200']['content']['application/json'] +>; + +export type GetRankSummaryQuery = NonNullable< + operations['getRankSummary']['parameters'] +>['query']; + +export const getMemberJoinDays = async () => { + return fetcher.get({ + path: '/members/me/join-days', + }); +}; + +export const getRankSummary = async ({ ...query }: GetRankSummaryQuery) => { + return fetcher.get({ + path: '/members/me/rank', + query, + }); +}; diff --git a/shared/src/core/apis/generated/mypage/mypage.query.ts b/shared/src/core/apis/generated/mypage/mypage.query.ts new file mode 100644 index 000000000..af8176e0b --- /dev/null +++ b/shared/src/core/apis/generated/mypage/mypage.query.ts @@ -0,0 +1,20 @@ +/* eslint-disable */ +/** + * @generated by @bombom/oas-gen + * Do not edit by hand — run `pnpm web:gen:api`. + */ +import { queryOptions } from '@tanstack/react-query'; +import { getMemberJoinDays, getRankSummary } from './mypage.api'; + +export const myPageQueries = { + getMemberJoinDays: () => + queryOptions({ + queryKey: ['mypage', 'getMemberJoinDays'], + queryFn: () => getMemberJoinDays(), + }), + getRankSummary: (params: Parameters[0]) => + queryOptions({ + queryKey: ['mypage', 'getRankSummary', params], + queryFn: () => getRankSummary(params), + }), +}; diff --git a/shared/src/core/apis/generated/pet/pet.api.ts b/shared/src/core/apis/generated/pet/pet.api.ts deleted file mode 100644 index b17f8fd0a..000000000 --- a/shared/src/core/apis/generated/pet/pet.api.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* eslint-disable */ -/** - * @generated by @bombom/oas-gen - * Do not edit by hand — run `pnpm web:gen:api`. - */ -import { fetcher } from '../../fetcher'; -import type { operations } from '../types'; - -export type GetPetResponse = NonNullable< - operations['getPet']['responses']['200']['content']['application/json'] ->; - -export const getPet = async () => { - return fetcher.get({ path: '/members/me/pet' }); -}; - -export const postPetAttendance = async () => { - return fetcher.post({ path: '/members/me/pet/attendance' }); -}; diff --git a/shared/src/core/apis/generated/pet/pet.query.ts b/shared/src/core/apis/generated/pet/pet.query.ts deleted file mode 100644 index 619074c81..000000000 --- a/shared/src/core/apis/generated/pet/pet.query.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* eslint-disable */ -/** - * @generated by @bombom/oas-gen - * Do not edit by hand — run `pnpm web:gen:api`. - */ -import { queryOptions } from '@tanstack/react-query'; -import { getPet } from './pet.api'; - -export const petQueries = { - getPet: () => - queryOptions({ - queryKey: ['pet', 'getPet'], - queryFn: () => getPet(), - }), -}; diff --git a/shared/src/core/apis/generated/types.d.ts b/shared/src/core/apis/generated/types.d.ts index e4db55f4c..ed14a3430 100644 --- a/shared/src/core/apis/generated/types.d.ts +++ b/shared/src/core/apis/generated/types.d.ts @@ -4,7 +4,7 @@ */ export interface paths { - '/api/v1/articles': { + '/api/v1/members/me/join-days': { parameters: { query?: never; header?: never; @@ -12,12 +12,10 @@ export interface paths { cookie?: never; }; /** - * 아티클 목록 조회 - * @description 조건에 맞는 아티클 목록을 페이징하여 조회합니다. - * (정렬 기본값: ?page=0&size=10&sort=arrivedDateTime,desc) - * + * 회원 가입일 기준 경과일 조회 + * @description 로그인한 회원의 가입일과 가입일 이래로 지난 일수를 조회합니다. */ - get: operations['getArticles']; + get: operations['getMemberJoinDays']; put?: never; post?: never; delete?: never; @@ -26,7 +24,7 @@ export interface paths { patch?: never; trace?: never; }; - '/api/v1/members/me/pet': { + '/api/v1/members/me/rank': { parameters: { query?: never; header?: never; @@ -34,10 +32,12 @@ export interface paths { cookie?: never; }; /** - * 내 펫 정보 조회 - * @description 현재 로그인한 사용자의 펫 정보를 조회합니다. + * 마이페이지 랭킹 요약 조회 + * @description 로그인한 회원의 마이페이지 랭킹 카드를 조회합니다. + * rankHistory와 currentRank는 현재월을 제외한 이전달까지의 확정 랭킹을 기준으로 합니다. + * reading 카드의 value는 누적 읽은 아티클 수, streak 카드의 value는 현재 연속 읽기 일수입니다. */ - get: operations['getPet']; + get: operations['getRankSummary']; put?: never; post?: never; delete?: never; @@ -46,20 +46,41 @@ export interface paths { patch?: never; trace?: never; }; - '/api/v1/members/me/pet/attendance': { + '/api/v1/members/me/reading/calendar': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** + * 월간 읽기 캘린더 조회 + * @description 로그인한 회원의 해당 연·월 일자별 읽기 현황을 조회합니다. + */ + get: operations['getReadingCalendar']; put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v1/members/me/reading/dashboard': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; /** - * 출석 점수 부여 - * @description 오늘의 출석 점수를 펫에게 부여합니다. + * 월간 읽기 대시보드 조회 + * @description 로그인한 회원의 해당 연·월 읽기 통계를 조회합니다. + * 읽은 아티클 수(지난 달 대비 증감 포함), 북마크 수, 자주 읽은 뉴스레터를 반환합니다. */ - post: operations['attend']; + get: operations['getReadingDashboard']; + put?: never; + post?: never; delete?: never; options?: never; head?: never; @@ -70,93 +91,144 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { - ArticleResponse: { - /** - * Format: int64 - * @description 아티클 ID - */ - articleId: number; - /** @description 아티클 제목 */ - title: string; - /** @description 본문 요약 */ - contentsSummary: string; + /** + * @description 증감 방향 + * @enum {string} + */ + ChangeDirection: 'UP' | 'DOWN' | 'SAME'; + /** @description 자주 읽은 뉴스레터 정보 */ + FrequentReadNewsletterResponse: { /** - * Format: date-time - * @description 도착 일시 + * Format: int32 + * @description 순위 (1-base) */ - arrivedDateTime: string; - /** @description 썸네일 이미지 URL */ - thumbnailUrl?: string | null; + rank: number; /** - * Format: int32 - * @description 예상 읽기 시간 (분) + * Format: int64 + * @description 뉴스레터 ID */ - expectedReadTime: number; - /** @description 읽음 여부 */ - isRead: boolean; - /** @description 북마크 여부 */ - isBookmarked: boolean; - newsletter: components['schemas']['NewsletterSummaryResponse']; - }; - NewsletterSummaryResponse: { + newsletterId: number; /** @description 뉴스레터명 */ name: string; - /** @description 이미지 URL */ - imageUrl?: string | null; - /** @description 카테고리 */ - category: string; - }; - /** @description 페이징된 아티클 목록 응답 */ - PageArticleResponse: { - content: components['schemas']['ArticleResponse'][]; /** * Format: int64 - * @description 전체 데이터 개수 + * @description 이번 달 읽은 아티클 수 */ - totalElements: number; + readCount: number; + }; + /** @description 회원 가입일 기준 경과일 정보 */ + MemberJoinDaysResponse: { /** * Format: int32 - * @description 전체 페이지 수 + * @description 가입일 이래로 지난 일수 + */ + daysSinceJoined: number; + /** + * Format: date + * @description 가입일 (yyyy-MM-dd) */ - totalPages: number; + joinedAt: string; + }; + /** @description 월간 리포트 대시보드 조회 조건 */ + MonthlyReportDashboardRequest: { /** * Format: int32 - * @description 현재 페이지 번호 (0-base) + * @description 조회할 연도 */ - number: number; + year: number; /** * Format: int32 - * @description 페이지 크기 + * @description 조회할 월 (1-12) */ - size: number; + month: number; /** * Format: int32 - * @description 현재 페이지 데이터 개수 + * @description 자주 읽은 뉴스레터 조회 개수 */ - numberOfElements: number; - /** @description 첫 페이지 여부 */ - first: boolean; - /** @description 마지막 페이지 여부 */ - last: boolean; + limit: number; }; - PetResponse: { + /** @description 월간 리포트 조회 조건 */ + MonthlyReportRequest: { /** * Format: int32 - * @description 펫 레벨 + * @description 조회할 연도 */ - level: number; + year: number; /** * Format: int32 - * @description 현재 스테이지 점수 + * @description 조회할 월 (1-12) + */ + month: number; + }; + /** @description 마이페이지 랭킹 카드 정보 */ + RankCardResponse: { + /** @description 랭킹 타입 (streak: 연속 읽기, reading: 다독왕) */ + type: string; + /** + * Format: int64 + * @description 이전달까지의 최신 확정 순위. 랭킹 이력이 없으면 null입니다. */ - currentStageScore: number; + currentRank: number | null; + rankHistory: components['schemas']['RankHistoryResponse'][]; /** * Format: int32 - * @description 필요한 스테이지 점수 + * @description 카드 표시 값. streak는 현재 연속 읽기 일수, reading은 누적 읽은 아티클 수입니다. + */ + value: number; + }; + /** @description 마이페이지 랭킹 히스토리 정보 */ + RankHistoryResponse: { + /** @description 랭킹 기준 월 (yyyy-MM) */ + month: string; + /** @description 화면 표시용 월 라벨 */ + label: string; + /** + * Format: int64 + * @description 해당 월의 순위 + */ + rank: number; + }; + /** @description 마이페이지 랭킹 요약 응답 */ + RankSummaryResponse: { + cards: components['schemas']['RankCardResponse'][]; + }; + /** @description 읽기 캘린더의 하루 정보 */ + ReadingCalendarDayResponse: { + /** + * Format: date + * @description 날짜 (yyyy-MM-dd) + */ + date: string; + /** @description 해당 날짜에 아티클을 읽었는지 여부 */ + read: boolean; + /** + * Format: int64 + * @description 해당 날짜에 읽은 아티클 수 + */ + readCount: number; + }; + /** @description 월간 읽기 대시보드 응답 */ + ReadingDashboardResponse: { + /** + * Format: int64 + * @description 이번 달 읽은 아티클 수 + */ + readArticleCount: number; + /** + * Format: double + * @description 지난 달 대비 읽은 아티클 수 증감률 (%) + */ + readArticleChangeRate: number | null; + readArticleChangeDirection: + | components['schemas']['ChangeDirection'] + | null; + /** + * Format: int64 + * @description 북마크 개수 */ - requiredStageScore: number; - /** @description 출석 여부 */ - isAttended: boolean; + bookmarkCount: number; + /** @description 자주 읽은 뉴스레터 TOP */ + frequentReadNewsletters: components['schemas']['FrequentReadNewsletterResponse'][]; }; }; responses: never; @@ -167,13 +239,38 @@ export interface components { } export type $defs = Record; export interface operations { - getArticles: { + getMemberJoinDays: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 회원 가입일 기준 경과일 조회 성공 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['MemberJoinDaysResponse']; + }; + }; + /** @description 인증 실패 */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + getRankSummary: { parameters: { query?: { - /** @description 필터링할 도착 날짜 (yyyy-MM-dd) */ - date?: string; - /** @description 필터링할 뉴스레터 ID */ - newsletterId?: number; + /** @description 랭킹 타입 (streak 또는 reading). 생략하면 모든 랭킹 카드를 반환합니다. */ + type?: string; }; header?: never; path?: never; @@ -181,16 +278,16 @@ export interface operations { }; requestBody?: never; responses: { - /** @description 아티클 목록 조회 성공 */ + /** @description 마이페이지 랭킹 요약 조회 성공 */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['PageArticleResponse']; + 'application/json': components['schemas']['RankSummaryResponse']; }; }; - /** @description 잘못된 정렬 파라미터 요청 */ + /** @description 잘못된 랭킹 타입 요청 */ 400: { headers: { [name: string]: unknown; @@ -206,23 +303,35 @@ export interface operations { }; }; }; - getPet: { + getReadingCalendar: { parameters: { - query?: never; + query: { + /** @description 조회할 연도 */ + year: number; + /** @description 조회할 월 (1-12) */ + month: number; + }; header?: never; path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description 펫 정보 조회 성공 */ + /** @description 읽기 캘린더 조회 성공 */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['PetResponse']; + 'application/json': components['schemas']['ReadingCalendarDayResponse'][]; + }; + }; + /** @description 잘못된 연·월 파라미터 요청 */ + 400: { + headers: { + [name: string]: unknown; }; + content?: never; }; /** @description 인증 실패 */ 401: { @@ -233,17 +342,33 @@ export interface operations { }; }; }; - attend: { + getReadingDashboard: { parameters: { - query?: never; + query: { + /** @description 조회할 연도 */ + year: number; + /** @description 조회할 월 (1-12) */ + month: number; + /** @description 자주 읽은 뉴스레터 조회 개수 */ + limit: number; + }; header?: never; path?: never; cookie?: never; }; requestBody?: never; responses: { - /** @description 출석 점수 부여 성공 */ + /** @description 읽기 대시보드 조회 성공 */ 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ReadingDashboardResponse']; + }; + }; + /** @description 잘못된 연·월 파라미터 요청 */ + 400: { headers: { [name: string]: unknown; }; diff --git a/web/public/assets/avif/landing-banner-cat.avif b/web/public/assets/avif/landing-banner-cat.avif new file mode 100644 index 000000000..8e257029e Binary files /dev/null and b/web/public/assets/avif/landing-banner-cat.avif differ diff --git a/web/public/assets/svg/crown.svg b/web/public/assets/svg/crown.svg new file mode 100644 index 000000000..45763d252 --- /dev/null +++ b/web/public/assets/svg/crown.svg @@ -0,0 +1,6 @@ + + + diff --git a/web/public/assets/svg/monthly-reading-report.svg b/web/public/assets/svg/monthly-reading-report.svg new file mode 100644 index 000000000..1ef82f6d7 --- /dev/null +++ b/web/public/assets/svg/monthly-reading-report.svg @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + 월간 리포트 ✦ + + + + 2024년 6월 + + + + + + 읽은 아티클 + 248 + + ↑ 32% 5월 기준의 지난 달과 비교 + + + + 북마크 개수 + 132 + + + 내가 자주 읽는 뉴스레터 TOP 3 + 1 + 뉴닉12개 + 2 + 데일리바이트5개 + 3 + 부딩2개 + + + 월간 읽기 요약 + 주요 독서 한눈에 보기 + + 읽은 아티클⌄ + + + + + + + 134566 + 78910111213 + 14151617181920 + 21222324252627 + 282930 + + + + + + + 17 + 가장 많이 읽은 날 + + 읽은 날 + + + + 이번 달에 읽은 뉴스레터 카테고리 + + 48 + 카테고리 + + 자기계발12개 25% + 경제9개 19% + 기술11개 23% + 시사/사회8개 17% + 취미/라이프8개 16% + + + 4경제 + + 14% + 5라이프스타일 + + 12% + + diff --git a/web/public/assets/svg/reading-companion.svg b/web/public/assets/svg/reading-companion.svg new file mode 100644 index 000000000..111e531ab --- /dev/null +++ b/web/public/assets/svg/reading-companion.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + 봄봄과 함께한 지 + 142일째 + 꾸준한 읽기 습관이 + 쌓이고 있어요! + + + + diff --git a/web/public/prerendered/index.html b/web/public/prerendered/index.html index 79e91800e..f6fd7616d 100644 --- a/web/public/prerendered/index.html +++ b/web/public/prerendered/index.html @@ -4,7 +4,7 @@ 봄봄 | 읽고 남기고 쌓는 뉴스레터 리딩 플랫폼 @@ -145,6 +145,11 @@ .newsletter-card:hover { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); } + .newsletter-card a { + text-decoration: none; + color: inherit; + display: block; + } .newsletter-card img { width: 80px; height: 80px; @@ -188,9 +193,8 @@ @@ -204,160 +208,146 @@

- - - + + + + +
diff --git a/web/public/sitemap.xml b/web/public/sitemap.xml index 1bb1f16c9..82bb5f674 100644 --- a/web/public/sitemap.xml +++ b/web/public/sitemap.xml @@ -12,11 +12,4 @@ 1.0 - - - https://www.bombom.news/landing - 2026-03-25T00:00:00+00:00 - monthly - 0.8 - \ No newline at end of file diff --git a/web/src/apis/members/members.api.ts b/web/src/apis/members/members.api.ts index e436240ff..c98208ef2 100644 --- a/web/src/apis/members/members.api.ts +++ b/web/src/apis/members/members.api.ts @@ -95,6 +95,32 @@ export const getMyStreakReadingRank = async () => { }); }; +export interface CategoryStat { + id: number; + name: string; + count: number; + percent: number; +} + +export interface CategoryStatsResponse { + type: 'cumulative' | 'monthly'; + total: number; + categories: CategoryStat[]; +} + +export interface GetCategoryStatsParams { + yearMonth?: string; +} + +export const getCategoryStats = async ({ + yearMonth, +}: GetCategoryStatsParams = {}) => { + return await fetcher.get({ + path: '/mypage/category-stats', + query: yearMonth ? { yearMonth } : undefined, + }); +}; + export type PatchMembersInfoParams = components['schemas']['MemberInfoUpdateRequest']; diff --git a/web/src/apis/members/members.query.ts b/web/src/apis/members/members.query.ts index f0801700a..c691109f8 100644 --- a/web/src/apis/members/members.query.ts +++ b/web/src/apis/members/members.query.ts @@ -1,5 +1,6 @@ import { queryOptions } from '@tanstack/react-query'; import { + getCategoryStats, getMonthlyReadingRank, getMyMonthlyReadingRank, getStreakReadingRank, @@ -11,6 +12,7 @@ import { getWarningVisible, type GetMonthlyReadingRankParams, type GetStreakReadingRankParams, + type GetCategoryStatsParams, } from './members.api'; export const membersQueries = { @@ -58,6 +60,12 @@ export const membersQueries = { queryFn: () => getMyStreakReadingRank(), }), + categoryStats: (params: GetCategoryStatsParams = {}) => + queryOptions({ + queryKey: ['mypage', 'category-stats', params], + queryFn: () => getCategoryStats(params), + }), + mySubscriptions: () => queryOptions({ queryKey: ['members', 'me', 'subscriptions'], diff --git a/web/src/apis/pet/pet.api.ts b/web/src/apis/pet/pet.api.ts new file mode 100644 index 000000000..10e5a710e --- /dev/null +++ b/web/src/apis/pet/pet.api.ts @@ -0,0 +1,16 @@ +import { fetcher } from '@bombom/shared/apis'; +import type { components } from '@/types/openapi'; + +export type GetPetResponse = components['schemas']['PetResponse']; + +export const getPet = async () => { + return await fetcher.get({ + path: '/members/me/pet', + }); +}; + +export const postPetAttendance = async () => { + return await fetcher.post({ + path: '/members/me/pet/attendance', + }); +}; diff --git a/web/src/apis/pet/pet.query.ts b/web/src/apis/pet/pet.query.ts new file mode 100644 index 000000000..68d954dee --- /dev/null +++ b/web/src/apis/pet/pet.query.ts @@ -0,0 +1,10 @@ +import { queryOptions } from '@tanstack/react-query'; +import { getPet } from './pet.api'; + +export const petQueries = { + pet: () => + queryOptions({ + queryKey: ['pet'], + queryFn: getPet, + }), +}; diff --git a/web/src/apis/queries.ts b/web/src/apis/queries.ts index f9576efd4..6517fb022 100644 --- a/web/src/apis/queries.ts +++ b/web/src/apis/queries.ts @@ -10,6 +10,7 @@ import { membersQueries } from './members/members.query'; import { newslettersQueries } from './newsletters/newsletters.query'; import { noticeQueries } from './notice/notice.query'; import { notificationQueries } from './notification/notification.query'; +import { petQueries } from './pet/pet.query'; import { previousArticlesQueries } from './previousArticles/previousArticles.query'; import { subscriptionsQueries } from './subscriptions/subscriptions.query'; @@ -44,6 +45,9 @@ export const queries = { // notification ...notificationQueries, + // pet + ...petQueries, + // previous articles ...previousArticlesQueries, diff --git a/web/src/apis/subscriptions/subscriptions.api.ts b/web/src/apis/subscriptions/subscriptions.api.ts index 64be93a23..1b7593602 100644 --- a/web/src/apis/subscriptions/subscriptions.api.ts +++ b/web/src/apis/subscriptions/subscriptions.api.ts @@ -2,7 +2,7 @@ import { fetcher } from '@bombom/shared/apis'; import type { components } from '@/types/openapi'; export type PostNativeMaeilMailSubscriptionParams = - components['schemas']['MaeilMailSubscribeRequest']; + components['schemas']['MaeilMailUpdateSubscriptionRequest']; export const postNativeMaeilMailSubscription = async ( params: PostNativeMaeilMailSubscriptionParams, @@ -23,3 +23,21 @@ export const getNativeMaeilMailSubscription = async () => { path: '/subscriptions/native/maeil-mail', }); }; + +export type PutNativeMaeilMailSubscriptionParams = + components['schemas']['MaeilMailUpdateSubscriptionRequest']; + +export const putNativeMaeilMailSubscription = async ( + params: PutNativeMaeilMailSubscriptionParams, +) => { + return await fetcher.put({ + path: '/subscriptions/native/maeil-mail', + body: params, + }); +}; + +export const deleteNativeMaeilMailSubscription = async () => { + return await fetcher.delete({ + path: '/subscriptions/native/maeil-mail', + }); +}; diff --git a/web/src/components/Carousel/CarouselSlide.tsx b/web/src/components/Carousel/CarouselSlide.tsx index 48bdf4d76..77612846c 100644 --- a/web/src/components/Carousel/CarouselSlide.tsx +++ b/web/src/components/Carousel/CarouselSlide.tsx @@ -19,5 +19,6 @@ const CarouselSlide = ({ children }: PropsWithChildren) => { export default CarouselSlide; const Slide = styled.li` + min-width: 0; flex: 0 0 100%; `; diff --git a/web/src/components/PetCard/PetCard.tsx b/web/src/components/PetCard/PetCard.tsx index 163484960..4a2141ab4 100644 --- a/web/src/components/PetCard/PetCard.tsx +++ b/web/src/components/PetCard/PetCard.tsx @@ -1,4 +1,3 @@ -import { postPetAttendance } from '@bombom/shared/apis/pet'; import { theme } from '@bombom/shared/theme'; import styled from '@emotion/styled'; import { useMutation } from '@tanstack/react-query'; @@ -7,11 +6,12 @@ import { LEVEL, PET_LABEL } from './PetCard.constants'; import { heartAnimation, jumpAnimation } from './PetCard.keyframes'; import Button from '../Button/Button'; import ProgressBar from '../ProgressBar/ProgressBar'; +import { postPetAttendance } from '@/apis/pet/pet.api'; import { useDevice } from '@/hooks/useDevice'; import { queryClient } from '@/main'; import { calculateRate } from '@/utils/math'; +import type { GetPetResponse } from '@/apis/pet/pet.api'; import type { Device } from '@/hooks/useDevice'; -import type { GetPetResponse } from '@bombom/shared/apis/pet'; import type { CSSObject, Theme } from '@emotion/react'; import petLv1 from '#/assets/avif/pet-lv1.avif'; import petLv10 from '#/assets/avif/pet-lv10.avif'; diff --git a/web/src/mocks/datas/categoryStats.ts b/web/src/mocks/datas/categoryStats.ts new file mode 100644 index 000000000..95ece386e --- /dev/null +++ b/web/src/mocks/datas/categoryStats.ts @@ -0,0 +1,57 @@ +import type { CategoryStatsResponse } from '@/apis/members/members.api'; + +export const CUMULATIVE_CATEGORY_STATS: CategoryStatsResponse = { + type: 'cumulative', + total: 56, + categories: [ + { + id: 1, + name: '자기계발', + count: 22, + percent: 39, + }, + { + id: 2, + name: '경제', + count: 18, + percent: 32, + }, + { + id: 3, + name: '기술', + count: 11, + percent: 20, + }, + { + id: 4, + name: '시사/사회', + count: 3, + percent: 5, + }, + { + id: 5, + name: '취미/라이프', + count: 2, + percent: 4, + }, + ], +}; + +export const MONTHLY_CATEGORY_STATS: CategoryStatsResponse = { + type: 'monthly', + total: 48, + categories: [ + { + id: 1, + name: '자기계발', + count: 12, + percent: 25, + }, + { + id: 2, + name: '경제', + count: 10, + percent: 21, + }, + ], +}; diff --git a/web/src/mocks/handlers/members.ts b/web/src/mocks/handlers/members.ts index 9cc8a4744..7637eea44 100644 --- a/web/src/mocks/handlers/members.ts +++ b/web/src/mocks/handlers/members.ts @@ -1,5 +1,9 @@ import { http, HttpResponse } from 'msw'; import { ENV } from '../../apis/env'; +import { + CUMULATIVE_CATEGORY_STATS, + MONTHLY_CATEGORY_STATS, +} from '../datas/categoryStats'; import { getRankingMetadata, getStreakRankingMetadata, @@ -9,6 +13,15 @@ import { TRENDY_NEWSLETTERS } from '../datas/trendyNewsLetter'; const baseURL = ENV.baseUrl; export const membersHandlers = [ + http.get(`${baseURL}/mypage/category-stats`, ({ request }) => { + const url = new URL(request.url); + const yearMonth = url.searchParams.get('yearMonth'); + + return HttpResponse.json( + yearMonth ? MONTHLY_CATEGORY_STATS : CUMULATIVE_CATEGORY_STATS, + ); + }), + http.get(`${baseURL}/members/me/subscriptions`, () => { const subscribedNewsletters = TRENDY_NEWSLETTERS.slice(0, 5).map( (newsletter, index) => { @@ -75,9 +88,9 @@ export const membersHandlers = [ http.get(`${baseURL}/members/me/reading/streak/rank/me`, () => { return HttpResponse.json({ - rank: 12, + rank: 3, nickname: '나', - dayCount: 7, + dayCount: 52, badges: { challenge: { name: '뉴스레터 한달 읽기', @@ -87,4 +100,13 @@ export const membersHandlers = [ }, }); }), + + http.get(`${baseURL}/members/me/reading/month/rank/me`, () => { + return HttpResponse.json({ + rank: 3, + nickname: '나', + monthlyReadCount: 248, + nextRankDifference: 12, + }); + }), ]; diff --git a/web/src/pages/my-page/components/ReadingActivitySection.tsx b/web/src/pages/my-page/components/ReadingActivitySection.tsx new file mode 100644 index 000000000..4cf47732a --- /dev/null +++ b/web/src/pages/my-page/components/ReadingActivitySection.tsx @@ -0,0 +1,327 @@ +import styled from '@emotion/styled'; +import { useQuery } from '@tanstack/react-query'; +import { queries } from '@/apis/queries'; +import { useDevice } from '@/hooks/useDevice'; +import DottedRankGraph from '@/pages/my-page/components/ReadingActivityStats/DottedRankGraph'; +import SubscribedCategoryStats from '@/pages/my-page/components/ReadingActivityStats/SubscribedCategoryStats'; +import LogoImage from '#/assets/avif/logo.avif'; +import CrownIcon from '#/assets/svg/crown.svg'; +import StreakIcon from '#/assets/svg/streak.svg'; + +const RANK_HISTORY = { + streak: [ + { label: '25.12', rank: 20 }, + { label: '1월', rank: 14 }, + { label: '2월', rank: 20 }, + { label: '3월', rank: 12 }, + { label: '4월', rank: 9 }, + { label: '5월', rank: 3 }, + ], + reading: [ + { label: '25.12', rank: 20 }, + { label: '1월', rank: 15 }, + { label: '2월', rank: 19 }, + { label: '3월', rank: 10 }, + { label: '4월', rank: 8 }, + { label: '5월', rank: 3 }, + ], +} as const; + +const ReadingActivitySection = () => { + const device = useDevice(); + const isMobile = device !== 'pc'; + const { data: streakRank, isLoading: isStreakLoading } = useQuery( + queries.myStreakReadingRank(), + ); + const { data: readingRank, isLoading: isReadingLoading } = useQuery( + queries.myMonthlyReadingRank(), + ); + const { data: categoryStats } = useQuery(queries.categoryStats()); + + return ( + + 읽기 활동 + {isMobile && ( + + + + 봄봄과 함께한 지 + 142일째 + + 꾸준한 읽기 습관이 쌓이고 있어요! + + + + + )} + + + + + + + {isStreakLoading || !streakRank ? ( + 연속 읽기 순위를 불러오는 중이에요. + ) : ( + + )} + + + + + + + {isMobile && readingRank ? ( + + 누적 읽은 아티클 + + {readingRank.monthlyReadCount} + + + + ) : ( + 읽은 아티클 수 기준 순위 + )} + + {isReadingLoading || !readingRank ? ( + 읽기 순위를 불러오는 중이에요. + ) : ( + <> + + + )} + + + {categoryStats && ( + + )} + + + + + ); +}; + +export default ReadingActivitySection; + +const Container = styled.section` + width: 100%; + + display: flex; + gap: 24px; + flex-direction: column; +`; + +const Title = styled.h2` + margin: 0; + + color: ${({ theme }) => theme.colors.textPrimary}; + font: ${({ theme }) => theme.fonts.t7Bold}; +`; + +const StatsWrapper = styled.div<{ isMobile: boolean }>` + width: 100%; + + display: grid; + gap: 16px; + + grid-template-columns: ${({ isMobile }) => + isMobile + ? 'repeat(2, minmax(0, 1fr))' + : 'minmax(0, 1fr) minmax(0, 1fr) minmax(360px, 1.4fr)'}; +`; + +const StatCard = styled.article<{ isMobile: boolean }>` + min-width: 0; + padding: ${({ isMobile }) => (isMobile ? '12px' : '20px')}; + border: 1px solid ${({ theme }) => theme.colors.stroke}; + border-radius: 16px; + + display: flex; + gap: 16px; + flex-direction: column; + + background-color: ${({ theme }) => theme.colors.white}; + + box-sizing: border-box; +`; + +const HeaderWrapper = styled.div` + display: flex; + gap: 4px; + flex-direction: column; +`; + +const StatTitleWrapper = styled.div<{ isMobile: boolean }>` + display: flex; + gap: 8px; + flex-wrap: ${({ isMobile }) => (isMobile ? 'wrap' : 'nowrap')}; + align-items: center; +`; + +const StatIcon = styled.svg<{ isMobile: boolean }>` + width: ${({ isMobile }) => (isMobile ? '20px' : '28px')}; + height: ${({ isMobile }) => (isMobile ? '20px' : '28px')}; + + flex-shrink: 0; + + color: ${({ theme }) => theme.colors.primaryBomBom}; +`; + +const StatTitle = styled.h3<{ isMobile: boolean }>` + margin: 0; + + color: ${({ theme }) => theme.colors.textPrimary}; + font: ${({ isMobile, theme }) => + isMobile ? theme.fonts.t6Bold : theme.fonts.t8Bold}; +`; + +const Description = styled.p` + margin: 0; + + color: ${({ theme }) => theme.colors.textSecondary}; + font: ${({ theme }) => theme.fonts.t4Regular}; +`; + +const StreakSummary = styled.div<{ isMobile: boolean }>` + width: ${({ isMobile }) => (isMobile ? 'calc(100% - 28px)' : 'auto')}; + margin-left: ${({ isMobile }) => (isMobile ? '28px' : 'auto')}; + + display: flex; + gap: 4px; + flex-direction: ${({ isMobile }) => (isMobile ? 'column' : 'row')}; + align-items: ${({ isMobile }) => (isMobile ? 'flex-start' : 'baseline')}; +`; + +const StreakValue = styled.p` + margin: 0; + + color: ${({ theme }) => theme.colors.textPrimary}; + font: ${({ theme }) => theme.fonts.t8Bold}; +`; + +const StreakUnit = styled.span` + font: ${({ theme }) => theme.fonts.t5Bold}; +`; + +const MobileReadingSummary = styled.div` + display: flex; + gap: 4px; + flex-direction: column; +`; + +const MobileReadingValue = styled.p` + margin: 0; + + color: ${({ theme }) => theme.colors.textPrimary}; + font: ${({ theme }) => theme.fonts.t8Bold}; +`; + +const MobileReadingUnit = styled.span` + font: ${({ theme }) => theme.fonts.t5Bold}; +`; + +const LoadingMessage = styled.p` + margin: auto; + + color: ${({ theme }) => theme.colors.textSecondary}; + font: ${({ theme }) => theme.fonts.t4Regular}; +`; + +const MonthlyReportImage = styled.img` + width: 100%; + height: auto; + border-radius: 16px; + + display: block; +`; + +const MobileCompanionCard = styled.article` + width: 100%; + padding: 20px; + border: 1px solid ${({ theme }) => theme.colors.primaryLight}; + border-radius: 20px; + box-shadow: 0 8px 20px rgb(254 94 4 / 10%); + + display: flex; + gap: 16px; + align-items: center; + + background-color: ${({ theme }) => theme.colors.white}; + + box-sizing: border-box; +`; + +const MobileCompanionImage = styled.img` + width: 72px; + height: 72px; + border-radius: 20px; + + flex-shrink: 0; + + object-fit: cover; +`; + +const MobileCompanionTextWrapper = styled.div` + min-width: 0; + + display: flex; + gap: 4px; + flex-direction: column; +`; + +const MobileCompanionLabel = styled.p` + margin: 0; + + color: ${({ theme }) => theme.colors.textSecondary}; + font: ${({ theme }) => theme.fonts.t4Regular}; +`; + +const MobileCompanionDays = styled.strong` + color: ${({ theme }) => theme.colors.textPrimary}; + font: ${({ theme }) => theme.fonts.t10Bold}; +`; + +const MobileCompanionDescription = styled.p` + margin: 0; + + color: ${({ theme }) => theme.colors.textSecondary}; + font: ${({ theme }) => theme.fonts.t4Regular}; +`; + +const MobileCompanionArrow = styled.span` + margin-left: auto; + + flex-shrink: 0; + + color: ${({ theme }) => theme.colors.textTertiary}; + font: ${({ theme }) => theme.fonts.t11Regular}; +`; diff --git a/web/src/pages/my-page/components/ReadingActivityStats/DottedRankGraph.tsx b/web/src/pages/my-page/components/ReadingActivityStats/DottedRankGraph.tsx new file mode 100644 index 000000000..f9e56bc3b --- /dev/null +++ b/web/src/pages/my-page/components/ReadingActivityStats/DottedRankGraph.tsx @@ -0,0 +1,194 @@ +import styled from '@emotion/styled'; + +interface RankPoint { + label: string; + rank: number; +} + +interface Props { + points: RankPoint[]; + currentRank: number; + showYAxis?: boolean; + showArea?: boolean; +} + +const CHART_WIDTH = 320; +const CHART_HEIGHT = 200; +const PADDING = { + top: 56, + right: 20, + bottom: 34, + left: 34, +} as const; +const MIN_RANK = 1; +const MAX_RANK = 30; +const Y_AXIS_LABELS = [1, 10, 20, 30] as const; + +const DottedRankGraph = ({ + points, + currentRank, + showYAxis = true, + showArea = false, +}: Props) => { + const rankPoints = points.map((point, index) => + index === points.length - 1 ? { ...point, rank: currentRank } : point, + ); + const graphWidth = CHART_WIDTH - PADDING.left - PADDING.right; + const graphHeight = CHART_HEIGHT - PADDING.top - PADDING.bottom; + const pointGap = + rankPoints.length > 1 ? graphWidth / (rankPoints.length - 1) : 0; + + const getX = (index: number) => PADDING.left + pointGap * index; + const getY = (rank: number) => { + const clampedRank = Math.min(Math.max(rank, MIN_RANK), MAX_RANK); + const rankRate = (clampedRank - MIN_RANK) / (MAX_RANK - MIN_RANK); + + return PADDING.top + graphHeight * rankRate; + }; + const polylinePoints = rankPoints + .map((point, index) => `${getX(index)},${getY(point.rank)}`) + .join(' '); + const areaPoints = `${PADDING.left},${PADDING.top + graphHeight} ${polylinePoints} ${PADDING.left + graphWidth},${PADDING.top + graphHeight}`; + const lastPoint = rankPoints.at(-1); + const lastPointX = + rankPoints.length > 0 ? getX(rankPoints.length - 1) : PADDING.left; + const lastPointY = lastPoint ? getY(lastPoint.rank) : PADDING.top; + + return ( + + + {showArea && ( + + + + + + + )} + {showYAxis && ( + + )} + + {showYAxis && + Y_AXIS_LABELS.map((label) => ( + + {label} + + ))} + {rankPoints.map((point, index) => ( + + {point.label} + + ))} + {showArea && } + + {rankPoints.map((point, index) => ( + + ))} + {lastPoint && ( + + + + + {currentRank}위 + + + )} + + + ); +}; + +export default DottedRankGraph; + +const Container = styled.div` + width: 100%; +`; + +const Chart = styled.svg` + width: 100%; + height: auto; + + display: block; +`; + +const AxisLine = styled.line` + stroke: ${({ theme }) => theme.colors.stroke}; + stroke-dasharray: 3 4; + stroke-width: 1; +`; + +const AxisLabel = styled.text` + font: ${({ theme }) => theme.fonts.t2Regular}; + fill: ${({ theme }) => theme.colors.textSecondary}; +`; + +const XAxisLabel = styled.text` + font: ${({ theme }) => theme.fonts.t2Regular}; + fill: ${({ theme }) => theme.colors.textPrimary}; +`; + +const RankLine = styled.polyline` + fill: none; + stroke: ${({ theme }) => theme.colors.primaryBomBom}; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 3; +`; + +const RankArea = styled.polygon` + fill: url('#rank-area-gradient'); +`; + +const RankDot = styled.circle` + fill: ${({ theme }) => theme.colors.white}; + stroke: ${({ theme }) => theme.colors.primaryBomBom}; + stroke-width: 3; +`; + +const RankBadge = styled.g` + rect, + path { + fill: ${({ theme }) => theme.colors.primaryBomBom}; + } + + text { + font: ${({ theme }) => theme.fonts.t5Bold}; + fill: ${({ theme }) => theme.colors.white}; + } +`; diff --git a/web/src/pages/my-page/components/ReadingActivityStats/SubscribedCategoryStats.tsx b/web/src/pages/my-page/components/ReadingActivityStats/SubscribedCategoryStats.tsx new file mode 100644 index 000000000..c94ebc425 --- /dev/null +++ b/web/src/pages/my-page/components/ReadingActivityStats/SubscribedCategoryStats.tsx @@ -0,0 +1,175 @@ +import styled from '@emotion/styled'; +import type { CategoryStatsResponse } from '@/apis/members/members.api'; + +interface Props { + stats: CategoryStatsResponse; + isMobile: boolean; +} + +const CATEGORY_COLORS = ['#FE5E04', '#2878F0', '#7C2CF4', '#36A65C', '#FFA400']; + +const SubscribedCategoryStats = ({ stats, isMobile }: Props) => { + let accumulatedRate = 0; + const categories = [...stats.categories] + .sort((first, second) => second.count - first.count) + .map((category, index) => { + const start = accumulatedRate; + accumulatedRate += category.percent; + + return { + ...category, + color: CATEGORY_COLORS[index % CATEGORY_COLORS.length] ?? '#FE5E04', + segment: `${CATEGORY_COLORS[index % CATEGORY_COLORS.length] ?? '#FE5E04'} ${start}% ${accumulatedRate}%`, + }; + }); + + return ( + + 구독 중인 뉴스레터 카테고리 + + segment).join(', ')} + /> + + {categories.map((category) => ( + + + + {category.name} + + + {category.count}개 + {category.percent}% + + + ))} + + + + ); +}; + +export default SubscribedCategoryStats; + +const Container = styled.article<{ isMobile: boolean }>` + min-width: 0; + padding: 20px; + border: 1px solid ${({ theme }) => theme.colors.stroke}; + border-radius: 16px; + + display: flex; + gap: 24px; + flex-direction: column; + + background-color: ${({ theme }) => theme.colors.white}; + + box-sizing: border-box; + + grid-column: ${({ isMobile }) => (isMobile ? '1 / -1' : 'auto')}; +`; + +const Title = styled.h3` + margin: 0; + + color: ${({ theme }) => theme.colors.textPrimary}; + font: ${({ theme }) => theme.fonts.t7Bold}; +`; + +const ContentWrapper = styled.div<{ isMobile: boolean }>` + display: flex; + gap: 16px; + align-items: ${({ isMobile }) => (isMobile ? 'flex-start' : 'center')}; +`; + +const DonutChart = styled.div<{ segments: string }>` + position: relative; + width: 120px; + height: 120px; + border-radius: 50%; + + flex-shrink: 0; + + background: conic-gradient(${({ segments }) => segments}); + + &::after { + position: absolute; + width: 64px; + height: 64px; + border-radius: 50%; + + background-color: ${({ theme }) => theme.colors.white}; + + content: ''; + inset: 28px; + } +`; + +const LegendList = styled.ul<{ isMobile: boolean }>` + width: 100%; + + display: ${({ isMobile }) => (isMobile ? 'grid' : 'flex')}; + gap: 12px; + flex-direction: ${({ isMobile }) => (isMobile ? 'initial' : 'column')}; + + grid-template-columns: ${({ isMobile }) => + isMobile ? 'repeat(2, minmax(0, 1fr))' : 'none'}; +`; + +const LegendItem = styled.li<{ isMobile: boolean }>` + min-width: 0; + + display: flex; + gap: 12px; + flex-direction: ${({ isMobile }) => (isMobile ? 'column' : 'row')}; + align-items: ${({ isMobile }) => (isMobile ? 'flex-start' : 'center')}; + justify-content: space-between; +`; + +const CategoryWrapper = styled.div` + min-width: 0; + + display: flex; + gap: 8px; + align-items: center; +`; + +const ColorDot = styled.span<{ color: string }>` + width: 8px; + height: 8px; + border-radius: 50%; + + flex-shrink: 0; + + background-color: ${({ color }) => color}; +`; + +const CategoryName = styled.span` + color: ${({ theme }) => theme.colors.textPrimary}; + font: ${({ theme }) => theme.fonts.t4Regular}; + white-space: nowrap; +`; + +const CategoryValue = styled.span<{ isMobile: boolean }>` + width: ${({ isMobile }) => (isMobile ? '100%' : 'auto')}; + + display: flex; + gap: 8px; + flex-shrink: 0; + align-items: baseline; + justify-content: ${({ isMobile }) => + isMobile ? 'space-between' : 'flex-start'}; + + font: ${({ theme }) => theme.fonts.t4Regular}; +`; + +const CategoryCount = styled.span` + color: ${({ theme }) => theme.colors.textSecondary}; +`; + +const CategoryPercent = styled.strong` + min-width: 36px; + + color: ${({ theme }) => theme.colors.primaryBomBom}; + font: ${({ theme }) => theme.fonts.t5Bold}; + text-align: right; +`; diff --git a/web/src/pages/my-page/components/SubscribedNewslettersSection/MaeilMailEditModal.tsx b/web/src/pages/my-page/components/SubscribedNewslettersSection/MaeilMailEditModal.tsx new file mode 100644 index 000000000..aa6f346c4 --- /dev/null +++ b/web/src/pages/my-page/components/SubscribedNewslettersSection/MaeilMailEditModal.tsx @@ -0,0 +1,107 @@ +import { Text } from '@bombom/shared/ui-web'; +import styled from '@emotion/styled'; +import { useState } from 'react'; +import Button from '@/components/Button/Button'; +import Checkbox from '@/components/Checkbox/Checkbox'; +import type { Track } from '../../types/subscribeNewsletters'; + +const TRACKS: { value: Track; label: string }[] = [ + { value: 'FE', label: '프론트엔드' }, + { value: 'BE', label: '백엔드' }, +]; + +interface MaeilMailEditModalProps { + initialTracks: Track[]; + isPending: boolean; + onSave: (tracks: Track[]) => void; + onClose: () => void; +} + +const MaeilMailEditModal = ({ + initialTracks, + isPending, + onSave, + onClose, +}: MaeilMailEditModalProps) => { + const [selectedTracks, setSelectedTracks] = useState(initialTracks); + + const handleToggleTrack = (track: Track) => { + setSelectedTracks((prev) => + prev.includes(track) ? prev.filter((t) => t !== track) : [...prev, track], + ); + }; + + return ( + + 구독 분야 수정 + + {TRACKS.map(({ value, label }) => ( + handleToggleTrack(value)} + > + {label} + + ))} + + + * 최소 하나 이상의 분야를 선택해주세요. +
+ 수정 시, 다음 발행일부터 반영됩니다. +
+ + onSave(selectedTracks)} + disabled={selectedTracks.length === 0 || isPending} + > + 수정 + + + 취소 + + +
+ ); +}; + +export default MaeilMailEditModal; + +const Container = styled.div` + width: 100%; + + display: flex; + gap: 20px; + flex-direction: column; + align-items: center; + + text-align: center; +`; + +const Title = styled.h2` + color: ${({ theme }) => theme.colors.textPrimary}; + font: ${({ theme }) => theme.fonts.t10Bold}; +`; + +const TrackGrid = styled.div` + display: flex; + gap: 16px; +`; + +const ButtonWrapper = styled.div` + display: flex; + gap: 12px; + justify-content: center; +`; + +const ModalButton = styled(Button)` + height: 48px; + min-width: 100px; + border-radius: 8px; + + font: ${({ theme }) => theme.fonts.t5Regular}; + + word-break: keep-all; +`; diff --git a/web/src/pages/my-page/components/SubscribedNewslettersSection/MaeilMailSubscriptionCard.tsx b/web/src/pages/my-page/components/SubscribedNewslettersSection/MaeilMailSubscriptionCard.tsx new file mode 100644 index 000000000..293e48241 --- /dev/null +++ b/web/src/pages/my-page/components/SubscribedNewslettersSection/MaeilMailSubscriptionCard.tsx @@ -0,0 +1,192 @@ +import styled from '@emotion/styled'; +import { useQuery } from '@tanstack/react-query'; +import MaeilMailEditModal from './MaeilMailEditModal'; +import NewsletterUnsubscribeModal from './NewsletterUnsubscribeModal'; +import { useUnsubscribeMaeilMailSubscriptionMutations } from '../../hooks/useUnsubscribeMaeilMailSubscriptionMutations'; +import { useUpdateMaeilMailSubscriptionMutations } from '../../hooks/useUpdateMaeilMailSubscriptionMutations'; +import { queries } from '@/apis/queries'; +import Button from '@/components/Button/Button'; +import ImageWithFallback from '@/components/ImageWithFallback/ImageWithFallback'; +import Modal from '@/components/Modal/Modal'; +import useModal from '@/components/Modal/useModal'; +import type { Track } from '../../types/subscribeNewsletters'; +import type { SubscribedNewsletterResponse } from '@/apis/members/members.api'; + +interface MaeilMailSubscriptionCardProps { + newsletter: SubscribedNewsletterResponse; +} + +const MaeilMailSubscriptionCard = ({ + newsletter, +}: MaeilMailSubscriptionCardProps) => { + const { data: subscription } = useQuery( + queries.nativeMaeilMailSubscription(), + ); + const { mutate: updateSubscription, isPending: isUpdatePending } = + useUpdateMaeilMailSubscriptionMutations(); + const { mutate: removeSubscription, isPending: isRemovePending } = + useUnsubscribeMaeilMailSubscriptionMutations(); + + const { + modalRef: editModalRef, + openModal: openEditModal, + closeModal: closeEditModal, + isOpen: isEditOpen, + } = useModal(); + + const { + modalRef: cancelModalRef, + openModal: openCancelModal, + closeModal: closeCancelModal, + isOpen: isCancelOpen, + } = useModal(); + + const updateSubscriptionTracks = (tracks: Track[]) => { + updateSubscription({ tracks }); + closeEditModal(); + }; + + return ( + <> + + + + + {newsletter.name} + + {newsletter.description} + + + + + + 수정 + + + 구독 취소 + + + + + + + + + + + + + ); +}; + +export default MaeilMailSubscriptionCard; + +const Container = styled.div` + padding: 16px; + border: 1px solid ${({ theme }) => theme.colors.stroke}; + border-radius: 12px; + + display: flex; + gap: 12px; + flex-direction: column; + + background: ${({ theme }) => theme.colors.white}; + + transition: all 0.2s ease-in-out; +`; + +const NewsletterContent = styled.div` + height: 72px; + + display: flex; + gap: 12px; + justify-content: center; +`; + +const NewsletterImage = styled(ImageWithFallback)` + width: 60px; + height: 60px; + border-radius: 8px; + + flex-shrink: 0; + + object-fit: cover; +`; + +const NewsletterInfo = styled.div` + overflow: hidden; + + display: flex; + gap: 4px; + flex: 1; + flex-direction: column; +`; + +const NewsletterName = styled.h3` + overflow: hidden; + + color: ${({ theme }) => theme.colors.textPrimary}; + font: ${({ theme }) => theme.fonts.t6Regular}; + font-weight: 600; + white-space: nowrap; + + text-overflow: ellipsis; +`; + +const NewsletterDescription = styled.p` + overflow: hidden; + + display: -webkit-box; + + color: ${({ theme }) => theme.colors.textSecondary}; + font: ${({ theme }) => theme.fonts.t5Regular}; + + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +`; + +const ActionWrapper = styled.div` + display: flex; + gap: 8px; + justify-content: flex-end; +`; + +const ActionButton = styled(Button)` + padding: 6px 10px; + border-radius: 8px; + + font: ${({ theme }) => theme.fonts.t3Regular}; + + &:disabled { + color: ${({ theme }) => theme.colors.textSecondary}; + opacity: 1; + } +`; diff --git a/web/src/pages/my-page/MySubscriptionCard.tsx b/web/src/pages/my-page/components/SubscribedNewslettersSection/MySubscriptionCard.tsx similarity index 100% rename from web/src/pages/my-page/MySubscriptionCard.tsx rename to web/src/pages/my-page/components/SubscribedNewslettersSection/MySubscriptionCard.tsx diff --git a/web/src/pages/my-page/components/NewsletterUnsubscribeModal.tsx b/web/src/pages/my-page/components/SubscribedNewslettersSection/NewsletterUnsubscribeModal.tsx similarity index 97% rename from web/src/pages/my-page/components/NewsletterUnsubscribeModal.tsx rename to web/src/pages/my-page/components/SubscribedNewslettersSection/NewsletterUnsubscribeModal.tsx index 58c9678ff..c855938b4 100644 --- a/web/src/pages/my-page/components/NewsletterUnsubscribeModal.tsx +++ b/web/src/pages/my-page/components/SubscribedNewslettersSection/NewsletterUnsubscribeModal.tsx @@ -62,7 +62,6 @@ export default NewsletterUnsubscribeModal; const Container = styled.div<{ isMobile: boolean }>` width: 100%; - min-width: ${({ isMobile }) => (isMobile ? '280px' : '440px')}; display: flex; gap: ${({ isMobile }) => (isMobile ? '12px' : '20px')}; diff --git a/web/src/pages/my-page/components/SubscribedNewslettersSection.tsx b/web/src/pages/my-page/components/SubscribedNewslettersSection/SubscribedNewslettersSection.tsx similarity index 74% rename from web/src/pages/my-page/components/SubscribedNewslettersSection.tsx rename to web/src/pages/my-page/components/SubscribedNewslettersSection/SubscribedNewslettersSection.tsx index 1626c0931..bd6a10482 100644 --- a/web/src/pages/my-page/components/SubscribedNewslettersSection.tsx +++ b/web/src/pages/my-page/components/SubscribedNewslettersSection/SubscribedNewslettersSection.tsx @@ -1,14 +1,17 @@ import styled from '@emotion/styled'; import { useState } from 'react'; import { createPortal } from 'react-dom'; +import MaeilMailSubscriptionCard from './MaeilMailSubscriptionCard'; +import MySubscriptionCard from './MySubscriptionCard'; import NewsletterUnsubscribeModal from './NewsletterUnsubscribeModal'; -import { useUnsubscribe } from '../hooks/useUnsubscribe'; +import { useUnsubscribe } from '../../hooks/useUnsubscribe'; import Modal from '@/components/Modal/Modal'; import useModal from '@/components/Modal/useModal'; -import MySubscriptionCard from '@/pages/my-page/MySubscriptionCard'; import type { GetMySubscriptionsResponse } from '@/apis/members/members.api'; import type { Device } from '@/hooks/useDevice'; +const NATIVE_NEWSLETTER_SOURCE = 'MAEIL_MAIL' as const; + interface SubscribedNewslettersSectionProps { newsletters: GetMySubscriptionsResponse; device: Device; @@ -49,16 +52,23 @@ const SubscribedNewslettersSection = ({ return ( <> - {newsletters && newsletters.length > 0 ? ( + {newsletters.length > 0 ? ( - {newsletters.map((newsletter) => ( - - ))} + {newsletters.map((newsletter) => + newsletter.newsletterSource === NATIVE_NEWSLETTER_SOURCE ? ( + + ) : ( + + ), + )} ) : ( 구독 중인 뉴스레터가 없습니다. diff --git a/web/src/pages/my-page/hooks/useUnsubscribeMaeilMailSubscriptionMutations.ts b/web/src/pages/my-page/hooks/useUnsubscribeMaeilMailSubscriptionMutations.ts new file mode 100644 index 000000000..bd62213bf --- /dev/null +++ b/web/src/pages/my-page/hooks/useUnsubscribeMaeilMailSubscriptionMutations.ts @@ -0,0 +1,23 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { queries } from '@/apis/queries'; +import { deleteNativeMaeilMailSubscription } from '@/apis/subscriptions/subscriptions.api'; +import { toast } from '@/components/Toast/utils/toastActions'; + +export const useUnsubscribeMaeilMailSubscriptionMutations = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: deleteNativeMaeilMailSubscription, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queries.nativeMaeilMailSubscription().queryKey, + }); + queryClient.invalidateQueries({ + queryKey: queries.mySubscriptions().queryKey, + }); + + toast.success('구독이 취소되었습니다.'); + }, + onError: () => toast.error('구독 취소에 실패했습니다. 다시 시도해주세요.'), + }); +}; diff --git a/web/src/pages/my-page/hooks/useUnsubscribeNewsletterMutation.ts b/web/src/pages/my-page/hooks/useUnsubscribeNewsletterMutation.ts index db8b44d13..aa509616a 100644 --- a/web/src/pages/my-page/hooks/useUnsubscribeNewsletterMutation.ts +++ b/web/src/pages/my-page/hooks/useUnsubscribeNewsletterMutation.ts @@ -14,6 +14,8 @@ export const useUnsubscribeNewsletterMutation = () => { queryClient.invalidateQueries({ queryKey: queries.mySubscriptions().queryKey, }); + + toast.success('구독이 취소되었습니다.'); }, onError: () => { toast.error('구독 취소에 실패했습니다. 다시 시도해주세요.'); diff --git a/web/src/pages/my-page/hooks/useUpdateMaeilMailSubscriptionMutations.ts b/web/src/pages/my-page/hooks/useUpdateMaeilMailSubscriptionMutations.ts new file mode 100644 index 000000000..1cf5a1b84 --- /dev/null +++ b/web/src/pages/my-page/hooks/useUpdateMaeilMailSubscriptionMutations.ts @@ -0,0 +1,21 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { queries } from '@/apis/queries'; +import { putNativeMaeilMailSubscription } from '@/apis/subscriptions/subscriptions.api'; +import { toast } from '@/components/Toast/utils/toastActions'; +import type { PutNativeMaeilMailSubscriptionParams } from '@/apis/subscriptions/subscriptions.api'; + +export const useUpdateMaeilMailSubscriptionMutations = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (params: PutNativeMaeilMailSubscriptionParams) => + putNativeMaeilMailSubscription(params), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queries.nativeMaeilMailSubscription().queryKey, + }); + toast.success('구독 정보가 수정되었습니다.'); + }, + onError: () => toast.error('수정에 실패했습니다. 다시 시도해주세요.'), + }); +}; diff --git a/web/src/pages/my-page/types/subscribeNewsletters.ts b/web/src/pages/my-page/types/subscribeNewsletters.ts new file mode 100644 index 000000000..5e0e5b0d0 --- /dev/null +++ b/web/src/pages/my-page/types/subscribeNewsletters.ts @@ -0,0 +1,3 @@ +import type { NativeMaeilMailSubscriptionTrack } from '@/apis/subscriptions/subscriptions.api'; + +export type Track = NativeMaeilMailSubscriptionTrack; diff --git a/web/src/pages/recommend/components/LandingIntroBanner/LandingIntroBanner.tsx b/web/src/pages/recommend/components/LandingIntroBanner/LandingIntroBanner.tsx new file mode 100644 index 000000000..e13462cf5 --- /dev/null +++ b/web/src/pages/recommend/components/LandingIntroBanner/LandingIntroBanner.tsx @@ -0,0 +1,128 @@ +import styled from '@emotion/styled'; +import { Link } from '@tanstack/react-router'; +import { useDevice } from '@/hooks/useDevice'; +import type { Device } from '@/hooks/useDevice'; +import landingCharacter from '#/assets/avif/landing-banner-cat.avif'; + +const LandingIntroBanner = () => { + const device = useDevice(); + + return ( + + + + + + <Highlight>봄봄</Highlight>이 처음이라면 + + 봄봄 서비스를 소개합니다. + + 서비스 소개 보기 + + + + + ); +}; + +export default LandingIntroBanner; + +const Container = styled(Link)` + overflow: hidden; + position: relative; + width: 100%; + padding: 0 clamp(40px, 5vw, 56px); + border-radius: 24px; + + display: flex; + align-items: center; + justify-content: center; + + background: + radial-gradient( + ellipse at top left, + rgb(254 94 4 / 9%) 0%, + rgb(254 94 4 / 4%) 44%, + transparent 68% + ), + linear-gradient(135deg, #fff6ef 0%, #fffaf2 48%, #fff1e8 100%); + + cursor: pointer; +`; + +const Content = styled.div` + width: 100%; + max-width: min(420px, 90%); + + display: grid; + gap: 12px; + align-items: center; + + grid-template-columns: 1fr auto; +`; + +const TextWrapper = styled.div` + width: 100%; + + display: flex; + gap: 12px; + flex-direction: column; +`; + +const TextGroup = styled.div` + display: flex; + gap: 8px; + flex-direction: column; +`; + +const Title = styled.p<{ device: Device }>` + width: calc(100% + clamp(80px, 20vw, 175px) + 12px); + + color: ${({ theme }) => theme.colors.textPrimary}; + font: ${({ device, theme }) => { + if (device === 'mobile') { + return theme.fonts.t8Bold; + } + if (device === 'tablet') { + return theme.fonts.t9Bold; + } + return theme.fonts.t12Bold; + }}; +`; + +const Description = styled.p<{ device: Device }>` + color: ${({ theme }) => theme.colors.textSecondary}; + font: ${({ device, theme }) => { + if (device === 'mobile') { + return theme.fonts.t5Regular; + } + if (device === 'tablet') { + return theme.fonts.t6Regular; + } + return theme.fonts.t7Regular; + }}; + + word-break: keep-all; +`; + +const Highlight = styled.span` + color: ${({ theme }) => theme.colors.primaryBomBom}; +`; + +const ActionText = styled.span<{ device: Device }>` + padding: ${({ device }) => (device === 'mobile' ? '4px 10px' : '8px 12px')}; + border-radius: 24px; + + align-self: flex-start; + + background: ${({ theme }) => theme.colors.primaryBomBom}; + color: ${({ theme }) => theme.colors.white}; + font: ${({ theme }) => theme.fonts.t4Bold}; + letter-spacing: -0.01em; + white-space: nowrap; +`; + +const CharacterImage = styled.img` + width: clamp(84px, 20vw, 175px); + height: auto; +`; diff --git a/web/src/pages/recommend/components/SlideCardList/SlideCardList.tsx b/web/src/pages/recommend/components/SlideCardList/SlideCardList.tsx index 272fd0ff3..26950d925 100644 --- a/web/src/pages/recommend/components/SlideCardList/SlideCardList.tsx +++ b/web/src/pages/recommend/components/SlideCardList/SlideCardList.tsx @@ -1,4 +1,5 @@ import styled from '@emotion/styled'; +import LandingIntroBanner from '../LandingIntroBanner/LandingIntroBanner'; import BlogOpenBanner from '../PromotionBanner/BlogOpenBanner'; import MaeilMailPromotionBanner from '../PromotionBanner/MaeilMailPromotionBanner'; import { Carousel } from '@/components/Carousel/Carousel'; @@ -23,6 +24,11 @@ const SlideCardList = () => { + + + + + diff --git a/web/src/routes/_bombom.tsx b/web/src/routes/_bombom.tsx index bae851ec4..f561bd320 100644 --- a/web/src/routes/_bombom.tsx +++ b/web/src/routes/_bombom.tsx @@ -3,7 +3,6 @@ import { queries } from '@/apis/queries'; import AppInstallPromptModal from '@/components/AppInstallPromptModal/AppInstallPromptModal'; import BomBomPageLayout from '@/components/PageLayout/BomBomPageLayout'; import { useWebViewRegisterToken } from '@/libs/webview/useWebViewRegisterToken'; -import { LANDING_VISITED_KEY } from '@/pages/landing/constants/localStorage'; let isFirstVisit = true; @@ -13,11 +12,6 @@ export const Route = createFileRoute('/_bombom')({ context, location, }): Promise> => { - const hasVisitedLanding = localStorage.getItem(LANDING_VISITED_KEY); - if (!hasVisitedLanding) { - return redirect({ to: '/landing' }); - } - if (!isFirstVisit) return; const { queryClient } = context; diff --git a/web/src/routes/_bombom/_main/my.tsx b/web/src/routes/_bombom/_main/my.tsx index 46f98f92d..c04c6899e 100644 --- a/web/src/routes/_bombom/_main/my.tsx +++ b/web/src/routes/_bombom/_main/my.tsx @@ -13,17 +13,24 @@ import Tabs from '@/components/Tabs/Tabs'; import { useDevice } from '@/hooks/useDevice'; import NotificationSettingsSection from '@/pages/my-page/components/NotificationSettingsSection/NotificationSettingsSection'; import ProfileSection from '@/pages/my-page/components/ProfileSection'; +import ReadingActivitySection from '@/pages/my-page/components/ReadingActivitySection'; import RewardsSection from '@/pages/my-page/components/RewardsSection'; -import SubscribedNewslettersSection from '@/pages/my-page/components/SubscribedNewslettersSection'; +import SubscribedNewslettersSection from '@/pages/my-page/components/SubscribedNewslettersSection/SubscribedNewslettersSection'; import { isWebView } from '@/utils/device'; import type { Device } from '@/hooks/useDevice'; import type { CSSObject, Theme } from '@emotion/react'; import AvatarIcon from '#/assets/svg/avatar.svg'; -type MyPageTab = 'profile' | 'newsletters' | 'notification' | 'rewards'; +type MyPageTab = + | 'profile' + | 'reading-activity' + | 'newsletters' + | 'notification' + | 'rewards'; const DEFAULT_TABS = [ { id: 'profile', label: '내 정보' }, + { id: 'reading-activity', label: '읽기 활동' }, { id: 'newsletters', label: '구독 뉴스레터' }, { id: 'rewards', label: '선물함' }, ] as const; @@ -82,6 +89,8 @@ function MyPage() { switch (activeTabParam) { case 'profile': return ; + case 'reading-activity': + return ; case 'newsletters': return ( ))} + {device !== 'mobile' && ( + + )} ` ${({ device, theme }) => tabsWrapperStyles[device](theme)} `; +const CompanionImage = styled.img` + width: 100%; + height: auto; + margin-top: auto; + border-radius: 16px; + + display: block; +`; + const tabsWrapperStyles: Record CSSObject> = { pc: (theme) => ({ flexShrink: 0, + alignSelf: 'stretch', border: `1px solid ${theme.colors.stroke}`, borderRadius: '12px', padding: '16px', }), tablet: (theme) => ({ flexShrink: 0, + alignSelf: 'stretch', border: `1px solid ${theme.colors.stroke}`, borderRadius: '12px', padding: '16px', diff --git a/web/src/routes/_bombom/_main/today.tsx b/web/src/routes/_bombom/_main/today.tsx index c137fdbef..3dca555a1 100644 --- a/web/src/routes/_bombom/_main/today.tsx +++ b/web/src/routes/_bombom/_main/today.tsx @@ -1,4 +1,3 @@ -import { getPet } from '@bombom/shared/apis/pet'; import { theme } from '@bombom/shared/theme'; import styled from '@emotion/styled'; import { useQuery } from '@tanstack/react-query'; @@ -51,10 +50,7 @@ function Index() { queries.articles({ date: todayDateStr }), ); - const { data: pet, isLoading: isPetLoading } = useQuery({ - queryKey: ['pet'], - queryFn: getPet, - }); + const { data: pet, isLoading: isPetLoading } = useQuery(queries.pet()); const { mutate: deleteArticles } = useDeleteArticlesMutation('today'); diff --git a/web/src/routes/landing.tsx b/web/src/routes/landing.tsx index 8d97c1f16..1ced635ea 100644 --- a/web/src/routes/landing.tsx +++ b/web/src/routes/landing.tsx @@ -23,6 +23,7 @@ export const Route = createFileRoute('/landing')({ title: '봄봄 | 읽고 남기고 쌓는 뉴스레터 리딩 플랫폼', }, ], + links: [{ rel: 'canonical', href: 'https://www.bombom.news' }], }), component: LandingPage, }); diff --git a/web/src/types/openapi.d.ts b/web/src/types/openapi.d.ts index 98c7be164..9b09780e1 100644 --- a/web/src/types/openapi.d.ts +++ b/web/src/types/openapi.d.ts @@ -4,6 +4,34 @@ */ export interface paths { + '/api/v1/subscriptions/native/maeil-mail': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * 봄봄 자체 뉴스레터 구독 여부 조회 + * @description 매일메일 구독 여부와 구독 중인 트랙 목록을 반환합니다. + */ + get: operations['getSubscription']; + /** + * 봄봄 자체 뉴스레터 구독 생성/수정 + * @description 요청한 트랙 목록으로 구독 상태를 치환합니다. 미구독 상태에서 트랙을 보내면 신규 구독, 구독 중에 다른 트랙을 보내면 수정합니다. + */ + put: operations['putSubscription']; + post: operations['postSubscription']; + /** + * 봄봄 자체 뉴스레터 구독 해지 + * @description 매일메일 구독을 해지하고 구독 트랙을 삭제합니다. + */ + delete: operations['deleteSubscription']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/v1/challenges/{challengeId}/reviews/{reviewId}': { parameters: { query?: never; @@ -64,30 +92,6 @@ export interface paths { patch?: never; trace?: never; }; - '/api/v1/subscriptions/native/maeil-mail': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * 봄봄 자체 뉴스레터 구독 여부 조회 - * @description 매일메일 구독 여부와 구독 중인 트랙 목록을 반환합니다. - */ - get: operations['getSubscription']; - put?: never; - /** - * 봄봄 자체 뉴스레터 구독 - * @description 봄봄 자체 뉴스레터(매일메일)를 구독합니다. - */ - post: operations['subscribe']; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; '/api/v1/members/me/warning/near-capacity': { parameters: { query?: never; @@ -1499,6 +1503,9 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + MaeilMailUpdateSubscriptionRequest: { + tracks: ('BE' | 'FE')[]; + }; UpdateChallengeReviewRequest: { comment: string; isPrivate?: boolean; @@ -1507,9 +1514,6 @@ export interface components { /** Format: int32 */ likeCount?: number; }; - MaeilMailSubscribeRequest: { - tracks: ('BE' | 'FE')[]; - }; /** @description 경고 설정 변경 요청 */ UpdateWarningSettingRequest: { isVisible?: boolean; @@ -1697,8 +1701,8 @@ export interface components { }; SortObject: { empty?: boolean; - unsorted?: boolean; sorted?: boolean; + unsorted?: boolean; }; CategoryResponse: { /** Format: int64 */ @@ -1752,12 +1756,13 @@ export interface components { name: string; imageUrl?: string; description: string; - category: string; unsubscribeUrl?: string; /** @enum {string} */ status: 'SUBSCRIBED' | 'UNSUBSCRIBING' | 'UNSUBSCRIBE_FAILED'; /** @enum {string} */ newsletterPublicationStatus: 'ACTIVE' | 'SUSPENDED' | 'DISCONTINUED'; + /** @enum {string} */ + newsletterSource: 'EXTERNAL' | 'MAEIL_MAIL'; }; ReadingInformationResponse: { /** Format: int32 */ @@ -2581,6 +2586,118 @@ export interface components { } export type $defs = Record; export interface operations { + getSubscription: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 조회 성공 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['MaeilMailSubscriptionResponse']; + }; + }; + /** @description 인증 실패 */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['MaeilMailSubscriptionResponse']; + }; + }; + }; + }; + putSubscription: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['MaeilMailUpdateSubscriptionRequest']; + }; + }; + responses: { + /** @description 처리 성공 */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description 잘못된 요청 (빈 트랙, 중복 트랙) */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description 인증 실패 */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + postSubscription: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['MaeilMailUpdateSubscriptionRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + deleteSubscription: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description 해지 성공 */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description 인증 실패 */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; updateReview: { parameters: { query?: never; @@ -2753,71 +2870,6 @@ export interface operations { }; }; }; - getSubscription: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description 조회 성공 */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - '*/*': components['schemas']['MaeilMailSubscriptionResponse']; - }; - }; - /** @description 인증 실패 */ - 401: { - headers: { - [name: string]: unknown; - }; - content: { - '*/*': components['schemas']['MaeilMailSubscriptionResponse']; - }; - }; - }; - }; - subscribe: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['MaeilMailSubscribeRequest']; - }; - }; - responses: { - /** @description 구독 성공 */ - 200: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description 잘못된 요청 (외부 뉴스레터, 중복 구독) */ - 400: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - /** @description 인증 실패 */ - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; getWarningSetting: { parameters: { query?: never;