welcome to sisi's space! ‎ε(*⌒▽⌒)੭*゚¨゚゚・*:..☆

Dev & Study

멋사 세미나 과제 4

Sisi_ 2024. 11. 21. 23:24

멋사 블로그 메인 페이지의 ShareSection, FAQSection, 그리고 Header 컴포넌트에 Tailwind Media Query를 적용하여 모바일 뷰를 구성했다.

 

우선 ShareSection에 반응형 디자인을 적용했다.

import { Button } from 'components/button';
import { SectionLayout } from './section-layout';

export const ShareSection = () => {
  const shareCardInfo = [
    {
      title: 'STEP 1',
      description: '오늘의 운세를 확인하세요',
      img: '/images/capture1.png',
    },
    {
      title: 'STEP 2',
      description: '공유할 친구를 선택하세요',
      img: '/images/capture2.png',
    },
  ];

  return (
    <SectionLayout>
      <div className="w-full h-full flex flex-col gap-[80px]">
        <div className="w-full flex flex-col mobile:flex-row justify-between items-start mobile:items-center gap-6">
          <div className="space-y-4">
            <h3 className="text-left text-3xl mobile:text-4xl nanum-extra-bold text-neutral-800">
              사주 공유하기
            </h3>
            <p className="text-lg mobile:text-xl font-bold text-neutral-800">
              채팅으로 사주를 공유해보세요
            </p>
          </div>
          <a href="/chat">
            <Button className="w-full mobile:w-[250px] h-[50px]" isRounded={true}>
              1:1 채팅 하러가기
            </Button>
          </a>
        </div>

        <div className="flex flex-col mobile:flex-row gap-6 mobile:gap-10 justify-center">
          {shareCardInfo.map((card) => (
            <ShareCard
              key={card.title}
              title={card.title}
              description={card.description}
              img={card.img}
            />
          ))}
        </div>
      </div>
    </SectionLayout>
  );
};

const ShareCard = ({ title, description, img }) => {
  return (
    <div className="flex flex-col rounded-xl shadow-md w-full max-w-[450px] overflow-hidden">
      <img src={img} alt={title} className="w-full h-auto" />
      <div className="p-5 flex flex-col items-start gap-1.5">
        <h4 className="text-base font-normal text-neutral-800">{title}</h4>
        <p className="text-lg mobile:text-xl font-extrabold text-neutral-800">
          {description}
        </p>
      </div>
    </div>
  );
};

 

 

Button 컴포넌트의 경우 데스크톱에서는 고정된 너비(w-[250px])를 유지하지만, 모바일에서는 w-full을 추가하여 버튼이 화면 크기에 맞게 조정된다.

카드 레이아웃의 경우 flex flex-col mobile:flex-row로 모바일에서 세로로 정렬되고, 데스크톱에서는 가로로 정렬되도록 설정했다. gap 값을 데스크톱과 모바일에서 다르게 적용했다. (gap-6 -> mobile:gap-10)

텍스트 크기 조정을 위해 text-lg와 text-xl을 조합하여 모바일에서 적절한 크기로 보여질 수 있도록 했다.

 

FAQSection에도 반응형 디자인을 적용했다.

import { SectionLayout } from './section-layout';
import { useState } from 'react';

export const FAQSection = () => {
  const faqAccordionInfo = [
    {
      question: 'Q. 사주 운세를 확인하고 싶은데, 비용은 무료인가요?',
      answer:
        '첫 번째 질문에 대한 답변입니다. 답변 내용은 어쩌구저쩌구입니다.\n첫 번째 질문에 대한 답변입니다. 답변 내용은 어쩌구저쩌구입니다.',
    },
    {
      question: 'Q. 어떤 기술이 활용되었나요?',
      answer:
        '두 번째 질문에 대한 답변입니다. 답변 내용은 어쩌구저쩌구입니다.\n두 번째 질문에 대한 답변입니다. 답변 내용은 어쩌구저쩌구입니다.',
    },
    {
      question:
        'Q. 세 번째 질문입니다. 한 줄까지 들어갈 수 있습니다. 그 이상은 말줄임표 처리합니다. 바로 이렇게... 이렇게... 이렇게... 이렇게... 이렇게...',
      answer:
        '세 번째 질문에 대한 답변입니다. 답변 내용은 어쩌구저쩌구입니다.\n세 번째 질문에 대한 답변입니다. 답변 내용은 어쩌구저쩌구입니다.',
    },
  ];

  return (
    <SectionLayout>
      <div className="w-full h-full flex flex-col gap-[40px] mobile:gap-[60px]">
        <h3 className="text-left text-3xl mobile:text-4xl nanum-extra-bold">
          FAQs
        </h3>
        <div className="flex flex-col gap-[20px] mobile:gap-[30px] justify-center">
          {faqAccordionInfo.map((accordion) => (
            <FAQAccordion
              key={accordion.question}
              question={accordion.question}
              answer={accordion.answer}
            />
          ))}
        </div>
      </div>
    </SectionLayout>
  );
};

const FAQAccordion = ({ question, answer }) => {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div className="flex flex-col rounded-xl px-6 py-8 shadow-lg w-full gap-4 mobile:px-[50px] mobile:py-10 mobile:gap-5">
      <div className="flex justify-between items-center gap-4">
        <p className="text-base mobile:text-xl font-bold truncate">
          {question}
        </p>
        <button
          className="rounded-full shadow-lg transition"
          onClick={() => {
            setIsOpen(!isOpen);
          }}
        >
          <svg
            className={`transition transform ${isOpen ? '' : '-rotate-90'}`}
            xmlns="http://www.w3.org/2000/svg"
            width="36"
            height="36"
            viewBox="0 0 51 51"
            fill="none"
          >
            <circle
              className="transition"
              cx="25.6691"
              cy="25.3309"
              r="25.3309"
              fill={!isOpen ? '#FFFFFF' : '#6F6C90'}
            />
            <path
              className="transition"
              d="M17.4125 22.2212L25.6691 30.4405L33.9257 22.2212"
              stroke={!isOpen ? '#6F6C90' : '#FFFFFF'}
              strokeWidth="2"
              strokeLinecap="round"
              strokeLinejoin="round"
            />
          </svg>
        </button>
      </div>
      {isOpen && (
        <p className="text-sm mobile:text-lg w-full text-left">{answer}</p>
      )}
    </div>
  );
};

 

 

padding, gap, text 크기를 조정하여 모바일 환경에서도 적절한 공간이 생기도록 했다. px-6 py-8를 모바일에서 px-[50px] py-10로 확장했다. 텍스트 크기도 조정하기 위해 질문과 답변의 폰트 크기를 text-base에서 mobile:text-xl로, 답변은 text-sm에서 mobile:text-lg로 조정했다. SVG 파일의 크기도 조정해 버튼 내 아이콘의 크기를 모바일에서 더 작게(width="36" height="36") 조정했다. 간격 및 여백에 대해서도 데스크톱에서는 기존 여백(gap-5 등)을 유지하고, 모바일에서는 여백이 조금 더 컴팩트하게 줄어들도록 설정했다.

 

Header 컴포넌트에도 반응형 디자인을 적용했다.

import React, { useEffect, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { PointModal } from './modals/point-modal';
import coin from '../assets/icons/coin.png';
import { removeCookie } from '../utils/cookie';
import { signOut } from '../apis/api';
import { useSelector, useDispatch } from 'react-redux';
import { setLoginState, setUserProfile } from '../redux/user-slice';
import { ProfileImage } from '../components/profile-image';

export const Header = () => {
  const [isLogin, setIsLogin] = useState(true);
  const location = useLocation();

  const [showProfile, setShowProfile] = useState(false);
  const [isPointModalOpen, setIsPointModalOpen] = useState(false);

  const nickname = useSelector((state) => state.user.nickname);
  const point = useSelector((state) => state.user.remaining_points);
  const profileImgIndex = useSelector((state) => state.user.profilepic_id);
  const loggedIn = useSelector((state) => state.user.isLogin);

  const dispatch = useDispatch();

  useEffect(() => {
    setIsLogin(loggedIn);
  }, []);

  const linkStyle =
    'text-base mobile:text-xl font-bold text-[#14142B] leading-6 hover:font-extrabold hover:text-[#4A3AFF] hover:cursor-pointer';
  const activeLinkStyle =
    'text-base mobile:text-xl font-extrabold text-[#4A3AFF] leading-6';

  const onClickPoint = () => {
    setIsPointModalOpen(true);
  };

  const onClickLogout = async () => {
    const res = await signOut();
    if (res !== null) {
      removeCookie('access_token');
      removeCookie('refresh_token');
      dispatch(setLoginState(false));
      dispatch(
        setUserProfile({
          user: null,
          nickname: null,
          profilepic_id: null,
          remaining_points: null,
        }),
      );
      window.location.href = '/';
    }
  };

  return (
    <div className="w-full flex flex-col mobile:flex-row items-center justify-between bg-white drop-shadow h-auto mobile:h-[80px] px-4 mobile:px-[68px] z-[999]">
      <Link
        to="/"
        className="text-lg mobile:text-[26px] font-extrabold text-[#14142B] leading-7 mobile:leading-9 tracking-tighter mb-4 mobile:mb-0"
      >
        멋쟁이 사주처럼
      </Link>

      <div className="flex flex-col mobile:flex-row items-center gap-4 mobile:gap-[50px]">
        <Link
          to="/saju"
          className={
            location.pathname === '/saju' ? activeLinkStyle : linkStyle
          }
        >
          사주
        </Link>
        <Link
          to="/chat"
          className={
            location.pathname === '/chat' ? activeLinkStyle : linkStyle
          }
        >
          채팅
        </Link>
        {isLogin ? (
          <div
            className="relative"
            onMouseOver={() => setShowProfile(true)}
            onMouseLeave={() => setShowProfile(false)}
          >
            <span className="text-base mobile:text-xl font-bold text-[#14142B] leading-6 hover:font-extrabold hover:text-[#4A3AFF] hover:cursor-pointer">
              프로필
            </span>
            {showProfile && (
              <div className="absolute top-[50px] mobile:top-[25px] right-0 bg-white drop-shadow w-[221px] p-4 mobile:p-[25px] rounded-[12px] flex flex-col gap-4 mobile:gap-5">
                {profileImgIndex && (
                  <div className="flex flex-row gap-4 mobile:gap-[10px] items-center">
                    <ProfileImage
                      profileImageId={profileImgIndex}
                      additionalClassName={'w-[30px] h-[30px]'}
                    />
                    <span className="text-base mobile:text-lg font-bold text-[#170F49] leading-6">
                      {nickname}
                    </span>
                  </div>
                )}
                <div className="flex flex-row items-center justify-between">
                  <div className="flex flex-row gap-2 mobile:gap-[10px] items-center">
                    <img src={coin} alt="coin" className="w-[20px] mobile:w-[30px] h-[20px] mobile:h-[30px]" />
                    <span className="text-base mobile:text-lg font-bold text-[#170F49] leading-6">
                      포인트
                    </span>
                  </div>
                  <span className="text-base mobile:text-lg font-bold text-[#4A3AFF] leading-6">
                    {point}
                    <span className="text-[#160F49]">P</span>
                  </span>
                </div>
                <button
                  onClick={onClickPoint}
                  className="bg-[#160F49] text-white text-base mobile:text-bases font-semibold leading-6 rounded-[50px] px-4 mobile:px-6 py-2 mobile:py-[6px]"
                >
                  충전하기
                </button>
                <span
                  onClick={onClickLogout}
                  className="text-sm mobile:text-base font-normal underline text-[#160F49] self-start cursor-pointer"
                >
                  로그아웃
                </span>
              </div>
            )}
          </div>
        ) : (
          <Link
            to="login"
            className="text-sm mobile:text-xl font-bold text-[#4A3AFF] leading-6 bg-[#F3F1FF] px-4 mobile:px-7 py-[10px] mobile:py-[17px] rounded-[50px]"
          >
            로그인
          </Link>
        )}
      </div>
      {isPointModalOpen && <PointModal setIsModalOpen={setIsPointModalOpen} />}
    </div>
  );
};

 

 

전체 Header를 flex-col mobile:flex-row로 설정하여 모바일에서는 위아래로 쌓이고, 데스크톱에서는 좌우로 정렬되게 했다. 모바일에서는 gap-4, 데스크톱에서는 gap-[50px]로 간격을 설정했다. 로고와 링크의 텍스트 크기를 text-lg에서 mobile:text-[26px] 또는 text-base에서 mobile:text-xl로 조정했다. Profile 모달의 패딩, 여백, 폰트 크기를 모바일에서 작게 설정했고(p-4), 데스크톱에서 여유롭게 설정했다(p-[25px]). 로그인 버튼의 크기도 조정하기 위해 px-4 및 py-[10px]로 모바일에서 작게, 데스크톱에서 더 큰 크기(px-7, py-[17px])로 설정했다.

 

메인페이지의 좌측 텍스트 섹션에 대해서도 스크롤 이벤트에 따라 왼쪽에서 오른쪽으로 튀어나오는 애니메이션을 적용했다. 해당 텍스트를 담고 있는 div에 대해 Ref를 적용했다.

import { Button } from 'components/button';
import { SectionLayout } from './section-layout';
import { useEffect, useRef } from 'react';
import gsap from 'gsap';

const GRADIENT_TOP_START_COLOR = '#170F49';
const GRADIENT_TOP_END_COLOR = '#E3E6F7';
const GRADIENT_BOTTOM_START_COLOR = '#6F6C8F';
const GRADIENT_BOTTOM_END_COLOR = '#F7F7F7';

export const MainSection = () => {
  const sectionRef = useRef(null);
  const designOuterRef = useRef(null);
  const designInnerRef = useRef(null);
  const welcomeMsgRef = useRef(null);
  const card1Ref = useRef(null);
  const card2Ref = useRef(null);
  const card3Ref = useRef(null);
  const card4Ref = useRef(null);
  const lionRef = useRef(null);
  const h1Ref = useRef(null); 
  const maxScroll = window.innerHeight * 5;

  useEffect(() => {
    interpolateBackground(0);
    interpolateDesignPosition(0);
    interpolateH1Animation(0); 

    const handleScroll = () => {
      interpolateBackground(window.scrollY);
      interpolateDesignPosition(window.scrollY);
      interpolateH1Animation(window.scrollY); 
    };

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

  const interpolateBackground = (scrollY) => {
    const factor = Math.min(scrollY / maxScroll, 1);

    if (sectionRef.current) {
      gsap.to(sectionRef.current, {
        background: `linear-gradient(to bottom, ${gsap.utils.interpolate(
          GRADIENT_TOP_START_COLOR,
          GRADIENT_TOP_END_COLOR,
          factor
        )}, ${gsap.utils.interpolate(
          GRADIENT_BOTTOM_START_COLOR,
          GRADIENT_BOTTOM_END_COLOR,
          factor
        )})`,
        duration: 0,
        ease: 'none',
      });
    }
  };

  const interpolateDesignPosition = (scrollY) => {
    const factor1 =
      scrollY < maxScroll / 2
        ? 0
        : Math.min(((scrollY - maxScroll / 2) * 2) / maxScroll, 1);
    const originalX = designOuterRef.current.getBoundingClientRect().x;
    const offsetXFromMiddle = window.innerWidth / 2 - (originalX + 170);

    if (designInnerRef.current) {
      gsap.to(designInnerRef.current, {
        x: offsetXFromMiddle * (1 - factor1),
      });
    }

    const factor2 = Math.min(scrollY / (maxScroll / 2), 1);
    const viewportYMiddle = window.innerHeight / 2;

    if (welcomeMsgRef.current) {
      gsap.to(welcomeMsgRef.current, {
        y: -50,
        opacity: 1 - factor2,
      });
    }

    if (card1Ref.current) {
      gsap.to(card1Ref.current, {
        x: -180 + 30 * factor2,
        y: -80 * factor2,
        rotate: -45 * factor2,
      });
    }
    if (card2Ref.current) {
      gsap.to(card2Ref.current, {
        x: -60,
        y: -160 * factor2,
        rotate: -15 * factor2,
      });
    }
    if (card3Ref.current) {
      gsap.to(card3Ref.current, {
        x: 60,
        y: -160 * factor2,
        rotate: 15 * factor2,
      });
    }
    if (card4Ref.current) {
      gsap.to(card4Ref.current, {
        x: 180 - 30 * factor2,
        y: -80 * factor2,
        rotate: 45 * factor2,
      });
    }
    if (lionRef.current) {
      gsap.to(lionRef.current, {
        y: viewportYMiddle * 1.5 * (1 - factor2),
      });
    }
  };

  const interpolateH1Animation = (scrollY) => {
    const factor = Math.min(scrollY / (window.innerHeight * 1), 1);

    if (h1Ref.current) {
      gsap.to(h1Ref.current, {
        x: `${-200 + 200 * factor}px`, 
        opacity: factor, 
        duration: 0.5,
        ease: 'power2.out',
      });
    }
  };

  return (
    <SectionLayout
      outerLayerClassName={'h-[500vh] flex items-start'}
      innerLayerClassName={`sticky top-[80px] h-[calc(100vh-80px)]`}
      innerLayerRef={sectionRef}
    >
      <div className="relative flex flex-col w-full gap-8 items-start mobile:items-center">
        <div className="flex flex-col items-start mobile:items-center gap-8" ref={h1Ref} >
          <h1
            className="text-[64px] mobile:text-[32px] leading-normal whitespace-pre-wrap text-left mobile:text-center nanum-extra-bold text-black dark:text-white transform translate-x-[-200px] opacity-0"
          >
            <span>멋쟁이</span>{' '}
            <s className="text-gray-500 dark:text-gray-400">사자</s> {'\n'}
            <span>사주처럼</span>
          </h1>
          <p className="text-lg mobile:text-sm text-left mobile:text-center mobile:whitespace-pre-wrap dark:text-white">
            {'오늘의 사주 운세를 확인하고,\n친구에게 공유하자!'}
          </p>
          <a href="/saju">
            <Button>멋사주 시작하기</Button>
          </a>
        </div>
        <div
          ref={designOuterRef}
          className="absolute -bottom-10 right-0 size-[340px]"
        >
          <div
            className="w-full h-full flex justify-center"
            ref={designInnerRef}
          >
            <p
              ref={welcomeMsgRef}
              className="font-extrabold text-[44px] mobile:text-[28px] text-white text-nowrap w-fit opacity-0"
            >
              오늘의 운세가 궁금해?
            </p>
            <img
              ref={lionRef}
              src="/images/snulion.png"
              className="absolute z-50 mobile:scale-[50%] object-contain"
              alt="Main Illustration"
            />
            <img
              ref={card1Ref}
              src="/images/card1.png"
              className="absolute scale-[60%] mobile:scale-[30%] object-contain"
              alt="Card1 Illustration"
            />
            <img
              ref={card2Ref}
              src="/images/card2.png"
              className="absolute scale-[60%] mobile:scale-[30%] object-contain"
              alt="Card2 Illustration"
            />
            <img
              ref={card3Ref}
              src="/images/card3.png"
              className="absolute scale-[60%] mobile:scale-[30%] object-contain"
              alt="Card3 Illustration"
            />
            <img
              ref={card4Ref}
              src="/images/card4.png"
              className="absolute scale-[60%] mobile:scale-[30%] object-contain"
              alt="Card4 Illustration"
            />
          </div>
        </div>
      </div>
    </SectionLayout>
  );
};

 

아래는 해당 레포지토리 링크이다.

https://github.com/sisihae/likesaju-frontend-seminar