File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
File name
Commit message
Commit date
'use client';
import { useActionState, useEffect, useRef, useState } from 'react';
import { FoxButton } from '@fox/core/components/fox-button';
import { FoxFormLabel } from '@fox/core/components/fox-form-label';
import { FoxHelperText } from '@fox/core/components/fox-helper-text';
import { FoxInput } from '@fox/core/components/fox-input';
import { FoxModal } from '@fox/core/components/fox-modal';
import { FoxRadio } from '@fox/core/components/fox-radio';
import { FoxRadioGroup } from '@fox/core/components/fox-radio-group';
import { FoxSelect } from '@fox/core/components/fox-select';
import { submitFormAction } from '@/app/_hooks/submit-form-action';
import { useFeedback } from '@/app/_hooks/use-feedback';
import {
MERCHANT_CATEGORY_OPTIONS,
formatOptionalValue,
type Merchant,
} from '@/lib/domain/merchant';
import {
ADDRESS_DETAIL_MAX_LENGTH,
BRAND_MAX_LENGTH,
INITIAL_MERCHANT_FORM_STATE,
MEMO_MAX_LENGTH,
NAME_MAX_LENGTH,
OWNER_MAX_LENGTH,
} from '@/lib/domain/merchant-form';
import { saveMerchantAction } from '../_actions';
import styles from './merchant-form.module.scss';
interface MerchantFormModalProps {
merchant?: Merchant;
onClose: () => void;
}
export function MerchantFormModal({ merchant, onClose }: MerchantFormModalProps) {
const isEdit = Boolean(merchant);
const { showToast } = useFeedback();
const formRef = useRef<HTMLFormElement>(null);
const [state, formAction, isPending] = useActionState(
saveMerchantAction,
INITIAL_MERCHANT_FORM_STATE
);
const [categoryCode, setCategoryCode] = useState(merchant?.categoryCode ?? '');
const [isVisible, setIsVisible] = useState(merchant?.isVisible ?? true);
// 주소·좌표는 검색 결과가 채운다(기획 ③ Read Only).
const [address, setAddress] = useState(merchant?.address ?? '');
const [postalCode, setPostalCode] = useState(merchant?.postalCode ?? '');
const [latitude, setLatitude] = useState(merchant?.latitude ?? '');
const [longitude, setLongitude] = useState(merchant?.longitude ?? '');
useEffect(() => {
if (state.status === 'success') {
showToast({
variant: 'success',
message: isEdit ? '가맹점을 수정했습니다.' : '가맹점을 등록했습니다.',
});
onClose();
}
}, [state, showToast, onClose, isEdit]);
const errors = state.status === 'error' ? (state.errors ?? {}) : {};
// TODO(기획): 주소 검색 서비스가 정해지지 않았다. 연동 전까지 직접 입력으로 받는다.
function searchAddress() {
const picked = window.prompt('주소를 입력하세요.', address);
if (picked === null) {
return;
}
setAddress(picked.trim());
setPostalCode('');
setLatitude('');
setLongitude('');
}
return (
<FoxModal
open
size="md"
title={isEdit ? '가맹점 수정' : '가맹점 등록'}
onClose={onClose}
actions={
<>
<FoxButton type="default" size="md" label="취소" onAction={onClose} />
<FoxButton
type="primary"
size="md"
label={isPending ? '저장 중...' : isEdit ? '수정' : '등록'}
disabled={isPending}
onAction={() => formRef.current?.requestSubmit()}
/>
</>
}
>
<form
ref={formRef}
onSubmit={(event) => submitFormAction(event, formAction)}
className={styles.fields}
>
<input type="hidden" name="id" value={merchant?.id ?? ''} />
<input type="hidden" name="categoryCode" value={categoryCode} />
<input type="hidden" name="isVisible" value={isVisible ? 'Y' : 'N'} />
<input type="hidden" name="address" value={address} />
<input type="hidden" name="postalCode" value={postalCode} />
<input type="hidden" name="latitude" value={latitude} />
<input type="hidden" name="longitude" value={longitude} />
{/* 등록 시에는 코드가 아직 없다 — 저장할 때 서버가 만든다. */}
{isEdit && (
<div className={styles.field}>
<FoxInput
size="md"
label="가맹점코드"
value={merchant?.code ?? ''}
readOnly
state="view"
/>
</div>
)}
{/* 브랜드명·메모·등록일은 백엔드에 컬럼이 없어 저장되지 않는다. 기획 시안에 있는
항목이라 화면에서는 지우지 않고, 값이 없으면 `-`로 보여준다(사용자 확정). */}
<div className={styles.field}>
<FoxInput
size="md"
name="brandName"
label="브랜드명"
defaultValue={merchant?.brandName ?? ''}
placeholder="브랜드명을 입력하세요."
maxLength={BRAND_MAX_LENGTH}
/>
</div>
<div className={styles.field}>
<FoxInput
size="md"
name="name"
label="가맹점명"
requirement="required"
defaultValue={merchant?.name ?? ''}
placeholder="가맹점명을 입력하세요."
maxLength={NAME_MAX_LENGTH}
invalid={Boolean(errors.name)}
message={errors.name}
/>
</div>
<div className={styles.field}>
<FoxSelect
size="md"
label="업종"
requirement="required"
options={MERCHANT_CATEGORY_OPTIONS.map((option) => ({
value: option.value,
label: option.label,
}))}
value={categoryCode}
onValueChange={setCategoryCode}
placeholder="선택"
error={Boolean(errors.categoryCode)}
hint={errors.categoryCode}
/>
</div>
<div className={styles.field}>
<div className={styles.pair}>
<FoxInput
size="md"
name="businessNumber"
label="사업자등록번호"
defaultValue={merchant?.businessNumber ?? ''}
placeholder="000-00-00000"
invalid={Boolean(errors.businessNumber)}
message={errors.businessNumber}
/>
<FoxInput
size="md"
name="ownerName"
label="대표자명"
defaultValue={merchant?.ownerName ?? ''}
placeholder="대표자명을 입력하세요."
maxLength={OWNER_MAX_LENGTH}
/>
</div>
</div>
<div className={styles.field}>
<FoxInput
size="md"
name="phoneNumber"
label="전화번호"
requirement="required"
defaultValue={merchant?.phoneNumber ?? ''}
placeholder="가게 전화번호를 입력하세요."
invalid={Boolean(errors.phoneNumber)}
message={errors.phoneNumber}
/>
</div>
<div className={styles.field}>
<FoxFormLabel as="span" requirement="required">
주소
</FoxFormLabel>
<div className={styles.addressRow}>
<FoxInput
size="md"
aria-label="주소"
value={address}
readOnly
state="view"
placeholder="주소 검색을 진행해주세요."
/>
<FoxButton
type="secondary"
size="md"
label="주소 검색"
onAction={searchAddress}
/>
</div>
<FoxInput
size="md"
name="addressDetail"
aria-label="상세주소"
defaultValue={merchant?.addressDetail ?? ''}
placeholder="상세주소를 입력하세요."
maxLength={ADDRESS_DETAIL_MAX_LENGTH}
/>
{errors.address && (
<FoxHelperText type="danger" message={errors.address} />
)}
</div>
<div className={styles.field}>
<FoxFormLabel as="span">위도 / 경도</FoxFormLabel>
<div className={styles.pair}>
<FoxInput
size="md"
aria-label="위도"
value={latitude}
readOnly
state="view"
placeholder="주소 검색 시 자동 입력"
/>
<FoxInput
size="md"
aria-label="경도"
value={longitude}
readOnly
state="view"
placeholder="주소 검색 시 자동 입력"
/>
</div>
</div>
<div className={styles.field}>
<FoxFormLabel as="span" requirement="required">
사용여부
</FoxFormLabel>
<FoxRadioGroup name="visibility" size="md" label="사용여부">
<FoxRadio
value="Y"
label="노출"
checked={isVisible}
onChange={() => setIsVisible(true)}
/>
<FoxRadio
value="N"
label="미노출"
checked={!isVisible}
onChange={() => setIsVisible(false)}
/>
</FoxRadioGroup>
</div>
<div className={styles.field}>
<FoxInput
size="md"
name="memo"
label="메모"
defaultValue={merchant?.memo ?? ''}
placeholder="관리자 메모 (사용자에게 노출되지 않음)"
maxLength={MEMO_MAX_LENGTH}
/>
</div>
{isEdit && (
<div className={styles.field}>
<FoxFormLabel as="span">등록일 / 최종수정일</FoxFormLabel>
<div className={styles.pair}>
<FoxInput
size="md"
aria-label="등록일"
value={formatOptionalValue(merchant?.createdAt)}
readOnly
state="view"
/>
<FoxInput
size="md"
aria-label="최종수정일"
value={formatOptionalValue(merchant?.updatedAt)}
readOnly
state="view"
/>
</div>
</div>
)}
{state.status === 'error' && state.message && (
<FoxHelperText type="danger" message={state.message} />
)}
</form>
</FoxModal>
);
}