Merge branch 'claude/confident-bell-efa28b' into hub
@e3760f387295ec521629cdc0b0f037c620b29ac4
--- app/(protected)/(basic)/system/merchants/_actions.ts
+++ app/(protected)/(basic)/system/merchants/_actions.ts
... | ... | @@ -3,6 +3,7 @@ |
| 3 | 3 |
import { unstable_rethrow } from 'next/navigation';
|
| 4 | 4 |
import { revalidatePath } from 'next/cache';
|
| 5 | 5 |
import { verifySession } from '@/lib/auth/dal';
|
| 6 |
+import { BackendRequestError } from '@/lib/http/backend-fetch';
|
|
| 6 | 7 |
import { createMerchant } from '@/lib/data/repositories/merchant-repository';
|
| 7 | 8 |
import {
|
| 8 | 9 |
validateMerchant, |
... | ... | @@ -11,6 +12,10 @@ |
| 11 | 12 |
import { MERCHANTS_PATH } from '@/lib/domain/merchant-query';
|
| 12 | 13 |
|
| 13 | 14 |
const SAVE_FAILED_MESSAGE = '저장하지 못했습니다. 잠시 후 다시 시도해 주세요.'; |
| 15 |
+ |
|
| 16 |
+function failureMessage(error: unknown, fallback: string): string {
|
|
| 17 |
+ return error instanceof BackendRequestError ? error.message : fallback; |
|
| 18 |
+} |
|
| 14 | 19 |
|
| 15 | 20 |
function readString(formData: FormData, key: string): string {
|
| 16 | 21 |
const value = formData.get(key); |
... | ... | @@ -49,7 +54,7 @@ |
| 49 | 54 |
} catch (error) {
|
| 50 | 55 |
// 이슈: 세션이 밀렸을 때 backendFetch가 던지는 redirect를 이 catch가 삼키면 안 된다. |
| 51 | 56 |
unstable_rethrow(error); |
| 52 |
- return { status: 'error', message: SAVE_FAILED_MESSAGE };
|
|
| 57 |
+ return { status: 'error', message: failureMessage(error, SAVE_FAILED_MESSAGE) };
|
|
| 53 | 58 |
} |
| 54 | 59 |
|
| 55 | 60 |
revalidatePath(MERCHANTS_PATH); |
--- app/(protected)/(basic)/system/merchants/_components/merchant-form-modal.tsx
+++ app/(protected)/(basic)/system/merchants/_components/merchant-form-modal.tsx
... | ... | @@ -64,6 +64,13 @@ |
| 64 | 64 |
message: isEdit ? '가맹점을 수정했습니다.' : '가맹점을 등록했습니다.', |
| 65 | 65 |
}); |
| 66 | 66 |
onClose(); |
| 67 |
+ return; |
|
| 68 |
+ } |
|
| 69 |
+ |
|
| 70 |
+ // 칸별 오류는 해당 입력칸에 그대로 두고, 폼 전체에 걸린 사유만 토스트로 알린다 — |
|
| 71 |
+ // 긴 폼이라 맨 아래 문구는 스크롤 밖에 있으면 보이지 않는다. |
|
| 72 |
+ if (state.status === 'error' && state.message) {
|
|
| 73 |
+ showToast({ variant: 'danger', message: state.message });
|
|
| 67 | 74 |
} |
| 68 | 75 |
}, [state, showToast, onClose, isEdit]); |
| 69 | 76 |
|
... | ... | @@ -325,10 +332,6 @@ |
| 325 | 332 |
/> |
| 326 | 333 |
</div> |
| 327 | 334 |
</div> |
| 328 |
- )} |
|
| 329 |
- |
|
| 330 |
- {state.status === 'error' && state.message && (
|
|
| 331 |
- <FoxHelperText type="danger" message={state.message} />
|
|
| 332 | 335 |
)} |
| 333 | 336 |
</form> |
| 334 | 337 |
</FoxModal> |
+++ app/_components/feedback-host.module.scss
... | ... | @@ -0,0 +1,58 @@ |
| 1 | +// 전역 피드백(토스트·스피너)이 화면에서 차지하는 자리. | |
| 2 | +// | |
| 3 | +// FoxToast·FoxSpinner는 모양만 갖고 어디에 뜰지는 호출부가 정한다(@fox 규칙) — 그 배치를 | |
| 4 | +// 여기서 준다. | |
| 5 | +// | |
| 6 | +// 이슈: FoxModal은 `<dialog>.showModal()`이라 브라우저 top layer에 올라간다. 보통 요소는 | |
| 7 | +// z-index를 아무리 올려도 그 위에 그릴 수 없어, 모달을 띄운 채 저장에 실패하면 토스트가 | |
| 8 | +// 모달 뒤에 가려진다. 그래서 토스트도 popover로 같은 top layer에 얹는다 — popover의 기본 | |
| 9 | +// 스타일(가운데 정렬·테두리·배경)을 아래에서 전부 지우는 이유다. | |
| 10 | + | |
| 11 | +@use "@fox/styles/abstracts" as fox; | |
| 12 | + | |
| 13 | +.toastLayer { | |
| 14 | + position: fixed; | |
| 15 | + inset-block-start: fox.padding(7); | |
| 16 | + inset-block-end: auto; | |
| 17 | + inset-inline: 0; | |
| 18 | + inline-size: auto; | |
| 19 | + max-inline-size: none; | |
| 20 | + block-size: auto; | |
| 21 | + max-block-size: none; | |
| 22 | + margin: 0; | |
| 23 | + padding-inline: fox.padding(6); | |
| 24 | + border: 0; | |
| 25 | + background: none; | |
| 26 | + color: inherit; | |
| 27 | + overflow: visible; | |
| 28 | + // 읽고 지나가는 자리라 아래 화면 조작을 막지 않는다. | |
| 29 | + pointer-events: none; | |
| 30 | +} | |
| 31 | + | |
| 32 | +.toastLayer:popover-open { | |
| 33 | + display: flex; | |
| 34 | + flex-direction: column; | |
| 35 | + align-items: center; | |
| 36 | + gap: fox.gap(3); | |
| 37 | +} | |
| 38 | + | |
| 39 | +// popover가 없는 브라우저 — top layer는 못 쓰지만 자리는 잡는다. | |
| 40 | +@supports not selector(:popover-open) { | |
| 41 | + .toastLayer { | |
| 42 | + display: flex; | |
| 43 | + flex-direction: column; | |
| 44 | + align-items: center; | |
| 45 | + gap: fox.gap(3); | |
| 46 | + z-index: 50; | |
| 47 | + } | |
| 48 | +} | |
| 49 | + | |
| 50 | +.spinnerOverlay { | |
| 51 | + position: fixed; | |
| 52 | + inset: 0; | |
| 53 | + z-index: 50; | |
| 54 | + display: flex; | |
| 55 | + align-items: center; | |
| 56 | + justify-content: center; | |
| 57 | + background: fox.color(background-dim); | |
| 58 | +} |
--- app/_components/feedback-host.tsx
+++ app/_components/feedback-host.tsx
... | ... | @@ -1,29 +1,52 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 |
+import { useEffect, useRef } from 'react';
|
|
| 3 | 4 |
import { FoxButton } from '@fox/core/components/fox-button';
|
| 4 | 5 |
import { FoxModal } from '@fox/core/components/fox-modal';
|
| 6 |
+import { FoxToast } from '@fox/core/components/fox-toast';
|
|
| 5 | 7 |
import { Spinner } from '@/components/ui/spinner';
|
| 6 |
-import { Toast } from '@/components/ui/toast';
|
|
| 7 | 8 |
import { useFeedback } from '@/app/_hooks/use-feedback';
|
| 9 |
+import styles from './feedback-host.module.scss'; |
|
| 8 | 10 |
|
| 9 | 11 |
/** |
| 10 |
- * FeedbackProvider의 상태를 design 레인의 Toast·Alert·Spinner 비주얼에 연결만 하는 역할 — |
|
| 12 |
+ * FeedbackProvider의 상태를 @fox 비주얼(Toast·Modal·Spinner)에 연결만 하는 역할 — |
|
| 11 | 13 |
* 위치·스택·표시 타이머 등 정책은 FeedbackProvider가 가지고, 이 컴포넌트는 마운트만 담당한다. |
| 14 |
+ * |
|
| 15 |
+ * 계열(success/danger/…)은 색으로 드러나지 않는다 — @fox 시안의 토스트가 하나뿐이라 |
|
| 16 |
+ * (`_fox-toast.scss`) 모양이 갈리지 않는다. 대신 `danger`만 `urgent`로 올려 보조기술이 |
|
| 17 |
+ * 읽던 것을 끊고 바로 읽게 한다. |
|
| 12 | 18 |
*/ |
| 13 | 19 |
export function FeedbackHost() {
|
| 14 | 20 |
const { toasts, alert, spinnerCount, hideAlert } = useFeedback();
|
| 21 |
+ const toastLayerRef = useRef<HTMLDivElement>(null); |
|
| 22 |
+ const hasToasts = toasts.length > 0; |
|
| 23 |
+ |
|
| 24 |
+ // 모달 위에서도 보이도록 top layer에 얹는다(feedback-host.module.scss의 이슈 주석 참고). |
|
| 25 |
+ useEffect(() => {
|
|
| 26 |
+ const layer = toastLayerRef.current; |
|
| 27 |
+ if (!layer || typeof layer.showPopover !== 'function') {
|
|
| 28 |
+ return; |
|
| 29 |
+ } |
|
| 30 |
+ |
|
| 31 |
+ const isOpen = layer.matches(':popover-open');
|
|
| 32 |
+ if (hasToasts && !isOpen) {
|
|
| 33 |
+ layer.showPopover(); |
|
| 34 |
+ } else if (!hasToasts && isOpen) {
|
|
| 35 |
+ layer.hidePopover(); |
|
| 36 |
+ } |
|
| 37 |
+ }, [hasToasts]); |
|
| 15 | 38 |
|
| 16 | 39 |
return ( |
| 17 | 40 |
<> |
| 18 |
- {toasts.length > 0 && (
|
|
| 19 |
- <div className="fixed inset-x-0 top-4 z-50 flex flex-col items-center gap-2 px-4"> |
|
| 20 |
- {toasts.map((toast) => (
|
|
| 21 |
- <Toast key={toast.id} variant={toast.variant}>
|
|
| 22 |
- {toast.message}
|
|
| 23 |
- </Toast> |
|
| 24 |
- ))} |
|
| 25 |
- </div> |
|
| 26 |
- )} |
|
| 41 |
+ <div ref={toastLayerRef} popover="manual" className={styles.toastLayer}>
|
|
| 42 |
+ {toasts.map((toast) => (
|
|
| 43 |
+ <FoxToast |
|
| 44 |
+ key={toast.id}
|
|
| 45 |
+ message={toast.message}
|
|
| 46 |
+ urgent={toast.variant === 'danger'}
|
|
| 47 |
+ /> |
|
| 48 |
+ ))} |
|
| 49 |
+ </div> |
|
| 27 | 50 |
|
| 28 | 51 |
{alert && (
|
| 29 | 52 |
<FoxModal |
... | ... | @@ -47,7 +70,7 @@ |
| 47 | 70 |
)} |
| 48 | 71 |
|
| 49 | 72 |
{spinnerCount > 0 && (
|
| 50 |
- <div className="fixed inset-0 z-50 flex items-center justify-center bg-scrim"> |
|
| 73 |
+ <div className={styles.spinnerOverlay}>
|
|
| 51 | 74 |
<Spinner size="lg" tone="inverse" /> |
| 52 | 75 |
</div> |
| 53 | 76 |
)} |
--- components/ui/toast.tsx
... | ... | @@ -1,30 +0,0 @@ |
| 1 | -import type { ReactNode } from "react"; | |
| 2 | - | |
| 3 | -type ToastVariant = "info" | "success" | "danger" | "warning"; | |
| 4 | - | |
| 5 | -const ACCENT_CLASSES: Record<ToastVariant, string> = { | |
| 6 | - info: "border-l-primary", | |
| 7 | - success: "border-l-success", | |
| 8 | - danger: "border-l-danger", | |
| 9 | - warning: "border-l-warning", | |
| 10 | -}; | |
| 11 | - | |
| 12 | -interface ToastProps { | |
| 13 | - variant?: ToastVariant; | |
| 14 | - children: ReactNode; | |
| 15 | -} | |
| 16 | - | |
| 17 | -/** | |
| 18 | - * 알림 비주얼(토스트 1개). 위치·스택·표시 타이머는 호출부(feature) 책임 — | |
| 19 | - * 이 컴포넌트는 상태를 가지지 않는다. | |
| 20 | - */ | |
| 21 | -export function Toast({ variant = "info", children }: ToastProps) { | |
| 22 | - return ( | |
| 23 | - <div | |
| 24 | - role="status" | |
| 25 | - className={`animate-toast-in rounded-lg border-l-4 bg-surface px-4 py-3 text-body-md text-foreground shadow-toast ${ACCENT_CLASSES[variant]}`} | |
| 26 | - > | |
| 27 | - {children} | |
| 28 | - </div> | |
| 29 | - ); | |
| 30 | -} |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?