swimminginthecode DIVE!

Dazz6/ABOUT

모각 프로젝트 - #2-3. 모각챌

dazz6 2025. 8. 3. 18:00

모각챌은 여러 명이 하나의 미션을 인증하는 공간이다.

챌린지 기간 동안 탈락하지 않고 완수하게 되면, 성공 뱃지를 부여받는다.

 

모각챌 메인은 challenge-search-card.tsx 로 별개 작성하였고, 챌린지 상태 (진행 전, 진행 중, 종료) 및 공식 챌린지 여부로 필터링 가능하며

최신순과 참가자순으로 정렬할 수 있다.

"use client";

import { useEffect, useState } from "react";
import SearchCardView from "@/app/components/shared/search-card-view";
import {
  ChallengeMainProps,
  ChallengeSearchCardProps,
  ChallengeStatusType,
} from "@/types/challenge.type";
import ChallengeMainCard from "./challenge-main-card";
import { ChallengeSearch } from "@/lib/shared/challenge.api";
import ChallengeMainCardSkeleton from "../../../components/skeleton/challenge/challenge-main-card-skeleton";
import Pagination from "@/app/components/shared/paginaiton";
import { mapSort } from "@/utils/shared/sort.util";

export default function ChallengeSearchCard({
  title,
  description,
  sort,
  section,
}: ChallengeSearchCardProps) {
  const [search, setSearch] = useState("");
  const [official, setOfficial] = useState<boolean>(false);
  const [finalSort, setFinalSort] = useState(sort);
  const [data, setData] = useState<ChallengeMainProps[]>([]);
  const [loading, setLoading] = useState(true);
  const [status, setStatus] = useState<string | null>(null);

  const [page, setPage] = useState(0);
  const [totalPages, setTotalPages] = useState(1);

  const fetchData = async (pageNumber = 0) => {
    setLoading(true);
    try {
      const res = await ChallengeSearch({
        search,
        official,
        sort: mapSort(finalSort),
        status: status as ChallengeStatusType,
        page: pageNumber,
        size: 12,
      });

      setData(res.data || []);
      setTotalPages(res.totalPages || 1);
      setPage(pageNumber);
    } catch (err) {
      console.error("챌린지 검색 실패", err);
    } finally {
      setLoading(false);
    }
  };

  useEffect(() => {
    fetchData(0);
  }, []);

  useEffect(() => {
    fetchData(0);
  }, [official, finalSort, status]);

  const toggleOfficial = () => {
    setOfficial((prev) => !prev);
  };

  return (
    <>
      <div className="w-full flex justify-center">
        <SearchCardView
          title={title}
          description={description}
          section={section}
          official={official}
          onOfficialToggle={toggleOfficial}
          sort={finalSort}
          onSortChange={setFinalSort}
          search={search}
          onSearchChange={setSearch}
          onSearch={() => fetchData(0)}
          status={status}
          onStatusChange={setStatus}
        />
      </div>

      <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-8">
        {loading ? (
          Array.from({ length: 6 }).map((_, i) => (
            <ChallengeMainCardSkeleton key={`challenge-skeleton-${i}`} />
          ))
        ) : data.length > 0 ? (
          data.map((item) => (
            <ChallengeMainCard
              key={item.challengeId}
              challengeId={item.challengeId}
              title={item.title}
              description={item.description}
              participants={item.participants}
              official={item.official}
              status={item.status}
            />
          ))
        ) : (
          <div className="col-span-full text-center text-gray-500 py-10">
            검색 결과가 없습니다.
          </div>
        )}
      </div>

      <Pagination
        page={page}
        totalPages={totalPages}
        onPageChange={(next) => fetchData(next)}
      />
    </>
  );
}

 

모각존과 모각챌 모두 검색 기능이 필요하기 때문에 search-card-view.tsx 를 작성하여 import 해서 사용했다

"use client";

import Link from "next/link";
import Button from "../ui/button";
import Input from "../ui/input";
import { AiOutlineSearch } from "react-icons/ai";
import { TagsProps } from "@/types/shared.type";
import SubTitle from "./sub-title";

type Props = {
  title: string;
  description: string;
  section: string;
  sort: string;
  onSortChange: (val: string) => void;
  search: string;
  onSearchChange: (val: string) => void;
  onSearch: () => void;

  tags?: TagsProps[];
  selectedTag?: string | null;
  onTagClick?: (tag: string) => void;

  official?: boolean;
  onOfficialToggle?: () => void;

  status?: string | null;
  onStatusChange?: (val: string | null) => void;
};

export default function SearchCardView({
  title,
  description,
  section,
  sort,
  onSortChange,
  search,
  onSearchChange,
  onSearch,
  tags,
  selectedTag,
  onTagClick,
  official,
  onOfficialToggle,
  status,
  onStatusChange,
}: Props) {
  return (
    <div className="flex flex-col w-fit items-center gap-2">
      <SubTitle contents={title} />
      <span className="text-sm text-border-dark dark:text-borders">
        {description}
      </span>

      <div className="mt-2 flex items-center gap-2">
        <Input
          value={search}
          onInput={(e) => onSearchChange(e.currentTarget.value)}
        />
        {onStatusChange && (
          <select
            value={status ?? ""}
            onChange={(e) =>
              onStatusChange(e.target.value === "" ? null : e.target.value)
            }
            className="bg-white dark:bg-border-dark dark:text-text-dark rounded-md px-5 py-2"
          >
            <option value="">전체</option>
            <option value="BEFORE">진행 전</option>
            <option value="ONGOING">진행 중</option>
            <option value="COMPLETED">종료</option>
          </select>
        )}
        <div className="flex items-center h-10">
          <AiOutlineSearch
            onClick={onSearch}
            className="text-primary w-7 h-7 cursor-pointer"
          />
        </div>
      </div>

      <div className="flex flex-col md:flex-row md:items-center gap-4 md:gap-10 mt-4 w-full">
        <div className="flex flex-wrap gap-2">
          {tags &&
            tags.map((tag, index) => (
              <div
                key={`${tag.name}-${index}`}
                onClick={() => onTagClick?.(tag.name)}
                className={`border cursor-pointer rounded-md px-5 py-1 ${
                  selectedTag === tag.name
                    ? "bg-secondary text-white"
                    : "border-borders dark:border-border-dark"
                }`}
              >
                #{tag.name}
              </div>
            ))}

          {onOfficialToggle && (
            <div
              onClick={onOfficialToggle}
              className={`border cursor-pointer rounded-md px-5 py-1 ${
                official
                  ? "bg-secondary text-white"
                  : "border-borders dark:border-border-dark"
              }`}
            >
              #공식 챌린지
            </div>
          )}
        </div>

        <select
          value={sort}
          onChange={(e) => onSortChange(e.target.value)}
          className="bg-white dark:bg-border-dark dark:text-text-dark rounded-md px-5 py-2"
        >
          <option value="최신순">최신순</option>
          <option value="참여순">참가자 순</option>
        </select>
      </div>

      <Link href={section === "모각존" ? `/zone/create` : `/challenge/create`}>
        <Button>{section} 만들기</Button>
      </Link>
    </div>
  );
}

 

또한 페이지네이션은 Pagination Component 를 통해 여러 페이지에서 활용하였다.

"use client";

interface PaginationProps {
  page: number;
  totalPages: number;
  onPageChange: (nextPage: number) => void;
}

function createPageRange(current: number, total: number, delta = 2): number[] {
  const range = [];

  const start = Math.max(0, current - delta);
  const end = Math.min(total - 1, current + delta);

  for (let i = start; i <= end; i++) {
    range.push(i);
  }

  return range;
}

function classNames(...classes: (string | boolean | undefined | null)[]) {
  return classes.filter(Boolean).join(" ");
}

export default function Pagination({
  page,
  totalPages,
  onPageChange,
}: PaginationProps) {
  const pages = createPageRange(page, totalPages);

  return (
    <div className="flex items-center justify-center mt-10 gap-2 text-sm select-none">
      <button
        className="px-3 py-1 border rounded hover:bg-gray-100 dark:hover:bg-border-dark disabled:opacity-40"
        onClick={() => onPageChange(page - 1)}
        disabled={page <= 0}
      >
        이전
      </button>

      {pages.map((p) => (
        <button
          key={p}
          onClick={() => onPageChange(p)}
          className={classNames(
            "px-3 py-1 border rounded hover:bg-primary/10",
            page === p
              ? "bg-primary text-white font-bold border-primary"
              : "text-gray-600 dark:text-gray-300"
          )}
        >
          {p + 1}
        </button>
      ))}

      <button
        className="px-3 py-1 border rounded hover:bg-gray-100 dark:hover:bg-border-dark disabled:opacity-40"
        onClick={() => onPageChange(page + 1)}
        disabled={page >= totalPages - 1}
      >
        다음
      </button>
    </div>
  );
}

 

모각챌 하나에 접근하게 되면, 상세 페이지가 출력된다.

참가자 section을 클릭하면 참가자 목록 페이지로 redirect 되며, 탈락자의 경우 프로필 사진이 흑백으로 렌더링된다.

전체 기간 대비 진행률과 생존자 비율이 간략하게 포함되어 있으며, 사진과 문구를 함께 인증할 수 있다.

 

 

인증을 완료한 모습이다.

인증은 하루에 한 번 가능하므로 인증이 완료되었을 경우 '오늘 인증이 완료되었어요!' 문구와 함께 인증 관련 section 이 비렌더링된다.

인증 게시글은 최신순으로 인증 section 에 렌더링되며 pagination 도 적용되어 있다.

 

challenge 페이지도 page.tsx 는 Server component 로 유지하되, wrapper 를 만들어 client component 로 만들어 주었다.

"use client";

import { convertDate } from "@/utils/shared/date.util";
import ChallengeHeader from "./challenge-header";
import ChallengeSurvivors from "./challenge-survivors";
import ChallengeProofGrid from "./challenge-proof-grid";
import ChallengeSummary from "./challenge-summary";
import { ChallengeDetileResponse } from "@/types/challenge.type";
import { ChallengeDetail } from "@/lib/client/challenge.client.api";
import { useEffect, useState } from "react";
import { useAuthStore } from "@/store/authStore";

export default function ChallengeWrapper({
  id,
  initial,
}: {
  id: string;
  initial: ChallengeDetileResponse;
}) {
  const { jwt } = useAuthStore();
  const [data, setData] = useState<ChallengeDetileResponse>(initial);
  const [refreshKey, setRefreshKey] = useState<number>(Date.now());

  useEffect(() => {
    if (!jwt) {
      return;
    }
    refetch();
  }, []);

  const refetch = async () => {
    const reload = await ChallengeDetail(id);
    setData(reload);
    setRefreshKey(Date.now());
  };

  return (
    <div className="flex flex-col lg:flex-row gap-10">
      <div className="flex-1 flex flex-col gap-6">
        <ChallengeHeader
          challengeId={id}
          status={data.status}
          title={data.title}
          description="챌린지에 참여하고 인증샷을 올려보세요!"
          challengeOwnerId={data.challengeOwnerId}
          startDate={convertDate(data.startDate)}
          endDate={convertDate(data.endDate)}
          official={data.official}
          totalParticipants={data.totalParticipants}
        />

        <ChallengeSurvivors
          avatars={data.memberImageList}
          extraCount={Math.max(
            data.totalParticipants - data.memberImageList.length,
            0
          )}
        />
        {data.joined && data.status !== "BEFORE" && (
          <ChallengeProofGrid challengeId={id} refreshTrigger={refreshKey} />
        )}
      </div>

      <div className="w-full lg:w-[280px] shrink-0">
        <ChallengeSummary challengeId={id} {...data} onRefetch={refetch} />
      </div>
    </div>
  );
}

 

ChallengeSummary Component 에 포함된 통계 component 는 아래처럼 작성하였다.

 

type ProgressBarProps = {
  label: string;
  value: number;
};

export function ChallengeSummaryChart({ label, value }: ProgressBarProps) {
  return (
    <div className="w-full">
      <div className="flex justify-between text-xs mb-1 text-border-dark dark:text-borders">
        <span>{label}</span>
        <span>{value}%</span>
      </div>
      <div className="w-full h-3 bg-gray-200 dark:bg-zinc-700 rounded-full overflow-hidden">
        <div
          className="h-full bg-primary dark:bg-primary-dark transition-all duration-300"
          style={{ width: `${value}%` }}
        />
      </div>
    </div>
  );
}

 

간단한 차트라서 tailwind css 선에서 끝낼 수 있었는데, 다음에는 더 다양한 통계를 다룰 수 있는 라이브러리를 적용해 보고 싶다.