Giter Site home page Giter Site logo

classroom-project's Introduction

Group 1855


💡프로젝트 소개

  • 프로젝트 명 : 클룸

  • 프로젝트 기간 : 2024년 3월 26일 ~ 2024년 5월 1일

  • 프로젝트 소개 : 원데이 클래스를 예약 및 등록 할 수 있는 원데이 클래스 웹사이트입니다.

  • 배포 링크 : https://www.cl-room.com

윤미주 김현진 여태원 서지원 임혜린 임향지

@anywhereim @hyun0zin @ccccliff @seopport @Hyerin -
팀장 부팀장 팀원 팀원 팀원 디자이너
- 로그인/회원가입
- 채팅/챗봇
- 결제페이지
- 마이페이지
- 대시보드
- 메인페이지
- 리스트페이지
- 디테일페이지
- 클래스 예약페이지
- 좋아요
- 클래스 등록페이지
- 클래스 수정페이지
- 알림창
- 와이어프레임
- 배너, 로고 디자인



💻 TECH STACKS

Environment

Framework

Development

Database

Design

Deployment

Library

그 외 : Zustand, DOMPurify, typebot.i, Embla-carousel, React-Quill, Toastify, Medium-zoom, Date-fns, React-day-picker, DOMPurify



🍀와이어프레임

로그인페이지 메인페이지
리스트페이지 디테일페이지
예약페이지 클래스 등록페이지
선생님 마이페이지 수강생 마이페이지
결제 페이지 채팅페이지

피그마

@피그마 링크

시연 영상

@시연 영상 링크


🖥️화면 구성 및 기능

1. 메인페이지

  • 화면 렌더링 시 로딩 페이지 구현
  • 헤더와 사이드바를 통한 사용자 편의성 개선
  • 카테고리 버튼 클릭시 리스트 페이지로 이동 후 필터링
  • Embla-Carousel 라이브러리를 이용하여 카드를 캐러셀 형태로 표시
  • 반응형 배너
반응형





2. 리스트페이지

기본형 반응형

1-2. 리스트페이지

  • supabase 쿼리 메서드를 사용한 데이터 필터링
  • useInfiniteQuery api와 IntersectionObserver 로직을 이용한 클래스 리스트 무한스크롤 기능




3. 클래스 디테일페이지

클래스 요약 클래스 설명란 댓글

2-1. 클래스 정보 디테일페이지

  • 클래스 상세 정보 표기
  • Embla-Carousel 라이브러리를 이용하여 카드를 캐러셀 형태로 표시
  • 평점을 입력할 수 있는 후기 기능
  • 클래스를 찜하면 마이페이지 위시리스트에 추가
  • 카카오 Map API를 통한 클래스 위치 표기





3. 로그인/회원가입

3-1. 로그인/회원가입

  • NextAuth를 이용한 사용자 인증
  • Json Web Token을 활용해 사용자 세션 관리
  • 서버사이드 미들웨어로 경로별 접근 권한 제어
  • 로그인 여부에 따라 적절한 페이지 리다이렉션 처리 자동화
기본형 반응형
image image





4. 마이페이지 - 수강생

  • 프로필 수정
  • 예약한 클래스 확인
  • 내가 작성한 후기 수정 및 삭제
  • 즐겨찾기한 클래스 위시 리스트
  • 강사 정보 등록하기 및 유효성 검사
프로필 수정하기 내가 예약한 클래스 보기 내가 쓴 후기 클래스 위시리스트 선생님 정보 등록하기





4-2. 마이페이지 - 강사

  • 프로필 수정
  • 강사 정보 수정하기
  • 내가 등록한 클래스 보기 및 삭제
  • 내 클래스 예약한 수강생 보기
  • supabase api 호출
  • Supabase Storage를 활용하여 이미지 파일 업로드
  • Supabase SQP Editor를 활용하여 DB Join을 통해 data api 호출
기본형 반응형
image image
image image
image image
image image



5. 클래스 등록/수정 페이지

5-1. 클래스 등록 페이지

  • 클래스 이름, 설명 등 기본적인 정보 입력
  • 여러 날짜와 시간 선택
  • 최대 5개의 이미지 업로드 가능
  • 업로드된 이미지 드래그하여 순서 변경
  • 첫번째 이미지 클래스 이미지 설정
  • React Quill 텍스트 에디터 라이브러리를 통한 클래스 설명 입력
  • 카카오 우편 조회 API 활용 위치 정보 입력
  • React Day picker 라이브러리를 통한 날짜 선택 기능
기본형 반응형
image image

5-2. 클래스 수정 페이지

기본형 반응형
image image

5-3. 클래스 등록/수정 완료 페이지

기본형 반응형
image image



6. 클래스 예약 페이지

6. 클래스 예약 페이지

  • 달력에서 예약 가능한 일자와 시간, 예약 인원 수를 선택하고 예약을 진행합니다
  • 클래스의 일자 정보를 관리하기 위해 date와 time 테이블을 생성하였고, 선택한 클래스 ID를 기준으로 date와 time 테이블을 조인하여 데이터를 불러옵니다.
  • 사용자가 선택한 시간의 고유 id값에 해당하는 레코드의 예약 인원수를 총합하여 세션별로 남은자리를 체크합니다.
  • 무료 클래스의 경우 결제과정 없이 바로 예약됩니다.
기본형 반응형
image image



7. 클래스 결제 페이지

7-1. 클래스 결제 페이지

  • 예약하기 클릭 시 URL 파라미터를 사용해 동적으로 결제금액을 받아 위젯에 반영
  • 원하는 결제 수단 클릭 후 결제진행
  • 결제 성공 시 라우트 핸들러로 리다이렉트
기본형
image

7-2. 클래스 결제 완료

  • 사용자가 결제를 완료하면 route handler에서 결제 승인 api를 호출하여 결제 승인 여부를 판단하고 적절하게 리다이렉트합니다
  • 예약이 성공적으로 완료되면 예약한 정보를 보여주고, 예약 상세보기를 통해 마이페이지에서 상세 정보를 확인할 수 있습니다
기본형
image



9. 클래스 채팅페이지

  • 채팅방에서 사용자 간의 대화를 표시하고, 각 메시지에는 보낸 시간과 함께 이미지 또는 텍스트 메시지 표시
  • 채팅목록을 표시하고 각 채팅방에서 마지막 메시지를 미리 볼 수 있습니다.
  • Optional Catch-all Segments를 이용해 채팅방 리스트와 상세 채팅 화면사이의 원활한 전환 제공
  • 드래그 앤 드롭으로 업로드할 이미지 순서 변경, 이미지를 첨부하고 순서를 드래그앤 드롭으로 조정 가능
채팅 목록 채팅 방



image_480

9. 알림/좋아요

9-1. 알림창 기능

  • supabase 테이블에서 현재 로그인한 계정의 등록/결제 알림 데이터 가져오기
  • 알림을 클릭시 해당 클래스 디테일 페이지 이동
  • 알림 최신순 정렬
  • IsRead 값을 통해 아직 읽지 않은 알림(false) 개수와 목록 표시
  • 알림 아이콘 토글, 바깥 영역 클릭 감지(닫기)
기본형

9-2. 좋아요 기능

  • 리스트페이지, 디테일 페이지에서 하트 이모티콘을 클릭하여 좋아요 기능
  • 좋아요 기능을 통한 위시리스트 추가
위시리스트페이지 리스트페이지



트러블슈팅


1.팀 전체 build 시, error 발생 및 해결과정

🚨 문제 발생

build 시 위 이미지와 같이 어떠한 에러인지 정확하게 보여주지 않고 build error만 발생

image

🛠️ 문제 해결 과정

소거법을 이용해 폴더, 파일, 코드들을 하나씩 비활성화 하면서 문제 원인 분석

🔥 찾아낸 문제점

  1. 클라이언트 사이드 전용 객체(예: window, localstorage)에 서버사이드 렌더링이나 빌드 시간에 접근하려고 할 때 문제 발생

☠️기존 코드

/* src > store > useRoleStore */
export const useUserRoleStore = create<UserRoleState>((set) => ({
  isTeacher:  sessionStorage.getItem('isTeacher') === 'true' ,

    setIsTeacher: (value: boolean) => {
        set({ isTeacher: value });
        sessionStorage.setItem('isTeacher', value.toString());
        }
    }
}));
  1. useSearchParams를 사용할 경우 해당 페이지가 클라이언트 사이드에서 렌더링되어 빌드 시점에 HTML이 비어있을 수 있어 오류 발생

🪙 해결 방법

  1. useEffect를 사용하여 렌더링 후 실행되도록 하거나 typeof window !== undefined 인 경우 체크
export default function PaymentSuspensePage() {
  return (
    <Suspense>
      <PaymentPageasync />
    </Suspense>
  );
}
2.라우트 핸들러를 활용한 서버 사이드에서의 결제 승인 처리

🤔 문제 상황

  • 초기 상태: 사용자가 예약 버튼을 클릭하면 바로 예약 완료 페이지로 넘어가며, 추가 검증 절차가 없었습니다.
  • 변경 후: 토스 api 테스트 결제 기능이 추가되어, 예약 과정에서 결제 성공 여부에 따라 예약을 진행하도록 변경해야 했습니다.

1. 리다이렉트 주소를 받는 페이지 만들기

첫번째로 구현한 방법은 리다이렉트 주소를 받는 페이지 컴포넌트를 만들었습니다.  이 페이지는 결제 완료 후 토스 API로부터 사용자를 리다이렉트하는 URL을 받아서 추가적인 검증 로직을 수행하여 결제의 성공 또는 실패를 판단하려고 했습니다.

  • 사용자가 예약 버튼을 클릭할 때, 오더 ID와 예약 정보를 로컬 스토리지에 저장합니다.
  • 토스 API로부터 받은 오더 ID와 로컬 스토리지의 오더 ID를 비교하여 결제의 성공 또는 실패를 검증합니다.
  • 성공 시, 데이터베이스에 정보를 업로드하고 예약 완료 페이지로 리다이렉트합니다.
// successUrl: `${window.location.origin}/reserve/checkPay`,
// 토스 api에서 사용자가 결제 완료 시 리다이렉트로 보내주는 url
/* ex) http://localhost:3000/reserve/checkPay?paymentType=NORMAL&orderId=7e773321-2610-49fc-807c-b2e08730b0c8&
paymentKey=tviva20240412020401crrz7&amount=55000 */

const CheckPage = () => {
  const searchParams = useSearchParams();
  const queryOrderId = searchParams.get('orderId');
  const storageOrderId = typeof window !== 'undefined' && window.localStorage.getItem('orderId');
  const [reserveId, setReserveId] = useState('');
  const [isLoaidng, setIsLoaidng] = useState(true);

  const [reservationRequest, setReservationRequest] = useState<ReserveInfo>();

  const router = useRouter();

  useEffect(() => {
    if (typeof window !== 'undefined') {
      // 로컬 스토리지에서 예약 정보 가져와서 set
      const storageData = window.localStorage.getItem('reservationInfo');
      const reserveInfo: ReserveInfo = storageData ? JSON.parse(storageData) : null; // null 처리
      setReservationRequest(reserveInfo);
    }
  }, []);

  useEffect(() => {
    if (reservationRequest) {
      const submitReservation = async () => {
        // db에 예약 정보  insert
        const responseReserveId = await insertNewReservation(reservationRequest);
        if (responseReserveId) {
          setReserveId(responseReserveId);
          setIsLoaidng(false);
        }
      };
      submitReservation();
    } else {
      // 요청 인자가 없으면 에러 메세지 출력을 위한 state set
      //  setIsInvalidRequest(true);
    }
  }, [reservationRequest]);

  useEffect(() => {
    if (queryOrderId === storageOrderId) {
      if (reserveId) {
        router.push(`/reserve/success/${reserveId}`);
      }
    }
  }, [reserveId]);

  return <div>CheckPage</div>;
};

export default CheckPage;

그러나 checkPay 페이지에서 새로고침하면 예약 정보를 데이터베이스에 중복으로 등록할 수 있으며, 사용자가 쿼리스트링을 조작할 수 있다는 문제점도 존재했습니다.

❗문제점

  • 사용자 또는 악의적인 제3자가 로컬 스토리지를 조작할 수 있으며 로컬 스토리지는 보안에 취약하다는 단점이 있습니다.
  • 사용자가 결제 대기 페이지 URL을 복사하거나 조작하여 checkPay 페이지에 접속할 경우, 실제로 결제를 거치지 않고도 예약 정보가 계속해서 데이터베이스에 등록될 수 있습니다.
  • 사용자가 브라우저 캐시를 지우거나 다른 브라우저/비공개 모드를 사용하는 경우 로컬 스토리지 데이터가 유실되어 결제가 비정상적으로 처리될 수 있습니다.

✅ 해결 방안 - API 라우트 핸들러를 활용한 서버사이드 결제 승인 처리

클라이언트 측에서 결제 로직을 처리하기에는 보안 문제와 결제 처리에 한계가 있다고 판단했고, Next에서 제공하는 라우트 핸들러를 통해 결제 성공 여부를 서버 사이드에서 처리하기로 결정했습니다.

토스 api의 결제 처리 방식은 아래 사진과 같습니다. 사용자가 결제를 완료하면 리다이렉트 되는 주소를 라우트 핸들러로 받고, 라우트 핸들러에서 결제 승인 api를 호출해서 결제 성공 여부를 판단하기로 결정했습니다.

image

토스 api 결제 과정

  • 토스 requestPayment의 successUrl 설정

    • 토스 결제 성공 시 사용자를 리다이렉트하는 URL을 라우트 핸들러로 설정합니다.
     try {
     await paymentWidget?.requestPayment({
      orderId: orderId as string,
      orderName: title as string,
      // 라우트 핸들러로 예약 정보 전송
      successUrl: `${window.location.origin}/api/payment?classId=${reserveInfo.classId}&reserveQuantity=${reserveInfo.reserveQuantity}&timeId=${reserveInfo.timeId}&userId=${reserveInfo.userId}`,
      //fail 시 보여줄 페이지 만들기
      failUrl: `${window.location.origin}/fail?orderId=${orderId}&classId=${classId}`
    });
  • 쿼리스트링으로 예약정보 받아오기

    예약 정보와 결제 승인 호출에 필요한 orderId, amount, paymentKey를 추출합니다.

    // api/payment/route.ts
    
    export async function GET(request: NextRequest) {
      const { searchParams } = new URL(request.url);
    
      const orderId = searchParams.get('orderId');
      const paymentKey = searchParams.get('paymentKey');
      const amount = searchParams.get('amount');
      const reserveQuantity = searchParams.get('reserveQuantity');
      const timeId = searchParams.get('timeId');
      const userId = searchParams.get('userId');
      const classId = searchParams.get('classId');
  • 결제 승인 API 호출

    orderId, amount, paymentKey를 사용하여 토스의 결제 승인 API를 호출합니다.

    if (!orderId || !classId || !amount || !reserveQuantity || !timeId || !userId) {
      // 값이 없으면 실패 페이지로 리다이렉트
      return NextResponse.redirect(new URL(`${process.env.NEXT_PUBLIC_BASE_URL}/reserve/fail`));
    }
    
    const response = await fetch('https://api.tosspayments.com/v1/payments/confirm', {
      method: 'POST',
      body: JSON.stringify({ orderId: orderId, amount: amount, paymentKey: paymentKey }),
      headers: {
        Authorization: `Basic ${Buffer.from(`${process.env.TOSS_SECRET_KEY}:`).toString('base64')}`,
        'Content-Type': 'application/json'
      }
    });
  • 결제 승인 시 데이터베이스 처리

    • 결제가 승인되었다면, 데이터베이스에 사용자의 예약 정보를 저장합니다.
    • 정보 저장이 성공하면, 완료 페이지로 사용자를 리다이렉트하면서 오더 ID를 URL 파라미터로 포함시킵니다.
    • 결제 승인이 거절되거나 정보 저장에 실패할 경우, 사용자는 실패 페이지로 리다이렉트됩니다.
    const res = await response.json();
    
    if (response.ok) {
      try {
        //DB에 예약 정보 insert
        await insertNewReservation({
          reserveId: res.orderId,
          classId,
          reservePrice: res.totalAmount,
          reserveQuantity: Number(reserveQuantity),
          timeId,
          userId
        });
    
        return NextResponse.redirect(new URL(`${process.env.NEXT_PUBLIC_BASE_URL}/reserve/success/${res.orderId}`));
      } catch (error) {
        console.log('라우트 핸들러의 insertNewReservation 오류 => ', error);
        return NextResponse.redirect(new URL(`${process.env.NEXT_PUBLIC_BASE_URL}/reserve/fail`));
      }
    } else {
      return NextResponse.redirect(
        new URL(`${process.env.NEXT_PUBLIC_BASE_URL}/reserve/fail?code=${res.code}&statusText=${res.message}`)
      );
    }
  • 완료 페이지에서 데이터 처리:

    완료 페이지에서는 URL에서 오더 ID를 추출하여, 해당 오더 ID로 데이터베이스에서 예약 정보를 조회하여 출력합니다.
    ```ts
        const ReservationCompletePage = ({ params }: { params: { reservationId: string } }) => {
        const reservationid = params.reservationId;
        const { reservationDetails, isError, isLoading } = useFetchReservationDetail(reservationid);
        // 중략
    ```
    
    
    ### 😊 결론
    라우트 핸들러를 사용한 서버 사이드에서 결제 성공여부를 판단하여 보안을 향상시키고, 사용자의 실수나 악의적인 조작으로 인한 문제를 최소화할 수 있었습니다.
    
3.디테일 페이지 렌더링 방식 결정

🚨 문제 발생

nextjs가 13버전으로오면서 AppRoute 디테일 페이지를 코딩하던 중에 문득 디테일 페이지는 프로젝트

SEO가 중요하다는 말이 떠올랐습니다. 디테일 페이지는 프로젝트 핵심 제품에 대한 정보를 상세히 담고 있고, 이러한 정보들이 검색 엔진 알고리즘을 통해 SEO 순위가 정해집니다.

제가 짜고 있던 코드는 다음과 같습니다.

User
import React from 'react';
import { detailClassInfo } from '@/app/api/classdetail/detailClassInfo';
import { useQuery } from '@tanstack/react-query';

const page = async ({ params }: { params: { classId: string } }) => {
  const classId = decodeURIComponent(params.classId);

  const { data, status, error } = useQuery({
    queryKey: ['detailClass'],
    queryFn: detailClassInfo(classId),
  });
  if (status === 'pending') {
    return <span>로딩중 입니다.</span>;
  }

  if (status === 'error') {
    return <span>Error: {error.message}</span>;
  }

  .....
};

export default page;
 CSR 페이지로 렌더링 속도가 늦고, SEO가 좋지 않습니다.

🛠️ 문제 해결 과정

SSG, ISR, SSR중 하나를 골라 페이지를 구성해야됩니다. SSG,ISR은 보통 렌딩 페이지, 소개 페이지 등 데이터가 변하지 않는 페이지에 사용됩니다.

반면, SSR은 데이터의 변화가 있으면서도 SEO를 챙기는 페이지, 즉 디테일 페이지에 적합하기 때문에 SSR로 페이지를 구성했습니다.

코드는 다음과 같습니다.

import { detailClassInfo } from '@/app/api/classdetail/detailClassInfo';
import { getDetailUserInfo } from '@/app/api/classdetail/detailUserInfo';
import ClassDetailContainer from '@/components/classDetail/ClassDetailContainer';
import ClassImageCarousel from '@/components/classDetail/ClassImageCarousel';
import ClassSummary from '@/components/classDetail/ClassSummary';
import DetailComments from '@/components/classDetail/DetailComments';
import MapComponent from '@/components/classDetail/MapComponent';
import MoveToTopBtn from '@/components/listpage/MoveToTopBtn';
import Link from 'next/link';
import { IoIosArrowBack } from 'react-icons/io';

export const dynamic = 'force-dynamic';

const DetailPage = async ({ params }: { params: { id: string } }) => {
  const classData = await detailClassInfo(params.id);
  const userData = await getDetailUserInfo(classData?.user_id);

  return (
    <div className="flex flex-col items-center">
      <div className="m-0 flex w-full  items-center bg-white p-2 text-text-dark-gray">
        <Link href={`/`} className="flex items-center justify-center">
          <IoIosArrowBack size={18} />
          뒤로가기
        </Link>
      </div>
      <div className="flex w-full justify-between gap-12 bg-pale-purple p-6">
        <ClassImageCarousel classData={classData} />
        <ClassSummary classData={classData} userData={userData} />
      </div>

      <div className="flex w-full  flex-col items-center justify-center p-6">
        <ClassDetailContainer classTitle={classData?.title} classDescription={classData?.description} />
        {classData?.location && (
          <MapComponent location={classData?.location} detailLocation={classData?.detail_location} />
        )}
        <DetailComments classData={classData} />
      </div>
      <MoveToTopBtn />
    </div>
  );
};

export default DetailPage;

image

 그 결과 lighthouse에서 검색엔진 최적화 만점!
4.선생님/수강생 role 전환 시, 문제 및 해결과정

🚨문제 발생

zustand에 isTeacher 값을 넣어서 상태를 유지 시켰는데, 새로고침 시 다시 제자리로 돌아옴…🥲

해결 과정


시도 1. zustand persist를 이용해서 isTeacher 값을 localStorage에 저장한다.

수강생↔강사 전환 버튼 시, isTeacher 값이 localStorage에서 바뀌는 것을 확인할 수 있다.

import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface UserRoleState {
  isTeacher: boolean | null;
  setIsTeacher: (value: boolean) => void;
}

export const useUserRoleStore = create(
  persist<UserRoleState>(
    (set) => ({
      isTeacher: false,
      setIsTeacher: (value: boolean) => {
        set({ isTeacher: value });
      }
    }),
    {
      name: 'userRoleStorage'
    }
  )
);

zustand의 persist 미들웨어를 이용해서 상태를 저장소(localStorage, SessionStorage 등)에 저장하여 데이터를 유지 시킬 수 있다.

zustand/docs/integrations/persisting-store-data.md at main · pmndrs/zustand

image

image

🚨하지만 여기서 문제가..!

persist를 이용해서 localStorage에 상태를 저장했고, 해당 상태가 zustand에 그대로 남아있기 때문에, 로그아웃을 하고 새로 로그인을 하여도 그대로 상태가 유지된다.

하지만 문제가… 우리는 지금 하나의 id로 로그인 할 경우에는 해당 id에 맞는 상태가 저장되는데, 카카오 로그인을 했을 때 마지막 상태가 isTeacher : true인 선생님 상태로 로그아웃을 하더라도, 이후 구글 로그인으로 처음 사이트에 로그인을 하게 되면 isTeacher : false인 수강생으로 시작해야하는데, localStorage에 남아있는 isTeacher : true 값 때문에 처음 로그인한 회원도 선생님 상태가 되어버린다…!

즉,,, 로그인 한 유저의 id에 따라 zustand에 상태가 유지되어야 한다는 말..!


시도 2. 처음부터 로그인한 사람의 isTeacher 값을 db로 부터 받아와서 zustand에 저장하자

: role-based authentication으로 access control을 하는 방법 중 하나이다.

export default function useSetSessionStorage() {
  const { data: session } = useSession();

  useEffect(() => {
    if (!session) return;

    const userEmail = session?.user?.email ?? null;

    if (userEmail) {
      sessionStorage.setItem('userEmail', userEmail);
    }

    // supabase에서 isTeacher 값 불러오기
    const fetchUserRole = async () => {
      if (!userEmail) {
        console.error('이메일 값을 받아오지 못합니다.');
        return;
      }
      try {
        const userRole = await getUserRole(userEmail);
				const isTeacher = userRole?.isTeacher ?? false;
				
				//zustand에 상태 저장
        useUserRoleStore.setState({ isTeacher });
      } catch (error) {
        console.error('Failed to fetch user role', error);
      }
    };
    fetchUserRole();
  }, [session]);
  return;
}

로그인 시 저장되는 session에서 userEmail 값을 이용해서 유저를 구분해주는 것!

유저의 email이 있을 경우, getUserRole을 통해 users 테이블에서 isTeacher의 초기값(false)을 불러와서 useUserRoleStore 에 저장한다!

따라서 해당 값은 user의 email에 따라서 zustand에 다르게 저장되고, 이에 따라서 유저가 다른 아이디로 로그인 하더라도 해당 로그인한 유저의 isTeacher 값은 독립적으로 움직인다는 말!!

role이 다른 경우, zustand에 값을 저장해서 비교해나가자🔥

5.로그인 프로세스와 관련된 문제 해결

처음 로그인 페이지 기획 시 로그인 단계는 총 3단계로 구현하기로 기획

DB > supabase

auth > nextAuth

모달 > paraller, InterCepting Routes


1단계 모달 : 선생님 또는 수강생선택

2단계 모달 : 선생님 선택 시 2단계 모달로 이동되며 직업, 분야를 추가로 받기

3단계 모달 : 실제 회원가입 창 (이메일, 소셜로그인)


구현 방법

1. zustand를 이용해 각각의 단계(1단계 2단계)에서 받은 내용을 스토어를 이용해 저장

2. 3단계에서 소셜 회원가입 버튼을 누르면 각각의 소셜에서 주는 유저의 정보를 담아 스토어에 저장

3. 유효성을 이용해 3개의 단계에서 받은 데이터가 잘 들어왔는 지 확인 후 수퍼베이스에 전송


🚨문제발생

  • 위 기획 단계를 모두 구현 후 실제 값이 잘 들어가는지 확인해 보았으나 값이 들어가지 않음

🛠️문제해결 과정

✅ 1,2 단계 값이 잘 담기는지 확인

✅  소셜 회원가입 후 값이 잘 담기는지 확인

✅  소셜 로그인 시 스토어에 모든 값이 잘 담겨 있는지 확인


🔥찾아낸 문제점

  • 1,2 단계에서 값이 잘 담기는 것을 확인하였으나 문제는 소셜 회원가입 시 화면이 리렌더링 되어 스토어에서 담고 있던 값들이 모두 초기화 되는 문제점
  • 소셜 회원가입은 회원가입과 로그인을 동시에 처리

즉, 회원가입 후 로그인이 동시에 처리되기 때문에 화면이 리렌더링 될 수 밖에 없고 로그인 후 유저의 정보를 받아 올 수 있음


해결 방법 모색

1. zustand persist 사용하기

  1. 회원가입 순서 갈아업기 (회원가입 후 별도의 페이지에서 추가 (직업, 비즈니스 분야)  정보 받기)

❓zustand persist 사용하기

스토어의 상태를 로컬스토리지나 세션스토리지와 같은 영구 저장소에 값을 저장하고 있다가

슈퍼베이스에 전송을 하는 방법이다.


⚠️해당 방법을 채택하는 경우

  • 하드코딩
  1. 처음 사용자의 정보가 없는 상태에서 사용자의 정보를 zustand persist(직업, 비즈니스 분야) 이용해 저장

  2. 소셜로그인이 되면 세션으로 받은 사용자의 정보를 상태값에서 따로 저장 (서버 컴포넌트에서 세션에 저장된 값을 불러와 상태값을 따로 저장 불가 때문에 사용자가 로그인 후 클라이언트 컴포넌트에서 세션을 통해 들어온 값을 불러 zustand에 저장하는 과정 필요)

3.  2번 단계에서 받은 값을 zustand에 추가로 저장4. 이후  zustand persist에 저장된 값을 supabase에 전달.5. 회원가입 완료 후에 선생님 정보로 필요한 데이터를 따로 입력 필요

  • 보안 취약

치명적 단점으로 zustand persist를 이용하는 경우 loclastorage에 모든 값이 저장되므로 보안에 취약해진다.


❓회원가입 순서 갈아업기

소셜로그인 후 사용자의 정보를 가지고 그 사용자의 정보를 추가로 받는 방법

⚠️해당 방법을 채택하는 경우

zustand persist를 사용하지 않아도 되기 때문에 보안 강화

코드 간편화


💡결론

채택은 회원가입 순서 갈아업기

그래서 작성해둔 코드가 너무 깝고 속상하지만 페러렐라우터와 인터셉터를 배웠다는 것으로 만족하기로 했다.

과연 다른 방법도 있는지 알아보고 싶으나 더 이상의 시간 소요는 할 수 없다고 판단 하였다.

소셜 로그인(학생으로 회원가입) > 마이페이지에서 강사로 전환 후 추가 데이터 받기

6.클래스 예약 시스템의 데이터 구조 개선

기술적 의사결정

🤔 문제 상황

클래스 예약 시스템을 구축하는 과정에서, 예상치 못한 문제에 직면했습니다. 초기 설계에서는 클래스의 일자와 시간을 클래스 테이블에 배열 형태로 저장하고, 예약된 인원 수를 count 필드를 사용해 관리하며, 남은 자리를 클래스의 최대 인원에서 예약 인원 수를 빼서 보여주는 방식을 채택했습니다.

image

❗발견된 문제

클래스에 다수의 시간대가 존재할 경우, 각 회차별로 남은 자리를 별도로 관리해야 할 필요가 있는데, 현재의 테이블 설계로는 이를 관리할 수 없었습니다. 이는 일자와 시간의 종속 관계를 제대로 반영하지 못한 설계 오류였습니다.

💫 제안된 의견

    1. 객체 형태로 관리하기 (클래스 - 일자 - 회차)
    장점👍
    - 직관적이다
    클래스 하나를 선택했을 때, 그 안에 포함된 일자와 회차까지 한 번에 조회할 수 있다.

    단점😰
    - 관계형 데이터베이스에 맞지 않는 설계

    1. 날짜, 시간 테이블을 따로 만들기
    장점👍
    - 데이터 무결성
    각 테이블이 독립적이고 관계를 통해 연결되어 있기 때문에, 데이터 무결성을 유지하면서 관리하기가 용이하다.

    - 확장성
    구조를 변경할 필요 없이 새로운 관계를 정의하거나 기존 관계를 수정할 수 있다
    ex) 만약 클래스의 회차별로 정원을 따로 관리하는 기능을 추가해야 할 때 갈아엎을 필요없이 테이블을 추가, 수정하면 된다.

    단점😰
    - 조회 성능 저하
    각 세션의 정보를 조회하기 위해서 여러 테이블을 조인해야한다. 따라서 조회 성능이 하락한다.

    - 기존 api 함수 다 갈아엎어야함.. 😂

✅ 결정한 방식

날짜, 시간 테이블을 따로 만들기

지금까지 json-server나 firebase같은 NoSQL 형식의 DB를 사용했기 때문에 1번 방식에 익숙하고, supabase에서도 객체를 담는식으로 구현할 수도 있지만 관계형 DB인 supabase를 사용하기로 결정한 만큼 그에 맞는 설계를 하는 것이 맞다고 판단했습니다.


DB 테이블 설계

1. Date, Time 테이블 만들기

Date 테이블

image

date 테이블에는 어떤 클래스에 등록 된 일자인지 알아야 하기때문에 class_id를 외래키로 넣어줬고, 일자인 day 필드를 넣었습니다.

Time 테이블

image

Time 테이블에는 이 시간이 어떤 일자에 종속되어있는지 알아야 하기 때문에 date_id를 외래키로 넣었습니다.

테이블 연결 관계

image

  • DB ERD 설명
  • reserve 테이블과 class 테이블은 **class_id**로 연결됩니다.
  • date 테이블은 **class_id**로 class 테이블과 연결됩니다.
  • reserve 테이블과 time 테이블은 **time_id**로 연결됩니다.
  • time 테이블은 **date_id**로 date 테이블과 연결됩니다.

테이블 조인을 활용한 예약 정보 불러오기

설계한 ERD를 바탕으로 예약정보의 클래스 정보, 시간, 일자를 받아오기 위한 과정은 다음과 같습니다.

  1. 클래스 정보 조인:
  • 예약 정보의 class_id 를 사용하여 class 테이블과 조인하고, 예약된 클래스의 title, total_time, location 정보를 가져옵니다.
  1. 시간 정보 조인:
  • 예약 정보의 time_id를 사용하여 time 테이블과 조인하고, 예약된 시간(times) 정보를 가져옵니다.
  1. 일자 정보 조인:
  • time 테이블에서 가져온 date_id 를 사용하여 date 테이블과 조인하고, 해당하는 일자의 day 정보를 가져옵니다.

supabase 메서드를 활용한 조인

supabase 메서드를 사용해 조인하는 함수입니다. 이 함수는 를 **reserve_id**를 받아 reserve 테이블에서 해당 예약에 대한 정보를 불러오며, **class**와 time, 그리고 date 테이블을 조인하여 필요한 정보를 불러오게 됩니다.

// reserve 테이블과 class 테이블을 class_id로 inner조인하고, class 테이블에서 title, total_time, location만 선택하여 결과에 포함
// time 테이블을 time_id로 조인
// time테이블에서 time_id가 일치하는 레코드의 date_id로 date 테이블 inner조인하고, date 테이블에서 day만 선택하여 결과에 포함

export const fetchReservationDetails = async (reserveId: string) => {
      const { data, error }: PostgrestSingleResponse<DBReservationDetailsType> = await supabase
        .from('reserve')
        .select(
          `
            class_id, reserve_quantity, reserve_price, time_id, user_id,
            class(title, total_time, location),
            time (times, date(day))
      `
        )
        .eq('reserve_id', reserveId)
        .single();

      if (error) {
        console.log('fetchReservationDetails error =>', error);
        return;
      }

      return data;
      };

함수의 반환값은 다음과 같이 출력됩니다.

image

결론

😊 결론

관계형 데이터베이스에 적합한 설계를 통해 각 테이블이 독립적인 역할을 할 수 있도록 하고, 외래 키 관계로 연결되도록 하여 데이터의 무결성을 지킬 수 있었습니다.

또한 이 경험을 통해 초기 설계의 중요성을 깊이 느꼈습니다. 철저한 초기 분석과 설계는 나중에 발생할 수 있는 많은 문제를 예방할 수 있다는 것을 배웠습니다.

7.라이브러리 css 충돌 해결과정

🚨 문제 발생

SPA에서 같은 라이브러리를 각 페이지에서 사용할 경우 현재 페이지의 CSS가 이전 페이지의 CSS로 적용되는 CSS 충돌 발생🛠️ 문제 해결 과정

image

css가 정상적으로 작동! 이걸 토대로 구글링을 해본 결과 SPA에서는 CSS 충돌이 빈번하기 때문에 사전에 조치를 취해야 된다는 결론이 나왔습니다.


🪙 해결 방법

  1. useEffect를 사용해서 css 적용 - 성능을 저하시키기 때문에 x
  2. Css 모듈화 - 가장 대중적으로 쓰이면서 유지 보수성, 재사용성도 높아지기 때문에 O
  3. useState로 조건부 css 적용 - css충돌 때문에 상태관리 까지 사용하면 비용적인 측면에서 x

이러한 이유로 CSS 모듈화를 선택했습니다.


기존 방식

image


🛠️ 문제 해결 과정

처음에는 디테일 페이지와 메인 페이지만 embla 캐러셀을 사용하기 때문에 메인 페이지의 캐러셀 css를 비활성화 시켜보았다 그 결과

image

css를 직접 임포트하는 방식



CSS 모듈화

  1. 파일 이름 변경

image

  1. 임포트 방식 변경

image

  1. 모듈 사용 방법

image

이러한 방법으로 CSS 충돌을 해결했습니다.

8.Image url은 있는데 사진이 안 뜨는 문제 해결 (with supabase)

트러블 슈팅🔥

문제 1) image url은 잘 들어오는데 사진이 안 뜨는 상황

댓글을 작성하는 페이지에서 다른 값들은 다 comment에 잘 담겨서 들어와서 해당 data를 렌더링 해주는데 문제가 없었는데, 이미지만 잘 안 불러와지는 문제가 생겼었다.

console.log를 계속 찍어서 확인도 해보고, 이미지 url도 복사해서 넣어보고 하니 사진이 잘 뜨는데

src={comment.image[0]} 에서만 사진을 불러오지 못하고 있었다.

<img
		src={comment.image[0]}
		alt="클래스 대표 사진"
		width={300}
		height={200}
		className="w-full h-full p-4"
		style={{ objectFit: 'contain' }}
/>

image

image


문제 원인 파악)

다른 mainpage에서는 image로 url을 잘 받아오고 있고, 메인 화면에서도 잘 뜨고 있음… 뭐지..

image

둘의 차이점이 무엇일까 비교하던 중 문제점 파악!!

내가 위에서 받아오고 있던 후기 작성하기에서는 image 자체를 ‘ ’ 바로 이 따옴표 안에서 string 으로 받아오고 있었기 때문…!!!

우리는 image 파일 여러 개를 넣기 위해 type을 array로 선택하였고, 그 중에서도 첫 번째 사진을 대표 사진으로 생각하고 src={comment.image[0]} 이렇게 사진을 넣으려고 했다.

하지만 현재 image 데이터 자체가 string 이기 때문에, 배열의 기능을 상실하고 index 0의 경우 그냥 { 만 들어오고 있었던 셈…!

왜 이부분만 따로 콘솔 찍어볼 생각을 못했을까…

image: '{https://d1x9f5mf11b8gz.cloudfront.net/class/20220308/ec9fa67b-0040-413d-ae8b-258d46df07c4.jpg}'

근데 여기서 image data가 string으로 들어오고 있던 이유는 바로 db join을 통해서 데이터를 받고 있었는데, image를 array가 아닌 text로 받아오고 있었기 때문…!!!

CREATE OR REPLACE FUNCTION fetch_class_info_on_comment(p_user_id uuid) 
RETURNS TABLE (
  class_id uuid,
  comment_id uuid,
  title text,
  image text,
  content text,
  create_at timestamp
) AS $$
SELECT
  c.class_id,
  m.comment_id,
  c.title,
  c.image,
  m.content,
  m.create_at
FROM
  comments m
JOIN
  class c ON m.class_id = c.class_id
WHERE
  m.user_id = p_user_id;
$$ LANGUAGE sql STABLE;

문제 해결)

여기서 image text 이 부분을 image text[] 이렇게 image가 array로 들어오도록 해주면 바로 값이 배열에 담긴채 들어오고 있는 걸 확인 할 수 있었다!!

하지만 기존의 함수에서 바로 수정을 하니 error를 뱉어서 새로운 New query를 생성해서 함수를 생성해주었다.

image

ERROR: 42P13: cannot change return type of existing function DETAIL: Row type defined by OUT parameters is different. HINT: Use DROP FUNCTION fetch_class_info_on_comment(uuid) first.

새로운 query 생성해서 하니 잘 작동하였고, 값도 배열로 아주 잘 받아져오는걸 확인 할 수 있었다.

image

image가 배열 형태로 아주 잘 들어오는걸 확인 할 수 있었다!!

image : ['https://d1x9f5mf11b8gz.cloudfront.net/class/20220308/ec9fa67b-0040-413d-ae8b-258d46df07c4.jpg']

image

🔥 typescript를 통해 type error를 많이 잡을 수 있었지만, SQL query에서 image type을 text로 작성해주는 것까지 잡아주지는 못 했다..!!

결국은 처음 설정한 type을 잘 지켜주는 것이 매우 중요하다. 정신차리고 코딩하자~~


문제 2) supabase storage에 업로드한 이미지 url이 404 not found ?!?!

supabase storage에서 이미지 파일을 업로드한 다음 table image data에 추가도 되었고, 콘솔에도 image url이 잘만 뜨는데 왜 404가 뜨는지… 봐도봐도 친해지지 못하는 404 not found

image : ["https://hdurwturhsczrdeugmon.supabase.co/storage/v1/object/public/uploads/b06db7ba-911e-4f4f-993e-147d47118307_143777_149131_434.jpg", ]

image


문제 원인 파악

원인 파악을 위해 우선 image url이 실제 storage에 저장된 url과 같은지 확인해보아야 한다.

일단 지난번 팀 프로젝트에서 supabase를 한 번 사용해 본 적이 있었고, 그때 storage를 담당하던 팀원분께서 엄청 고생을 많이 하시고 해당 url을 확인하는 방법을 찾아내서 알려주신 적이 있었다.

image

그래서 아래 GET URL 을 통해 주소를 확인해보니 다음과 같았다.

(저 버튼을 클릭하면 url 주소가 복사되므로 해당 url을 주소창에 넣어서 확인해보자..)

https://hdurwturhsczrdeugmon.supabase.co/storage/v1/object/public/classImages/uploads/'b06db7ba-911e-4f4f-993e-147d47118307_143777_149131_434.jpg

https://hdurwturhsczrdeugmon.supabase.co/storage/v1/object/public/uploads/b06db7ba-911e-4f4f-993e-147d47118307_143777_149131_434.jpg

얼핏 보기엔 두 url 주소가 비슷해보여서 같은 주소가 맞는데 왜 값이 이미지를 못 불러오지? 라고 생각할 수 있는데… 진짜 자~알 뜯어보니 두 주소가 다른 걸 확인할 수 있었다.

→ 바로 public/classImages/uploadspublic/uploads 이 부분이었다.. 처음부터 url path 설정을 해줄 때 ‘classImages’(bucket name) 를 추가해주지 않았고, 제대로된 경로가 아니므로 이미지 파일을 찾을 수 없다고 뜨고 있었다.

  // supabase storage에 등록한 이미지 업로드
  const uploadFile = async (file: File) => {
    const cleanName = cleanFileName(file.name);
    const filePath = `uploads/${uuidv4()}_${cleanName}`;
    const { data, error } = await supabase.storage.from('classImages').upload(filePath, file);
    if (error) {
      console.error('파일 업로드 실패:', error);
      return null;
    } else {
      const url = `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/${data.path}`;
      return url;
    }
  };

문제 해결) 정확한 경로로 url 접근하기

🔥 제대로된 url 주소만 따로 보면 다음과 같다.

const url = `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/classImages/${data.path}`;

여기서 url 주소를 만들어주는 부분이 있는데,

  1. ${process.env.NEXT_PUBLIC_SUPABASE_URL} : 초기 설정한 supabase의 url 주소
  2. /storage/v1/object/public : supabase 내에서 storage에 접근하는 주소
  3. /classImages : storage bucket 이름을 작성
  4. /${data.path} : 내가 설정한 filePath를 가장 마지막에 붙여준다.
const filePath = uploads/${uuidv4()}_${cleanName};

image

9.Image 최적화 및 성능

이미지 속도 최적화하기

fill + className v.s fill + sizes + objectFit

  1. Image 컴포넌트 내에서 fill 속성 사용 + className으로 object-cover 속성 사용
/* MainPage */
<Image
  fill={true}
  src={classInfos.image && classInfos.image.length > 0 ? classInfos.image[0] : noImage}
  alt="클래스 이미지"
  style={{ objectFit: 'cover' }}
/>

⇒ size 및 time 확인

image

대체적으로 120~216 ms 정도의 시간이 걸림

  1. Image 컴포넌트 내에서 fill 속성 + sizes 속성 함께 사용 + placeholder 속성 추가
<Image
  sizes="(max-width: 768px) 128px, 256px"
  placeholder="empty"
  fill={true}
  src={classInfos.image && classInfos.image.length > 0 ? classInfos.image[0] : noImage}
  alt="클래스 이미지"
  style={{ objectFit: 'cover' }}
/>

⇒ size 및 time 확인 image

대체적으로 10~100ms 이하의 시간으로 이미지가 로딩되는 시간이 줄어든 것을 확인할 수 있다.


회고

윤미주

욕심많게 시작했던 프로젝트이기에 팀원들에게 부담이되고 많이 힘들었을 것 같다고 생각합니다.
한 달이 조금 넘는 기간 동안 함께 프로젝트를 만들기 위해 밤낮없이 회의하면서도 마지막까지 웃으며 프로젝트를 마무리할 수 있게 되어 팀원분들께 감사한 마음뿐입니다!🙏
부족한 리더였던 것 같은데 다들 너무 잘 따라주셔서 감사했고 너무 고생 많으셨습니다!💕

김현진

한 달이 넘는 시간 동안 좋은 팀원분들과 함께 프로젝트에 집중하여 마무리까지 올 수 있어서 너무 값지고 행복한 시간이었습니다!
실력적으로 많이 부족하여 팀에 도움이 될 수 있을까 많은 걱정을 가지고 시작한 프로젝트였는데, 정말 멋진 팀원분들이 없었다면 여기까지 올 수 없었다고 생각합니다.
또한 소통하고 함께 코드 리뷰를 하면서 공유하는 개발 문화와 함께 저또한 성장할 수 있는 좋은 경험이라고 생각합니다.
튜터님들, 디자이너님, 팀원분들 모두 고생하셨습니다!

여태원

한달동안 동고동락하면서 같이 지낸 팀원분들 정말 재밌었습니다!!
처음에 기획하면서 부터 개발 끝마칠 때 까지 우여곡절이 많았지만 그래서 더 좋았던거 같습니다!
다 같이 고생하면서 모두 열심히 한 걸 알기 때문에, 그래서 더 시원섭섭한 것 같아요..ㅎㅎ
또한 프로젝트를 도와주신 튜터님들도 정말 감사합니다 덕분에 많이 배워서 가는거 같아요!! 모두 앞으로 개발자의 길을 걸어갈 때, 이 경험이 든든한 밑거름이 되길 희망하면서 다시 한번 함께 해준 모든 분들께 진심으로 감사드립니다

서지원

한 달이라는 시간 동안 훌륭한 팀원 분들과 함께할 수 있어서 너무 좋았습니다!
매일 다 같이 새벽까지 잠 못 자고 작업한 걸 알기 때문에 프로젝트에 더 애착이 가는 것 같아요!
최종프로젝트 마지막까지 열심히 같이 달려주신 팀원 분들께 정말정말 감사하고 매일 함께 데일리 스크럼 하면서 많은 도움 주신 창식 튜터님께도 감사드립니다!!! 😊🧡

임혜린

혼자서 한가지 문제를 계속 붙잡고 있었던 때가 있었는데 팀원 분들이 비슷한 경험하셨고 어떻게 해결하셨는지 공유해주시면서 혼자서는 풀리지 않던 문제들이 쉽게 풀리는 것을 경험할 수 있었고 공유의 중요성을 깨달을 수 있었습니다.
그리고 팀원 분들 모두 매일 잠도 못 자가며 피곤하셨을 텐데도 늘 팀 분위기를 밝게 조성해주셔서 힘들더라도 힘내서 열심히 임할 수 있었던 것 같습니다.
진심으로 팀원분들과 튜터님, 디자이너님 너무 감사해요❤️

임향지(디자이너)

저는 개발자 팀과 협업하여 웹사이트를 디자인하고 배포하는 프로젝트에 참여했습니다. 이 프로젝트를 통해 웹 개발 프로세스에 대한 실무 경험을 쌓고 팀워크의 중요성을 다시 한번 느낄 수 있었습니다. 디자인 회의에 적극적으로 참여하고, 개발 과정에서 발생하는 문제들을 개발자분들과 함께 해결하기 위해 노력했습니다. 팀원들과의 열린 소통과 협력을 통해 어려움에도 능동적으로 대처하고 최적의 해결책을 모색할 수 있었습니다. 덕분에 원데이 클래스 웹사이트를 완성할 수 있었습니다. 저는 앞으로도 개발자 팀원들과의 소통을 가장 중요하게 생각하고, 협력을 통해 더욱 발전된 디자이너가 되도록 노력하겠습니다. 이번 프로젝트에 참여해주신 개발자 팀원들과 모든 관계자분들께 진심으로 감사드립니다. 💕




파일구조

📦src
 ┣ 📂app
 ┃ ┣ 📂(clrm)
 ┃ ┃ ┣ 📂(mypage)
 ┃ ┃ ┃ ┣ 📂myClassStudentList
 ┃ ┃ ┃ ┃ ┗ 📜page.tsx
 ┃ ┃ ┃ ┣ 📂studentMypage
 ┃ ┃ ┃ ┃ ┗ 📜page.tsx
 ┃ ┃ ┃ ┗ 📂teacherMypage
 ┃ ┃ ┃ ┃ ┗ 📜page.tsx
 ┃ ┃ ┣ 📂bugBusters
 ┃ ┃ ┃ ┗ 📜page.tsx
 ┃ ┃ ┣ 📂list
 ┃ ┃ ┃ ┣ 📂detail
 ┃ ┃ ┃ ┃ ┗ 📂[id]
 ┃ ┃ ┃ ┃ ┃ ┗ 📜page.tsx
 ┃ ┃ ┃ ┗ 📜page.tsx
 ┃ ┃ ┣ 📂messages
 ┃ ┃ ┃ ┗ 📂[[...chatRoomId]]
 ┃ ┃ ┃ ┃ ┣ 📂_components
 ┃ ┃ ┃ ┃ ┃ ┣ 📜ChatImageModal.tsx
 ┃ ┃ ┃ ┃ ┃ ┣ 📜ChatMessages.tsx
 ┃ ┃ ┃ ┃ ┃ ┣ 📜ChatPreview.tsx
 ┃ ┃ ┃ ┃ ┃ ┣ 📜MessageBoxs.tsx
 ┃ ┃ ┃ ┃ ┃ ┗ 📜MessagesPage.tsx
 ┃ ┃ ┃ ┃ ┗ 📜page.tsx
 ┃ ┃ ┣ 📂payment
 ┃ ┃ ┃ ┗ 📜page.tsx
 ┃ ┃ ┣ 📂register
 ┃ ┃ ┃ ┣ 📂completedPage
 ┃ ┃ ┃ ┃ ┗ 📂[id]
 ┃ ┃ ┃ ┃ ┃ ┗ 📜page.tsx
 ┃ ┃ ┃ ┣ 📂registerEditPage
 ┃ ┃ ┃ ┃ ┗ 📂[id]
 ┃ ┃ ┃ ┃ ┃ ┗ 📜page.tsx
 ┃ ┃ ┃ ┗ 📜page.tsx
 ┃ ┃ ┣ 📂reserve
 ┃ ┃ ┃ ┣ 📂fail
 ┃ ┃ ┃ ┃ ┗ 📜page.tsx
 ┃ ┃ ┃ ┣ 📂success
 ┃ ┃ ┃ ┃ ┗ 📂[reservationId]
 ┃ ┃ ┃ ┃ ┃ ┗ 📜page.tsx
 ┃ ┃ ┃ ┗ 📜page.tsx
 ┃ ┃ ┣ 📜error.tsx
 ┃ ┃ ┣ 📜layout.tsx
 ┃ ┃ ┗ 📜page.tsx
 ┃ ┣ 📂api
 ┃ ┃ ┣ 📂auth
 ┃ ┃ ┃ ┗ 📂[...nextauth]
 ┃ ┃ ┃ ┃ ┗ 📜route.ts
 ┃ ┃ ┣ 📂chatRooms
 ┃ ┃ ┃ ┗ 📜getChatRooms.ts
 ┃ ┃ ┣ 📂classdetail
 ┃ ┃ ┃ ┣ 📜detailClassInfo.ts
 ┃ ┃ ┃ ┣ 📜detailComment.ts
 ┃ ┃ ┃ ┣ 📜detailCommentsInfo.ts
 ┃ ┃ ┃ ┗ 📜detailUserInfo.ts
 ┃ ┃ ┣ 📂listpage
 ┃ ┃ ┃ ┣ 📜classInfoForList.ts
 ┃ ┃ ┃ ┗ 📜determineAMPM.ts
 ┃ ┃ ┣ 📂mainpage
 ┃ ┃ ┃ ┗ 📜getClassAllInfo.ts
 ┃ ┃ ┣ 📂mypage
 ┃ ┃ ┃ ┣ 📜fetchMyClasses.ts
 ┃ ┃ ┃ ┣ 📜my-class-api.ts
 ┃ ┃ ┃ ┣ 📜my-comments-api.ts
 ┃ ┃ ┃ ┗ 📜user-api.ts
 ┃ ┃ ┣ 📂payment
 ┃ ┃ ┃ ┗ 📜route.ts
 ┃ ┃ ┣ 📂reserve
 ┃ ┃ ┃ ┣ 📜checkIsReserved.ts
 ┃ ┃ ┃ ┣ 📜fetchReservationDetails.ts
 ┃ ┃ ┃ ┣ 📜fetchReserveClassInfo.ts
 ┃ ┃ ┃ ┣ 📜insertNewReservation.ts
 ┃ ┃ ┃ ┣ 📜insertNotice.ts
 ┃ ┃ ┃ ┗ 📜sumReserveQuantityByTimeId.ts
 ┃ ┃ ┣ 📂supabase
 ┃ ┃ ┃ ┗ 📜supabase.ts
 ┃ ┃ ┣ 📂userEmail
 ┃ ┃ ┃ ┗ 📜loginUserId.ts
 ┃ ┃ ┗ 📂wish
 ┃ ┃ ┃ ┗ 📜wishApi.ts
 ┃ ┣ 📂hello
 ┃ ┃ ┗ 📂(loginRoot)
 ┃ ┃ ┃ ┣ 📂_component
 ┃ ┃ ┃ ┃ ┗ 📜LoginPage.tsx
 ┃ ┃ ┃ ┣ 📜layout.tsx
 ┃ ┃ ┃ ┗ 📜page.tsx
 ┃ ┣ 📜global-error.tsx
 ┃ ┣ 📜globals.css
 ┃ ┣ 📜layout.tsx
 ┃ ┣ 📜not-found.tsx
 ┃ ┗ 📜provider.tsx
 ┣ 📂assets
 ┃ ┗ 📂images
 ┃ ┃ ┣ 📜bannerCookie.png
 ┃ ┃ ┣ 📜bannerFlower.png
 ┃ ┃ ┣ 📜bannerImage1.png
 ┃ ┃ ┣ 📜bannerMeeting.png
 ┃ ┃ ┣ 📜bannerPersimmon.png
 ┃ ┃ ┣ 📜bannerText.svg
 ┃ ┃ ┣ 📜BugBusters.png
 ┃ ┃ ┣ 📜candle.png
 ┃ ┃ ┣ 📜clRoomTextLogo.png
 ┃ ┃ ┣ 📜clroom_no_img_purple.png
 ┃ ┃ ┣ 📜clroom_no_img_purple_wide.png
 ┃ ┃ ┣ 📜clroom_no_img_white.png
 ┃ ┃ ┣ 📜Ellipse.png
 ┃ ┃ ┣ 📜failIcon.gif
 ┃ ┃ ┣ 📜filterIcon.svg
 ┃ ┃ ┣ 📜Frame 1775.svg
 ┃ ┃ ┣ 📜Group 1791.png
 ┃ ┃ ┣ 📜Group 1805.svg
 ┃ ┃ ┣ 📜Hyangji.png
 ┃ ┃ ┣ 📜Hyerin.png
 ┃ ┃ ┣ 📜Hyunjin.png
 ┃ ┃ ┣ 📜Jiwon.png
 ┃ ┃ ┣ 📜lightPurpleGraphic.svg
 ┃ ┃ ┣ 📜lightPurpleGraphic2.png
 ┃ ┃ ┣ 📜loginLogo.svg
 ┃ ┃ ┣ 📜loginMetadataImage.png
 ┃ ┃ ┣ 📜loginTextImage.svg
 ┃ ┃ ┣ 📜logo.svg
 ┃ ┃ ┣ 📜Miju.png
 ┃ ┃ ┣ 📜no_img.jpg
 ┃ ┃ ┣ 📜orangeStar.png
 ┃ ┃ ┣ 📜profile-image.png
 ┃ ┃ ┣ 📜purpleFlower.png
 ┃ ┃ ┣ 📜soap.png
 ┃ ┃ ┣ 📜Teawon.png
 ┃ ┃ ┣ 📜yellowStar.png
 ┃ ┃ ┣ 📜yellowStarStroke.png
 ┃ ┃ ┗ 📜브로슈어 디자인 파이널.jpg
 ┣ 📂components
 ┃ ┣ 📂bugBusters
 ┃ ┃ ┗ 📜PersonalCard.tsx
 ┃ ┣ 📂chatbot
 ┃ ┃ ┗ 📜ChatBot.tsx
 ┃ ┣ 📂chatRooms
 ┃ ┃ ┣ 📜AskButton.tsx
 ┃ ┃ ┗ 📜ChatButton.tsx
 ┃ ┣ 📂classDetail
 ┃ ┃ ┣ 📜ClassDetailBtn.tsx
 ┃ ┃ ┣ 📜ClassDetailContainer.tsx
 ┃ ┃ ┣ 📜ClassDetailDayPicker.tsx
 ┃ ┃ ┣ 📜ClassImageCarousel.tsx
 ┃ ┃ ┣ 📜ClassInfoBox.tsx
 ┃ ┃ ┣ 📜ClassInfos.tsx
 ┃ ┃ ┣ 📜ClassSummary.tsx
 ┃ ┃ ┣ 📜CommentsCard.tsx
 ┃ ┃ ┣ 📜CreateComments.tsx
 ┃ ┃ ┣ 📜DetailComments.tsx
 ┃ ┃ ┣ 📜DetailWishButton.tsx
 ┃ ┃ ┣ 📜embla.module.css
 ┃ ┃ ┣ 📜EmblaCarouselArrowButtons.tsx
 ┃ ┃ ┣ 📜EmblaCarouselDotButton.tsx
 ┃ ┃ ┣ 📜EmblaCarouselLazyLoadImage.tsx
 ┃ ┃ ┣ 📜MapComponent.tsx
 ┃ ┃ ┗ 📜ShowComments.tsx
 ┃ ┣ 📂common
 ┃ ┃ ┣ 📂bars
 ┃ ┃ ┃ ┣ 📂categories
 ┃ ┃ ┃ ┃ ┗ 📜SearchClass.tsx
 ┃ ┃ ┃ ┣ 📜ConvertBtn.tsx
 ┃ ┃ ┃ ┣ 📜Header.tsx
 ┃ ┃ ┃ ┣ 📜SideBar.tsx
 ┃ ┃ ┃ ┗ 📜SideBarBtn.tsx
 ┃ ┃ ┣ 📜AlertModal.tsx
 ┃ ┃ ┣ 📜BackButton.tsx
 ┃ ┃ ┣ 📜CustomCaption.tsx
 ┃ ┃ ┣ 📜day-picker.css
 ┃ ┃ ┣ 📜LoadingSpinner.tsx
 ┃ ┃ ┣ 📜LoginError.tsx
 ┃ ┃ ┣ 📜Modal.tsx
 ┃ ┃ ┣ 📜Notification.tsx
 ┃ ┃ ┣ 📜Pagination.tsx
 ┃ ┃ ┣ 📜Stars.tsx
 ┃ ┃ ┣ 📜Toastify.tsx
 ┃ ┃ ┗ 📜WishIcon.tsx
 ┃ ┣ 📂listpage
 ┃ ┃ ┣ 📜CategoryBtns.tsx
 ┃ ┃ ┣ 📜ClassList.tsx
 ┃ ┃ ┣ 📜listpageBtns.tsx
 ┃ ┃ ┣ 📜ListPageWishButton.tsx
 ┃ ┃ ┣ 📜MoveToTopBtn.tsx
 ┃ ┃ ┗ 📜SearchFilter.tsx
 ┃ ┣ 📂login
 ┃ ┃ ┣ 📜LoginState.tsx
 ┃ ┃ ┗ 📜SocialLogin.tsx
 ┃ ┣ 📂main
 ┃ ┃ ┣ 📜Banner.tsx
 ┃ ┃ ┣ 📜BestClass.tsx
 ┃ ┃ ┣ 📜ChatBubble.tsx
 ┃ ┃ ┣ 📜ClassCard.tsx
 ┃ ┃ ┣ 📜ClassSlick.tsx
 ┃ ┃ ┣ 📜DeadlineClass.tsx
 ┃ ┃ ┣ 📜emblaCarousel.module.css
 ┃ ┃ ┣ 📜LatestClass.tsx
 ┃ ┃ ┗ 📜MainFooter.tsx
 ┃ ┣ 📂mypage
 ┃ ┃ ┣ 📂student
 ┃ ┃ ┃ ┣ 📜AddTeacherInfo.tsx
 ┃ ┃ ┃ ┣ 📜MyCommentItem.tsx
 ┃ ┃ ┃ ┣ 📜MyComments.tsx
 ┃ ┃ ┃ ┣ 📜MyPageWishButton.tsx
 ┃ ┃ ┃ ┣ 📜MyReservedClass.tsx
 ┃ ┃ ┃ ┣ 📜MyReservedClassItem.tsx
 ┃ ┃ ┃ ┣ 📜MyWishClass.tsx
 ┃ ┃ ┃ ┣ 📜MyWishClassItem.tsx
 ┃ ┃ ┃ ┗ 📜StudentMyPageTab.tsx
 ┃ ┃ ┣ 📂teacher
 ┃ ┃ ┃ ┣ 📜EditTeacherInfo.tsx
 ┃ ┃ ┃ ┣ 📜MyClass.tsx
 ┃ ┃ ┃ ┣ 📜MyClassStudentList.tsx
 ┃ ┃ ┃ ┣ 📜MyClassStudentPage.tsx
 ┃ ┃ ┃ ┗ 📜TeacherMyPageTab.tsx
 ┃ ┃ ┣ 📜EditProfile.tsx
 ┃ ┃ ┣ 📜EditProfileImage.tsx
 ┃ ┃ ┣ 📜MyPageBtn.tsx
 ┃ ┃ ┗ 📜SelectOption.tsx
 ┃ ┣ 📂payments
 ┃ ┃ ┣ 📜PaymentFailPage.tsx
 ┃ ┃ ┗ 📜PaymentPage.tsx
 ┃ ┣ 📂register
 ┃ ┃ ┣ 📜Address.tsx
 ┃ ┃ ┣ 📜Category.tsx
 ┃ ┃ ┣ 📜ClassContent.tsx
 ┃ ┃ ┣ 📜ClassDiff.tsx
 ┃ ┃ ┣ 📜ClassTitleType.tsx
 ┃ ┃ ┣ 📜CreateBtn.tsx
 ┃ ┃ ┣ 📜HashTag.tsx
 ┃ ┃ ┣ 📜ImageUpload.tsx
 ┃ ┃ ┣ 📜MinMaxNumber.tsx
 ┃ ┃ ┣ 📜Price.tsx
 ┃ ┃ ┣ 📜SelectTime.tsx
 ┃ ┃ ┗ 📜TotalTime.tsx
 ┃ ┗ 📂reserve
 ┃ ┃ ┣ 📂reservationComplete
 ┃ ┃ ┃ ┗ 📜NavigationButtons.tsx
 ┃ ┃ ┣ 📜ClassInfo.tsx
 ┃ ┃ ┣ 📜CurrentReserveQuantity.tsx
 ┃ ┃ ┣ 📜DateTimePicker.tsx
 ┃ ┃ ┣ 📜PriceCalculator.tsx
 ┃ ┃ ┣ 📜ReservationScheduler.tsx
 ┃ ┃ ┣ 📜ReserveButton.tsx
 ┃ ┃ ┣ 📜ReserveInfo.tsx
 ┃ ┃ ┣ 📜ReserveUserInfo.tsx
 ┃ ┃ ┗ 📜SetQuantityAndPay.tsx
 ┣ 📂constants
 ┃ ┣ 📜options.ts
 ┃ ┗ 📜QueryKeys.ts
 ┣ 📂hooks
 ┃ ┣ 📂useChatRoom
 ┃ ┃ ┗ 📜useNewChatRoom.tsx
 ┃ ┣ 📂useLogin
 ┃ ┃ ┣ 📜useLoginUserId.tsx
 ┃ ┃ ┣ 📜useSessionStorageUserEmail.tsx
 ┃ ┃ ┣ 📜useSetEmailToApi.tsx
 ┃ ┃ ┣ 📜useSetStorage.tsx
 ┃ ┃ ┣ 📜useTeacherInfo.tsx
 ┃ ┃ ┗ 📜useUserEmail.tsx
 ┃ ┣ 📂usePayment
 ┃ ┃ ┗ 📜usePayment.tsx
 ┃ ┣ 📂useReserve
 ┃ ┃ ┗ 📜useFetchReservationDetail.ts
 ┃ ┣ 📂useWish
 ┃ ┃ ┗ 📜useWishQueries.ts
 ┃ ┣ 📜test.tsx
 ┃ ┗ 📜useEditComment.ts
 ┣ 📂store
 ┃ ┣ 📂login
 ┃ ┃ ┗ 📜loginUserIdStore.ts
 ┃ ┣ 📂mypage
 ┃ ┃ ┣ 📜myClassInfoStore.ts
 ┃ ┃ ┣ 📜userInfoStore.ts
 ┃ ┃ ┗ 📜userRoleStore.ts
 ┃ ┣ 📜classFilterStore.ts
 ┃ ┣ 📜classInfoStore.ts
 ┃ ┣ 📜modalstore.ts
 ┃ ┣ 📜registerScheduleStore.ts
 ┃ ┣ 📜registerStore.ts
 ┃ ┗ 📜reserveClassStore.ts
 ┣ 📂types
 ┃ ┣ 📂authUser
 ┃ ┃ ┗ 📜authUserTypes.ts
 ┃ ┣ 📂chat
 ┃ ┃ ┗ 📜chatTypes.ts
 ┃ ┣ 📜class.ts
 ┃ ┣ 📜classFilter.ts
 ┃ ┣ 📜comments.ts
 ┃ ┣ 📜date.ts
 ┃ ┣ 📜detailComment.ts
 ┃ ┣ 📜editClass.ts
 ┃ ┣ 📜notice.ts
 ┃ ┣ 📜reactSlick.ts
 ┃ ┣ 📜register.ts
 ┃ ┣ 📜reserve.ts
 ┃ ┗ 📜user.ts
 ┣ 📂utils
 ┃ ┣ 📂fonts
 ┃ ┃ ┣ 📜fonts.ts
 ┃ ┃ ┗ 📜PretendardVariable.woff2
 ┃ ┣ 📂supabase
 ┃ ┃ ┣ 📜client.ts
 ┃ ┃ ┣ 📜middleware.ts
 ┃ ┃ ┗ 📜server.ts
 ┃ ┗ 📜convertTimeTo12HourClock.ts
 ┗ 📜middleware.ts

```****

classroom-project's People

Contributors

seopport avatar hyun0zin avatar ccccliff avatar limhyerin avatar anywhereim avatar caesiumy avatar

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.