diff --git a/apps/web/src/api/analyze/api.ts b/apps/web/src/api/analyze/api.ts index 8529fa3..4960583 100644 --- a/apps/web/src/api/analyze/api.ts +++ b/apps/web/src/api/analyze/api.ts @@ -3,7 +3,11 @@ import { type RiskDetectAnalyzeResponse, type RiskSaveBody } from './types'; const isDev = process.env.NODE_ENV === 'development'; -export const analyzeRisk = async (formData: FormData, signal?: AbortSignal): Promise => { +export const analyzeRisk = async ( + formData: FormData, + userId: number, + signal?: AbortSignal +): Promise => { if (isDev) { console.group('[진단] analyzeRisk — POST /api/v1/risk-detector/analyze'); for (const [key, value] of formData.entries()) { @@ -18,7 +22,7 @@ export const analyzeRisk = async (formData: FormData, signal?: AbortSignal): Pro const res = await client .post('api/v1/risk-detector/analyze', { body: formData, - headers: { 'content-type': undefined }, + headers: { 'content-type': undefined, 'x-user-id': String(userId) }, timeout: 180000, signal, }) diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index cf4a97b..e8a703a 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -56,6 +56,10 @@ const createClient = () => hooks: { beforeRetry: [ async ({ request, error }) => { + const status = (error as { status?: number }).status; + if (status !== 401) { + throw error; + } if (isPreAuth(request.url) || isRefresh(request.url)) { throw error; } diff --git a/apps/web/src/app/(main)/_components/LoadingScreen/LoadingScreen.css.ts b/apps/web/src/app/(main)/_components/LoadingScreen/LoadingScreen.css.ts new file mode 100644 index 0000000..d4d6202 --- /dev/null +++ b/apps/web/src/app/(main)/_components/LoadingScreen/LoadingScreen.css.ts @@ -0,0 +1,256 @@ +import { vars } from '@muneo/design-system'; +import { keyframes, style } from '@vanilla-extract/css'; + +const float = keyframes({ + '0%, 100%': { transform: 'translateY(0px)' }, + '50%': { transform: 'translateY(-12px)' }, +}); + +const shadowPulse = keyframes({ + '0%, 100%': { transform: 'scaleX(1)', opacity: 0.18 }, + '50%': { transform: 'scaleX(0.72)', opacity: 0.08 }, +}); + +const workingBounce = keyframes({ + '0%, 80%, 100%': { transform: 'translateY(0)', opacity: 0.35 }, + '40%': { transform: 'translateY(-6px)', opacity: 1 }, +}); + +const msgIn = keyframes({ + from: { opacity: 0, transform: 'translateY(8px)' }, + to: { opacity: 1, transform: 'translateY(0)' }, +}); + +const msgOut = keyframes({ + from: { opacity: 1, transform: 'translateY(0)' }, + to: { opacity: 0, transform: 'translateY(-6px)' }, +}); + +const shimmer = keyframes({ + from: { transform: 'translateX(-100%)' }, + to: { transform: 'translateX(400%)' }, +}); + +const cardIn = keyframes({ + from: { opacity: 0, transform: 'translateX(24px)' }, + to: { opacity: 1, transform: 'translateX(0)' }, +}); + +const cardOut = keyframes({ + from: { opacity: 1, transform: 'translateX(0)' }, + to: { opacity: 0, transform: 'translateX(-24px)' }, +}); + +const MOTION = '(prefers-reduced-motion: reduce)' as const; + +export const container = style({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + minHeight: '60vh', + padding: '120px 24px 40px', +}); + +export const mascotWrap = style({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + marginBottom: '8px', +}); + +export const mascotFloating = style({ + animation: `${float} 2.6s ease-in-out infinite`, + '@media': { [MOTION]: { animation: 'none' } }, +}); + +export const mascotImg = style({ + width: '92px', + height: 'auto', + display: 'block', +}); + +export const mascotShadow = style({ + width: '56px', + height: '8px', + borderRadius: '50%', + background: 'rgba(132, 85, 223, 0.22)', + filter: 'blur(4px)', + marginTop: '2px', + animation: `${shadowPulse} 2.6s ease-in-out infinite`, + '@media': { [MOTION]: { animation: 'none' } }, +}); + +export const workingDots = style({ + display: 'flex', + gap: '6px', + justifyContent: 'center', + marginTop: '12px', + marginBottom: '20px', +}); + +export const workingDot = style({ + width: '7px', + height: '7px', + borderRadius: '50%', + backgroundColor: vars.color.brand.primary, + opacity: 0.35, + animation: `${workingBounce} 1.4s ease-in-out infinite`, + '@media': { [MOTION]: { animation: 'none', opacity: 1 } }, +}); + +export const msgWrap = style({ + height: '22px', + display: 'flex', + alignItems: 'center', + marginBottom: '14px', +}); + +export const msg = style({ + fontSize: vars.typography.fontSize.sm, + color: vars.color.neutral.n500, + textAlign: 'center', + fontWeight: vars.typography.fontWeight.medium, + animation: `${msgIn} 0.35s ease-out both`, + '@media': { [MOTION]: { animation: 'none' } }, +}); + +export const msgExiting = style({ + animation: `${msgOut} 0.25s ease-in both`, + '@media': { [MOTION]: { animation: 'none' } }, +}); + +export const progressWrap = style({ + width: '100%', + maxWidth: '320px', + height: '5px', + borderRadius: '10px', + backgroundColor: vars.color.neutral.n200, + overflow: 'hidden', + marginBottom: '32px', + position: 'relative', +}); + +export const progressFill = style({ + height: '100%', + borderRadius: '10px', + background: 'linear-gradient(90deg, #7B5CE5 0%, #B199FF 100%)', + width: '0%', + willChange: 'width', + position: 'relative', + overflow: 'hidden', + selectors: { + '&::after': { + content: '""', + position: 'absolute', + top: 0, + left: 0, + width: '25%', + height: '100%', + background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.5), transparent)', + animation: `${shimmer} 2.2s ease-in-out infinite`, + }, + }, + '@media': { + [MOTION]: { + selectors: { + '&::after': { animation: 'none' }, + }, + }, + }, +}); + +export const tipCard = style({ + width: '100%', + maxWidth: '480px', + boxSizing: 'border-box', + backgroundColor: '#F5F0FF', + border: '1px solid #E3D8FF', + borderRadius: '16px', + padding: '20px 24px 16px', + display: 'flex', + flexDirection: 'column', + gap: '10px', + marginBottom: '20px', + animation: `${cardIn} 0.4s cubic-bezier(0.22, 1, 0.36, 1) both`, + '@media': { [MOTION]: { animation: 'none' } }, +}); + +export const tipCardExiting = style({ + animation: `${cardOut} 0.28s ease-in both`, + '@media': { [MOTION]: { animation: 'none' } }, +}); + +export const tipHeader = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', +}); + +export const tipLabel = style({ + display: 'flex', + alignItems: 'center', + gap: '5px', + fontSize: vars.typography.fontSize.xs, + fontWeight: vars.typography.fontWeight.semiBold, + color: '#7B5CE5', + letterSpacing: '0.02em', +}); + +export const tipCounter = style({ + fontSize: vars.typography.fontSize.xs, + color: '#B199FF', + fontWeight: vars.typography.fontWeight.medium, + fontVariantNumeric: 'tabular-nums', +}); + +export const tipText = style({ + fontSize: vars.typography.fontSize.base, + color: vars.color.neutral.n700, + lineHeight: '1.75', + fontWeight: vars.typography.fontWeight.regular, + wordBreak: 'keep-all', +}); + +export const tipProgressTrack = style({ + width: '100%', + height: '3px', + borderRadius: '10px', + backgroundColor: '#E3D8FF', + overflow: 'hidden', + marginTop: '4px', +}); + +export const tipProgressFill = style({ + height: '100%', + borderRadius: '10px', + background: 'linear-gradient(90deg, #8455DF, #B199FF)', + transition: 'width 0.6s cubic-bezier(0.4, 0, 0.2, 1)', +}); + +export const footer = style({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '10px', +}); + +export const footerText = style({ + fontSize: vars.typography.fontSize.xs, + color: vars.color.neutral.n400, +}); + +export const cancelBtn = style({ + background: 'none', + border: `1px solid ${vars.color.neutral.n300}`, + borderRadius: vars.radius.sm, + padding: '8px 20px', + fontSize: vars.typography.fontSize.sm, + color: vars.color.neutral.n500, + cursor: 'pointer', + fontFamily: vars.typography.fontFamily, + transition: 'opacity 0.15s ease', + selectors: { + '&:hover': { opacity: 0.65 }, + }, +}); diff --git a/apps/web/src/app/(main)/_components/LoadingScreen/LoadingScreen.tsx b/apps/web/src/app/(main)/_components/LoadingScreen/LoadingScreen.tsx new file mode 100644 index 0000000..37d9c32 --- /dev/null +++ b/apps/web/src/app/(main)/_components/LoadingScreen/LoadingScreen.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { LoadingFooter } from './_components/LoadingFooter'; +import { Mascot } from './_components/Mascot'; +import { ProgressBar } from './_components/ProgressBar'; +import { RotatingMessage } from './_components/RotatingMessage'; +import { TipCard } from './_components/TipCard'; +import { WorkingDots } from './_components/WorkingDots'; +import { type ProgressStep } from './_hooks/useProgressSteps'; +import * as styles from './LoadingScreen.css'; + +export type { ProgressStep }; + +interface Props { + messages: string[]; + tips: string[]; + warningMessage: string; + warningDelayMs: number; + progressSteps: ProgressStep[]; + footerText: string; + onCancel: () => void; +} + +export const LoadingScreen = ({ + messages, + tips, + warningMessage, + warningDelayMs, + progressSteps, + footerText, + onCancel, +}: Props) => ( +
+ + + + + + +
+); diff --git a/apps/web/src/app/(main)/_components/LoadingScreen/_components/LoadingFooter.tsx b/apps/web/src/app/(main)/_components/LoadingScreen/_components/LoadingFooter.tsx new file mode 100644 index 0000000..7052bf1 --- /dev/null +++ b/apps/web/src/app/(main)/_components/LoadingScreen/_components/LoadingFooter.tsx @@ -0,0 +1,15 @@ +import * as styles from '../LoadingScreen.css'; + +interface Props { + footerText: string; + onCancel: () => void; +} + +export const LoadingFooter = ({ footerText, onCancel }: Props) => ( +
+ {footerText} + +
+); diff --git a/apps/web/src/app/(main)/_components/LoadingScreen/_components/Mascot.tsx b/apps/web/src/app/(main)/_components/LoadingScreen/_components/Mascot.tsx new file mode 100644 index 0000000..0f47b70 --- /dev/null +++ b/apps/web/src/app/(main)/_components/LoadingScreen/_components/Mascot.tsx @@ -0,0 +1,27 @@ +import * as styles from '../LoadingScreen.css'; + +export const Mascot = () => ( +