반응형

배경


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