diff --git a/src/app/(home)/_components/Story/Story.tsx b/src/app/(home)/_components/Story/Story.tsx new file mode 100644 index 00000000..580da548 --- /dev/null +++ b/src/app/(home)/_components/Story/Story.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useRef } from "react"; + +import { useImageUploadContext } from "@/app/story/register/_contexts"; +import { imageFileSchema } from "@/app/story/register/_schemas"; + +export const Story = () => { + const router = useRouter(); + const { setUpload } = useImageUploadContext(); + const fileInputRef = useRef(null); + + const handleOpenPhotoGallery = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const validationResult = imageFileSchema.safeParse(file); + + if (!validationResult.success) { + const errorMessage = validationResult.error.errors[0]?.message; + // TODO: Toast 변경 + alert(errorMessage || "올바르지 않은 파일입니다."); + + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + return; + } + + const previewUrl = URL.createObjectURL(file); + setUpload(file, previewUrl); + + router.push("/story/register"); + }; + return ( + <> + + + + ); +}; diff --git a/src/app/(home)/_components/Story/index.ts b/src/app/(home)/_components/Story/index.ts new file mode 100644 index 00000000..11912c62 --- /dev/null +++ b/src/app/(home)/_components/Story/index.ts @@ -0,0 +1 @@ +export { Story } from "./Story"; diff --git a/src/app/(home)/_components/index.ts b/src/app/(home)/_components/index.ts index 3b0dcfb4..9188f1f7 100644 --- a/src/app/(home)/_components/index.ts +++ b/src/app/(home)/_components/index.ts @@ -1,3 +1,4 @@ export { RecentCheers } from "./RecentCheers"; export { RecentlySupportedStores } from "./RecentlySupportStories"; export { StoreStory } from "./StoreStory"; +export { Story } from "./Story"; diff --git a/src/app/(home)/layout.tsx b/src/app/(home)/layout.tsx index ab480010..1fbac253 100644 --- a/src/app/(home)/layout.tsx +++ b/src/app/(home)/layout.tsx @@ -1,3 +1,11 @@ +"use client"; + +import Link from "next/link"; + +import LogoWordmarkIcon from "@/assets/logo-wordmark.svg"; +import { Button } from "@/components/ui/Button"; +import { GNB } from "@/components/ui/GNB"; + import * as styles from "./layout.css"; export default function MainLayout({ @@ -5,5 +13,20 @@ export default function MainLayout({ }: { children: React.ReactNode; }) { - return
{children}
; + return ( + <> + } + align='left' + rightAddon={ + + + + } + /> +
{children}
+ + ); } diff --git a/src/app/(home)/page.tsx b/src/app/(home)/page.tsx index 043dfaa9..3219c13a 100644 --- a/src/app/(home)/page.tsx +++ b/src/app/(home)/page.tsx @@ -1,3 +1,6 @@ +"use client"; + +import { Bleed } from "@/components/ui/Bleed"; import { Spacer } from "@/components/ui/Spacer"; import { VStack } from "@/components/ui/Stack"; @@ -5,13 +8,16 @@ import { RecentCheers, RecentlySupportedStores, StoreStory, + Story, } from "./_components"; export default function HomePage() { return ( <> + + + -
대충 스토리
diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ac7d0c97..ad999261 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -9,6 +9,7 @@ import { MSWProvider, QueryProvider } from "@/providers"; import { pretendard } from "@/styles/pretendard"; import * as styles from "./layout.css"; +import { UploadProvider } from "./story/register/_contexts"; export const metadata: Metadata = { title: "Eat-da", @@ -35,7 +36,9 @@ export default function RootLayout({
- {children} + + {children} +
diff --git a/src/app/story/[id]/page.tsx b/src/app/story/[id]/page.tsx new file mode 100644 index 00000000..5aa486d7 --- /dev/null +++ b/src/app/story/[id]/page.tsx @@ -0,0 +1,3 @@ +export default function StoryIdPage() { + return
StoryIdPage
; +} diff --git a/src/app/story/register/_api/index.ts b/src/app/story/register/_api/index.ts new file mode 100644 index 00000000..183bb6c6 --- /dev/null +++ b/src/app/story/register/_api/index.ts @@ -0,0 +1,3 @@ +export * from "./register.api"; +export * from "./register.queries"; +export * from "./register.types"; diff --git a/src/app/story/register/_api/register.api.ts b/src/app/story/register/_api/register.api.ts new file mode 100644 index 00000000..3f14b727 --- /dev/null +++ b/src/app/story/register/_api/register.api.ts @@ -0,0 +1,33 @@ +import { authHttp } from "@/lib/api"; + +import { + type StoryRegisterRequest, + type StoryRegisterResponse, +} from "./register.types"; + +/** + * 스토리 등록 API + * @param {StoryRegisterRequest} storyRequest - 스토리 등록 요청 데이터 + * @param {File} imageFile - 업로드할 이미지 파일 + * + * @returns {Promise} 등록된 스토리 ID 반환 + */ +export const postStory = async ( + storyRequest: StoryRegisterRequest, + imageFile: File +): Promise => { + const formData = new FormData(); + + formData.append( + "request", + new Blob([JSON.stringify(storyRequest)], { type: "application/json" }) + ); + + formData.append("image", imageFile); + + return await authHttp + .post("api/stories", { + body: formData, + }) + .json(); +}; diff --git a/src/app/story/register/_api/register.queries.ts b/src/app/story/register/_api/register.queries.ts new file mode 100644 index 00000000..72bd5d09 --- /dev/null +++ b/src/app/story/register/_api/register.queries.ts @@ -0,0 +1,30 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { postStory } from "./register.api"; +import type { StoryRegisterRequest } from "./register.types"; + +export const storyQueryKeys = { + all: ["story"] as const, + lists: () => [...storyQueryKeys.all, "list"] as const, +} as const; + +export const usePostStoryMutation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + storyRequest, + imageFile, + }: { + storyRequest: StoryRegisterRequest; + imageFile: File; + }) => { + return postStory(storyRequest, imageFile); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: storyQueryKeys.lists(), + }); + }, + }); +}; diff --git a/src/app/story/register/_api/register.types.ts b/src/app/story/register/_api/register.types.ts new file mode 100644 index 00000000..44fbb20f --- /dev/null +++ b/src/app/story/register/_api/register.types.ts @@ -0,0 +1,9 @@ +export type StoryRegisterRequest = { + storeKakaoId: string; + storeName: string; + description?: string; +}; + +export type StoryRegisterResponse = { + storyId: number; +}; diff --git a/src/app/story/register/_components/StoryDescription/StoryDescription.css.ts b/src/app/story/register/_components/StoryDescription/StoryDescription.css.ts new file mode 100644 index 00000000..992479f5 --- /dev/null +++ b/src/app/story/register/_components/StoryDescription/StoryDescription.css.ts @@ -0,0 +1,9 @@ +import { style } from "@vanilla-extract/css"; + +export const wrapper = style({ + margin: "2rem 0", +}); + +export const textField = style({ + height: "9.6rem", +}); diff --git a/src/app/story/register/_components/StoryDescription/StoryDescription.tsx b/src/app/story/register/_components/StoryDescription/StoryDescription.tsx new file mode 100644 index 00000000..2a61e0aa --- /dev/null +++ b/src/app/story/register/_components/StoryDescription/StoryDescription.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useFormContext } from "react-hook-form"; + +import { TextField } from "@/components/ui/TextField"; + +import { type StoryRegisterFormData } from "../../_schemas"; +import * as styles from "./StoryDescription.css"; + +export const StoryDescription = () => { + const { + register, + formState: { errors }, + } = useFormContext(); + + return ( +
+ +
+ ); +}; diff --git a/src/app/story/register/_components/StoryDescription/index.ts b/src/app/story/register/_components/StoryDescription/index.ts new file mode 100644 index 00000000..ebe37a9a --- /dev/null +++ b/src/app/story/register/_components/StoryDescription/index.ts @@ -0,0 +1 @@ +export { StoryDescription } from "./StoryDescription"; diff --git a/src/app/story/register/_components/StoryImagePreview/StoryImagePreview.css.ts b/src/app/story/register/_components/StoryImagePreview/StoryImagePreview.css.ts new file mode 100644 index 00000000..47d1299a --- /dev/null +++ b/src/app/story/register/_components/StoryImagePreview/StoryImagePreview.css.ts @@ -0,0 +1,35 @@ +import { style } from "@vanilla-extract/css"; + +import { colors, radius, semantic, typography } from "@/styles"; + +export const imageWrapper = style({ + position: "relative", + width: "12.1rem", + height: "21.3rem", + borderRadius: radius[160], + overflow: "hidden", + margin: "0 auto", + backgroundColor: colors.neutral[10], +}); + +export const image = style({ + objectFit: "contain", +}); + +export const overlayButtonWrapper = style({ + position: "absolute", + bottom: "0", + width: "100%", + display: "flex", + justifyContent: "center", + padding: "0 1.2rem 1.2rem", +}); + +export const overlayButton = style({ + padding: "0.5rem 1.2rem", + ...typography.label1Sb, + color: semantic.text.white, + background: "rgba(23, 23, 23, 0.60)", + borderRadius: radius.circle, + cursor: "pointer", +}); diff --git a/src/app/story/register/_components/StoryImagePreview/StoryImagePreview.tsx b/src/app/story/register/_components/StoryImagePreview/StoryImagePreview.tsx new file mode 100644 index 00000000..074d4ebf --- /dev/null +++ b/src/app/story/register/_components/StoryImagePreview/StoryImagePreview.tsx @@ -0,0 +1,60 @@ +"use client"; + +import Image from "next/image"; +import { useFormContext } from "react-hook-form"; + +import { imageFileSchema, type StoryRegisterFormData } from "../../_schemas"; +import * as styles from "./StoryImagePreview.css"; + +export const StoryImagePreview = () => { + const { watch, setValue } = useFormContext(); + const imageFile = watch("image"); + + const previewUrl = URL.createObjectURL(imageFile); + + const validateImage = (file: File) => { + const result = imageFileSchema.safeParse(file); + + if (!result.success) { + const errorMessage = result.error.errors[0]?.message; + // TODO: Toast 변경 + alert(errorMessage); + return; + } + + setValue("image", file, { shouldValidate: true }); + }; + + const handleImageChange = (e: React.ChangeEvent) => { + const newFile = e.target.files?.[0]; + + if (newFile) { + validateImage(newFile); + } + + e.target.value = ""; + }; + + return ( +
+ 스토리 등록 사진 프리뷰 +
+ +
+
+ ); +}; diff --git a/src/app/story/register/_components/StoryImagePreview/index.ts b/src/app/story/register/_components/StoryImagePreview/index.ts new file mode 100644 index 00000000..7318c1c5 --- /dev/null +++ b/src/app/story/register/_components/StoryImagePreview/index.ts @@ -0,0 +1 @@ +export { StoryImagePreview } from "./StoryImagePreview"; diff --git a/src/app/story/register/_components/StorySearchStore/StorySearchStore.css.ts b/src/app/story/register/_components/StorySearchStore/StorySearchStore.css.ts new file mode 100644 index 00000000..b03a2a06 --- /dev/null +++ b/src/app/story/register/_components/StorySearchStore/StorySearchStore.css.ts @@ -0,0 +1,12 @@ +import { style } from "@vanilla-extract/css"; + +import { colors } from "@/styles"; + +export const star = style({ + marginLeft: "0.4rem", + color: colors.redOrange[50], +}); + +export const field = style({ + cursor: "pointer", +}); diff --git a/src/app/story/register/_components/StorySearchStore/StorySearchStore.tsx b/src/app/story/register/_components/StorySearchStore/StorySearchStore.tsx new file mode 100644 index 00000000..bdb75aec --- /dev/null +++ b/src/app/story/register/_components/StorySearchStore/StorySearchStore.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { useState } from "react"; +import { useFormContext } from "react-hook-form"; + +import { SearchStoreBottomSheet } from "@/app/(search)/_components/SearchStoreBottomSheet"; +import { type SelectedStore } from "@/app/(search)/_types/searchStore.types"; +import SearchIcon from "@/assets/search.svg"; +import { TextField } from "@/components/ui/TextField"; + +import { type StoryRegisterFormData } from "../../_schemas"; +import * as styles from "./StorySearchStore.css"; + +export const StorySearchStore = () => { + const [isOpen, setIsOpen] = useState(false); + const { register, setValue, watch } = useFormContext(); + + const selectedStoreValue = watch("storeName"); + + const handleOpenClick = () => { + setIsOpen(true); + }; + + const handleSelectStore = (store: SelectedStore) => { + setValue("storeName", store.name, { shouldValidate: true }); + setValue("storeKakaoId", store.kakaoId, { shouldValidate: true }); + }; + + return ( +
+ + 방문한 가게 + * + + } + placeholder='가게명을 입력해주세요' + value={selectedStoreValue || ""} + rightAddon={} + onClick={handleOpenClick} + className={styles.field} + readOnly + /> + +
+ ); +}; diff --git a/src/app/story/register/_components/StorySearchStore/index.ts b/src/app/story/register/_components/StorySearchStore/index.ts new file mode 100644 index 00000000..2dafe26f --- /dev/null +++ b/src/app/story/register/_components/StorySearchStore/index.ts @@ -0,0 +1 @@ +export { StorySearchStore } from "./StorySearchStore"; diff --git a/src/app/story/register/_components/StorySubmitButton/StorySubmitButton.tsx b/src/app/story/register/_components/StorySubmitButton/StorySubmitButton.tsx new file mode 100644 index 00000000..c4985aac --- /dev/null +++ b/src/app/story/register/_components/StorySubmitButton/StorySubmitButton.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { useFormContext, useWatch } from "react-hook-form"; + +import { Button } from "@/components/ui/Button"; + +import { type StoryRegisterFormData } from "../../_schemas"; + +type StorySubmitButtonProps = { + isPending?: boolean; +}; + +export const StorySubmitButton = ({ + isPending = false, +}: StorySubmitButtonProps) => { + const { + formState: { isValid }, + } = useFormContext(); + + const image = useWatch({ name: "image" }); + const storeName = useWatch({ name: "storeName" }); + + const isDisabled = !image || !storeName || !isValid || isPending; + + return ( + + ); +}; diff --git a/src/app/story/register/_components/StorySubmitButton/index.ts b/src/app/story/register/_components/StorySubmitButton/index.ts new file mode 100644 index 00000000..ad9fdf93 --- /dev/null +++ b/src/app/story/register/_components/StorySubmitButton/index.ts @@ -0,0 +1 @@ +export { StorySubmitButton } from "./StorySubmitButton"; diff --git a/src/app/story/register/_contexts/UploadContext.tsx b/src/app/story/register/_contexts/UploadContext.tsx new file mode 100644 index 00000000..4d4db841 --- /dev/null +++ b/src/app/story/register/_contexts/UploadContext.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { createContext, type ReactNode, useContext, useState } from "react"; + +type UploadContextProps = { + file?: File; + previewUrl?: string; + setUpload: (file: File, previewUrl: string) => void; + clearUpload: () => void; +}; + +const UploadContext = createContext(undefined); + +export const UploadProvider = ({ children }: { children: ReactNode }) => { + const [file, setFile] = useState(); + const [previewUrl, setPreviewUrl] = useState(); + + const setUpload = (newFile: File, newPreviewUrl: string) => { + setFile(newFile); + setPreviewUrl(newPreviewUrl); + }; + + const clearUpload = () => { + setFile(undefined); + setPreviewUrl(undefined); + }; + + return ( + + {children} + + ); +}; + +export const useImageUploadContext = () => { + const context = useContext(UploadContext); + if (!context) + throw new Error("useImageUploadContext must be used within UploadProvider"); + return context; +}; diff --git a/src/app/story/register/_contexts/index.ts b/src/app/story/register/_contexts/index.ts new file mode 100644 index 00000000..8e4cbdd9 --- /dev/null +++ b/src/app/story/register/_contexts/index.ts @@ -0,0 +1 @@ +export { UploadProvider, useImageUploadContext } from "./UploadContext"; diff --git a/src/app/story/register/_schemas/index.ts b/src/app/story/register/_schemas/index.ts new file mode 100644 index 00000000..f8384051 --- /dev/null +++ b/src/app/story/register/_schemas/index.ts @@ -0,0 +1 @@ +export * from "./storyRegister.schema"; diff --git a/src/app/story/register/_schemas/storyRegister.schema.ts b/src/app/story/register/_schemas/storyRegister.schema.ts new file mode 100644 index 00000000..d3b09350 --- /dev/null +++ b/src/app/story/register/_schemas/storyRegister.schema.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; + +export const imageFileSchema = z + .instanceof(File) + .refine(file => file.size <= 5 * 1024 * 1024, { + message: "5MB 이하 사진만 업로드할 수 있어요.", + }) + .refine( + file => ["image/jpg", "image/jpeg", "image/png"].includes(file.type), + { + message: "지원하지 않는 이미지 형식입니다.", + } + ); + +export const storyRegisterSchema = z.object({ + storeKakaoId: z.string().trim(), + storeName: z.string().trim(), + description: z + .string() + .max(300, "최대 300자까지 입력할 수 있어요") + .trim() + .optional(), + image: imageFileSchema, +}); + +export type StoryRegisterFormData = z.infer; diff --git a/src/app/story/register/page.css.ts b/src/app/story/register/page.css.ts new file mode 100644 index 00000000..9194b195 --- /dev/null +++ b/src/app/story/register/page.css.ts @@ -0,0 +1,11 @@ +import { style } from "@vanilla-extract/css"; + +export const wrapper = style({ + width: "100%", + height: "100dvh", +}); + +export const mainWrapper = style({ + flex: 1, + padding: "2rem", +}); diff --git a/src/app/story/register/page.tsx b/src/app/story/register/page.tsx new file mode 100644 index 00000000..382e919c --- /dev/null +++ b/src/app/story/register/page.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useRouter } from "next/navigation"; +import { FormProvider, useForm } from "react-hook-form"; + +import CancelIcon from "@/assets/cancel.svg"; +import { GNB } from "@/components/ui/GNB"; +import { VStack } from "@/components/ui/Stack"; + +import { usePostStoryMutation } from "./_api"; +import { StoryDescription } from "./_components/StoryDescription"; +import { StoryImagePreview } from "./_components/StoryImagePreview"; +import { StorySearchStore } from "./_components/StorySearchStore"; +import { StorySubmitButton } from "./_components/StorySubmitButton"; +import { useImageUploadContext } from "./_contexts"; +import { type StoryRegisterFormData, storyRegisterSchema } from "./_schemas"; +import * as styles from "./page.css"; + +export default function StoryRegisterPage() { + const { file, clearUpload } = useImageUploadContext(); + const router = useRouter(); + const { mutate: postStory, isPending } = usePostStoryMutation(); + + const handleClick = () => { + router.back(); + }; + + const methods = useForm({ + resolver: zodResolver(storyRegisterSchema), + defaultValues: { + storeKakaoId: "", + storeName: "", + description: "", + image: file, + }, + mode: "onChange", + }); + + const onSubmit = async (data: StoryRegisterFormData) => { + postStory( + { + storyRequest: { + storeKakaoId: data.storeKakaoId, + storeName: data.storeName, + description: data.description || "", + }, + imageFile: data.image, + }, + { + onSuccess: response => { + clearUpload(); + router.push(`/story/${response.storyId}`); + }, + onError: error => { + console.error("스토리 등록 실패:", error); + }, + } + ); + }; + + return ( + +
+ + + + + } + /> + + + + + + + + + +
+
+ ); +} diff --git a/src/assets/cancel.svg b/src/assets/cancel.svg new file mode 100644 index 00000000..e6b4972c --- /dev/null +++ b/src/assets/cancel.svg @@ -0,0 +1,4 @@ + + + +