diff --git a/.github/workflows/cd_workflow_dev.yml b/.github/workflows/cd_workflow_dev.yml index 5ea70d58..311badf3 100644 --- a/.github/workflows/cd_workflow_dev.yml +++ b/.github/workflows/cd_workflow_dev.yml @@ -3,7 +3,7 @@ name: Frontend Dev Server CD on: push: branches: - - 'release/**' + - "release/**" jobs: build: @@ -28,7 +28,8 @@ jobs: echo "NEXT_PUBLIC_KAKAO_DOMAIN=${{ secrets.NEXT_PUBLIC_KAKAO_DOMAIN }}" >> .env echo "NEXT_PUBLIC_AUTH_KAKAO_KEY=${{ secrets.NEXT_PUBLIC_AUTH_KAKAO_KEY_DEV }}" >> .env echo "NEXT_PUBLIC_DOMAIN=${{ secrets.NEXT_PUBLIC_DOMAIN_DEV }}" >> .env - + echo "NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID=${{ secrets.NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID }}" >> .env + - name: 압축 run: zip -r ./frontend_freebe.zip . diff --git a/.github/workflows/cd_workflow_prod.yml b/.github/workflows/cd_workflow_prod.yml index c2d1e902..41af82ad 100644 --- a/.github/workflows/cd_workflow_prod.yml +++ b/.github/workflows/cd_workflow_prod.yml @@ -2,7 +2,7 @@ name: Frontend Prod Server CD on: push: - branches: + branches: - master jobs: @@ -30,6 +30,7 @@ jobs: echo "NEXT_PUBLIC_AUTH_KAKAO_KEY=${{ secrets.NEXT_PUBLIC_AUTH_KAKAO_KEY }}" >> .env echo "NEXT_PUBLIC_DOMAIN=${{ secrets.NEXT_PUBLIC_DOMAIN }}" >> .env echo "NEXT_PUBLIC_GOOGLE_ANALYTICS_ID=${{ secrets.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID }}" >> .env + echo "NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID=${{ secrets.NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID }}" >> .env - name: 압축 run: zip -r ./frontend_freebe.zip . diff --git a/next.config.mjs b/next.config.mjs index 5effbcca..8339996c 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -43,7 +43,7 @@ export default withSentryConfig(withVanillaExtract(nextConfig), { // For all available options, see: // https://github.com/getsentry/sentry-webpack-plugin#options - org: "for-u-5h", + org: "freebe", project: "javascript-nextjs", // Only print logs for uploading source maps in CI diff --git a/public/icons/loading.svg b/public/icons/loading.svg new file mode 100644 index 00000000..d0998e3e --- /dev/null +++ b/public/icons/loading.svg @@ -0,0 +1,4 @@ + + + + diff --git a/sentry.client.config.ts b/sentry.client.config.ts index 0bbf23bb..bb1322a1 100644 --- a/sentry.client.config.ts +++ b/sentry.client.config.ts @@ -5,8 +5,8 @@ import * as Sentry from "@sentry/nextjs"; Sentry.init({ - dsn: "https://8aa61c4c6bf4aca3fb8552ed092d88ee@o4508070352257024.ingest.us.sentry.io/4508070354616320", - + dsn: "https://92e2c980bb6bf71c3be5e4f0170006ed@o4508237874462720.ingest.us.sentry.io/4508237875773440", + denyUrls: [/https:\/\/www\.freebe\.n-e\.kr/, /http:\/\/43\.200\.240\.195/], // Add optional integrations for additional features integrations: [Sentry.replayIntegration()], diff --git a/sentry.edge.config.ts b/sentry.edge.config.ts index 5851395f..441e68c7 100644 --- a/sentry.edge.config.ts +++ b/sentry.edge.config.ts @@ -6,7 +6,7 @@ import * as Sentry from "@sentry/nextjs"; Sentry.init({ - dsn: "https://8aa61c4c6bf4aca3fb8552ed092d88ee@o4508070352257024.ingest.us.sentry.io/4508070354616320", + dsn: "https://92e2c980bb6bf71c3be5e4f0170006ed@o4508237874462720.ingest.us.sentry.io/4508237875773440", // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. tracesSampleRate: 1, diff --git a/sentry.server.config.ts b/sentry.server.config.ts index 17ef17a5..3f2f356d 100644 --- a/sentry.server.config.ts +++ b/sentry.server.config.ts @@ -5,8 +5,8 @@ import * as Sentry from "@sentry/nextjs"; Sentry.init({ - dsn: "https://8aa61c4c6bf4aca3fb8552ed092d88ee@o4508070352257024.ingest.us.sentry.io/4508070354616320", - + dsn: "https://92e2c980bb6bf71c3be5e4f0170006ed@o4508237874462720.ingest.us.sentry.io/4508237875773440", + denyUrls: [/https:\/\/www\.freebe\.n-e\.kr/, /http:\/\/43\.200\.240\.195/], // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. tracesSampleRate: 1, diff --git a/src/app/(customer)/[profileName]/page.tsx b/src/app/(customer)/[profileName]/page.tsx index e40d5961..022a452e 100644 --- a/src/app/(customer)/[profileName]/page.tsx +++ b/src/app/(customer)/[profileName]/page.tsx @@ -1,5 +1,6 @@ import { Metadata } from "next"; import { PageParams } from "route-parameters"; +import { Photographer } from "profile-types"; import BackgroundImage from "@/containers/customer/main/background-image"; import { defaultLinks } from "@/constants/photographer/mypage"; import InfoSheet from "@/containers/customer/main/info-sheet"; @@ -16,12 +17,26 @@ export async function generateMetadata({ }; } +const EXCEPTIONAL_PROFILE_NAMES = [ + ".env", + "index.php", + "resolve", + "query", + "dns-query", +]; + const CustomerMainPage = async ({ params, }: { params: Pick; }) => { - const photographerProfile = await getPhotographerProfile(params.profileName); + function isExceptionalName() { + return EXCEPTIONAL_PROFILE_NAMES.includes(params.profileName); + } + + const photographerProfile: Photographer = isExceptionalName() + ? { linkInfos: [], message: "", profileName: "" } + : await getPhotographerProfile(params.profileName); return (
diff --git a/src/app/globals.css b/src/app/globals.css index 0492d713..c72a75b2 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -56,3 +56,12 @@ input:-webkit-autofill:active { width: 8px; height: 8px; } + +button { + cursor: pointer; +} + +button:hover, +button:active { + filter: brightness(0.92); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1ce61d4c..4c22ac0b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -7,7 +7,7 @@ import "@mantine/notifications/styles.css"; import "./globals.css"; import { ColorSchemeScript, MantineProvider } from "@mantine/core"; import { Notifications } from "@mantine/notifications"; -import { GoogleAnalytics } from "@next/third-parties/google"; +import { GoogleAnalytics, GoogleTagManager } from "@next/third-parties/google"; import { METADATA } from "@/constants/metadata"; const pretendard = localFont({ @@ -52,6 +52,9 @@ export default function RootLayout({ className={pretendard.className} style={{ overflowX: "hidden", overflowY: "hidden" }} > + diff --git a/src/app/robots.txt b/src/app/robots.txt new file mode 100644 index 00000000..d7e6ce7b --- /dev/null +++ b/src/app/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: /photographer/, /customer/ \ No newline at end of file diff --git a/src/app/sitemap.xml b/src/app/sitemap.xml new file mode 100644 index 00000000..e63b8a03 --- /dev/null +++ b/src/app/sitemap.xml @@ -0,0 +1,16 @@ + + + + + + + https://www.freebe.co.kr/ + 2024-11-05T07:25:39+00:00 + + + + \ No newline at end of file diff --git a/src/components/buttons/buttons.css.ts b/src/components/buttons/buttons.css.ts index bd184008..2d081682 100644 --- a/src/components/buttons/buttons.css.ts +++ b/src/components/buttons/buttons.css.ts @@ -1,7 +1,7 @@ import { breakpoints } from "@/styles/breakpoints.css"; import sprinkles from "@/styles/sprinkles.css"; import { texts } from "@/styles/text.css"; -import { style, styleVariants } from "@vanilla-extract/css"; +import { keyframes, style, styleVariants } from "@vanilla-extract/css"; const baseTab = style([ sprinkles({ borderColor: "stroke-grey" }), @@ -51,6 +51,19 @@ const baseBottomButton = style([ }, ]); +const rotateAnimation = keyframes({ + from: { + transform: "rotate(0deg)", + }, + to: { + transform: "rotate(360deg)", + }, +}); + +export const loaderStyle = style({ + animation: `${rotateAnimation} 2s linear infinite`, +}); + const buttonStyles = styleVariants({ kakao: { display: "flex", @@ -68,9 +81,7 @@ const buttonStyles = styleVariants({ fontSize: 17, fontWeight: 600, color: "#1a1a1a", - ":hover": { - cursor: "pointer", - }, + cursor: "pointer", }, add: [ texts["button-01"], @@ -145,6 +156,12 @@ const buttonStyles = styleVariants({ { border: "none", cursor: "initial", + ":hover": { + filter: "none", + }, + ":active": { + filter: "none", + }, }, ], primary: [ @@ -174,10 +191,16 @@ const buttonStyles = styleVariants({ color: "blue", }), { - background: "none", + backgroundColor: "transparent", borderStyle: "solid", borderRadius: 8, borderWidth: 1, + ":hover": { + backgroundColor: "white", + }, + ":active": { + backgroundColor: "white", + }, }, ], secondary: [ @@ -203,6 +226,12 @@ const buttonStyles = styleVariants({ { border: "none", cursor: "initial", + ":hover": { + filter: "none", + }, + ":active": { + filter: "none", + }, }, ], linkArea: { @@ -220,11 +249,20 @@ export const closeStyles = styleVariants({ zIndex: 3, display: "flex", alignItems: "center", + cursor: "pointer", }, grey: [ sprinkles({ color: "stroke-grey", }), + { + ":hover": { + filter: "brightness(0.7);", + }, + ":active": { + filter: "brightness(0.7);", + }, + }, ], white: [ sprinkles({ diff --git a/src/components/buttons/common-buttons.tsx b/src/components/buttons/common-buttons.tsx index ce180b2d..9ef6d6b2 100644 --- a/src/components/buttons/common-buttons.tsx +++ b/src/components/buttons/common-buttons.tsx @@ -3,9 +3,10 @@ import { ButtonHTMLAttributes, MouseEventHandler, } from "react"; +import Image from "next/image"; import Link from "next/link"; import { LinkType } from "profile-types"; -import buttonStyles from "./buttons.css"; +import buttonStyles, { loaderStyle } from "./buttons.css"; interface ButtonProps extends DetailedHTMLProps< @@ -20,6 +21,7 @@ interface ButtonOptions { size: "xs" | "sm" | "md" | "lg"; styleType: "primary" | "secondary" | "line" | "danger"; link?: string; + loading?: boolean; } export const CustomButton = ({ @@ -30,18 +32,29 @@ export const CustomButton = ({ disabled, children, link, + loading, ...props }: ButtonProps & ButtonOptions) => { return ( ); @@ -125,16 +138,28 @@ export const BottomButton = ({ onClick, disabled, type, + loading, ...props -}: ButtonProps) => { +}: ButtonProps & { loading?: boolean }) => { return ( ); }; diff --git a/src/components/common/bottom-sheet.tsx b/src/components/common/bottom-sheet.tsx index 92df5c04..68673bcf 100644 --- a/src/components/common/bottom-sheet.tsx +++ b/src/components/common/bottom-sheet.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import Image from "next/image"; import { bottomSheetStyles } from "./common.css"; const BottomSheet = ({ children }: { children: React.ReactNode }) => { @@ -25,13 +26,22 @@ const BottomSheet = ({ children }: { children: React.ReactNode }) => { } } > -
-
+
+
{children}
diff --git a/src/components/common/common.css.ts b/src/components/common/common.css.ts index 212e6259..c238d70c 100644 --- a/src/components/common/common.css.ts +++ b/src/components/common/common.css.ts @@ -1,6 +1,6 @@ import sprinkles from "@/styles/sprinkles.css"; import { texts } from "@/styles/text.css"; -import { style, styleVariants } from "@vanilla-extract/css"; +import { keyframes, style, styleVariants } from "@vanilla-extract/css"; export const captionStyle = style([ texts["caption-01"], @@ -15,6 +15,24 @@ export const captionStyle = style([ }, ]); +const rotateAnimation = keyframes({ + from: { + transform: "rotate(0deg)", + }, + to: { + transform: "rotate(360deg)", + }, +}); + +export const loaderStyle = style({ + animation: `${rotateAnimation} 2s linear infinite`, +}); + +const baseIcon = style({ + transition: "transform 0.5s ease", + paddingLeft: 3, +}); + export const bottomSheetStyles = styleVariants({ container: { display: "flex", @@ -51,6 +69,24 @@ export const bottomSheetStyles = styleVariants({ position: "relative", height: "100%", }, + control: [ + sprinkles({ borderColor: "stroke-grey", backgroundColor: "white" }), + { + borderWidth: 1, + borderStyle: "solid", + borderRadius: "100%", + position: "absolute", + top: -10, + left: "calc(50% - 19px)", + width: 38, + height: 38, + display: "flex", + alignItems: "center", + justifyContent: "center", + }, + ], + openIcon: [baseIcon, { transform: "rotate(270deg)" }], + closeIcon: [baseIcon, { transform: "rotate(90deg)" }], }); export const profileStyles = styleVariants({ @@ -271,12 +307,10 @@ export const chipStyles = styleVariants({ highlight: [ commonChipStyle, sprinkles({ + backgroundColor: "white", color: "blue", borderColor: "blue", }), - { - background: "none", - }, ], selectedContainer: [ sprinkles({ diff --git a/src/components/common/loader.tsx b/src/components/common/loader.tsx new file mode 100644 index 00000000..c127f3b4 --- /dev/null +++ b/src/components/common/loader.tsx @@ -0,0 +1,16 @@ +import Image from "next/image"; +import { loaderStyle } from "./common.css"; + +const Loader = () => { + return ( + now loading + ); +}; + +export default Loader; diff --git a/src/components/images/images.css.ts b/src/components/images/images.css.ts index d8608525..d6f104ea 100644 --- a/src/components/images/images.css.ts +++ b/src/components/images/images.css.ts @@ -55,6 +55,12 @@ export const GridStyles = styleVariants({ outlineOffset: -1, outlineColor: BLUE01, boxSizing: "content-box", + ":hover": { + filter: "brightness(0.92);", + }, + ":active": { + filter: "brightness(0.92);", + }, }, }); diff --git a/src/containers/common/image-stack/image-stack.css.ts b/src/containers/common/image-stack/image-stack.css.ts index fe39e8ff..f3f92691 100644 --- a/src/containers/common/image-stack/image-stack.css.ts +++ b/src/containers/common/image-stack/image-stack.css.ts @@ -30,6 +30,9 @@ const navButtonStyle = style([ ":hover": { animation: `${navButtonFrames} 1s ease-in-out infinite`, }, + ":active": { + animation: `${navButtonFrames} 1s ease-in-out infinite`, + }, "@media": { [breakpoints.mobile]: { aspectRatio: "1", @@ -100,6 +103,12 @@ export const imageStackStyles = styleVariants({ buttonWrapper: { border: "none", background: "none", + ":hover": { + filter: "none", + }, + ":active": { + filter: "none", + }, }, prev: [navButtonStyle, { left: "-50px" }], next: [navButtonStyle, { right: "-50px" }], diff --git a/src/containers/common/notice-banner/index.tsx b/src/containers/common/notice-banner/index.tsx index 53e8a121..10c63781 100644 --- a/src/containers/common/notice-banner/index.tsx +++ b/src/containers/common/notice-banner/index.tsx @@ -4,35 +4,38 @@ import { carouselStyles } from "@/styles/mantine.css"; import { Carousel } from "@mantine/carousel"; import Image from "next/image"; import Link from "next/link"; -import { CSSProperties } from "react"; +import { CSSProperties, useEffect, useState } from "react"; import { customedCarouselStyles, noticeBannerStyles, } from "./notice-banner.css"; +type BannerTargets = "join" | "main"; + +type Banner = { + image: string; + link?: string; +} & { + [key in BannerTargets]: boolean; +}; + const NoticeBanner = ({ container, target, }: { container?: CSSProperties; - target: "join" | "main"; + target: BannerTargets; }) => { - const bannerDatas = { - join: [ - { - image: - "https://local-freebe-data.s3.ap-northeast-2.amazonaws.com/service-banner/join-banner.png", - link: undefined, - }, - ], - main: [ - { - image: - "https://local-freebe-data.s3.ap-northeast-2.amazonaws.com/service-banner/main-banner.png", - link: "https://slashpage.com/freebe/xjqy1g2vqrk4dm6vd54z", - }, - ], - }; + const [bannerDatas, setBannerDatas] = useState([]); + + useEffect(() => { + const dataLayer = (window as any).dataLayer || []; + const bannerData = dataLayer.find( + (data: any) => data.event === "loadBannerData", + )?.bannerData; + if (bannerData) setBannerDatas(bannerData); + }, []); + return ( - {bannerDatas[target].map((banner, index) => ( - - {banner.link ? ( - + {bannerDatas + .filter((data) => data[target]) + .map((banner, index) => ( + + {banner.link ? ( + + {`banner + + ) : ( {`banner - - ) : ( - {`banner - )} - - ))} + )} + + ))} ); }; diff --git a/src/containers/common/notice-banner/notice-banner.css.ts b/src/containers/common/notice-banner/notice-banner.css.ts index bdf72424..a160aade 100644 --- a/src/containers/common/notice-banner/notice-banner.css.ts +++ b/src/containers/common/notice-banner/notice-banner.css.ts @@ -6,7 +6,7 @@ export const customedCarouselStyles = styleVariants({ position: "relative", width: "100%", minWidth: 700, - maxWidth: 950, + maxWidth: 800, paddingBottom: 30, "@media": { diff --git a/src/containers/customer/products/info.tsx b/src/containers/customer/products/info.tsx index 4bccd8e2..259f283e 100644 --- a/src/containers/customer/products/info.tsx +++ b/src/containers/customer/products/info.tsx @@ -3,6 +3,7 @@ import Image from "next/image"; import { Product } from "product-types"; import { PageParams } from "route-parameters"; +import { sendGAEvent } from "@next/third-parties/google"; import { Carousel } from "@mantine/carousel"; import { useDisclosure } from "@mantine/hooks"; import { Modal, Tabs } from "@mantine/core"; @@ -34,6 +35,14 @@ const ProductInfo = ({ }: Product & Pick) => { const [opened, { open, close }] = useDisclosure(false); + function handleStartReservation() { + sendGAEvent("event", "start_reservation", { + profile_name: profileName, + product_id: productId, + }); + open(); + } + return (
{ return ( - + ); })} @@ -101,7 +116,7 @@ const ProductInfo = ({
diff --git a/src/containers/customer/reservation/details/cancel-modal.tsx b/src/containers/customer/reservation/details/cancel-modal.tsx index 017fd0ca..984ff084 100644 --- a/src/containers/customer/reservation/details/cancel-modal.tsx +++ b/src/containers/customer/reservation/details/cancel-modal.tsx @@ -22,8 +22,10 @@ const CancelModal = ({ const router = useRouter(); const { formId } = useParams>(); const [cancellationReason, setCancellationReason] = useState(""); + const [loadingCancel, setLoadingCancel] = useState(false); async function handleCancel() { + setLoadingCancel(true); await responseHandler( cancelReservation( parseInt(formId, PARAMETER_DEFAULT_RADIX), @@ -39,6 +41,7 @@ const CancelModal = ({ router.refresh(); }, ); + setLoadingCancel(false); } return ( @@ -64,6 +67,7 @@ const CancelModal = ({ styleType="primary" title="취소하기" disabled={cancellationReason === ""} + loading={loadingCancel} onClick={handleCancel} /> diff --git a/src/containers/customer/reservation/reference-form.tsx b/src/containers/customer/reservation/reference-form.tsx index d53596bd..ce8c3520 100644 --- a/src/containers/customer/reservation/reference-form.tsx +++ b/src/containers/customer/reservation/reference-form.tsx @@ -3,6 +3,7 @@ import { usePathname, useRouter } from "next/navigation"; import { useFormContext } from "react-hook-form"; import { reservation } from "product-types"; +import { sendGAEvent } from "@next/third-parties/google"; import { BottomButton } from "@/components/buttons/common-buttons"; import popToast from "@/components/common/toast"; import ReferenceGrid from "@/containers/customer/reservation/reference/grid"; @@ -17,7 +18,11 @@ const ReferenceForm = ({ images }: { images: string[] }) => { const { setValue, watch } = useFormContext(); const currentPath = usePathname(); - const selectedImageList = watch("referenceImages"); + const [selectedImageList, productId, profileName] = watch([ + "referenceImages", + "productId", + "profileName", + ]); const addImage = (url: string, file?: File) => { const currentImages = selectedImageList || []; @@ -49,6 +54,10 @@ const ReferenceForm = ({ images }: { images: string[] }) => { function handleNext() { const nextPath = getNextPath("submit"); + sendGAEvent("event", "add_reference", { + profile_name: profileName, + product_id: productId, + }); router.push(nextPath); } diff --git a/src/containers/customer/reservation/reference/grid/grid.css.ts b/src/containers/customer/reservation/reference/grid/grid.css.ts index 42a3c8f5..af571673 100644 --- a/src/containers/customer/reservation/reference/grid/grid.css.ts +++ b/src/containers/customer/reservation/reference/grid/grid.css.ts @@ -4,7 +4,7 @@ import { styleVariants } from "@vanilla-extract/css"; export const fileSelectStyles = styleVariants({ container: [ - sprinkles({ borderColor: "blue" }), + sprinkles({ borderColor: "blue", backgroundColor: "white" }), { width: "100%", position: "relative", @@ -15,6 +15,12 @@ export const fileSelectStyles = styleVariants({ paddingTop: 8, paddingBottom: 8, marginBottom: 20, + ":hover": { + filter: "brightness(0.92);", + }, + ":active": { + filter: "brightness(0.92);", + }, }, ], info: [ diff --git a/src/containers/customer/reservation/reservation-form-provider.tsx b/src/containers/customer/reservation/reservation-form-provider.tsx index ad9328b8..6a395695 100644 --- a/src/containers/customer/reservation/reservation-form-provider.tsx +++ b/src/containers/customer/reservation/reservation-form-provider.tsx @@ -6,6 +6,7 @@ import { ID_REGEX } from "@/constants/common/user"; import { postReservation } from "@/services/client/customer/reservation"; import { responseHandler } from "@/services/common/error"; import { zodResolver } from "@hookform/resolvers/zod"; +import { sendGAEvent } from "@next/third-parties/google"; import { useRouter } from "next/navigation"; import { reservation } from "product-types"; import { FormProvider, useForm } from "react-hook-form"; @@ -87,6 +88,10 @@ const ReservationFormProvider = ({ const { handleSubmit } = method; async function onSubmit(data: reservation.FormType) { + sendGAEvent("event", "submit_reservation", { + profile_name: data.profileName, + product_id: data.productId, + }); await responseHandler( postReservation(data), (formId) => { diff --git a/src/containers/customer/reservation/submit-form.tsx b/src/containers/customer/reservation/submit-form.tsx index 6452eae9..70a7b3a8 100644 --- a/src/containers/customer/reservation/submit-form.tsx +++ b/src/containers/customer/reservation/submit-form.tsx @@ -33,7 +33,7 @@ const SubmitForm = ({ const { setValue, watch, - formState: { touchedFields }, + formState: { touchedFields, isSubmitting }, } = useFormContext(); const [totalPrice, noticeAgreement] = watch([ "totalPrice", @@ -80,6 +80,7 @@ const SubmitForm = ({ title="신청하기" type="submit" disabled={!noticeAgreement} + loading={isSubmitting} />
); diff --git a/src/containers/photographer/join/index.tsx b/src/containers/photographer/join/index.tsx index 456a6ede..3d4613bb 100644 --- a/src/containers/photographer/join/index.tsx +++ b/src/containers/photographer/join/index.tsx @@ -4,6 +4,7 @@ import { useRouter } from "next/navigation"; import { FormProvider, useForm } from "react-hook-form"; import { z } from "zod"; import { Join } from "profile-types"; +import { sendGAEvent } from "@next/third-parties/google"; import { zodResolver } from "@hookform/resolvers/zod"; import { MAX_LENGTHS } from "@/constants/common/schema"; import { ID_REGEX } from "@/constants/common/user"; @@ -15,6 +16,16 @@ import { CUSTOMED_CODE, responseHandler } from "@/services/common/error"; import Profile from "./profile"; import { joinStyles } from "./join.css"; +const RESERVED_PROFILE_NAMES = [ + ".env", + "photographer", + "customer", + "api", + "auth", + "login", +]; +const IS_RESERVED_PROFILE_NAME = "사용할 수 없는 아이디입니다."; + const PhotographerJoin = () => { const router = useRouter(); @@ -39,10 +50,17 @@ const PhotographerJoin = () => { defaultValues, resolver: zodResolver(joinSchema), }); - const { handleSubmit, setError } = method; + const { + handleSubmit, + setError, + formState: { isSubmitting }, + } = method; function handleSubmitFail(message?: string) { - if (message === CUSTOMED_CODE.PROFILE_NAME_ALREADY_EXISTS) { + if ( + message === CUSTOMED_CODE.PROFILE_NAME_ALREADY_EXISTS || + message === IS_RESERVED_PROFILE_NAME + ) { setError( "profileName", { message: "아이디를 다시 설정해주세요." }, @@ -52,15 +70,24 @@ const PhotographerJoin = () => { popToast("다시 시도해 주세요.", message || "가입에 실패했습니다.", true); } + function isReservedProfileName(profileName: string) { + return RESERVED_PROFILE_NAMES.includes(profileName); + } + async function onSubmit(data: Join) { - await responseHandler( - postProfile(data), - (url) => { - popToast("가입이 완료되었습니다!"); - router.push(`/photographer?url=${url}&tutorial=true`); - }, - handleSubmitFail, - ); + if (isReservedProfileName(data.profileName)) { + handleSubmitFail(IS_RESERVED_PROFILE_NAME); + } else { + sendGAEvent("event", "enroll", { profile_name: data.profileName }); + await responseHandler( + postProfile(data), + (url) => { + popToast("가입이 완료되었습니다!"); + router.push(`/photographer?url=${url}&tutorial=true`); + }, + handleSubmitFail, + ); + } } return ( @@ -76,6 +103,7 @@ const PhotographerJoin = () => { size="md" styleType="primary" title="가입하기" + loading={isSubmitting} style={{ marginTop: 30 }} /> diff --git a/src/containers/photographer/main/header.tsx b/src/containers/photographer/main/header.tsx index 2b6cb8d5..cb38cc9f 100644 --- a/src/containers/photographer/main/header.tsx +++ b/src/containers/photographer/main/header.tsx @@ -1,11 +1,11 @@ "use client"; import { useEffect } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import Link from "next/link"; import Image from "next/image"; import { LinkType } from "profile-types"; -import { Drawer, Menu } from "@mantine/core"; +import { Drawer, FocusTrap, Menu } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; import { SERVICE_LINKS } from "@/constants/common/common"; import CloseButton from "@/components/buttons/close-button"; @@ -23,6 +23,7 @@ const Header = ({ links?: LinkType[]; }) => { const router = useRouter(); + const pathName = usePathname(); const searchParams = useSearchParams(); const [opened, { close, open }] = useDisclosure(false); const [isOnTutorial, { close: closeTutorial, open: openTutorial }] = @@ -36,9 +37,15 @@ const Header = ({ const tutorialParam = searchParams.get("tutorial"); if (tutorialParam) { openTutorial(); + } else { + closeTutorial(); } }, [searchParams]); + useEffect(() => { + close(); + }, [pathName]); + return (
{isOnboarding ? ( @@ -134,8 +141,10 @@ const Header = ({ onClose={close} opened={opened || isOnTutorial} position="right" + autoFocus={false} classNames={{ ...customDrawerStyles }} > + { return (
- - - {view === "list" && ( - + + - )} - {view === "calender" && } - + {view === "list" && ( + + )} + {view === "calender" && } +
+
); }; diff --git a/src/containers/photographer/main/main.css.ts b/src/containers/photographer/main/main.css.ts index c167c60c..a0d6e3b0 100644 --- a/src/containers/photographer/main/main.css.ts +++ b/src/containers/photographer/main/main.css.ts @@ -6,16 +6,24 @@ import { styleVariants } from "@vanilla-extract/css"; export const mainviewStyles = styleVariants({ container: { width: "100%", - minWidth: 375, display: "flex", flexDirection: "column", + justifyContent: "space-between", padding: "32px 40px 80px 40px", "@media": { [breakpoints.mobile]: { padding: "24px 20px 50px 20px", + flexDirection: "column-reverse", + justifyContent: "flex-end", }, }, }, + content: { + width: "100%", + minWidth: 375, + display: "flex", + flexDirection: "column", + }, controller: { position: "relative", display: "flex", @@ -48,6 +56,6 @@ export const customDrawerStyles = styleVariants({ }, }, body: { - padding: "0px 0px 0px 10px", + padding: "0px 0px 60px 10px", }, }); diff --git a/src/containers/photographer/main/sidebar/link-copy.tsx b/src/containers/photographer/main/sidebar/link-copy.tsx index c8d11059..859c901f 100644 --- a/src/containers/photographer/main/sidebar/link-copy.tsx +++ b/src/containers/photographer/main/sidebar/link-copy.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import Link from "next/link"; +import { sendGAEvent } from "@next/third-parties/google"; import popToast from "@/components/common/toast"; import { CustomButton } from "@/components/buttons/common-buttons"; import InfoCaption from "@/components/common/info-caption"; @@ -19,6 +20,7 @@ const LinkCopy = () => { async function handleCopy() { try { + sendGAEvent("event", "copy_link", { url }); await navigator.clipboard.writeText(url); popToast( "원하는 곳에 공유해 프리비로 예약을 받아 보세요!", diff --git a/src/containers/photographer/main/sidebar/menu-list.tsx b/src/containers/photographer/main/sidebar/menu-list.tsx index bb70ec83..33a1a695 100644 --- a/src/containers/photographer/main/sidebar/menu-list.tsx +++ b/src/containers/photographer/main/sidebar/menu-list.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; +import { sendGAEvent } from "@next/third-parties/google"; import { Modal } from "@mantine/core"; import { mypageTabs } from "@/constants/photographer/mypage"; import { SERVICE_LINKS } from "@/constants/common/common"; @@ -29,13 +30,23 @@ const MenuList = ({ hasServiceLinks }: { hasServiceLinks?: boolean }) => { } function handleOpenTutorial() { + sendGAEvent("event", "view_tutorial"); router.replace("/photographer?tutorial=true", { scroll: true }); } return (
- 메뉴 +
+ 메뉴 + +
{ sendGAEvent("event", "start_product_register")} icon={ currentTab === "new-product" ? "/icons/sidebar/camera-blue.svg" diff --git a/src/containers/photographer/main/sidebar/sidebar.css.ts b/src/containers/photographer/main/sidebar/sidebar.css.ts index 2138d47e..70e1e132 100644 --- a/src/containers/photographer/main/sidebar/sidebar.css.ts +++ b/src/containers/photographer/main/sidebar/sidebar.css.ts @@ -11,9 +11,12 @@ const baseButton = style({ display: "flex", flexDirection: "row", alignItems: "center", - background: "none", border: "none", gap: 10, + + ":active": { + filter: "none", + }, }); export const itemStyles = styleVariants({ @@ -32,6 +35,12 @@ export const itemStyles = styleVariants({ }, }, }, + header: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + paddingRight: 50, + }, title: [ texts["headline-02"], sprinkles({ color: "text-01" }), @@ -52,6 +61,7 @@ export const itemStyles = styleVariants({ button: [ baseButton, sprinkles({ + backgroundColor: "white", color: "text-02", fontSize: { desktop: "xs", @@ -60,6 +70,19 @@ export const itemStyles = styleVariants({ fontWeight: "regular", }), ], + caption: [ + sprinkles({ color: "text-03", fontSize: "xs", fontWeight: "medium" }), + { + display: "none", + background: "none", + border: "none", + "@media": { + [breakpoints.mobile]: { + display: "inline", + }, + }, + }, + ], mobileDisable: { "@media": { [breakpoints.mobile]: { diff --git a/src/containers/photographer/main/tutorial/index.tsx b/src/containers/photographer/main/tutorial/index.tsx index 7bf2f0c6..37499965 100644 --- a/src/containers/photographer/main/tutorial/index.tsx +++ b/src/containers/photographer/main/tutorial/index.tsx @@ -183,7 +183,7 @@ const Tutorial = ({ onClick={handleClose} /> {TUTORIAL_LEVELS[index]?.link && ( - + )} diff --git a/src/containers/photographer/main/tutorial/tutorial.css.ts b/src/containers/photographer/main/tutorial/tutorial.css.ts index 85d2c518..40748b5d 100644 --- a/src/containers/photographer/main/tutorial/tutorial.css.ts +++ b/src/containers/photographer/main/tutorial/tutorial.css.ts @@ -162,7 +162,7 @@ export const mobileTutorialStyles = styleVariants({ borderWidth: 1, cursor: "pointer", boxShadow: "0px 5px 5px rgba(86, 152, 218, 0.15)", - ":hover": { + ":active": { animation: `${navButtonFrames} 1s ease-in-out infinite`, }, }, diff --git a/src/containers/photographer/mypage/notice/edit.tsx b/src/containers/photographer/mypage/notice/edit.tsx index ed710a72..1017f998 100644 --- a/src/containers/photographer/mypage/notice/edit.tsx +++ b/src/containers/photographer/mypage/notice/edit.tsx @@ -17,7 +17,7 @@ const NoticeEdit = () => { const { watch, control, - formState: { errors }, + formState: { errors, isSubmitting }, } = useFormContext(); const { remove, append } = useFieldArray({ control, @@ -58,11 +58,13 @@ const NoticeEdit = () => {
diff --git a/src/containers/photographer/mypage/notice/notice.css.ts b/src/containers/photographer/mypage/notice/notice.css.ts index deab9407..9662f7c3 100644 --- a/src/containers/photographer/mypage/notice/notice.css.ts +++ b/src/containers/photographer/mypage/notice/notice.css.ts @@ -27,6 +27,7 @@ export const editStyles = styleVariants({ header: { display: "flex", justifyContent: "space-between", + alignItems: "center", }, infoWrapper: [ texts["headline-03"], diff --git a/src/containers/photographer/mypage/profile/edit/banner.tsx b/src/containers/photographer/mypage/profile/edit/banner.tsx index 2a38ef0d..a75a1da4 100644 --- a/src/containers/photographer/mypage/profile/edit/banner.tsx +++ b/src/containers/photographer/mypage/profile/edit/banner.tsx @@ -1,12 +1,15 @@ import { ChangeEvent } from "react"; import { useFormContext } from "react-hook-form"; import { PhotographerForm } from "profile-types"; +import BackgroundImage from "@/containers/customer/main/background-image"; import { CustomButton } from "@/components/buttons/common-buttons"; import { ACCEPTED_IMAGE } from "@/constants/common/common"; import { editStyles } from "./edit.css"; const Banner = () => { - const { setValue } = useFormContext(); + const { setValue, watch } = useFormContext(); + + const bannerImg = watch("bannerImg"); function handleDeleteBanner() { setValue("bannerImg", undefined); @@ -24,6 +27,11 @@ const Banner = () => { return (
배너 이미지 + {bannerImg && ( +
+ +
+ )}
{ formField="profileName" disabled container={{ marginTop: 0 }} + style={{ width: 150, minWidth: 0 }} /> title="연락처" @@ -70,6 +71,7 @@ const BasicInfo = () => { formField="contact" multiline container={{ marginBottom: 10 }} + style={{ width: 150, minWidth: 0 }} /> {errors.message && ( {errors.message.message} diff --git a/src/containers/photographer/mypage/profile/edit/edit.css.ts b/src/containers/photographer/mypage/profile/edit/edit.css.ts index f6240b32..5d001de0 100644 --- a/src/containers/photographer/mypage/profile/edit/edit.css.ts +++ b/src/containers/photographer/mypage/profile/edit/edit.css.ts @@ -33,6 +33,11 @@ export const linkStyles = styleVariants({ outline: "none", borderBottomColor: "#007AFF", }, + "@media": { + [breakpoints.mobile]: { + minWidth: 0, + }, + }, }, ], error: [ @@ -78,6 +83,12 @@ export const editStyles = styleVariants({ display: "flex", alignItems: "center", minWidth: "fit-content", + "@media": { + [breakpoints.mobile]: { + flexDirection: "column", + alignItems: "flex-start", + }, + }, }, link: { paddingTop: 20, @@ -91,6 +102,14 @@ export const editStyles = styleVariants({ flex: "0 0 104px", marginRight: 20, gap: 12, + "@media": { + [breakpoints.mobile]: { + flexDirection: "row", + width: 300, + alignItems: "flex-end", + marginBottom: 30, + }, + }, }, title: [texts["headline-03"], sprinkles({ color: "text-02" })], fieldsWrapper: { @@ -115,6 +134,15 @@ export const editStyles = styleVariants({ }), { margin: 3, display: "block" }, ], + banner: { + margin: "20px 0px", + display: "none", + "@media": { + [breakpoints.mobile]: { + display: "block", + }, + }, + }, }); export const leaveStyles = styleVariants({ diff --git a/src/containers/photographer/mypage/profile/edit/leave-modal.tsx b/src/containers/photographer/mypage/profile/edit/leave-modal.tsx index c0a41f82..14a71022 100644 --- a/src/containers/photographer/mypage/profile/edit/leave-modal.tsx +++ b/src/containers/photographer/mypage/profile/edit/leave-modal.tsx @@ -30,8 +30,10 @@ const LeaveModal = ({ const router = useRouter(); const [selectedReason, setSelectedReason] = useState(""); const [reason, setReason] = useState(""); + const [loading, setLoading] = useState(false); async function handleLeave() { + setLoading(true); await responseHandler( leaveService(selectedReason === "기타" ? reason : selectedReason), () => { @@ -46,6 +48,7 @@ const LeaveModal = ({ popToast("다시 시도해주세요.", "탈퇴에 실패했습니다.", true); }, ); + setLoading(false); } function handleSelectReason(newReason: string) { @@ -122,6 +125,7 @@ const LeaveModal = ({ (reason === "" && selectedReason === "기타") || selectedReason === "" } onClick={handleLeave} + loading={loading} /> ); diff --git a/src/containers/photographer/mypage/profile/index.tsx b/src/containers/photographer/mypage/profile/index.tsx index efb5f002..b0fea1a9 100644 --- a/src/containers/photographer/mypage/profile/index.tsx +++ b/src/containers/photographer/mypage/profile/index.tsx @@ -4,6 +4,7 @@ import { useRouter } from "next/navigation"; import { PhotographerForm } from "profile-types"; import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; import { z } from "zod"; +import { sendGAEvent } from "@next/third-parties/google"; import { zodResolver } from "@hookform/resolvers/zod"; import { MAX_LENGTHS } from "@/constants/common/schema"; import { putProfile } from "@/services/client/photographer/mypage/profile"; @@ -60,9 +61,13 @@ const MyProfile = ({ profile }: { profile: PhotographerForm }) => { defaultValues: { ...profile }, resolver: zodResolver(profileSchema), }); - const { handleSubmit } = method; + const { + handleSubmit, + formState: { isSubmitting }, + } = method; const onSubmit: SubmitHandler = async (data) => { + sendGAEvent("event", "edit_profile", { profile_name: data.profileName }); await responseHandler( putProfile(data), () => { @@ -93,6 +98,7 @@ const MyProfile = ({ profile }: { profile: PhotographerForm }) => { styleType="primary" size="sm" title="프로필 저장" + loading={isSubmitting} style={{ marginLeft: "auto", width: 96, height: 40 }} />
diff --git a/src/containers/photographer/product/form/field/image-input.tsx b/src/containers/photographer/product/form/field/image-input.tsx index 913062f3..e8aa7e26 100644 --- a/src/containers/photographer/product/form/field/image-input.tsx +++ b/src/containers/photographer/product/form/field/image-input.tsx @@ -5,7 +5,11 @@ import { useDisclosure } from "@mantine/hooks"; import { ACCEPTED_IMAGE } from "@/constants/common/common"; import InfoCaption from "@/components/common/info-caption"; import ImageThumbnail from "@/components/images/image-thumbnail"; -import { validatingFiles } from "@/utils/image"; +import { + getFormImageFromFiles, + resizeImages, + validatingFiles, +} from "@/utils/image"; import { CustomButton, FinishButton, @@ -43,23 +47,34 @@ const ImagesInput = ({ images, setImage, disabled }: ImageInputProps) => { return null; } - function handleAddImage( + async function handleAddImage( e: React.DragEvent | React.ChangeEvent, ) { e.preventDefault(); const selectedFiles = getFileList(e); - const { isOver, selectedImages } = validatingFiles(selectedFiles); + const { isOver, validatedFiles } = validatingFiles(selectedFiles); if (isOver) { popToast("10MB 이하의 이미지만 등록할 수 있습니다."); } - if (selectedImages.length > 0) { - setImage((prev) => { - const newImages = [...prev, ...selectedImages]; - if (newImages.length > MAX_IMAGE_COUNT) { - popToast("이미지는 최대 10개까지 등록할 수 있습니다."); - } - return newImages.slice(ARRAY_START_INDEX, MAX_IMAGE_COUNT); - }); + if (validatedFiles.length > 0) { + try { + const resizedImages = await resizeImages(validatedFiles); + setImage((prev) => { + const newImages = [...prev, ...getFormImageFromFiles(resizedImages)]; + console.log(newImages); + if (newImages.length > MAX_IMAGE_COUNT) { + popToast("이미지는 최대 10개까지 등록할 수 있습니다."); + } + return newImages.slice(ARRAY_START_INDEX, MAX_IMAGE_COUNT); + }); + console.log(images); + } catch { + popToast("다시 시도해주세요.", "이미지 업로드에 실패했습니다.", true); + } + } + if (e.type === "change") { + const input = e.target as HTMLInputElement; + input.value = ""; } } @@ -107,7 +122,7 @@ const ImagesInput = ({ images, setImage, disabled }: ImageInputProps) => { information="업로드하기 전, 포트폴리오로 사용할 수 있는 사진인지 확인해주세요. 도용 및 무단 사용된 이미지는 삭제될 수 있습니다." /> - + )}
(imageBase); const subtitle = watch("subtitle"); @@ -222,8 +222,9 @@ const ProductForm = ({ type="submit" styleType="primary" size="md" - title="저장하기" + title="등록하기" style={{ marginTop: 40 }} + loading={isSubmitting} /> )} diff --git a/src/containers/photographer/product/list/product-banner.tsx b/src/containers/photographer/product/list/product-banner.tsx index 3bec429e..2df28f1d 100644 --- a/src/containers/photographer/product/list/product-banner.tsx +++ b/src/containers/photographer/product/list/product-banner.tsx @@ -1,8 +1,10 @@ +import { useState } from "react"; import Image from "next/image"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { Switch } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; +import Loader from "@/components/common/loader"; import { responseHandler } from "@/services/common/error"; import { CustomButton } from "@/components/buttons/common-buttons"; import { ProductResponseData } from "@/services/server/photographer/mypage/products"; @@ -27,8 +29,12 @@ const ProductBanner = ({ useDisclosure(false); const [disableOpened, { open: openDisable, close: closeDisable }] = useDisclosure(false); + const [switchLoading, setSwitchLoading] = useState(false); + const [deleteLoading, setDeleteLoading] = useState(false); async function handleSwitchOpen() { + closeDisable(); + setSwitchLoading(true); await responseHandler( putProductStatus( productId, @@ -46,9 +52,12 @@ const ProductBanner = ({ ); }, ); + setSwitchLoading(false); } async function handleClickDelete() { + closeDelete(); + setDeleteLoading(true); await responseHandler( deleteProduct(productId), () => { @@ -63,6 +72,7 @@ const ProductBanner = ({ ); }, ); + setDeleteLoading(false); } return ( @@ -88,14 +98,21 @@ const ProductBanner = ({
예약 활성화 - + {switchLoading ? ( + + ) : ( + + )} diff --git a/src/containers/photographer/product/new-product.tsx b/src/containers/photographer/product/new-product.tsx index 4b305316..591edbbe 100644 --- a/src/containers/photographer/product/new-product.tsx +++ b/src/containers/photographer/product/new-product.tsx @@ -6,6 +6,7 @@ import Image from "next/image"; import { FormImage, Notice, ProductFormdata } from "product-types"; import { Popover } from "@mantine/core"; import { CustomButton } from "@/components/buttons/common-buttons"; +import { sendGAEvent } from "@next/third-parties/google"; import popToast from "@/components/common/toast"; import { postNewProduct } from "@/services/client/photographer/products"; import { responseHandler } from "@/services/common/error"; @@ -52,6 +53,7 @@ const NewProduct = ({ baseNotice }: { baseNotice: Notice[] }) => { }; async function addNewProduct(data: ProductFormdata, images: FormImage[]) { + sendGAEvent("event", "product_register", { product_title: data.title }); await responseHandler( postNewProduct(data, images), () => { @@ -101,6 +103,7 @@ const NewProduct = ({ baseNotice }: { baseNotice: Notice[] }) => { size="sm" styleType="primary" title="등록 예시 미리 보기" + onClick={() => sendGAEvent("event", "view_example_product")} /> diff --git a/src/containers/photographer/product/product-list.tsx b/src/containers/photographer/product/product-list.tsx index 0f1320ce..a9bc4dac 100644 --- a/src/containers/photographer/product/product-list.tsx +++ b/src/containers/photographer/product/product-list.tsx @@ -2,6 +2,8 @@ import Link from "next/link"; import { Status } from "product-types"; +import { sendGAEvent } from "@next/third-parties/google"; +import { CustomButton } from "@/components/buttons/common-buttons"; import { ProductResponseData } from "@/services/server/photographer/mypage/products"; import ProductBanner from "./list/product-banner"; import { listStyles } from "./product.css"; @@ -23,8 +25,14 @@ const ProductList = ({ productDatas, status }: ProductListProps) => {
{titles[status]} {status === "ACTIVE" && ( - - 추가 + + sendGAEvent("event", "start_product_register")} + title="추가" + size="sm" + styleType="primary" + style={{ paddingLeft: 15, paddingRight: 15 }} + /> )}
diff --git a/src/containers/photographer/product/product.css.ts b/src/containers/photographer/product/product.css.ts index 0391f572..277a0c25 100644 --- a/src/containers/photographer/product/product.css.ts +++ b/src/containers/photographer/product/product.css.ts @@ -115,21 +115,6 @@ export const listStyles = styleVariants({ gap: 10, minWidth: "100%", }, - add: [ - texts["button-01"], - sprinkles({ - backgroundColor: "blue", - color: "white", - }), - { - borderRadius: 4, - textDecoration: "none", - padding: "8px 20px", - display: "flex", - flexDirection: "row", - alignItems: "center", - }, - ], }); export const headerStyle = { diff --git a/src/containers/photographer/reservation/section/control/cancel-modal.tsx b/src/containers/photographer/reservation/section/control/cancel-modal.tsx index 0e199f85..25c02993 100644 --- a/src/containers/photographer/reservation/section/control/cancel-modal.tsx +++ b/src/containers/photographer/reservation/section/control/cancel-modal.tsx @@ -22,8 +22,10 @@ const CancelModal = ({ const router = useRouter(); const { formId } = useParams>(); const [cancellationReason, setCancellationReason] = useState(""); + const [loadingCancel, setLoadingCancel] = useState(false); async function handleCancel() { + setLoadingCancel(true); await responseHandler( putReservationStatus( parseInt(formId, PARAMETER_DEFAULT_RADIX), @@ -41,6 +43,7 @@ const CancelModal = ({ router.refresh(); }, ); + setLoadingCancel(false); } return ( @@ -67,6 +70,7 @@ const CancelModal = ({ title="취소하기" disabled={cancellationReason === ""} onClick={handleCancel} + loading={loadingCancel} /> ); diff --git a/src/containers/photographer/reservation/section/control/confirm.tsx b/src/containers/photographer/reservation/section/control/confirm.tsx index 8ebe3bbb..76ee0dec 100644 --- a/src/containers/photographer/reservation/section/control/confirm.tsx +++ b/src/containers/photographer/reservation/section/control/confirm.tsx @@ -49,6 +49,7 @@ const Confirm = () => { ]); const prices = options.map((option) => option.price); const [totalPrice, setTotalPrice] = useState(0); + const [saveLoading, setSaveLoading] = useState(false); useEffect(() => { const newPrice = prices.reduce((sum, price) => sum + price, 0) + basicPrice; @@ -58,6 +59,7 @@ const Confirm = () => { }, [prices, totalPrice, basicPrice]); async function handlePutNewDetails() { + setSaveLoading(true); const reservationNumber = getValues("reservationNumber"); await responseHandler( putReservationDetails({ @@ -75,6 +77,7 @@ const Confirm = () => { popToast("다시 시도해주세요.", "수정에 실패했습니다.", true); }, ); + setSaveLoading(false); } function progressNextStatus() { @@ -170,6 +173,7 @@ const Confirm = () => { size="sm" styleType="primary" onClick={handlePutNewDetails} + loading={saveLoading} style={{ flex: 1 }} /> diff --git a/src/containers/photographer/reservation/section/control/memo.tsx b/src/containers/photographer/reservation/section/control/memo.tsx index 2a35eda6..ef7b1202 100644 --- a/src/containers/photographer/reservation/section/control/memo.tsx +++ b/src/containers/photographer/reservation/section/control/memo.tsx @@ -12,10 +12,12 @@ import { sectionStyles } from "../section.css"; const PhotographerMemo = () => { const [isEditing, setIsEditing] = useState(false); + const [saveLoading, setSaveLoading] = useState(false); const router = useRouter(); const { getValues } = useFormContext
(); async function handleSubmitMemo() { + setSaveLoading(true); const [reservationNumber, photographerMemo] = getValues([ "reservationNumber", "photographerMemo", @@ -30,6 +32,7 @@ const PhotographerMemo = () => { popToast("다시 시도해주세요.", "수정에 실패했습니다.", true); }, ); + setSaveLoading(false); } return ( @@ -61,6 +64,7 @@ const PhotographerMemo = () => { /> { const router = useRouter(); const { formId } = useParams>(); + const [statusLoading, setStatusLoading] = useState(false); async function handleStatusChange() { + setStatusLoading(true); await responseHandler( putReservationStatus( parseInt(formId, PARAMETER_DEFAULT_RADIX), @@ -41,6 +44,7 @@ const StatusModal = ({ router.refresh(); }, ); + setStatusLoading(false); } return ( @@ -61,6 +65,7 @@ const StatusModal = ({ styleType="primary" title="변경하기" onClick={handleStatusChange} + loading={statusLoading} />
diff --git a/src/services/client/photographer/products.ts b/src/services/client/photographer/products.ts index a370cca4..c8e8e8d1 100644 --- a/src/services/client/photographer/products.ts +++ b/src/services/client/photographer/products.ts @@ -3,6 +3,8 @@ import { FilterItemType } from "common-types"; import { PARAMETER_DEFAULT_RADIX } from "@/constants/common/common"; import apiClient from "../core"; +const TIMEOUT_BOUND_FOR_IMAGE_REQUESTS = 20000; + export async function getProductTitles(): Promise { const { data } = await apiClient.get("photographer/product/title").json<{ data: { @@ -64,6 +66,7 @@ export async function postNewProduct( await apiClient .post("photographer/product", { body: formData, + timeout: TIMEOUT_BOUND_FOR_IMAGE_REQUESTS, }) .json(); } @@ -146,6 +149,7 @@ export async function putProductDetails( const response = await apiClient.put("photographer/product", { body: formData, + timeout: TIMEOUT_BOUND_FOR_IMAGE_REQUESTS, }); return response; } diff --git a/src/services/common/error.ts b/src/services/common/error.ts index f937f506..32586b81 100644 --- a/src/services/common/error.ts +++ b/src/services/common/error.ts @@ -5,14 +5,30 @@ export interface CustomedError extends HTTPError { } export const CUSTOMED_CODE: { [key: string]: string } = { - PROFILE_NAME_ALREADY_EXISTS: "이미 존재하는 프로필명입니다.", + PROFILE_NAME_ALREADY_EXISTS: "이미 존재하는 아이디입니다.", PRODUCT_ALREADY_EXISTS: "이미 존재하는 상품입니다.", - INVALID_MEMBER_ROLE_TYPE: "잘못된 아이디입니다.", - INVALID_STATUS_TRANSITION: "상태 전환이 불가능합니다", + ACCESS_DENIED: "권한이 없습니다.", + INVALID_STATUS_TRANSITION: "현재 신청 상태를 변경할 수 없습니다.", + PRODUCT_INACTIVE_STATUS: "현재 예약할 수 없는 상품입니다.", + COMPONENT_TITLE_ALREADY_EXISTS: + "같은 이름의 항목은 하나만 등록할 수 있습니다.", + PRODUCT_NOT_FOUND: "존재하지 않는 상품입니다.", + MEMBER_NOT_FOUND: "존재하지 않는 작가입니다.", + PROFILE_NAME_NOT_FOUND: "존재하지 않는 아이디입니다.", + NO_RESERVATION_FORM: "존재하지 않는 신청서입니다.", + PRODUCT_IMAGE_NOT_FOUND: "상품의 이미지가 존재하지 않습니다.", + INVALID_SHOOTING_DATE: "해당 날짜로 촬영 일정을 확정할 수 없습니다.", + INVALID_SHOOTING_TIME: "해당 시간으로 촬영 일정을 확정할 수 없습니다.", + LINK_TITLE_DUPLICATE: "같은 이름의 링크를 등록할 수 없습니다.", + NOTICE_TITLE_DUPLICATE: "같은 제목의 공지사항을 등록할 수 없습니다.", + MAXIMUM_UPLOAD_SIZE_EXCEEDED: "이미지의 용량이 너무 큽니다.", + NOT_FOUND_ESSENTIAL_TITLE: + "공지사항에는 '취소 및 환불 규정', '예약 변경 규정'이 필수로 포함되어야 합니다.", }; const CUSTOMED_STATUS: { [key: number]: string } = { 404: "존재하지 않습니다.", + 413: "요청의 용량이 너무 큽니다.", }; export function getCustomedErrorMessage( diff --git a/src/services/server/photographer/mypage/notice.ts b/src/services/server/photographer/mypage/notice.ts index 85417b26..ba78eaa1 100644 --- a/src/services/server/photographer/mypage/notice.ts +++ b/src/services/server/photographer/mypage/notice.ts @@ -10,7 +10,7 @@ export async function getCurrentNotices(): Promise { { title: "취소 및 환불 규정", content: - "촬영일 기준 3일 전까지는 취소시 예약금을 환불해 드립니다. \n노쇼시 환불이 어렵습니다.", + "촬영일 기준 일 전까지는 취소시 예약금을 환불해 드립니다. \n노쇼시 환불이 어렵습니다.", }, { title: "예약 변경 규정", diff --git a/src/utils/image.ts b/src/utils/image.ts index 6849c1dc..337db5de 100644 --- a/src/utils/image.ts +++ b/src/utils/image.ts @@ -20,17 +20,74 @@ export function getFormImageFromFiles(files: File[]): FormImage[] { export function validatingFiles(inputFiles: FileList | null): { isOver: boolean; - selectedImages: FormImage[]; + validatedFiles: File[]; } { if (inputFiles === null) { - return { isOver: false, selectedImages: [] }; + return { isOver: false, validatedFiles: [] }; } const filesArray = Array.from(inputFiles); return { isOver: filesArray.filter((file) => file.size > ACCEPTED_IMAGE.size).length > 0, - selectedImages: getFormImageFromFiles( - filesArray.filter((file) => file.size <= ACCEPTED_IMAGE.size), + validatedFiles: filesArray.filter( + (file) => file.size <= ACCEPTED_IMAGE.size, ), }; } + +const MAX_WIDTH_UPLOAD = 1000; +const MAX_HEIGHT_UPLOAD = 1000; + +async function resizeImage(file: File): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.src = URL.createObjectURL(file); + + img.onload = () => { + let targetWidth = img.width; + let targetHeight = img.height; + + if ( + targetHeight <= MAX_HEIGHT_UPLOAD && + targetWidth <= MAX_WIDTH_UPLOAD + ) { + resolve(file); + } + if (targetWidth > MAX_WIDTH_UPLOAD) { + targetHeight *= MAX_WIDTH_UPLOAD / targetWidth; + targetWidth = MAX_WIDTH_UPLOAD; + } + if (targetHeight > MAX_HEIGHT_UPLOAD) { + targetWidth *= MAX_HEIGHT_UPLOAD / targetHeight; + targetHeight = MAX_HEIGHT_UPLOAD; + } + const canvas = document.createElement("canvas"); + canvas.width = targetWidth; + canvas.height = targetHeight; + + const ctx = canvas.getContext("2d"); + if (!ctx) return reject(new Error("failed to use canvas")); + + ctx.drawImage(img, 0, 0, targetWidth, targetHeight); + + canvas.toBlob((blob) => { + if (!blob) return reject(new Error("failed to extract blob")); + const resizedFile = new File([blob], file.name, { type: file.type }); + resolve(resizedFile); + }, file.type); + }; + + img.onerror = () => reject(new Error("failed to load image")); + }); +} + +export async function resizeImages(files: File[]): Promise { + const resizedFiles = await Promise.all( + files.map(async (file) => { + const resizedImage = await resizeImage(file); + return resizedImage; + }), + ); + + return resizedFiles; +}