Skip to content
114 changes: 88 additions & 26 deletions apps/nowait-admin/src/pages/AdminBooth/components/MenuSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,28 @@ import MenuRemoveModal from "./Modal/MenuRemoveModal";
import { useDeleteMenu } from "../../../hooks/booth/menu/useDeleteMenu";
import { useToggleMenuSoldOut } from "../../../hooks/booth/menu/useToggleMenuSoldOut";
import { useUpdateMenuSort } from "../../../hooks/booth/menu/useUpadateMenuSort";
import { useVerticalLockStyle } from "../../../utils/useVerticalLockStyle";
import { SwipeableRow } from "./Swipe/SwipeableRow";

function lockVertical(
style?: React.CSSProperties
): React.CSSProperties | undefined {
if (!style || !style.transform) return style;
const t = String(style.transform);
const m2d = t.match(/translate\((-?\d+\.?\d*)px,\s*(-?\d+\.?\d*)px\)/);
if (m2d) {
const [, , y] = m2d;
return { ...style, transform: `translate(0px, ${y}px)` };
}
const m3d = t.match(
/translate3d\((-?\d+\.?\d*)px,\s*(-?\d+\.?\d*)px,\s*(-?\d+\.?\d*)px\)/
);
if (m3d) {
const [, , y, z] = m3d;
return { ...style, transform: `translate3d(0px, ${y}px, ${z}px)` };
}
return style;
}

// 세 자리마다 , 붙여서 가격표시
const formatNumber = (num: number) => {
if (!num) return "";
Expand Down Expand Up @@ -132,6 +151,7 @@ const MenuSection = ({ isTablet }: { isTablet: boolean }) => {
adminDisplayName: string;
description: string;
price: string;
image?: File | string;
}) => {
const payload = {
menuId: updated.id,
Expand All @@ -153,6 +173,25 @@ const MenuSection = ({ isTablet }: { isTablet: boolean }) => {
console.log("메뉴 수정에 실패했습니다.");
},
});

if (updated.image && updated.image instanceof File) {
uploadMenuImage(
{ menuId: updated.id, image: updated.image },
{
onSuccess: (imgData) => {
const url = imgData.url;
setMenus((prev) =>
prev.map((m) =>
m.id === updated.id ? { ...m, imageUrl: url } : m
)
);
},
onError: () => {
console.log("이미지 업로드 실패");
},
}
);
}
};

const handleDeleteMenu = () => {
Expand Down Expand Up @@ -201,12 +240,12 @@ const MenuSection = ({ isTablet }: { isTablet: boolean }) => {
const reordered = Array.from(menus);
const [removed] = reordered.splice(result.source.index, 1);
reordered.splice(result.destination.index, 0, removed);
const next = reordered.map((m, i) => ({ ...m, sortOrder: i })); // 서버가 1-base면 i+1
const next = reordered.map((m, i) => ({ ...m, sortOrder: i }));
setMenus(next);

const body = next.map(({ id, sortOrder }) => ({
menuId: id,
sortOrder, // 1-base면 sortOrder: sortOrder + 1
sortOrder,
}));
updateMenuSort(body, {
onSuccess: (res) => {
Expand Down Expand Up @@ -287,17 +326,22 @@ const MenuSection = ({ isTablet }: { isTablet: boolean }) => {
isDragDisabled={!editMode}
>
{(provided) => {
const lockedStyle = useVerticalLockStyle(
const lockedStyle = lockVertical(
provided.draggableProps.style
);

const rowContent = (
return editMode ? (
// ✅ 편집 모드: SwipeableRow 사용 안 함 (충돌 차단)
<div
className="flex justify-between items-center py-4"
ref={provided.innerRef}
{...provided.draggableProps}
style={lockedStyle}
className="flex justify-between items-center py-4 w-full touch-pan-y select-none"
>
{/* rowContent 대신, 핸들 아이콘에만 dragHandleProps를 붙여줘야 해 */}
<div
className="flex items-center w-full gap-4"
// 편집 모드에선 클릭으로 모달 열리지 않게 막고 싶다면 아래 조건 유지
onClick={() => !editMode && openEditModal(menu)}
>
<div className="w-[70px] h-[70px] bg-black-5 rounded-md flex items-center justify-center overflow-hidden">
Expand All @@ -317,37 +361,55 @@ const MenuSection = ({ isTablet }: { isTablet: boolean }) => {
</div>
</div>

<div className="text-black-60">
{editMode ? (
<img
src={editOrderIcon}
alt="순서 변경"
className="w-5 h-5 cursor-grab"
{...provided.dragHandleProps}
/>
) : (
<ToggleSwitch
isOn={menu.soldOut}
toggle={() => toggleSoldOut(idx)}
/>
)}
</div>
{/* ✨ 여기만 드래그 핸들! */}
<img
src={editOrderIcon}
alt="순서 변경"
className="w-5 h-5 cursor-grab select-none"
{...provided.dragHandleProps}
/>
</div>
);
return (
) : (
// ✅ 보기 모드: SwipeableRow로 스와이프 삭제
<SwipeableRow
ref={provided.innerRef}
disabled={editMode}
disabled={false}
onDeleteClick={() => {
setSelectedMenu(menu);
setIsRemoveModalOpen(true);
}}
contentProps={{
...provided.draggableProps,
...provided.draggableProps, // isDragDisabled=true라 드래그는 안됨
style: lockedStyle,
className:
"flex justify-between items-center py-4 w-full",
}}
>
{rowContent}
<div
className="flex items-center w-full gap-4"
onClick={() => openEditModal(menu)}
>
<div className="w-[70px] h-[70px] bg-black-5 rounded-md flex items-center justify-center overflow-hidden">
<img
src={menu.imageUrl}
className="w-full h-full object-cover"
alt="placeholder"
/>
</div>
<div className="flex flex-col">
<span className="text-16-semibold">
{menu.name}
</span>
<span className="text-16-regular text-black-60">
{formatNumber(menu.price)}원
</span>
</div>
</div>

<ToggleSwitch
isOn={menu.soldOut}
toggle={() => toggleSoldOut(idx)}
/>
</SwipeableRow>
);
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,19 @@ const PriceInput: React.FC<PriceInputProps> = ({ price, setPrice }) => {
setPrice(rawValue);
};

const displayValue = isFocused
? price
: price
? formatNumber(parseInt(price))
: "";

return (
<div className="mb-[30px]">
<label className="block text-title-16-bold mb-3">가격</label>
<div className="relative w-full">
<input
type="text"
value={price ? formatNumber(parseInt(price)) : ""}
value={displayValue}
onChange={handleChange}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
Expand Down
2 changes: 1 addition & 1 deletion apps/nowait-user/src/assets/icon/arrow-right.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions apps/nowait-user/src/assets/icon/check.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 11 additions & 0 deletions apps/nowait-user/src/assets/icon/signup.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions apps/nowait-user/src/assets/icon/x-circle.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
43 changes: 43 additions & 0 deletions apps/nowait-user/src/pages/login/components/AnimatedModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { motion, AnimatePresence } from "framer-motion";
import { type ReactNode } from "react";

interface AnimatedModalProps {
isOpen: boolean;
onClose: () => void;
children: ReactNode;
}

const AnimatedModal = ({ isOpen, onClose, children }: AnimatedModalProps) => {
return (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
className="fixed inset-0 bg-black/60 z-50 flex items-end"
onClick={onClose}
>
<motion.div
initial={{ y: "100%" }}
animate={{ y: 0 }}
exit={{ y: "100%" }}
transition={{
type: "spring",
damping: 25,
stiffness: 200,
duration: 0.4,
}}
className="w-full bg-white rounded-t-3xl"
onClick={(e) => e.stopPropagation()}
>
{children}
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
};

export default AnimatedModal;
44 changes: 44 additions & 0 deletions apps/nowait-user/src/pages/login/components/Checkbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { useState } from "react";
import Check from "../../../assets/icon/check.svg?react";

interface CheckboxProps {
checked?: boolean;
onChange?: (checked: boolean) => void;
className?: string;
}

const Checkbox = ({
checked: controlledChecked,
onChange,
className = "",
}: CheckboxProps) => {
const [internalChecked, setInternalChecked] = useState(false);

// controlled 또는 uncontrolled 모드 지원
const isControlled = controlledChecked !== undefined;
const isChecked = isControlled ? controlledChecked : internalChecked;

const handleClick = () => {
const newChecked = !isChecked;

if (isControlled) {
onChange?.(newChecked);
} else {
setInternalChecked(newChecked);
onChange?.(newChecked);
}
};

return (
<div
className={`flex w-[22px] h-[22px] rounded-[5.5px] items-center justify-center cursor-pointer ${
isChecked ? "bg-black-100" : "border border-black-25"
} ${className}`}
onClick={handleClick}
>
{isChecked && <Check className="text-white" />}
</div>
);
};

export default Checkbox;
81 changes: 81 additions & 0 deletions apps/nowait-user/src/pages/login/components/PhoneNumberInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { useState } from "react";
import XCircle from "../../../assets/icon/x-circle.svg?react";

interface PhoneNumberInputProps {
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
className?: string;
}

const PhoneNumberInput = ({
value: controlledValue,
onChange,
placeholder = "전화번호 입력",
className = "text-title-20-semibold text-black-90 leading-[144%] tracking-[-0.01em] placeholder:text-black-50 outline-none focus:outline-none",
}: PhoneNumberInputProps) => {
const [internalValue, setInternalValue] = useState("");

// controlled 또는 uncontrolled 모드 지원
const isControlled = controlledValue !== undefined;
const currentValue = isControlled ? controlledValue : internalValue;

const formatPhoneNumber = (value: string) => {
// 숫자만 추출
const numbers = value.replace(/[^\d]/g, "");

// 길이에 따라 포맷팅
if (numbers.length <= 3) {
return numbers;
} else if (numbers.length <= 7) {
return `${numbers.slice(0, 3)}-${numbers.slice(3)}`;
} else {
return `${numbers.slice(0, 3)}-${numbers.slice(3, 7)}-${numbers.slice(
7,
11
)}`;
}
};

const handlePhoneNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const formatted = formatPhoneNumber(e.target.value);

if (isControlled) {
onChange?.(formatted);
} else {
setInternalValue(formatted);
onChange?.(formatted);
}
};

const handleClearPhoneNumber = () => {
if (isControlled) {
onChange?.("");
} else {
setInternalValue("");
onChange?.("");
}
};

return (
<div className="flex items-center justify-between px-3.5 py-5 rounded-[12px] border-black-25 bg-black-15">
<input
type="tel"
inputMode="numeric"
pattern="[0-9]*"
value={currentValue}
onChange={handlePhoneNumberChange}
placeholder={placeholder}
className={className}
/>
<div
className="flex items-center justify-center w-6 h-6 cursor-pointer"
onClick={handleClearPhoneNumber}
>
<XCircle className="icons-m" />
</div>
</div>
);
};

export default PhoneNumberInput;
Loading