Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions apps/web/pages/404.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { useRouter } from 'next/router'
import { useEffect } from 'react'
import { PagePath } from '../src/common/constants/PagePath'

const PageNotFound = () => {
const router = useRouter()

useEffect(() => {
router.replace(PagePath.LANDING_PAGE)
router.replace('/download/app')
}, [router])

return <div className="h-100v w-screen" />
Expand Down
9 changes: 7 additions & 2 deletions apps/web/pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import { Analytics } from '@vercel/analytics/react'
import type { Metadata } from 'next'
import Script from 'next/script'
import { AuthProvider } from '../src/club/auth/AuthContext'
import * as GA from '../src/common/connectors/ga'
import { AppBannerProvider } from '../src/club/web/AppInstallBanner'
import * as GA from '../src/club/web/ga'
import { WEB_ENV } from '../src/WEB_ENV'

export const metadata: Metadata = {
Expand Down Expand Up @@ -78,10 +79,14 @@ const App = ({ Component, pageProps }: AppProps) => {
/>
<QueryClientProvider client={queryClientRef.current}>
<AuthProvider>
<Component {...pageProps} />
<AppBannerProvider>
<Component {...pageProps} />
</AppBannerProvider>
</AuthProvider>
<ToastContainer
position="top-center"
// 앱 설치 배너가 떠 있으면 그 아래에 뜨게 한다 (배너가 없으면 기본 위치)
style={{ top: 'calc(1em + var(--app-banner-h, 0px))' }}
autoClose={2000}
theme="colored"
hideProgressBar={false}
Expand Down
154 changes: 112 additions & 42 deletions apps/web/pages/club/[uuid].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,76 +4,108 @@ import { useRouter } from 'next/router'
import { useRef, useState } from 'react'
import { toast } from 'react-toastify'
import { NotFoundError } from 'server/domain/error'
import type { Club } from 'server/domain/model/Club'
import type { ClubDetail } from 'server/domain/model/Club'
import { Provider } from 'server/provider'
import { ClubService } from 'server/service/club.service'
import { z } from 'zod'
import { useRequireLogin } from '../../src/club/auth/AuthContext'
import { BackgroundCard } from '../../src/club/components/BackgroundCard'
import { ClubDetailHeader } from '../../src/club/components/ClubDetailHeader'
import { ClubDetailTabBar, type ClubTabKey } from '../../src/club/components/ClubDetailTabBar'
import { InfoTab } from '../../src/club/components/InfoTab'
import { MdiIcon } from '../../src/club/components/icons'
import { RecruitTab } from '../../src/club/components/RecruitTab'
import { ReviewKeywordPill } from '../../src/club/components/ReviewKeywordPill'
import { ReviewTab } from '../../src/club/components/ReviewTab'
import { getCategoryTheme } from '../../src/club/constants'
import { openClubInApp } from '../../src/club/openInApp'
import { useSaveClub } from '../../src/club/useSaveClub'
import { OpenGraph } from '../../src/common/components/OpenGraph'
import { useProfile, useRequireLogin } from '../../src/club/auth/AuthContext'
import { BackgroundCard } from '../../src/club/features/club/detail/BackgroundCard'
import {
ClubDetailHeader,
DETAIL_HEADER_HEIGHT,
} from '../../src/club/features/club/detail/ClubDetailHeader'
import {
CLUB_DETAIL_TABS,
ClubDetailTabBar,
type ClubTabKey,
} from '../../src/club/features/club/detail/ClubDetailTabBar'
import { InfoTab } from '../../src/club/features/club/detail/InfoTab'
import { RecruitTab } from '../../src/club/features/club/detail/RecruitTab'
import { ReviewTab } from '../../src/club/features/club/detail/ReviewTab'
import { SnsRow } from '../../src/club/features/club/detail/SnsRow'
import { VerificationMarkButton } from '../../src/club/features/club/detail/VerificationMark'
import { VerificationOverlay } from '../../src/club/features/club/detail/VerificationOverlay'
import { MdiIcon } from '../../src/club/shared/components/icons'
import { ReviewKeywordPill } from '../../src/club/shared/components/ReviewKeywordPill'
import { getCategoryTheme } from '../../src/club/shared/constants'
import { useSaveClub } from '../../src/club/shared/hooks/useSaveClub'
import { getClubSummaryWithAffiliation } from '../../src/club/shared/utils/club'
import { getClubSnsUrls } from '../../src/club/shared/utils/clubSns'
import { OpenGraph } from '../../src/club/web/OpenGraph'
import { openClubInApp } from '../../src/club/web/openInApp'

type Props = {
club: Club
club: ClubDetail
}

/**
* 앱 ClubDetailScreen 의 웹 미러.
* 로고 배너(300, 상하 페이드) → 히어로 카드(-72 겹침) → 탭바(스크롤 시 헤더 아래 고정) → 탭 콘텐츠.
* 헤더는 항상 떠 있고 스크롤에 따라 배경이 채워지며, 탭바가 고정되는 순간 동아리명이 노출된다 (ClubDetailHeader).
*/
const ClubDetailPage = ({ club }: Props) => {
const router = useRouter()
const [activeTab, setActiveTab] = useState<ClubTabKey>('detail')
const [isVerificationOverlayVisible, setIsVerificationOverlayVisible] = useState(false)
const tabBarRef = useRef<HTMLDivElement>(null)
const theme = getCategoryTheme(club.category)
const { user } = useProfile()
const { isSaved, toggle: toggleSave } = useSaveClub(club)
const requireLogin = useRequireLogin()
const verificationStatus = club.officialVerificationStatus
const heroDescription = getClubSummaryWithAffiliation(club)
const snsUrls = getClubSnsUrls(club)

const handleWriteReview = () => {
requireLogin(() => router.push(`/club/${club.uuid}/review`))
}

const handlePreviousRecruitments = (representativeRecruitmentId: number | null) => {
const query =
representativeRecruitmentId === null ? '' : `?representative=${representativeRecruitmentId}`
router.push(`/club/${club.uuid}/recruitments${query}`)
}

const handleLoginRequired = () => {
requireLogin(() => {})
}

const handleShare = async () => {
const url = window.location.href
const shareUrl = `${window.location.origin}/club/${club.uuid}`
if (navigator.share) {
try {
await navigator.share({
title: club.name,
text: `${club.name}의 동아리 정보를 확인해보세요!`,
url,
url: shareUrl,
})
} catch {
// 사용자가 공유를 취소한 경우
}
return
}
await navigator.clipboard.writeText(url)
await navigator.clipboard.writeText(shareUrl)
toast.success('링크를 복사했어요!')
}

return (
<>
<Head>
<title>{`${club.name} - 서울대 모든 동아리 올클리어`}</title>
<meta name="description" content={club.description} />
<meta name="description" content={heroDescription || club.description} />
</Head>
<OpenGraph
container={Head}
title={club.name}
description={club.description}
description={heroDescription || club.description}
imageUrl={club.imageUri}
/>

<div className="min-h-screen bg-[#FAFAFA] font-pretendard text-[#202020]">
<ClubDetailHeader title={club.name} tabBarRef={tabBarRef} />

<main className="mx-auto w-full max-w-[480px] pb-32">
{/* 로고 배너 */}
{/* 로고 배너: 상/하단은 배경색(#FAFAFA)으로 완전히 덮고, 중앙은 30% 베일 */}
<div className="relative h-[300px] overflow-hidden">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
Expand All @@ -90,40 +122,55 @@ const ClubDetailPage = ({ club }: Props) => {
/>
</div>

{/* 히어로 카드 */}
{/* 히어로 카드: 로고 하단을 72px 덮고 올라온다 */}
<div className="relative -mt-[72px] mx-4 mb-3">
<BackgroundCard>
<BackgroundCard className="overflow-visible">
<div className="mb-1.5 flex items-center justify-between">
<h1 className="min-w-0 truncate text-[20px] font-bold text-[#202020]">
{club.name}
</h1>
<div className="ml-3 flex shrink-0 items-center gap-3">
<div className="flex min-w-0 shrink items-center gap-1">
<h1 className="min-w-0 truncate text-[20px] font-bold text-[#202020]">
{club.name}
</h1>
<VerificationMarkButton
status={verificationStatus}
onPress={() => setIsVerificationOverlayVisible((prev) => !prev)}
/>
</div>
<div className="ml-3 flex shrink-0 items-center gap-2.5">
<button
type="button"
onClick={toggleSave}
aria-label={isSaved ? '동아리 저장 해제' : '동아리 저장'}
className="active:opacity-50"
aria-label={isSaved ? '저장 취소' : '동아리 저장'}
className="flex items-center active:opacity-50"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={isSaved ? '/icons/heart-fill.png' : '/icons/heart.png'}
alt=""
width={22}
height={22}
className="object-contain"
width={18}
height={18}
className="h-[18px] w-[18px] object-contain"
/>
</button>
<button
type="button"
onClick={handleShare}
aria-label="공유하기"
className="text-[#874FFF] active:opacity-50"
aria-label="동아리 공유"
className="flex items-center text-[#874FFF] active:opacity-50"
>
<MdiIcon name="shareVariant" size={22} />
<MdiIcon name="shareVariant" size={18} />
</button>
</div>
</div>
<p className="mb-2.5 text-[14px] font-normal text-[#757474]">{club.description}</p>
{/* 인증 안내 말풍선: 카드 오른쪽 위, 카드 상단에서 8px 위에 떠 있다 (앱과 동일) */}
<VerificationOverlay
status={verificationStatus}
visible={isVerificationOverlayVisible}
className="absolute right-0 top-0"
style={{ transform: 'translateY(calc(-100% - 8px))' }}
/>
{!!heroDescription && (
<p className="mb-2.5 text-[14px] font-normal text-[#757474]">{heroDescription}</p>
)}
{club.reviewKeywords.length > 0 && (
<div className="flex flex-wrap gap-1">
{club.reviewKeywords.slice(0, 2).map((keyword) => (
Expand All @@ -136,23 +183,46 @@ const ClubDetailPage = ({ club }: Props) => {
))}
</div>
)}
<SnsRow clubName={club.name} snsUrls={snsUrls} />
</BackgroundCard>
</div>

{/* 탭바: 스크롤 시 헤더(56px) 아래에 핀 고정 (앱의 pinned 탭바와 동일) */}
<div ref={tabBarRef} className="sticky top-14 z-20">
{/* 탭바: 스크롤 시 헤더 아래에 핀 고정 (앱의 pinned 탭바와 동일).
ClubDetailHeader가 핀 여부를 같은 값으로 계산하므로 상수를 공유한다 */}
<div
ref={tabBarRef}
className="sticky z-20"
style={{ top: `calc(${DETAIL_HEADER_HEIGHT}px + var(--app-banner-h, 0px))` }}
>
<ClubDetailTabBar activeTab={activeTab} onChange={setActiveTab} />
</div>

{/* 탭 콘텐츠 */}
<div className="px-4">
{activeTab === 'detail' && <InfoTab club={club} />}
{activeTab === 'recruit' && <RecruitTab club={club} />}
{activeTab === 'review' && <ReviewTab club={club} onWriteReview={handleWriteReview} />}
{activeTab === 'recruit' && (
<RecruitTab
club={club}
tabLabel={CLUB_DETAIL_TABS[activeTab]}
isLoggedIn={!!user}
onLoginPress={handleLoginRequired}
onPreviousPress={handlePreviousRecruitments}
/>
)}
{activeTab === 'review' && (
<ReviewTab
club={club}
tabLabel={CLUB_DETAIL_TABS[activeTab]}
theme={theme}
isLoggedIn={!!user}
onLoginPress={handleLoginRequired}
onWriteReview={handleWriteReview}
/>
)}
</div>
</main>

{/* 하단 고정 앱 유도 CTA */}
{/* 하단 고정 앱 유도 CTA (웹 전용) */}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

기존 하단 CTA가 그대로 남아 있어 동아리 상세에서는 새 상단 배너와 앱 유도 UI가 중복됩니다. 상단 배너를 닫아도 이 CTA는 계속 보이고, 데스크톱이나 React Native WebView에서도 이 CTA에는 배너의 노출 제한이 적용되지 않습니다. 이번 변경이 기존 CTA를 상단으로 옮기는 것이라면 이 하단 CTA를 제거해 주세요. 두 위치를 의도적으로 병행한다면 닫기 및 플랫폼별 노출 정책을 명확히 맞춰야 합니다.

<div className="fixed inset-x-0 bottom-0 z-30">
<div className="mx-auto max-w-[480px] bg-gradient-to-t from-[#FAFAFA] via-[#FAFAFA]/80 to-transparent px-4 pb-[max(16px,env(safe-area-inset-bottom))] pt-6">
<button
Expand All @@ -177,7 +247,7 @@ export const getServerSideProps: GetServerSideProps<Props> = async ({ params })

try {
const clubService = Provider.getService(ClubService)
// v2: APPROVED 상태의 공개 동아리만 노출
// v2: APPROVED 상태의 공개 동아리만 노출 (officialVerificationStatus 포함)
const club = await clubService.findPublicByUuid(uuid.data)
return {
props: {
Expand Down
Loading
Loading