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