Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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.
10 changes: 10 additions & 0 deletions src/constants/shopCategory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export const CATEGORY_OPTIONS = [
"한식",
"중식",
"일식",
"양식",
"분식",
"카페",
"편의점",
"기타",
].map((category) => ({ label: category, value: category }));
268 changes: 266 additions & 2 deletions src/pages/ShopEditPage.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,267 @@
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 { ROUTES } from "@/constants/router";
import { CATEGORY_OPTIONS } from "@/constants/shopCategory";
import { useUserStore } from "@/hooks/useUserStore";
import { extractDigits, numberCommaFormatter } from "@/utils/number";

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 {
name,
category,
address1,
address2,
originalHourlyPay,
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);

if (imagePreview) URL.revokeObjectURL(imagePreview);
const blobUrl = URL.createObjectURL(file);
setImagePreview(blobUrl);
};

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

const requiredFields: Array<keyof FormType> = [
"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;
}

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);
payload.imageUrl = imageUrl;
}
await putShop(shopId, payload);
navigate(ROUTES.SHOP.ROOT);
} 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>
);
}
59 changes: 24 additions & 35 deletions src/pages/ShopRegisterPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,10 @@ import { Camera, Close } from "@/assets/icon";
import Button from "@/components/Button";
import Select from "@/components/Select";
import TextField from "@/components/TextField";
import { ROUTES } from "@/constants/router";
import { CATEGORY_OPTIONS } from "@/constants/shopCategory";
import { extractDigits, numberCommaFormatter } from "@/utils/number";

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

type FormType = {
name: string;
category: ShopCategory;
Expand Down Expand Up @@ -62,7 +53,7 @@ export default function ShopRegisterPage() {
});

const handleChange = (
key: keyof typeof form,
key: keyof FormType,
value: string | SeoulDistrict | ShopCategory,
) => {
setForm((prev) => ({ ...prev, [key]: value }));
Expand All @@ -74,19 +65,17 @@ export default function ShopRegisterPage() {

setImageFile(file);

const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === "string") {
setImagePreview(reader.result);
}
};
reader.readAsDataURL(file);
if (imagePreview) {
URL.revokeObjectURL(imagePreview);
}
const blobUrl = URL.createObjectURL(file);
setImagePreview(blobUrl);
};

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

const requiredFields: Array<keyof typeof form> = [
const requiredFields: Array<keyof FormType> = [
"name",
"category",
"address1",
Expand All @@ -107,28 +96,28 @@ export default function ShopRegisterPage() {
return;
}

try {
setIsSubmitting(true);
setIsSubmitting(true);

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

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

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

await postShop(payload);
navigate("/shop");
navigate(ROUTES.SHOP.ROOT);
} finally {
setIsSubmitting(false);
}
Expand Down