반응형

How To Eat

우리 매장은 맛있게 먹는 방법이 있다. 깻잎에 마요네즈가 섞인 천사채와 날치알, 환상의 마요네즈 소스까지 곁들여 쭈꾸미와 함께 쏙하면 기가막힌 천상의 맛이다. 하지만, 외국인들에게 이 맛있는 조합을 소개하기가 매우 어려운 사실.. 이를 해결하기 위해 만들어 보도록 하겠다.

 

이전 시간에 사용한 모달을 재활용해서 How To Eat 버튼과 모달창을 구현해보겠다.

 

const HowtoeatButton = () => {
    const [isModalOpen, setIsModalOpen] = useState(false);
    const [curPage, setCurPage] = useState(0);

    const navigatePage = (direction : Direction) => {
        setCurPage((prevPage) => {
            if (direction === 'prev' && prevPage > 0) {
                return prevPage - 1;
            } else if (direction === 'next' && prevPage < MAX_PAGE) {
                return prevPage + 1;
            }
            return prevPage;
        });
    };

    return (
        <div style={{ position: "fixed", bottom: "20px", zIndex: 1000 }} className="left-1/2 how-to-eat">
            <button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" onClick={() => setIsModalOpen(true)}>How To Eat</button>
            {isModalOpen && (
                <HowtoeatModal open={isModalOpen} onClose={() => setIsModalOpen(false)}>
                    <div className="flex h-11/12 bg-blue-700">
                        <NavigationButton direction="prev" onClick={() => navigatePage('prev')} />
                        <ContentDisplay img={img} description={desc[curPage]} pageNumber={curPage} />
                        <NavigationButton direction="next" onClick={() => navigatePage('next')} />
                    </div>
                </HowtoeatModal>
            )}
        </div>
    );
};

 

여기에서 두가지 버튼은 모달의 이미지를 넘기는 왼쪽, 오른쪽 버튼이다.

 

const navigatePage = (direction : Direction) => {
        setCurPage((prevPage) => {
            if (direction === 'prev' && prevPage > 0) {
                return prevPage - 1;
            } else if (direction === 'next' && prevPage < MAX_PAGE) {
                return prevPage + 1;
            }
            return prevPage;
        });
    };

 

위 함수를 통해 방향이 무엇인지에 따라 누른 버튼을 확인하고, 방향에 따른 페이지수 변화를 주는 역할이다.

대충 출력되는 화면을 확인해보자면

 

 

으 정말 멋없는 디자인.. 위의 색들은 화면 크기 조정을 위해 프레임을 만들어둔 것 이므로 뭐라하지 마세요!!

이제 여기에 찍어둔 사진을 적용해보겠다.

 

는 실패 (* 아직 사진 최신화가 되지 않아 불가능 ㅠㅠ)

 

사진 적용은 나중으로 미루고 디자인을 수정해야겠다. 기존 버튼 배치가 불편해보여서 하단으로 이동했다. 그리고 이미지와 컨텐츠 높이를 반반으로 수정했다.

 

 

 

 

 

 

무이스!!!! 확실히 이전보다 나아진 느낌!! 디자인 전공자 동생왈 매장의 메인 색 테마를 정하고 칠해라. 그리하여 색깔을 셀렉

해보았다. 전체적인 색 조합을 고려해보려고 이것저것 뒤져보았다.

 

 

요놈들이 뭔가 열정도 쭈꾸미의 유니폼 '검정' 과 열정도의 '빨강', 쭈꾸미의 '선홍' 이 어울리는 듯한 뭔가 그럴싸한 느낌을 받아서 셀렉!

 

 

확실히 디자인이 사는구만!! 좋습니다. 이거지 이거야. 이제 사진만 최신화하면 완성될 맛있게 먹는법 완성!

반응형
반응형

Use Case

  1. 메뉴 출력을 N x 2 로 만들고 메뉴 마다 디테일 모달 띄우기
  2. Sticky Navbar 제작
  3. How to Eat

디테일 모달

현재 메뉴판은 한 줄로 쭉 나열되어있고 메뉴 카드마다 메뉴명, 메뉴 한국어명, 메뉴 디테일, 가격 이렇게 정보가 한꺼번에 나와있다. 내 생각에는 정보가 한 번에 다 나오면 귀찮은 동작없이 메뉴를 살필 수 있다고 생각하는데 아무래도 메뉴가 하나씩 나오게 되면 사용자 입장에서는 더 많은 스크롤을 내려야 한다. 따라서, N * 2 로 메뉴 리스트를 바꾸고 각 메뉴를 눌렀을 때 디테일 모달이 나오도록 변경하려고 한다.

우선, 모달 띄우기 전 화면에 출력할 내용은 외국어 메뉴명, 한국어 메뉴명, 가격이다. 이후 각 메뉴의 Element 에 onClick 을 부여해 데이터를 모달에 디테일 한 작업을 추가할 예정이다. 추후에 디테일 모달에는 Carousel 을 넣고 싶다.

 

N x 2의 메뉴

 

.menu-list {
    @apply flex flex-wrap gap-4 justify-center items-center;
}

@media (max-width: 600px) {
    .menu-list {
        @apply flex-row;
    }
}

 

gap 클래스를 기호에 맞게 수정해 주어 카드간의 간격을 조정하고, .menu-list 클래스에 flex-row로 변경하여 카드가 열별로 출력되게 변경했다.

 

이제 디테일 모달을 만들차례

 

만들려고 시도를 하자마자 클릭 이벤트를 넣는데 문제가 발생했다.

어이없는 오류 1 : 부모 Div 안에 모달 넣지말자

문제 상황

처음에는 모달을 열고 닫는 간단한 기능을 구현하려고 했다. MenuItemCard 컴포넌트에서 카드 요소를 클릭하면 모달이 열리고, 모달 내부의 닫기 버튼을 클릭하면 모달이 닫히도록 했다. 코드의 핵심 부분은 다음과 같다.

<div className="rounded-lg shadow-md bg-white overflow-hidden w-72 h-150 card" onClick={handleModalOpen}> 
    <Image src={imageUrl} alt={name} className="object-cover w-full h-1/2" width={240} height={100} /> 
    <div className="p-4 h-1/2"> 
        <h3 className="text-xl font-extrabold mb-2">{name}</h3> 
        <div className="text-md font-normal">{discription}</div> 
        <div className="text-gray-700 card-price">{price}</div> 
    </div> 
    {isModalOpen && ( 
        <DetailModal open={isModalOpen} onClose={handleModalClose}> 
            <div className="flex h-11/12 bg-blue-700"> {/* 모달 내용 */} </div> </DetailModal> )} 
</div>

 

이렇게 보면 당연히! 동작되었을 줄 알았지만 모달창이 영원히 닫히지 않는 문제가 생기게 되었다. 도대체 무엇이 문제였을까?!

 

문제 원인

문제의 원인은 모달이 부모 divonClick 이벤트에 의해 닫히는 것이었다. 모달을 클릭할 때도 부모 divonClick 이벤트가 호출되면서 setIsModalOpen 함수가 번복되는 상황이 발생했다.

 

해결 방법

해결 방법은 간단했다. onClick 이벤트를 부모 div에서 제거하고, 필요한 자식 요소에만 이벤트 핸들러를 설정하면 된다.

 

이 따위 문제를 2시간 넘게 잡고 있다는 것. 개발자로 살아남아가고 있다는 것.

 

수정된 코드
<div className="rounded-lg shadow-md bg-white overflow-hidden w-72 h-150 card">
            <div onClick={handleModalOpen}>
                <Image
                    src={imageUrl}
                    alt={name}
                    className="object-cover w-full h-1/2"
                    width={240}
                    height={100}
                />
                <div className="p-4 h-1/2"  >
                    <h3 className="text-xl font-extrabold mb-2">{name}</h3>
                    <div className="text-md font-normal">{discription}</div>
                    <div className="text-gray-700 card-price">{price}</div>
                </div>
            </div>

            {isModalOpen && (
                <DetailModal open={isModalOpen} onClose={()=>handleModalClose()}>
                    <div className="flex h-11/12 bg-blue-700">
                        {/* 모달 내용 */}
                    </div>
                </DetailModal>
            )}
        </div>

 

결과물

 

 

한국어 메뉴일 때는 공백이 매우 신경쓰이는데 일단 디자인 적인 부분은 나중에 신경쓰기로 했다. 깨알 수정안한 맛있게먹는법의 흔적

 

다음에는 How To Eat 모달을 수정할 예정!

반응형
반응형

오픈톡방에 눈에 띄는 한 채팅

What? 나의 쭈꾸미 메뉴 사이트는 vercel 로 배포가 되어있는데 링크를 타고 들어가보았다.

 

대충 알고보니 LG uplus 가 해당 도메인을 차단한듯 보인다. 그간 무슨 문제가 많았던 vercel .. 차단 엔딩이라니.. 언제 복구가 되려나? 당장 서비스가 완성되면 바로 사용해야하는데..

 

https://www.youtube.com/shorts/yWdFwIlsIqc

 

제로초님이 설명해주신 영상 참고해보자

 

나에게 선택지는 두 가지가 있다.

  1. 도메인 구입 후 적용
  2. 새로운 사이트에서 배포

도메인 구입


일단 나의 서비스도 아닐 뿐 더러 AWS 로 이것저것 귀찮게 해야하는게 싫었다. 또, 도메인 구입비용도 드는게 좀 그래서 선택하지 않았다.

새로운 사이트 배포


결국, 새로운 사이트에 배포를 하기로 결정했고, 저번에 한 번 이용한 적이 있는 Netlify에 배포하기로 결정했다.

 

vercel 과 비슷하게 깃헙과 연동 후 배포를 원하는 리파지터리를 선택하면 알아서 배포가 된다.

 

 

배포완료

 

반응형
반응형

배경


필자는 열정도 쭈꾸미 음식점에서 일하고 있다. 일을 하면서 외국인 손님이 하루에 한 팀은 꼭 오는 것을 알게 되었다. 일본인 비중이 많아 일본인 전용 메뉴판을 하나 만들어 두었다. 하지만, 가끔, 중국, 대만, 태국, 등 다양한 국적의 사람들이 오고 쭈꾸미라는 상상하기 어려운 음식을 사진없이 메뉴판만으로는 어렵다는 생각이 들었다. 메뉴판을 일일이 만들기 어려워 웹 사이트로 간단히 QR 코드 한방에 확인가능 하면 좋을 것 같아. 계획했다.

 

개발환경


프론트엔드 개발자를 목표로 하고 있기에 왠만하면 서버없이 구동을 하려고 한다. 혹시나, 서버를 사용할 수도 있기에 요새 배우고 있는 NextJS 를 이용하고자 한다. 배포 서버는 역시 무료 업체인 Vercel에 배포를 하려고 한다.

사용 프레임워크 : NextJS
배포 환경 : Vercel

 

요구사항


우선, 요구사항을 분석해보겠다.

  1. 외국인 손님을 위한 언어변환이 가능한 메뉴판
  2. 이해하기 쉬운 메뉴와 사진 제공

당장에 구현해야할 요구사항은 다음과 같다고 생각한다.

 

1. 언어변환


처음에는 실시간 변환을 하는게 어떨까 생각이 들었는데 Next에 대한 이해가 부족해서 그런지 Next i18 을 사용하기엔 조금 개발이 걸릴 것 같아 노가다로 진행하기로 했다.

 

Dropdown Button

 

한국어, 영어, 일본어, 중국어, 태국어 총 5가지 언어를 지원하기로 했다. 일해본 결과 5개언어면 될 것 같다.

"use client";

import { useRouter, usePathname } from "next/navigation";

type Language = 'ko' | 'en' | 'ja' | 'th' | 'ch';

type Props = {
    selectedLanguage: Language;
    setselectedLanguage: (language: Language) => void;
};


const MenuDropdown = ({ selectedLanguage, setSelectedLanguage }: Props) => {
    const router = useRouter();
    const changeLanguage = (lng: Language) => {
        setSelectedLanguage(lng);
    };
    return (
        <select
            value={selectedLanguage}
            onChange={(e) => changeLanguage(e.target.value as Language)}
            className="block  px-4 py-2 text-base text-gray-700 bg-white border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500"
        >
            <option value="ko">한국어</option>
            <option value="en">English</option>
            <option value="ja">日本語</option>
            <option value="ch">中國語</option>
            <option value="th">ภาษาไทย</option>
        </select>
    );
};

export default MenuDropdown;

 

이렇게 5개언어를 그다지 이쁘지 않은 드롭다운 디자인과 함께 배치해 보았다.

2. 메뉴카드


이제 사진과 함께 메뉴설명을 적을 차례. 직원 친구가 외국어 메뉴와 함께 한글도 있으면 주문받기 편할 것 같다고 말해줘 적용하기로 했다.

 

 

우선 결과물 부터

 

처음에는 언어별 메뉴판 데이터를 구성해보았다. ts 를 써서 그런지 타입 정의하는 것부터 해맸다.

const translations :MenuTranslations = {

    ko: {

        title: "3인 세트 메뉴",

        dishes: ["쭈꾸미 시그니처 세트", "쭈꾸미삼겹 시그니처 세트"],

        prices:["51,000원","55,000원"],

        imageUrl :[img,img2],

        discriptions:["철판 쭈꾸미2 + 우동사리 + 계란찜(치즈변경 +1,500) + 소금구이","철판 쭈꾸미삼겹2 + 우동사리 + 계란찜(치즈변경 +1,500) + 소금구이"],

        title2:"2인 세트 메뉴",

        dishes2: ["쭈꾸미 세트", "쭈꾸미삼겹 세트", "구이 세트"],

        prices2:["36,000원","40,000원","38,000원"],

        imageUrl2 :[img3,img4,img5],

        discriptions2:["철판 쭈꾸미2 + 우동사리 + 계란찜(치즈변경 +1,500)","철판 쭈꾸미삼겹살2 + 우동사리 + 계란찜(치즈변경 + 1,500)","택2 (소금/쭈삼(+2,000)/양념/꼼장어(+1,000) + 떡사리 + 계란찜(치즈변경 + 1,500)"],

        title33:"단품 메뉴",

        dishes33: ["철판 쭈꾸미", "철판 쭈꾸미 삼겹살", "소금구이", "쭈삼구이", "양념구이","꼼장어구이"],

        prices33:["14,000원","16,000원","15,000원","17,000원", "15,000원","16,000원"],

        imageUrl33 :[img6,img7,img8,img9,img10,img11],

        discriptions33:[],

        title3:"사이드 메뉴",

        dishes3: ["화산폭발계란찜", "화산폭발치즈계란찜", "깨불고기 주먹밥", "철판볶음밥", "볶음밥 치즈추가","바지락 백탕"],

        prices3:["5,000원","6,500원","5,000원","4,000원", "3,000원","8,000원"],

        imageUrl3 :[img12,img13,img14,img15,img16,img17],

        discriptions3:[],

        title4:"사리 메뉴",

        dishes4: ["우동 사리", "떡 사리", "치즈 사리", "삽겹 사리(200g)", "미나리 사리", "날치알"],

        prices4:["3,000원","3,000원","3,000원","6,000원","4,000원","3,000원"],

        imageUrl4 :[img18,img19,img20,img21,img22,img23],

        discriptions4:[],

        title5:"주류 / 음료",

        dishes5: ["소주(참이슬, 처음처럼, 진로, 새로)", "청하", "한라산","맥주(카스, 테라, 켈리, 크러시)","논알콜 맥주(칭따오, 하이네켄)","음료(콜라, 사이다, 제로콜라, 제로사이다)","하이볼","열정 하이볼"],

        prices5:["5,000원","6,000원","6,000원","5,000원","6,000원/8,000원","2,000원","8,000원","8,000원"],

        imageUrl5 :[img24,img25,img26,img27,img28,img29,img30,img31],

        discriptions5:[],

    },
    en:{
    //
    //
    }
    ...
    };

 

코드는 이렇게 달랑 하나지만 포맷을 자꾸 바꿔서 정말 힘들고 귀찮고 막그랬다. 이렇게 언어별로 다하니까 정말 가독성 떨어지는데 나중에 리팩토링을 하던가 해야겠다.

 

const MenuList = ({ selectedLanguage }: Props) => {

    const [menu, setMenu] = useState(translations[selectedLanguage] || defaultMenu);



    useEffect(() => {

        setMenu(translations[selectedLanguage] || defaultMenu);

    }, [selectedLanguage]);



    return (

        <div className="space-y-8">

            {[menu.title, menu.title2,menu.title33, menu.title3, menu.title4, menu.title5].map((title, index) => (

                <div key={index}>

                    <h1 className="text-3xl font-bold text-center mb-4" id={index.toString()}>{title}</h1>

                    <div className="menu-list">

                        {[menu.dishes, menu.dishes2,menu.dishes33, menu.dishes3, menu.dishes4, menu.dishes5][index].map((dish, dishIndex) => (

                            <MenuItemCard

                                key={dishIndex}

                                name={dish}

                                price={[menu.prices, menu.prices2,menu.prices33, menu.prices3, menu.prices4, menu.prices5][index][dishIndex]}

                                imageUrl={[menu.imageUrl, menu.imageUrl2, menu.imageUrl33, menu.imageUrl3, menu.imageUrl4, menu.imageUrl5][index][dishIndex]}

                                discription={[menu.discriptions,menu.discriptions2,menu.discriptions33,menu.discriptions3,menu.discriptions4,menu.discriptions5][index][dishIndex]}

                            />

                        ))}

                    </div>

                </div>

            ))}

        </div>

    );

};

 

이제 만든 데이터를 통해 map 함수를 이용해서 카드를 생성한다.

다른 언어일때의 모습은

 


요로코롬 아주 이쁜 카드가 만들어진다.

사진은 아직 직원친구가 이쁜 사진들을 안보내줘서 대체사진을 사용중이다. 이정도면 쓸만하겠지?

3. 메뉴별 이동버튼


개발을 하고나니 스크롤이 너무 길다는 불편함이 있었다. 그래서 버튼 두개를 만들어 하나는 맨위로 올라가는 버튼과 하나는 누를 때 마다 다음 메뉴 카테고리로 이동하는 버튼을 구현했다.

현재 위치 확인

useEffect를 통해 현재 위치와 가장가까운 title id를 찾는다.

    useEffect(() => {

        window.addEventListener("scroll", handleScroll);
        return () => {
            window.removeEventListener("scroll", handleScroll);
        };
    }, []);

    const handleScroll = () => {
        const offsets = titles.map(title => {
            const element = document.getElementById(title);
            // 요소가 존재하지 않는 경우, 매우 큰 값을 반환하여 화면 밖으로 간주하도록 합니다.
            return element ? window.pageYOffset + element.getBoundingClientRect().top : Number.MAX_VALUE;
        });
        const closest = offsets.reduce((prev, curr, index) => {
           if (curr !== Number.MAX_VALUE && (Math.abs(curr - window.pageYOffset) < Math.abs(offsets[prev] - window.pageYOffset))) {
                return index;
            }
            return prev;
        }, 0);
        setCurrentIndex(closest);
    };
  • titles 배열에서 각 타이틀의 ID를 사용하여 해당하는 DOM 요소를 찾는다. document.getElementById(title)는 ID가 title인 요소를 반환한다.
  • 각 요소의 위치는 element.getBoundingClientRect().top + window.pageYOffset을 통해 계산된다. 여기서 getBoundingClientRect().top은 요소의 상단 경계로부터 뷰포트 상단까지의 거리를 제공하고, window.pageYOffset은 문서가 수직으로 얼마나 스크롤 되었는지를 나타낸다. 이 두 값을 더하면 페이지 최상단으로부터의 절대적인 거리가 된다.
  • 만약 특정 타이틀의 요소가 없다면(elementnull인 경우), Number.MAX_VALUE를 반환하여, 해당 타이틀을 스크롤 위치 계산에서 제외시킨다.
  • reduce 함수는 offsets 배열을 순회하면서 각 요소와 현재 스크롤 위치와의 차이가 가장 작은 요소의 인덱스를 찾는다. 이 인덱스는 현재 뷰포트에 가장 가까운 요소를 나타낸다.
  • 계산된 closest 인덱스는 setCurrentIndex를 통해 상태로 저장되어, 다른 부분의 렌더링이나 로직에서 사용될 수 있다.

이렇게 저장된 currentIndex를 통해 다음 타이틀 이동 함수를 만들 수 있다.

const scrollToNextTitle = () => {

        if (currentIndex + 1 < titles.length) {

            const nextTitleElement = document.getElementById(titles[currentIndex + 1]);

            if (nextTitleElement) {

                nextTitleElement.scrollIntoView({ behavior: "smooth" });

                setCurrentIndex(currentIndex + 1);

            }

        }

    };

 

현재 인덱스보다 하나 앞선 인덱스의 위치를 가져오고 smooth 하게 움직이는 함수다.

맨 위로 가는 함수는 상당히 간단하다.

 

const scrollToTop = () => {

        window.scrollTo({

            top: 0,

            behavior: "smooth"

        });

    };

 

이렇게 하고 이쁜 버튼과 함께 배치해보겠다.

 

 

 

 

다음에는 사진을 좀 제대로 넣고 수정하고 싶다. 그리고, 메뉴가 오류가 났는데 그냥 메뉴 이름만 수정하면돼서 문제없을 것 같다.

 

그리고 맛있게 먹는 방법과 메뉴 눌렀을 때, 메뉴 상세정보도 띄우고싶다. 그리고 방문수 확인하는것도! 이건 백있어야하나?

반응형

+ Recent posts