Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/assets/icon/camera.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
273 changes: 271 additions & 2 deletions src/pages/ShopEditPage.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,272 @@
export default function ShopEditPage() {
return <div>ShopEditPage</div>;
import { ChangeEvent, useEffect, useRef, useState } from "react";

import { useNavigate } from "react-router-dom";

import { SeoulDistrict, SeoulDistricts, ShopCategory } from "../types";

import {
getPublicURL,
postImage,
putImage,
} from "@/apis/services/imageService";
import { getShop, putShop } from "@/apis/services/shopService";
import { Camera, Close } from "@/assets/icon";
import Button from "@/components/Button";
import Select from "@/components/Select";
import TextField from "@/components/TextField";
import { useUserStore } from "@/hooks/useUserStore";
import { extractDigits, numberCommaFormatter } from "@/utils/number";

const CATEGORY_OPTIONS = [
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. ShopRegisterPage에도 동일한 상수가 있던데, constants 폴더로 분리해서 재사용 하면 어떨까요? 🤔
  2. labelvalue 값이 같다면, 아래처럼 작성해도 좋을 것 같아요!
const CATEGORY_OPTIONS = ["한식", "중식", "일식", ... ].map((category) => ({ label: category, value: category }));

{ label: " ... ", value: " ... " }를 반복 작성하지 않아도 되니까요! 😄

{ label: "한식", value: "한식" },
{ label: "중식", value: "중식" },
{ label: "일식", value: "일식" },
{ label: "양식", value: "양식" },
{ label: "분식", value: "분식" },
{ label: "카페", value: "카페" },
{ label: "편의점", value: "편의점" },
{ label: "기타", value: "기타" },
];

type FormType = {
name: string;
category: ShopCategory;
address1: SeoulDistrict;
address2: string;
originalHourlyPay: string;
description: string;
};

const FIELD_LABELS: Record<keyof FormType, string> = {
name: "가게 이름",
category: "분류",
address1: "주소",
address2: "상세 주소",
originalHourlyPay: "기본 시급",
description: "가게 설명",
};

export default function ShopRegisterPage() {
const navigate = useNavigate();
const { user } = useUserStore();
const shopId = user?.shopId;
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [imageFile, setImageFile] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);

const [form, setForm] = useState<FormType>({
name: "",
category: "" as ShopCategory,
address1: "" as SeoulDistrict,
Comment on lines +51 to +52
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오..! 여기에 as 타입 단언이 들어간 이유가 있나요?!
궁금합니다! 🤔

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

초기값으로 빈 문자열을 두면 아래와 같은 오류가 발생하더라구요!

Type '""' is not assignable to type '"한식" | "중식" | "일식" | "양식" | "분식" | "카페" | "편의점" | "기타"'.

ShopCategory"한식" | "중식" | "일식" | ...와 같은 리터럴 유니언 타입이라서 "한식"은 괜찮아도 ""는 허용하지 않는 것 같아요. 위와 같이 as 타입 단언을 사용해 지금은 빈 문자열이지만, 나중에 반드시 올바른 ShopCategory 또는 SeoulDistrict 값으로 바뀔 거라고 말해주는 방식을 사용해보았습니다!

address2: "",
originalHourlyPay: "",
description: "",
});

useEffect(() => {
async function fetchShop() {
if (!shopId) return;
const res = await getShop(shopId);
const shop = res.data.item;
setForm({
name: shop.name,
category: shop.category,
address1: shop.address1,
address2: shop.address2,
originalHourlyPay: numberCommaFormatter(shop.originalHourlyPay),
description: shop.description,
});
if (shop.imageUrl) setImagePreview(shop.imageUrl);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

구조분해할당, 단축 속성을 활용하면 더 코드가 깔끔해 보일 것 같아요! 🤔

Suggested change
const shop = res.data.item;
setForm({
name: shop.name,
category: shop.category,
address1: shop.address1,
address2: shop.address2,
originalHourlyPay: numberCommaFormatter(shop.originalHourlyPay),
description: shop.description,
});
if (shop.imageUrl) setImagePreview(shop.imageUrl);
const { name, category, address1, address2, originHourlyPay, description, imageUrl }
= res.data.item;
setForm({
name,
category,
address1,
address2,
originalHourlyPay: numberCommaFormatter(originalHourlyPay),
description,
});
if (imageUrl) setImagePreview(imageUrl);

}
fetchShop();
}, [shopId]);

const handleChange = (
key: keyof FormType,
value: string | SeoulDistrict | ShopCategory,
) => {
setForm((prev) => ({ ...prev, [key]: value }));
};

const handleImageUpload = (e: ChangeEvent<HTMLInputElement>) => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

추후에 리팩토링을 하게 된다면 이미지 업로드 시 파일 크기나 형식 제한 정도 검증하는 유효성 검사를 고려해 볼 수 있을 것 같습니다.!

const file = e.target.files?.[0];
if (!file) return;

setImageFile(file);

const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
setImagePreview(reader.result);
}
};
reader.readAsDataURL(file);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

URL.createObjectURL API를 활용한 코드가
위->아래 흐름으로 더 보기 쉬운 것 같아요! 🤔

Suggested change
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
setImagePreview(reader.result);
}
};
reader.readAsDataURL(file);
// 이미 메모리에 blobURL이 존재한다면 해제하기
if(imagePreview) {
URL.revokeObjectURL(imagePreview);
}
// blobURL 생성하기
const blobUrl = URL.createObjectURL(file);
setImagePreview(blobUrl);

};

const handleSubmit = async () => {
if (isSubmitting || !shopId) return;

const requiredFields: Array<keyof typeof form> = [
"name",
"category",
"address1",
"address2",
"originalHourlyPay",
];

const missingField = requiredFields.find((key) => form[key].trim() === "");

if (missingField) {
alert(`${FIELD_LABELS[missingField]}을(를) 입력해 주세요.`);
return;
}

const hourlyPay = Number(extractDigits(form.originalHourlyPay));
if (isNaN(hourlyPay) || hourlyPay <= 0) {
alert("유효한 시급을 입력해 주세요.");
return;
}

try {
setIsSubmitting(true);

let imageUrl = imagePreview || "";
if (imageFile) {
const presignedURL = await postImage(imageFile.name);
await putImage(presignedURL, imageFile);
imageUrl = getPublicURL(presignedURL);
}

const payload = {
name: form.name.trim(),
category: form.category,
address1: form.address1,
address2: form.address2.trim(),
originalHourlyPay: hourlyPay,
description: form.description.trim(),
imageUrl,
};

await putShop(shopId, payload);
navigate("/shop");
} finally {
setIsSubmitting(false);
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

하나의 의견입니다! 😅

기존에 작성해주신 코드는
비동기 요청과 관련된 변수들을 가까이 두시려는 의도로 보이는데요 🤔

에러 발생가능성이 높은 비동기 요청과
에러 발생 가능성이 없는 변수를 번갈아봐야 해서
조금 읽기가 어려울 수 있을 것 같아요.

Suggested change
try {
setIsSubmitting(true);
let imageUrl = imagePreview || "";
if (imageFile) {
const presignedURL = await postImage(imageFile.name);
await putImage(presignedURL, imageFile);
imageUrl = getPublicURL(presignedURL);
}
const payload = {
name: form.name.trim(),
category: form.category,
address1: form.address1,
address2: form.address2.trim(),
originalHourlyPay: hourlyPay,
description: form.description.trim(),
imageUrl,
};
await putShop(shopId, payload);
navigate("/shop");
} finally {
setIsSubmitting(false);
}
setIsSubmitting(true);
let imageUrl = imagePreview || "";
const payload = {
name: form.name.trim(),
category: form.category,
address1: form.address1,
address2: form.address2.trim(),
originalHourlyPay: hourlyPay,
description: form.description.trim(),
imageUrl,
};
try {
if (imageFile) {
const presignedURL = await postImage(imageFile.name);
await putImage(presignedURL, imageFile);
imageUrl = getPublicURL(presignedURL);
}
await putShop(shopId, payload);
navigate("/shop");
} finally {
setIsSubmitting(false);
}

};

return (
<form
className="w-full max-w-[964px] mx-auto px-4 py-12"
onSubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
<div className="flex justify-between items-center mb-8">
<h2 className="sm:text-[1.75rem] text-[1.25rem] font-bold">
가게 정보
</h2>
<button onClick={() => navigate("/shop")}>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

라우트 상수를 활용하면 더 좋을 것 같아요 👍

import { ROUTES } from "./constants/router";
...  
export default function ShopRegisterPage() {
  ...
  return (
    ...
        <button onClick={() => navigate(ROUTES.SHOP.ROOT)}>
    ...
  );
}

<Close className="sm:w-8 sm:h-8 w-6 h-6 cursor-pointer" />
</button>
</div>

<div className="grid sm:grid-cols-2 grid-cols-1 gap-5 mb-6">
<TextField.Input
label="가게 이름*"
placeholder="입력"
fullWidth
value={form.name}
onChange={(e) => handleChange("name", e.target.value)}
/>
<Select
label="분류*"
placeholder="선택"
fullWidth
options={CATEGORY_OPTIONS}
value={form.category}
onValueChange={(value) => handleChange("category", value)}
/>
<Select
label="주소*"
placeholder="선택"
fullWidth
options={SeoulDistricts.map((d) => ({ label: d, value: d }))}
value={form.address1}
onValueChange={(value) => handleChange("address1", value)}
/>
<TextField.Input
label="상세 주소*"
placeholder="입력"
fullWidth
value={form.address2}
onChange={(e) => handleChange("address2", e.target.value)}
/>
<TextField.Input
label="기본 시급*"
placeholder="입력"
fullWidth
value={form.originalHourlyPay}
onChange={(e) => {
const rawValue = e.target.value;
const digitsOnly = extractDigits(rawValue);
const formatted = digitsOnly
? numberCommaFormatter(Number(digitsOnly))
: "";
handleChange("originalHourlyPay", formatted);
}}
postfix={<span className="text-black mr-2"></span>}
/>
</div>
<div className="mb-6">
<label className="block mb-2">가게 이미지</label>
<div
className="relative sm:w-[483px] sm:h-[276px] w-full h-[200px] rounded-lg bg-gray-10 border border-gray-30 overflow-hidden cursor-pointer"
onClick={() => fileInputRef.current?.click()}
>
{imagePreview && (
<img
src={imagePreview}
alt="preview"
className="object-cover w-full h-full z-0"
/>
)}
<div className="absolute inset-0 bg-black/40 z-10" />
<div className="absolute inset-0 flex flex-col items-center justify-center text-white z-20">
<Camera className="w-8 h-8 mb-2.5 text-white" />
<span>이미지 변경하기</span>
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
hidden
onChange={handleImageUpload}
/>
</div>
<div className="mb-10">
<TextField.TextArea
label="가게 설명"
placeholder="입력"
fullWidth
rows={4}
value={form.description}
onChange={(e) => handleChange("description", e.target.value)}
/>
</div>
<div className="text-center">
<Button
variant="primary"
textSize="md"
className="sm:w-[350px] w-full px-34 py-3.5"
disabled={isSubmitting}
type="submit"
>
수정하기
</Button>
</div>
</form>
);
}