diff --git a/festival-app/backend/src/main/java/com/festivalapp/api/PerformanceController.java b/festival-app/backend/src/main/java/com/festivalapp/api/PerformanceController.java new file mode 100644 index 0000000..d5a1f08 --- /dev/null +++ b/festival-app/backend/src/main/java/com/festivalapp/api/PerformanceController.java @@ -0,0 +1,29 @@ +package com.festivalapp.api; + +import com.festivalapp.dto.PerformanceResponse; +import com.festivalapp.service.PerformanceService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/performances") +@RequiredArgsConstructor +public class PerformanceController { + + private final PerformanceService performanceService; + + @GetMapping + ResponseEntity> getPerformances() { + return ResponseEntity.ok(performanceService.getPerformances()); + } + + @GetMapping("/{performanceId}") + ResponseEntity getPerformance(@PathVariable Long performanceId) { + return ResponseEntity.ok(performanceService.getPerformance(performanceId)); + } +} diff --git a/festival-app/backend/src/main/java/com/festivalapp/config/SecurityConfig.java b/festival-app/backend/src/main/java/com/festivalapp/config/SecurityConfig.java index 41d15d0..4e9fe2e 100644 --- a/festival-app/backend/src/main/java/com/festivalapp/config/SecurityConfig.java +++ b/festival-app/backend/src/main/java/com/festivalapp/config/SecurityConfig.java @@ -50,6 +50,8 @@ SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { "/api/booths/**", "/api/timeline", "/api/timeline/**", + "/api/performances", + "/api/performances/**", "/api/map-locations", "/api/map-locations/**") .permitAll() diff --git a/festival-app/backend/src/main/java/com/festivalapp/domain/performance/Performance.java b/festival-app/backend/src/main/java/com/festivalapp/domain/performance/Performance.java new file mode 100644 index 0000000..e3f5ad4 --- /dev/null +++ b/festival-app/backend/src/main/java/com/festivalapp/domain/performance/Performance.java @@ -0,0 +1,19 @@ +package com.festivalapp.domain.performance; + +import java.time.LocalDateTime; + +public record Performance( + Long id, + String title, + String artist, + LocalDateTime startsAt, + LocalDateTime endsAt, + String location, + String description, + int totalSeats, + int reservedSeats) { + + public int remainingSeats() { + return Math.max(totalSeats - reservedSeats, 0); + } +} diff --git a/festival-app/backend/src/main/java/com/festivalapp/dto/PerformanceResponse.java b/festival-app/backend/src/main/java/com/festivalapp/dto/PerformanceResponse.java new file mode 100644 index 0000000..2685591 --- /dev/null +++ b/festival-app/backend/src/main/java/com/festivalapp/dto/PerformanceResponse.java @@ -0,0 +1,31 @@ +package com.festivalapp.dto; + +import com.festivalapp.domain.performance.Performance; +import java.time.LocalDateTime; + +public record PerformanceResponse( + Long id, + String title, + String artist, + LocalDateTime startsAt, + LocalDateTime endsAt, + String location, + String description, + int totalSeats, + int reservedSeats, + int remainingSeats) { + + public static PerformanceResponse from(Performance performance) { + return new PerformanceResponse( + performance.id(), + performance.title(), + performance.artist(), + performance.startsAt(), + performance.endsAt(), + performance.location(), + performance.description(), + performance.totalSeats(), + performance.reservedSeats(), + performance.remainingSeats()); + } +} diff --git a/festival-app/backend/src/main/java/com/festivalapp/repository/performance/PerformanceRepository.java b/festival-app/backend/src/main/java/com/festivalapp/repository/performance/PerformanceRepository.java new file mode 100644 index 0000000..74d3675 --- /dev/null +++ b/festival-app/backend/src/main/java/com/festivalapp/repository/performance/PerformanceRepository.java @@ -0,0 +1,28 @@ +package com.festivalapp.repository.performance; + +import com.festivalapp.domain.performance.Performance; +import com.festivalapp.repository.performance.datasource.PerformanceDataSource; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class PerformanceRepository { + + private final PerformanceDataSource performanceDataSource; + + public List findAll() { + return performanceDataSource.findAll().stream() + .sorted(Comparator.comparing(Performance::startsAt)) + .toList(); + } + + public Optional findById(Long performanceId) { + return performanceDataSource.findAll().stream() + .filter(performance -> performance.id().equals(performanceId)) + .findFirst(); + } +} diff --git a/festival-app/backend/src/main/java/com/festivalapp/repository/performance/datasource/InMemoryPerformanceDataSource.java b/festival-app/backend/src/main/java/com/festivalapp/repository/performance/datasource/InMemoryPerformanceDataSource.java new file mode 100644 index 0000000..829c053 --- /dev/null +++ b/festival-app/backend/src/main/java/com/festivalapp/repository/performance/datasource/InMemoryPerformanceDataSource.java @@ -0,0 +1,90 @@ +package com.festivalapp.repository.performance.datasource; + +import com.festivalapp.domain.performance.Performance; +import java.time.LocalDateTime; +import java.util.List; +import org.springframework.stereotype.Component; + +@Component +public class InMemoryPerformanceDataSource implements PerformanceDataSource { + + private final List performances = + List.of( + performance( + 1L, + "와우 스테이지 헤드라이너", + "헤드라이너 아티스트", + 2026, + 5, + 13, + 19, + 0, + 21, + 0, + "대운동장 메인 스테이지", + "축제 첫날 밤을 여는 메인 스테이지 공연입니다.", + 500, + 372), + performance( + 2L, + "동아리 밴드 쇼케이스", + "학생 밴드 연합", + 2026, + 5, + 14, + 18, + 0, + 19, + 30, + "학생회관 야외무대", + "교내 밴드 동아리들이 준비한 라이브 쇼케이스입니다.", + 220, + 156), + performance( + 3L, + "WOW DJ Festival", + "WOW DJ Crew", + 2026, + 5, + 15, + 20, + 0, + 23, + 30, + "운동장 DJ 스테이지", + "축제 마지막 밤을 채우는 DJ 페스티벌 공연입니다.", + 800, + 615)); + + @Override + public List findAll() { + return performances; + } + + private static Performance performance( + Long id, + String title, + String artist, + int year, + int month, + int day, + int startHour, + int startMinute, + int endHour, + int endMinute, + String location, + String description, + int totalSeats, + int reservedSeats) { + return new Performance( + id, + title, + artist, + LocalDateTime.of(year, month, day, startHour, startMinute), + LocalDateTime.of(year, month, day, endHour, endMinute), + location, + description, + totalSeats, + reservedSeats); + } +} diff --git a/festival-app/backend/src/main/java/com/festivalapp/repository/performance/datasource/PerformanceDataSource.java b/festival-app/backend/src/main/java/com/festivalapp/repository/performance/datasource/PerformanceDataSource.java new file mode 100644 index 0000000..e2b436c --- /dev/null +++ b/festival-app/backend/src/main/java/com/festivalapp/repository/performance/datasource/PerformanceDataSource.java @@ -0,0 +1,9 @@ +package com.festivalapp.repository.performance.datasource; + +import com.festivalapp.domain.performance.Performance; +import java.util.List; + +public interface PerformanceDataSource { + + List findAll(); +} diff --git a/festival-app/backend/src/main/java/com/festivalapp/service/PerformanceService.java b/festival-app/backend/src/main/java/com/festivalapp/service/PerformanceService.java new file mode 100644 index 0000000..9180ecc --- /dev/null +++ b/festival-app/backend/src/main/java/com/festivalapp/service/PerformanceService.java @@ -0,0 +1,28 @@ +package com.festivalapp.service; + +import com.festivalapp.dto.PerformanceResponse; +import com.festivalapp.repository.performance.PerformanceRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +@Service +@RequiredArgsConstructor +public class PerformanceService { + + private final PerformanceRepository performanceRepository; + + public List getPerformances() { + return performanceRepository.findAll().stream() + .map(PerformanceResponse::from) + .toList(); + } + + public PerformanceResponse getPerformance(Long performanceId) { + return performanceRepository.findById(performanceId) + .map(PerformanceResponse::from) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "공연을 찾을 수 없습니다.")); + } +} diff --git a/festival-app/backend/src/test/java/com/festivalapp/api/PerformanceControllerTest.java b/festival-app/backend/src/test/java/com/festivalapp/api/PerformanceControllerTest.java new file mode 100644 index 0000000..0cbbf3c --- /dev/null +++ b/festival-app/backend/src/test/java/com/festivalapp/api/PerformanceControllerTest.java @@ -0,0 +1,62 @@ +package com.festivalapp.api; + +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.festivalapp.dto.PerformanceResponse; +import com.festivalapp.service.PerformanceService; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +class PerformanceControllerTest { + + private final PerformanceService performanceService = mock(PerformanceService.class); + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(new PerformanceController(performanceService)).build(); + } + + @Test + void getPerformancesReturnsPerformanceSummaries() throws Exception { + given(performanceService.getPerformances()).willReturn(List.of(performanceResponse())); + + mockMvc.perform(get("/api/performances")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].title").value("와우 스테이지 헤드라이너")) + .andExpect(jsonPath("$[0].remainingSeats").value(128)); + } + + @Test + void getPerformanceReturnsPerformanceDetail() throws Exception { + given(performanceService.getPerformance(1L)).willReturn(performanceResponse()); + + mockMvc.perform(get("/api/performances/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.title").value("와우 스테이지 헤드라이너")) + .andExpect(jsonPath("$.description").value("축제 첫날 밤을 여는 메인 스테이지 공연입니다.")) + .andExpect(jsonPath("$.remainingSeats").value(128)); + } + + private PerformanceResponse performanceResponse() { + return new PerformanceResponse( + 1L, + "와우 스테이지 헤드라이너", + "헤드라이너 아티스트", + LocalDateTime.of(2026, 5, 13, 19, 0), + LocalDateTime.of(2026, 5, 13, 21, 0), + "대운동장 메인 스테이지", + "축제 첫날 밤을 여는 메인 스테이지 공연입니다.", + 500, + 372, + 128); + } +} diff --git a/festival-app/backend/src/test/java/com/festivalapp/repository/performance/PerformanceRepositoryTest.java b/festival-app/backend/src/test/java/com/festivalapp/repository/performance/PerformanceRepositoryTest.java new file mode 100644 index 0000000..a9c300a --- /dev/null +++ b/festival-app/backend/src/test/java/com/festivalapp/repository/performance/PerformanceRepositoryTest.java @@ -0,0 +1,48 @@ +package com.festivalapp.repository.performance; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.festivalapp.domain.performance.Performance; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.Test; + +class PerformanceRepositoryTest { + + @Test + void findAllReturnsPerformancesSortedByStartTime() { + Performance latePerformance = + performance(2L, "늦은 공연", LocalDateTime.of(2026, 5, 14, 20, 0)); + Performance earlyPerformance = + performance(1L, "이른 공연", LocalDateTime.of(2026, 5, 14, 18, 0)); + PerformanceRepository performanceRepository = + new PerformanceRepository(() -> List.of(latePerformance, earlyPerformance)); + + List performances = performanceRepository.findAll(); + + assertThat(performances).containsExactly(earlyPerformance, latePerformance); + } + + @Test + void findByIdReturnsMatchingPerformance() { + Performance performance = + performance(1L, "와우 스테이지 헤드라이너", LocalDateTime.of(2026, 5, 13, 19, 0)); + PerformanceRepository performanceRepository = new PerformanceRepository(() -> List.of(performance)); + + assertThat(performanceRepository.findById(1L)).contains(performance); + assertThat(performanceRepository.findById(999L)).isEmpty(); + } + + private Performance performance(Long id, String title, LocalDateTime startsAt) { + return new Performance( + id, + title, + "아티스트", + startsAt, + startsAt.plusHours(2), + "메인무대", + "공연 설명입니다.", + 100, + 40); + } +} diff --git a/festival-app/backend/src/test/java/com/festivalapp/service/PerformanceServiceTest.java b/festival-app/backend/src/test/java/com/festivalapp/service/PerformanceServiceTest.java new file mode 100644 index 0000000..da02274 --- /dev/null +++ b/festival-app/backend/src/test/java/com/festivalapp/service/PerformanceServiceTest.java @@ -0,0 +1,49 @@ +package com.festivalapp.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.festivalapp.domain.performance.Performance; +import com.festivalapp.dto.PerformanceResponse; +import com.festivalapp.repository.performance.PerformanceRepository; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +class PerformanceServiceTest { + + @Test + void getPerformancesIncludesRemainingSeats() { + Performance performance = + new Performance( + 1L, + "와우 스테이지 헤드라이너", + "헤드라이너 아티스트", + LocalDateTime.of(2026, 5, 13, 19, 0), + LocalDateTime.of(2026, 5, 13, 21, 0), + "대운동장 메인 스테이지", + "축제 첫날 밤을 여는 메인 스테이지 공연입니다.", + 500, + 372); + PerformanceService performanceService = + new PerformanceService(new PerformanceRepository(() -> List.of(performance))); + + List performances = performanceService.getPerformances(); + + assertThat(performances).hasSize(1); + assertThat(performances.get(0).remainingSeats()).isEqualTo(128); + } + + @Test + void getPerformanceThrowsNotFoundWhenPerformanceDoesNotExist() { + PerformanceService performanceService = + new PerformanceService(new PerformanceRepository(List::of)); + + assertThatThrownBy(() -> performanceService.getPerformance(999L)) + .isInstanceOf(ResponseStatusException.class) + .extracting("statusCode") + .isEqualTo(HttpStatus.NOT_FOUND); + } +} diff --git a/festival-app/frontend/src/apis/performances/performance.api.ts b/festival-app/frontend/src/apis/performances/performance.api.ts new file mode 100644 index 0000000..2793bbb --- /dev/null +++ b/festival-app/frontend/src/apis/performances/performance.api.ts @@ -0,0 +1,16 @@ +import type { + GetPerformanceParams, + GetPerformancesParams, +} from "@/apis/performances/performance.api.types"; +import { apiClient } from "@/libs/api/api-client"; +import type { Performance } from "@/types/performance/performance.types"; + +// 예매 가능한 공연 목록을 조회합니다. +export function getPerformances({ signal }: GetPerformancesParams = {}) { + return apiClient.get("api/performances", { signal }).json(); +} + +// 선택한 공연 상세 정보를 조회합니다. +export function getPerformance({ id, signal }: GetPerformanceParams) { + return apiClient.get(`api/performances/${id}`, { signal }).json(); +} diff --git a/festival-app/frontend/src/apis/performances/performance.api.types.ts b/festival-app/frontend/src/apis/performances/performance.api.types.ts new file mode 100644 index 0000000..6fd1c17 --- /dev/null +++ b/festival-app/frontend/src/apis/performances/performance.api.types.ts @@ -0,0 +1,8 @@ +export type GetPerformanceParams = { + id: number; + signal?: AbortSignal; +}; + +export type GetPerformancesParams = { + signal?: AbortSignal; +}; diff --git a/festival-app/frontend/src/app/performances/[performanceId]/page.tsx b/festival-app/frontend/src/app/performances/[performanceId]/page.tsx new file mode 100644 index 0000000..391a41e --- /dev/null +++ b/festival-app/frontend/src/app/performances/[performanceId]/page.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useMemo } from "react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; + +import { getPerformance } from "@/apis/performances/performance.api"; +import PerformanceDetailPanel from "@/components/performances/PerformanceDetailPanel"; +import PerformanceReservationPanel from "@/components/performances/PerformanceReservationPanel"; +import SiteHeader from "@/components/SiteHeader"; +import { useAuth } from "@/providers/AuthProvider"; +import type { Performance } from "@/types/performance/performance.types"; + +export default function PerformanceDetailPage() { + const params = useParams<{ performanceId: string }>(); + const { isAuthenticated, isInitialized } = useAuth(); + const performanceId = useMemo(() => Number(params.performanceId), [params.performanceId]); + const { + data: performance, + error, + isError, + isLoading, + } = useQuery({ + queryKey: ["performance", performanceId], + queryFn: ({ signal }) => getPerformance({ id: performanceId, signal }), + enabled: Number.isFinite(performanceId), + }); + + return ( +
+ + +
+ + 공연 목록으로 돌아가기 + + +
+ {isLoading && ( + <> +
+
+ + )} + + {!isLoading && isError && ( +
+ {error instanceof Error ? error.message : "공연 상세 정보를 불러오지 못했습니다."} +
+ )} + + {!isLoading && !isError && performance && ( + <> + + + + )} +
+
+
+ ); +} diff --git a/festival-app/frontend/src/app/performances/page.tsx b/festival-app/frontend/src/app/performances/page.tsx index db4835e..9129c77 100644 --- a/festival-app/frontend/src/app/performances/page.tsx +++ b/festival-app/frontend/src/app/performances/page.tsx @@ -1,29 +1,24 @@ "use client"; -import Link from "next/link"; +import { useQuery } from "@tanstack/react-query"; +import { getPerformances } from "@/apis/performances/performance.api"; +import PerformanceCard from "@/components/performances/PerformanceCard"; import SiteHeader from "@/components/SiteHeader"; import { useAuth } from "@/providers/AuthProvider"; - -const performances = [ - { - id: "performance-1", - title: "와우 스테이지 헤드라이너", - time: "2026.05.13 19:00", - location: "대운동장 메인 스테이지", - remainingSeats: 128, - }, - { - id: "performance-2", - title: "동아리 밴드 쇼케이스", - time: "2026.05.14 18:00", - location: "학생회관 야외무대", - remainingSeats: 64, - }, -]; +import type { Performance } from "@/types/performance/performance.types"; export default function PerformancesPage() { const { isAuthenticated, isInitialized, user } = useAuth(); + const { + data: performances = [], + error, + isError, + isLoading, + } = useQuery({ + queryKey: ["performances"], + queryFn: ({ signal }) => getPerformances({ signal }), + }); return (
@@ -34,57 +29,54 @@ export default function PerformancesPage() {

PERFORMANCE TICKETS

공연 예매

- 로그인한 사용자만 공연 예매를 진행할 수 있습니다. + 축제 공연 일정과 잔여 좌석을 확인하고 상세 정보에서 예매 준비 상태를 확인하세요.

- {!isInitialized ? ( -

- 로그인 상태를 확인하는 중입니다. -

- ) : !isAuthenticated ? ( -
-

로그인이 필요합니다

-

- 공연 좌석 선점과 QR 티켓 발급은 계정 확인 후 진행됩니다. -

- - 로그인하고 예매하기 - -
- ) : ( -
-

- {user?.name}님, 예매 가능한 공연입니다. +

+
+
+

공연 목록

+

+ {isInitialized && isAuthenticated && user + ? `${user.name}님, 예매 가능한 공연입니다.` + : "로그인 전에도 공연 정보와 잔여 좌석을 확인할 수 있습니다."} +

+
+ {performances.length}개 공연 +
+ + {isLoading && ( +
+ {Array.from({ length: 4 }).map((_, index) => ( +
+ ))} +
+ )} + + {!isLoading && isError && ( +
+ {error instanceof Error ? error.message : "공연 정보를 불러오지 못했습니다."} +
+ )} + + {!isLoading && !isError && performances.length === 0 && ( +

+ 등록된 공연 정보가 없습니다.

+ )} + + {!isLoading && !isError && performances.length > 0 && (
{performances.map((performance) => ( -
-

{performance.title}

-

{performance.time}

-

{performance.location}

-
- - 잔여 {performance.remainingSeats}석 - - -
-
+ ))}
-
- )} + )} +
); diff --git a/festival-app/frontend/src/components/performances/PerformanceCard.test.tsx b/festival-app/frontend/src/components/performances/PerformanceCard.test.tsx new file mode 100644 index 0000000..9aac047 --- /dev/null +++ b/festival-app/frontend/src/components/performances/PerformanceCard.test.tsx @@ -0,0 +1,33 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import PerformanceCard from "@/components/performances/PerformanceCard"; +import type { Performance } from "@/types/performance/performance.types"; + +const performance: Performance = { + id: 1, + title: "와우 스테이지 헤드라이너", + artist: "헤드라이너 아티스트", + startsAt: "2026-05-13T19:00:00", + endsAt: "2026-05-13T21:00:00", + location: "대운동장 메인 스테이지", + description: "축제 첫날 밤을 여는 메인 스테이지 공연입니다.", + totalSeats: 500, + reservedSeats: 372, + remainingSeats: 128, +}; + +describe("PerformanceCard", () => { + it("공연 요약과 상세 링크를 표시한다", () => { + render(); + + expect(screen.getByText("헤드라이너 아티스트")).toBeInTheDocument(); + expect(screen.getByText("와우 스테이지 헤드라이너")).toBeInTheDocument(); + expect(screen.getByText("대운동장 메인 스테이지")).toBeInTheDocument(); + expect(screen.getByText("잔여 128석")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "상세 보기" })).toHaveAttribute( + "href", + "/performances/1", + ); + }); +}); diff --git a/festival-app/frontend/src/components/performances/PerformanceCard.tsx b/festival-app/frontend/src/components/performances/PerformanceCard.tsx new file mode 100644 index 0000000..14c28a1 --- /dev/null +++ b/festival-app/frontend/src/components/performances/PerformanceCard.tsx @@ -0,0 +1,42 @@ +import Link from "next/link"; + +import type { Performance } from "@/types/performance/performance.types"; +import { + formatPerformanceTimeRange, + getRemainingSeatLabel, +} from "@/utils/performance/performance.utils"; + +type PerformanceCardProps = { + performance: Performance; +}; + +export default function PerformanceCard({ performance }: PerformanceCardProps) { + return ( +
+
+
+

{performance.artist}

+

{performance.title}

+

+ {formatPerformanceTimeRange(performance)} +

+

{performance.location}

+
+ + {getRemainingSeatLabel(performance.remainingSeats)} + +
+ +

+ {performance.description} +

+ + + 상세 보기 + +
+ ); +} diff --git a/festival-app/frontend/src/components/performances/PerformanceDetailPanel.test.tsx b/festival-app/frontend/src/components/performances/PerformanceDetailPanel.test.tsx new file mode 100644 index 0000000..41b7558 --- /dev/null +++ b/festival-app/frontend/src/components/performances/PerformanceDetailPanel.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import PerformanceDetailPanel from "@/components/performances/PerformanceDetailPanel"; +import type { Performance } from "@/types/performance/performance.types"; + +const performance: Performance = { + id: 1, + title: "와우 스테이지 헤드라이너", + artist: "헤드라이너 아티스트", + startsAt: "2026-05-13T19:00:00", + endsAt: "2026-05-13T21:00:00", + location: "대운동장 메인 스테이지", + description: "축제 첫날 밤을 여는 메인 스테이지 공연입니다.", + totalSeats: 500, + reservedSeats: 372, + remainingSeats: 128, +}; + +describe("PerformanceDetailPanel", () => { + it("공연 상세 정보와 잔여 좌석을 표시한다", () => { + render(); + + expect(screen.getByText("헤드라이너 아티스트")).toBeInTheDocument(); + expect(screen.getByText("와우 스테이지 헤드라이너")).toBeInTheDocument(); + expect(screen.getByText("대운동장 메인 스테이지")).toBeInTheDocument(); + expect(screen.getByText("잔여 128석")).toBeInTheDocument(); + expect(screen.getByText("축제 첫날 밤을 여는 메인 스테이지 공연입니다.")).toBeInTheDocument(); + }); +}); diff --git a/festival-app/frontend/src/components/performances/PerformanceDetailPanel.tsx b/festival-app/frontend/src/components/performances/PerformanceDetailPanel.tsx new file mode 100644 index 0000000..5a34cc0 --- /dev/null +++ b/festival-app/frontend/src/components/performances/PerformanceDetailPanel.tsx @@ -0,0 +1,39 @@ +import type { Performance } from "@/types/performance/performance.types"; +import { + formatPerformanceTimeRange, + getRemainingSeatLabel, +} from "@/utils/performance/performance.utils"; + +type PerformanceDetailPanelProps = { + performance: Performance; +}; + +export default function PerformanceDetailPanel({ performance }: PerformanceDetailPanelProps) { + return ( +
+

{performance.artist}

+

{performance.title}

+ +
+
+
공연 시간
+
{formatPerformanceTimeRange(performance)}
+
+
+
장소
+
{performance.location}
+
+
+
좌석
+
+ {getRemainingSeatLabel(performance.remainingSeats)} +
+
+
+ +

+ {performance.description} +

+
+ ); +} diff --git a/festival-app/frontend/src/components/performances/PerformanceReservationPanel.test.tsx b/festival-app/frontend/src/components/performances/PerformanceReservationPanel.test.tsx new file mode 100644 index 0000000..cf822fd --- /dev/null +++ b/festival-app/frontend/src/components/performances/PerformanceReservationPanel.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import PerformanceReservationPanel from "@/components/performances/PerformanceReservationPanel"; + +describe("PerformanceReservationPanel", () => { + it("비로그인 사용자는 로그인 예매 링크를 볼 수 있다", () => { + render( + , + ); + + expect(screen.getByText("128석")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "로그인하고 예매하기" })).toHaveAttribute( + "href", + "/login?redirect=/performances", + ); + }); + + it("로그인 사용자는 향후 예매 기능 버튼 영역을 볼 수 있다", () => { + render(); + + expect(screen.getByRole("button", { name: "예매 기능 준비 중" })).toBeDisabled(); + }); + + it("잔여 좌석이 없으면 매진 상태를 표시한다", () => { + render(); + + expect(screen.getByRole("button", { name: "매진" })).toBeDisabled(); + }); +}); diff --git a/festival-app/frontend/src/components/performances/PerformanceReservationPanel.tsx b/festival-app/frontend/src/components/performances/PerformanceReservationPanel.tsx new file mode 100644 index 0000000..ca168bb --- /dev/null +++ b/festival-app/frontend/src/components/performances/PerformanceReservationPanel.tsx @@ -0,0 +1,60 @@ +import Link from "next/link"; + +type PerformanceReservationPanelProps = { + isAuthenticated: boolean; + isInitialized: boolean; + remainingSeats: number; +}; + +export default function PerformanceReservationPanel({ + isAuthenticated, + isInitialized, + remainingSeats, +}: PerformanceReservationPanelProps) { + const isSoldOut = remainingSeats <= 0; + + return ( +
+

예매

+

이후 티켓 예매 기능이 연결될 영역입니다.

+ +
+

잔여 좌석

+

{remainingSeats.toLocaleString("ko-KR")}석

+
+ + {!isInitialized ? ( + + ) : isSoldOut ? ( + + ) : isAuthenticated ? ( + + ) : ( + + 로그인하고 예매하기 + + )} +
+ ); +} diff --git a/festival-app/frontend/src/types/performance/performance.types.ts b/festival-app/frontend/src/types/performance/performance.types.ts new file mode 100644 index 0000000..3dc1d65 --- /dev/null +++ b/festival-app/frontend/src/types/performance/performance.types.ts @@ -0,0 +1,12 @@ +export type Performance = { + id: number; + title: string; + artist: string; + startsAt: string; + endsAt: string; + location: string; + description: string; + totalSeats: number; + reservedSeats: number; + remainingSeats: number; +}; diff --git a/festival-app/frontend/src/utils/performance/performance.utils.ts b/festival-app/frontend/src/utils/performance/performance.utils.ts new file mode 100644 index 0000000..1ba145f --- /dev/null +++ b/festival-app/frontend/src/utils/performance/performance.utils.ts @@ -0,0 +1,25 @@ +import type { Performance } from "@/types/performance/performance.types"; + +export function formatPerformanceDateTime(value: string) { + return new Intl.DateTimeFormat("ko-KR", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(new Date(value)); +} + +export function formatPerformanceTimeRange(performance: Performance) { + return `${formatPerformanceDateTime(performance.startsAt)} - ${formatPerformanceDateTime( + performance.endsAt, + )}`; +} + +export function getRemainingSeatLabel(remainingSeats: number) { + if (remainingSeats <= 0) { + return "매진"; + } + + return `잔여 ${remainingSeats.toLocaleString("ko-KR")}석`; +}