feat(app): premium immersive entry ui 적용

This commit is contained in:
2026-03-16 13:26:15 +09:00
parent 81e969c116
commit 8f4a69fc77
7 changed files with 432 additions and 436 deletions

View File

@@ -77,8 +77,10 @@ review 구조와 BM 연결을 볼 때 읽는다.
구현 규칙이나 구조를 볼 때 읽는다. 구현 규칙이나 구조를 볼 때 읽는다.
- [08_premium_uiux_guideline.md](./foundation/08_premium_uiux_guideline.md) `source-of-truth`
- 세계 최고급(LifeAt, Portal 수준) UI/UX 톤앤매너, 글래스모피즘, 모션 등 프리미엄 디자인 절대 원칙
- [01_ui_guidelines.md](./foundation/01_ui_guidelines.md) `source-of-truth` - [01_ui_guidelines.md](./foundation/01_ui_guidelines.md) `source-of-truth`
- UI 톤, CTA 위계, premium 품질 기준 - UI 톤, CTA 위계, premium 품질 기준 (일반 가이드)
- [02_arch_fsd_rules.md](./foundation/02_arch_fsd_rules.md) `source-of-truth` - [02_arch_fsd_rules.md](./foundation/02_arch_fsd_rules.md) `source-of-truth`
- FSD/레이어 구조 규칙 - FSD/레이어 구조 규칙
- [03_routes_map.md](./foundation/03_routes_map.md) `source-of-truth` - [03_routes_map.md](./foundation/03_routes_map.md) `source-of-truth`

View File

@@ -0,0 +1,122 @@
# Premium Immersive UI/UX Guidelines (VibeRoom Core)
이 문서는 VibeRoom 프로젝트가 LifeAt, Portal, Focusmate 등 **세계 최고급의 프리미엄 UI/UX 경험**을 일관되게 유지하기 위해 작성된 절대적인 디자인 헌장입니다.
어떤 에이전트, 어떤 개발자가 코드를 작성하든 화면을 추가하거나 수정할 때 이 가이드라인을 반드시 숙지하고 엄격하게 준수해야 합니다.
---
## 1. 핵심 철학 (Core Philosophy)
### 1-1. 무대 우선주의 (Stage-first & Immersive)
- **절대 원칙:** 사용자가 선택한 **배경(Atmosphere/Scene) 자체가 곧 앱의 정체성이자 무대**입니다.
- 배경을 가리거나 시야를 방해하는 거대한 대시보드 형태의 레이아웃(Split-screen, 거대한 Solid Card Grid)은 절대 금지합니다.
- UI는 무대 위에 떠 있는 얇고 투명한 유리 조각(Glass)처럼 존재해야 하며, 화면의 여백(White Space)을 극대화하여 공간감을 제공해야 합니다.
### 1-2. 중앙 집중의 의식 (Minimal Central Ritual)
- 사용자가 수행해야 할 가장 중요한 단 하나의 핵심 액션(예: "무엇에 집중할 것인가?")은 **항상 화면의 정중앙에 거대하고 우아하게 배치**합니다.
- 텍스트 입력창은 투명하게(`bg-transparent`), 폰트는 크고 얇게(`text-4xl font-light tracking-tight`) 유지하여 단순한 '입력'이 아닌 '의식(Ritual)'처럼 느껴지게 합니다.
### 1-3. 압도적인 글래스모피즘 (Premium Glassmorphism)
- 단순히 투명도를 낮추는 것이 아닙니다. 뒤의 빛과 배경이 은은하게 굴절되는 진짜 유리의 질감을 구현해야 합니다.
- **필수 속성:** `backdrop-blur-xl` 또는 `backdrop-blur-2xl`을 반드시 사용합니다.
- **테두리와 명암:** 투박한 solid border 대신, `border-white/5` ~ `border-white/10` 수준의 매우 얇고 투명한 테두리를 사용합니다. 깊이감을 위해 다중 그림자(`shadow-2xl` 등)와 미세한 그라데이션(`bg-[linear-gradient(...)]`)을 조합합니다.
---
## 2. 레이아웃 & 컴포넌트 배치 원칙 (Layout & Placement)
### 2-1. 절대 위치(Absolute) 사용의 엄격한 제한
- UI 요소가 창 크기 조절(Resizing) 시 중앙의 핵심 컨텐츠(Main Ritual)를 가리거나 겹치는 현상(Overlap)은 치명적인 결함입니다.
- **해결책:** 좌/우측에 둥둥 떠 있는(Floating) 위젯 형태를 구현할 때, 단순히 `absolute top-x left-y`로 띄워두지 마십시오. 창이 작아져도 겹치지 않게 하려면 중앙 컨테이너의 흐름(Inline Flex) 내부에 배치하거나, 화면 크기에 따른 철저한 미디어 쿼리 제어를 통해 **어떤 해상도에서도 메인 텍스트 영역을 침범하지 않도록 보장**해야 합니다.
### 2-2. 보조 위젯의 극단적 미니멀리즘 (Subtle Accessories)
- 메인 액션이 아닌 모든 정보(예: Weekly Review, Error Message, 힌트 등)는 **크기를 최소화하고 시각적 대비를 낮춥니다.**
- 정보 텍스트는 `text-[12px]` 또는 `text-[13px]`, 라벨이나 뱃지는 `text-[9px] ~ text-[10px]`에 두꺼운 자간(`tracking-[0.25em]`)과 대문자(`uppercase`) 조합을 사용하여 명품 브랜드의 타이포그래피처럼 디자인합니다.
- 예: 투박한 'Weekly Review' 카드 ❌ -> 한 줄의 세련된 'Smart Hint Pill' 형태 ⭕
### 2-3. 가장자리 도킹 (Edge Docking)
- 선택형 리스트(Atmosphere 선택 등)는 화면을 덮는 Grid 대신 **화면 최하단에 스와이프 가능한 가로 독(Carousel Dock)** 형태로 배치합니다.
- 독 내부의 아이템이 확대(Scale)되거나 애니메이션 될 때, 스크롤 컨테이너의 영역이 좁아 카드가 잘려 보이는 현상(Clipping)이 발생하지 않도록 **컨테이너 자체에 충분한 상하 여백(`py-8` 등)**을 확보해야 합니다.
---
## 3. 애니메이션 및 상호작용 (Motion & Interaction)
### 3-1. 부드럽고 웅장한 진입 (Stately Entrance)
- 뚝 떨어지거나 딱딱하게 나타나는 화면 전환은 금지합니다.
- 프리미엄 서비스 특유의 '서서히 떠오르는' 모션을 위해 커스텀 Keyframe(`fade-in-up`, `fade-in`)과 섬세한 이징 커브(`cubic-bezier(0.16, 1, 0.3, 1)`)를 적용합니다.
- `animation-delay`를 활용하여 헤더 -> 중앙 텍스트 -> 하단 독 순서로 물결치듯 순차적으로 나타나는 시퀀스를 구성합니다.
### 3-2. Hover 및 포커스 상태 (Fluid Feedback)
- 카드 호버 시 단순히 색만 변하는 것이 아니라, 부드러운 스케일 업(`hover:scale-105 ~ 110`)과 함께 그림자가 깊어지고(`hover:shadow-2xl`) 내부 텍스트 및 오버레이의 명도가 미세하게 조절되어야 합니다.
- 버튼의 경우 누를 때 미세하게 작아지는 햅틱 피드백(`active:scale-[0.98]`)을 적용하여 쫀득한 터치감을 줍니다.
---
## 4. 타이포그래피 및 카피 (Typography & Copy)
### 4-1. 소음 줄이기 (Reduce Visual Noise)
- 중요하지 않은 부가 설명(Helper Text)은 `text-white/40` ~ `text-white/60` 정도로 투명도를 과감히 낮춰 시야에서 멀어지게 합니다.
- 강렬한 Primary Color(파란색, 빨간색 등)는 에러나 꼭 필요한 CTA에만 극도로 제한적으로 사용하고, 기본적으로는 **무채색(흰색, 검은색)의 투명도 조절만으로 위계를 표현**합니다.
### 4-2. 프리미엄 카피라이팅 (Tone & Manner)
- 기능적인 설명보다 감성적이고 몰입을 돕는 문구를 사용합니다.
- "시간을 입력하세요" ❌ -> "What will you focus on?" / "의식을 시작합니다" ⭕
- 업그레이드 등 상업적 CTA도 "결제하기"보다 작고 섬세한 캡슐 버튼(`Upgrade →`)으로 디자인하여 브랜드의 우아함을 지킵니다.
## 5. AI 에이전트를 위한 Tailwind CSS 프롬프팅 & 코드 패턴 (Crucial for AI)
AI 에이전트(Codex, Cursor, Cline 등)는 "프리미엄하게 만들어줘"라는 추상적인 지시를 이해하지 못하고 평범한 UI(`bg-gray-100 rounded-md` 등)를 생성하는 경향이 있습니다. AI에게 작업을 지시할 때는 아래의 **명확한 Tailwind 유틸리티 패턴(Snippet)** 을 그대로 복사해서 사용하라고 지시해야 합니다.
### 5-1. Floating Smart Pill (보조 정보 위젯)
AI가 투박한 Card를 만들지 못하게 하고, 이 코드를 복사하라고 지시하세요.
```tsx
// DO: 아주 작고 은은하게 떠 있는 스마트 필
<div className="inline-flex items-center gap-3 rounded-full border border-white/5 bg-white/5 py-2 pl-4 pr-3 backdrop-blur-md transition-all hover:bg-white/10 hover:border-white/10">
<span className="flex h-1.5 w-1.5 rounded-full bg-white/40 group-hover:bg-white/60 transition-colors" />
<span className="text-[13px] font-medium text-white/70 group-hover:text-white/90">
</span>
</div>
// DON'T: AI가 자주 실수하는 투박한 솔리드 카드
<div className="bg-white rounded-lg p-4 shadow-md"> </div>
```
### 5-2. 메인 입력창 (Ritual Input)
AI가 흔한 폼 인풋(`border rounded px-4`)을 만들지 못하게 하세요.
```tsx
// DO: 거대하고 얇고 투명한 의식적 텍스트
<input
className="w-full bg-transparent text-center text-4xl font-light tracking-tight text-white outline-none placeholder:text-white/20 md:text-5xl lg:text-[4.5rem] lg:leading-[1.1]"
/>
// DON'T: 일반적인 대시보드 폼
<input className="border border-gray-300 rounded px-4 py-2 w-full" />
```
### 5-3. 프리미엄 버튼 (Primary Action)
```tsx
// DO: 빛나는 투명 테두리와 쫀득한 햅틱 모션을 가진 버튼
<button className="group relative flex h-16 items-center justify-center overflow-hidden rounded-full border border-white/20 bg-white/10 px-12 text-lg font-medium tracking-wide text-white shadow-2xl backdrop-blur-md transition-all duration-300 hover:bg-white/20 hover:scale-[1.02] active:scale-[0.98]">
</button>
```
### 5-4. 그라데이션 오버레이 (배경 어둡게 하기)
이미지 위에서 글씨가 잘 보이게 하려면 단순 `bg-black/50` 대신 깊이감 있는 그라데이션을 써야 합니다.
```tsx
// DO: 복합 그라데이션 (중앙은 비우고 테두리만 어둡게)
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,transparent_0%,rgba(0,0,0,0.6)_100%)] mix-blend-multiply pointer-events-none" />
<div className="absolute inset-0 bg-black/10 pointer-events-none" />
```
---
## 6. QA 체크리스트 (커밋 전 반드시 확인)
1. **Overlap Check:** 브라우저 창을 최소 크기(모바일/작은 데스크탑)로 줄였을 때 위젯이나 텍스트가 겹치거나 잘리는 곳이 단 한 곳이라도 있는가? (있다면 즉시 수정)
2. **Clipping Check:** 리스트 내의 아이템에 마우스를 올렸을 때(hover:scale) 아이템의 상하좌우 테두리가 부모 컨테이너에 의해 잘려 나가는 현상이 없는가?
3. **Hierarchy Check:** 화면 내에서 가장 눈에 띄는 것이 "현재 사용자가 해야 할 단 하나의 액션"인가? 부가 정보가 너무 커서 메인 액션을 압도하지 않는가?
4. **Motion Check:** 화면 진입 시 모든 요소가 우아하고 부드럽게 등장하는가? 깜빡이거나 투박하게 나타나는 요소는 없는가?
5. **Glass Check:** 모든 팝오버, 시트, 위젯이 뒷 배경을 우아하게 투영(backdrop-blur)하고 있으며, 테두리가 지나치게 두껍지 않은가?

10
package-lock.json generated
View File

@@ -15,6 +15,7 @@
"react-apple-signin-auth": "^1.1.2", "react-apple-signin-auth": "^1.1.2",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-facebook-login": "^4.1.1", "react-facebook-login": "^4.1.1",
"tailwindcss-animate": "^1.0.7",
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {
@@ -6167,6 +6168,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/tailwindcss-animate": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz",
"integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==",
"license": "MIT",
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders"
}
},
"node_modules/tapable": { "node_modules/tapable": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz",

View File

@@ -18,6 +18,7 @@
"react-apple-signin-auth": "^1.1.2", "react-apple-signin-auth": "^1.1.2",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"react-facebook-login": "^4.1.1", "react-facebook-login": "^4.1.1",
"tailwindcss-animate": "^1.0.7",
"zustand": "^5.0.11" "zustand": "^5.0.11"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,4 +1,5 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "tailwindcss-animate";
@theme { @theme {
/* Noto Sans 다국어 폰트 적용 (next/font/google 변수) */ /* Noto Sans 다국어 폰트 적용 (next/font/google 변수) */
@@ -81,6 +82,42 @@ body {
} }
} }
@keyframes fade-in-up {
0% {
opacity: 0;
transform: translateY(20px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.animate-fade-in-up {
animation: fade-in-up 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.animate-fade-in {
animation: fade-in 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.delay-150 {
animation-delay: 150ms;
}
.delay-300 {
animation-delay: 300ms;
}
.scrollbar-none { .scrollbar-none {
-ms-overflow-style: none; -ms-overflow-style: none;
scrollbar-width: none; scrollbar-width: none;

View File

@@ -5,15 +5,6 @@ import { getSceneCardPhotoUrl } from '@/entities/scene';
import { cn } from '@/shared/lib/cn'; import { cn } from '@/shared/lib/cn';
import type { AtmosphereOption } from '../model/atmosphereEntry'; import type { AtmosphereOption } from '../model/atmosphereEntry';
const stageShellClass =
'relative overflow-hidden rounded-[2.35rem] border border-white/12 bg-[linear-gradient(160deg,rgba(9,13,20,0.54)_0%,rgba(9,13,20,0.2)_52%,rgba(9,13,20,0.48)_100%)] shadow-[0_26px_90px_rgba(3,7,18,0.34)] backdrop-blur-[26px]';
const fieldShellClass =
'w-full rounded-[1.45rem] border border-white/12 bg-[linear-gradient(180deg,rgba(255,255,255,0.09)_0%,rgba(255,255,255,0.05)_100%)] px-5 py-4 text-white outline-none transition focus:border-white/24 focus:bg-white/[0.1]';
const reviewDockClass =
'group relative overflow-hidden rounded-[1.65rem] border border-white/12 bg-[linear-gradient(145deg,rgba(255,255,255,0.09)_0%,rgba(255,255,255,0.04)_100%)] px-5 py-4 backdrop-blur-xl transition hover:border-white/18 hover:bg-white/[0.1]';
const primaryButtonClass =
'inline-flex min-h-[3.65rem] items-center justify-center rounded-full border border-white/14 bg-white/[0.16] px-6 text-[15px] font-medium tracking-[-0.01em] text-white shadow-[0_10px_22px_rgba(5,10,20,0.24)] transition hover:bg-white/[0.2] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-50';
interface AppAtmosphereEntryShellProps { interface AppAtmosphereEntryShellProps {
canStart: boolean; canStart: boolean;
durationDraft: string; durationDraft: string;
@@ -25,9 +16,9 @@ interface AppAtmosphereEntryShellProps {
goalInputRef: RefObject<HTMLInputElement | null>; goalInputRef: RefObject<HTMLInputElement | null>;
goalPlaceholder: string; goalPlaceholder: string;
isStartingSession: boolean; isStartingSession: boolean;
reviewEntry?: ReactNode; topAccessory?: ReactNode;
errorAccessory?: ReactNode;
selectedAtmosphere: AtmosphereOption; selectedAtmosphere: AtmosphereOption;
sessionLookupError?: string | null;
startButtonLabel: string; startButtonLabel: string;
startButtonLoadingLabel: string; startButtonLoadingLabel: string;
atmosphereOptions: AtmosphereOption[]; atmosphereOptions: AtmosphereOption[];
@@ -44,21 +35,17 @@ export const AppAtmosphereEntryShell = ({
canStart, canStart,
durationDraft, durationDraft,
durationHelper, durationHelper,
durationInputLabel,
durationPlaceholder,
durationSuggestions, durationSuggestions,
goalDraft, goalDraft,
goalInputRef, goalInputRef,
goalPlaceholder, goalPlaceholder,
isStartingSession, isStartingSession,
reviewEntry, topAccessory,
errorAccessory,
selectedAtmosphere, selectedAtmosphere,
sessionLookupError,
startButtonLabel, startButtonLabel,
startButtonLoadingLabel, startButtonLoadingLabel,
atmosphereOptions, atmosphereOptions,
atmosphereTitle,
atmosphereBody,
onDurationChange, onDurationChange,
onGoalChange, onGoalChange,
onSelectAtmosphere, onSelectAtmosphere,
@@ -66,49 +53,19 @@ export const AppAtmosphereEntryShell = ({
onStartSession, onStartSession,
}: AppAtmosphereEntryShellProps) => { }: AppAtmosphereEntryShellProps) => {
return ( return (
<div className="space-y-6 md:space-y-7"> <div className="flex h-full flex-col justify-between pt-8 md:pt-16">
<div className="grid gap-5 xl:grid-cols-[minmax(20rem,0.92fr)_minmax(0,1.08fr)]"> {/* Main Focus Entry Ritual */}
<section className={cn(stageShellClass, 'px-6 py-6 md:px-8 md:py-8')}> <div className="flex flex-1 flex-col items-center justify-center px-4 pb-10">
<div className="pointer-events-none absolute inset-x-0 top-0 h-28 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.18),rgba(255,255,255,0)_64%)]" /> {/* Inline Accessories (No overlap guarantee) */}
<div className="pointer-events-none absolute inset-y-0 right-0 w-40 bg-[linear-gradient(270deg,rgba(255,255,255,0.06),rgba(255,255,255,0))]" /> <div className="mb-10 flex min-h-[5rem] flex-col items-center justify-end opacity-0 animate-fade-in-up delay-150">
{errorAccessory}
<div className="relative"> {!errorAccessory && topAccessory}
<div className="flex items-start justify-between gap-6">
<div className="max-w-[30rem] space-y-3">
<p className="text-[11px] font-medium uppercase tracking-[0.28em] text-white/46">
Focus Entry
</p>
<h1 className="max-w-[13ch] text-[2.45rem] font-light leading-[0.96] tracking-[-0.055em] text-white md:text-[3.65rem]">
<br />
?
</h1>
<p className="max-w-[30rem] text-[14px] leading-[1.72] text-white/64 md:text-[14.5px]">
. , atmosphere
.
</p>
</div> </div>
<div className="hidden min-w-[11.5rem] rounded-[1.4rem] border border-white/10 bg-white/[0.05] px-4 py-4 text-right xl:block"> <div className="w-full max-w-4xl space-y-8 text-center opacity-0 animate-fade-in-up delay-150">
<p className="text-[10px] font-medium uppercase tracking-[0.2em] text-white/42"> <p className="text-sm font-medium uppercase tracking-[0.25em] text-white/50">
Atmosphere What will you focus on?
</p> </p>
<p className="mt-3 text-[1rem] font-medium tracking-[-0.03em] text-white/90">
{selectedAtmosphere.name}
</p>
<p className="mt-2 text-[12px] leading-[1.6] text-white/52">
{selectedAtmosphere.soundLabel}
<br />
{selectedAtmosphere.caption}
</p>
</div>
</div>
<div className="mt-8 space-y-5 border-t border-white/10 pt-6">
<label className="block space-y-2.5">
<span className="text-[11px] font-medium uppercase tracking-[0.2em] text-white/42">
</span>
<input <input
ref={goalInputRef} ref={goalInputRef}
value={goalDraft} value={goalDraft}
@@ -120,21 +77,36 @@ export const AppAtmosphereEntryShell = ({
} }
}} }}
placeholder={goalPlaceholder} placeholder={goalPlaceholder}
className={cn( className="w-full bg-transparent text-center text-4xl font-light tracking-tight text-white outline-none placeholder:text-white/20 md:text-5xl lg:text-[4.5rem] lg:leading-[1.1]"
fieldShellClass,
'text-[1.16rem] font-light tracking-[-0.032em] placeholder:text-white/28 md:text-[1.42rem]',
)}
autoFocus autoFocus
/> />
</label> </div>
<div className="grid gap-5 lg:grid-cols-[minmax(0,0.88fr)_minmax(14rem,0.72fr)]"> <div className="flex w-full flex-col items-center space-y-8 opacity-0 animate-fade-in-up delay-150">
<div className="space-y-2.5"> <div className="flex flex-col items-center gap-3">
<label className="block space-y-2.5"> <div className="flex items-center gap-4 rounded-[2rem] border border-white/10 bg-white/5 p-2 pr-6 shadow-2xl backdrop-blur-xl">
<span className="text-[11px] font-medium uppercase tracking-[0.2em] text-white/42"> <div className="flex gap-2">
{durationInputLabel} {durationSuggestions.map((minutes) => {
</span> const isSelected = durationDraft === String(minutes);
<div className="relative"> return (
<button
key={minutes}
type="button"
onClick={() => onSelectDuration(minutes)}
className={cn(
'rounded-full px-5 py-2.5 text-sm font-medium transition-all duration-300',
isSelected
? 'bg-white text-black shadow-md'
: 'text-white/70 hover:bg-white/10 hover:text-white',
)}
>
{minutes}m
</button>
);
})}
</div>
<div className="h-8 w-px bg-white/10" />
<div className="flex items-center gap-2">
<input <input
value={durationDraft} value={durationDraft}
onChange={(event) => onDurationChange(event.target.value)} onChange={(event) => onDurationChange(event.target.value)}
@@ -145,169 +117,35 @@ export const AppAtmosphereEntryShell = ({
} }
}} }}
inputMode="numeric" inputMode="numeric"
placeholder={durationPlaceholder} placeholder="Custom"
className={cn( className="w-16 bg-transparent text-right text-lg font-medium text-white outline-none placeholder:text-white/30"
fieldShellClass,
'pr-14 text-[1.04rem] font-medium tracking-[-0.02em] placeholder:text-white/30',
)}
/> />
<span className="pointer-events-none absolute inset-y-0 right-5 flex items-center text-sm text-white/46"> <span className="text-sm text-white/50">min</span>
</span>
</div>
</label>
<p className="text-[12px] leading-[1.65] text-white/46">{durationHelper}</p>
</div>
<div className="space-y-2.5">
<p className="text-[11px] font-medium uppercase tracking-[0.2em] text-white/42">
</p>
<div className="grid grid-cols-2 gap-2">
{durationSuggestions.map((minutes) => (
<button
key={minutes}
type="button"
onClick={() => onSelectDuration(minutes)}
className={cn(
'rounded-[1.1rem] border border-white/12 bg-white/[0.05] px-3 py-3 text-[12px] font-medium text-white/74 transition hover:border-white/18 hover:bg-white/[0.09] hover:text-white',
durationDraft === String(minutes) &&
'border-white/24 bg-white/[0.14] text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.12)]',
)}
>
{minutes}
</button>
))}
</div> </div>
</div> </div>
</div> <p className="text-xs text-white/40">{durationHelper}</p>
<div className="grid gap-4 border-t border-white/10 pt-5 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
<div className="space-y-3">
<div className="rounded-[1.45rem] border border-white/10 bg-[linear-gradient(145deg,rgba(255,255,255,0.08)_0%,rgba(255,255,255,0.03)_100%)] px-4 py-4">
<p className="text-[10px] font-medium uppercase tracking-[0.18em] text-white/40">
</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<span className="text-[1rem] font-medium tracking-[-0.03em] text-white/90">
{selectedAtmosphere.name}
</span>
<span className="rounded-full border border-white/12 bg-white/[0.08] px-2.5 py-1 text-[11px] text-white/66">
{selectedAtmosphere.soundLabel}
</span>
</div>
<p className="mt-3 max-w-[28rem] text-[12px] leading-[1.65] text-white/54">
{selectedAtmosphere.description}
</p>
</div>
{sessionLookupError ? (
<p className="text-[13px] leading-[1.6] text-amber-100/84">{sessionLookupError}</p>
) : null}
</div> </div>
<button <button
type="button" type="button"
onClick={onStartSession} onClick={onStartSession}
disabled={!canStart} disabled={!canStart}
className={primaryButtonClass} className="group relative flex h-16 items-center justify-center overflow-hidden rounded-full border border-white/20 bg-white/10 px-12 text-lg font-medium tracking-wide text-white shadow-2xl backdrop-blur-md transition-all duration-300 hover:bg-white/20 hover:scale-[1.02] active:scale-[0.98] disabled:pointer-events-none disabled:opacity-40"
> >
<span className="relative z-10">
{isStartingSession ? startButtonLoadingLabel : startButtonLabel} {isStartingSession ? startButtonLoadingLabel : startButtonLabel}
</span>
</button> </button>
</div> </div>
{reviewEntry ? (
<div className="border-t border-white/10 pt-5">
<div className={reviewDockClass}>{reviewEntry}</div>
</div>
) : null}
</div>
</div>
</section>
<aside className={cn(stageShellClass, 'min-h-[29rem]')}>
<div
className="absolute inset-0 bg-cover bg-center"
style={{
backgroundImage: `linear-gradient(180deg, rgba(7,10,16,0.08) 0%, rgba(7,10,16,0.46) 46%, rgba(7,10,16,0.88) 100%), url('${getSceneCardPhotoUrl(selectedAtmosphere.scene)}')`,
}}
/>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.2),rgba(255,255,255,0)_36%)]" />
<div className="absolute inset-y-0 left-0 w-[52%] bg-[linear-gradient(90deg,rgba(5,8,14,0.56)_0%,rgba(5,8,14,0.22)_56%,rgba(5,8,14,0)_100%)]" />
<div className="relative flex min-h-[29rem] h-full flex-col justify-between p-6 md:p-7">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="rounded-full border border-white/14 bg-white/[0.08] px-3 py-1.5 text-[11px] font-medium tracking-[0.14em] text-white/76">
Atmosphere Preview
</div>
<div className="rounded-full border border-white/14 bg-black/10 px-3 py-1.5 text-[11px] text-white/70 backdrop-blur-md">
{selectedAtmosphere.caption}
</div>
</div> </div>
<div className="max-w-[28rem] space-y-4"> {/* Atmosphere Selection Dock */}
<div className="space-y-3"> <div className="w-full pb-6 opacity-0 animate-fade-in-up delay-300">
<p className="text-[11px] font-medium uppercase tracking-[0.18em] text-white/46"> <div className="mx-auto flex w-full max-w-6xl flex-col items-center">
<p className="mb-2 text-[10px] font-bold uppercase tracking-[0.3em] text-white/40 drop-shadow-md">
Atmosphere
</p> </p>
<h2 className="text-[2rem] font-light leading-[0.95] tracking-[-0.05em] text-white md:text-[2.85rem]"> <div className="flex w-full snap-x snap-mandatory gap-5 overflow-x-auto px-8 py-8 [&::-webkit-scrollbar]:hidden">
{selectedAtmosphere.name}
</h2>
<p className="max-w-[26rem] text-[14px] leading-[1.72] text-white/72">
{selectedAtmosphere.description}
</p>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-[1.35rem] border border-white/12 bg-white/[0.07] px-4 py-4 backdrop-blur-xl">
<p className="text-[10px] font-medium uppercase tracking-[0.18em] text-white/42">
Sound
</p>
<p className="mt-3 text-[13px] font-medium text-white/84">
{selectedAtmosphere.soundLabel}
</p>
</div>
<div className="rounded-[1.35rem] border border-white/12 bg-white/[0.07] px-4 py-4 backdrop-blur-xl">
<p className="text-[10px] font-medium uppercase tracking-[0.18em] text-white/42">
Scene
</p>
<p className="mt-3 text-[13px] font-medium text-white/84">
{selectedAtmosphere.scene.name}
</p>
</div>
<div className="rounded-[1.35rem] border border-white/12 bg-white/[0.07] px-4 py-4 backdrop-blur-xl">
<p className="text-[10px] font-medium uppercase tracking-[0.18em] text-white/42">
Best For
</p>
<p className="mt-3 text-[13px] font-medium text-white/84">
{selectedAtmosphere.scene.recommendedTime}
</p>
</div>
</div>
</div>
</div>
</aside>
</div>
<section className="space-y-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-[44rem]">
<p className="text-[11px] font-medium uppercase tracking-[0.22em] text-white/42">
Atmosphere Library
</p>
<h2 className="mt-3 text-[1.42rem] font-medium tracking-[-0.04em] text-white md:text-[1.9rem]">
{atmosphereTitle}
</h2>
<p className="mt-3 text-[13px] leading-[1.7] text-white/58 md:text-[13.5px]">
{atmosphereBody}
</p>
</div>
<p className="text-[12px] text-white/40 lg:text-right">
{atmosphereOptions.length} curated atmosphere
</p>
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-4">
{atmosphereOptions.map((option) => { {atmosphereOptions.map((option) => {
const isSelected = option.id === selectedAtmosphere.id; const isSelected = option.id === selectedAtmosphere.id;
@@ -317,62 +155,42 @@ export const AppAtmosphereEntryShell = ({
type="button" type="button"
onClick={() => onSelectAtmosphere(option.id)} onClick={() => onSelectAtmosphere(option.id)}
className={cn( className={cn(
'group relative min-h-[14rem] overflow-hidden rounded-[1.65rem] border text-left transition duration-300 ease-out', 'group relative flex min-w-[140px] snap-center flex-col overflow-hidden rounded-[1.75rem] border text-left transition-all duration-500 ease-[cubic-bezier(0.16,1,0.3,1)] md:min-w-[160px]',
isSelected isSelected
? 'border-white/26 shadow-[0_18px_42px_rgba(2,6,23,0.24)]' ? 'border-white/40 shadow-[0_20px_40px_-10px_rgba(0,0,0,0.5),0_0_30px_rgba(255,255,255,0.15)] ring-1 ring-white/40 scale-110 z-10'
: 'border-white/10 hover:border-white/18 hover:shadow-[0_14px_34px_rgba(2,6,23,0.18)]', : 'border-white/10 hover:border-white/30 hover:scale-105 hover:z-10 hover:shadow-[0_10px_20px_-10px_rgba(0,0,0,0.5)]',
)} )}
aria-pressed={isSelected}
> >
<div className="aspect-[4/5] w-full overflow-hidden">
<div <div
className="absolute inset-0 bg-cover bg-center transition duration-500 ease-out group-hover:scale-[1.035]" className="h-full w-full bg-cover bg-center transition-transform duration-1000 group-hover:scale-110"
style={{ style={{
backgroundImage: `linear-gradient(180deg, rgba(7,10,14,0.08) 0%, rgba(7,10,14,0.42) 44%, rgba(7,10,14,0.9) 100%), url('${getSceneCardPhotoUrl(option.scene)}')`, backgroundImage: `url('${getSceneCardPhotoUrl(option.scene)}')`,
}} }}
/> />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.14),rgba(255,255,255,0)_42%)]" />
<div className="relative flex h-full flex-col justify-between p-4">
<div className="flex items-start justify-between gap-3">
<span className="rounded-full border border-white/14 bg-white/[0.08] px-2.5 py-1 text-[10px] font-medium tracking-[0.14em] text-white/72">
{option.caption}
</span>
{isSelected ? (
<span className="rounded-full border border-white/16 bg-white/[0.14] px-2.5 py-1 text-[10px] font-medium tracking-[0.14em] text-white">
Current
</span>
) : null}
</div> </div>
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/20 to-transparent" />
<div className="space-y-3"> <div className="absolute bottom-0 w-full p-4">
<div> <p className="text-[10px] font-semibold uppercase tracking-wider text-white/60 mb-1">
<h3 className="text-[1.08rem] font-medium tracking-[-0.03em] text-white"> {option.soundLabel}
</p>
<p className="text-sm font-medium leading-tight text-white">
{option.name} {option.name}
</h3>
<p className="mt-2 line-clamp-3 text-[12px] leading-[1.62] text-white/66">
{option.description}
</p> </p>
</div> </div>
<div className="flex flex-wrap items-center gap-2 text-[11px] text-white/60"> {isSelected && (
<span className="rounded-full border border-white/12 bg-black/10 px-2.5 py-1 backdrop-blur-sm"> <div className="absolute right-3 top-3 rounded-full bg-white/20 p-1.5 backdrop-blur-md">
{option.soundLabel} <div className="h-2 w-2 rounded-full bg-white shadow-[0_0_10px_rgba(255,255,255,0.8)]" />
</span>
<span className="rounded-full border border-white/12 bg-black/10 px-2.5 py-1 backdrop-blur-sm">
{option.scene.recommendedTime}
</span>
</div>
</div>
</div> </div>
)}
</button> </button>
); );
})} })}
</div> </div>
</div>
<p className="text-[12px] leading-[1.6] text-white/38"> </div>
atmosphere는 .
, atmosphere의 .
</p>
</section>
</div> </div>
); );
}; };

View File

@@ -8,7 +8,6 @@ import { usePlanTier } from '@/entities/plan';
import { getSceneById, SCENE_THEMES } from '@/entities/scene'; import { getSceneById, SCENE_THEMES } from '@/entities/scene';
import { SOUND_PRESETS } from '@/entities/session'; import { SOUND_PRESETS } from '@/entities/session';
import { PaywallSheetContent } from '@/features/paywall-sheet'; import { PaywallSheetContent } from '@/features/paywall-sheet';
import { PlanPill } from '@/features/plan-pill';
import { focusSessionApi, type FocusSession } from '@/features/focus-session/api/focusSessionApi'; import { focusSessionApi, type FocusSession } from '@/features/focus-session/api/focusSessionApi';
import { useFocusStats, type ReviewCarryHint } from '@/features/stats'; import { useFocusStats, type ReviewCarryHint } from '@/features/stats';
import { copy } from '@/shared/i18n'; import { copy } from '@/shared/i18n';
@@ -42,40 +41,37 @@ const DEFAULT_ATMOSPHERE =
const entryCopy = { const entryCopy = {
eyebrow: 'VibeRoom', eyebrow: 'VibeRoom',
goalPlaceholder: '예: 제안서 첫 문단만 다듬기', goalPlaceholder: 'e.g. Write the first draft',
durationLabel: '예상 시간(분)', durationLabel: 'Estimated Time',
durationPlaceholder: '예: 70', durationPlaceholder: 'e.g. 70',
durationHelper: '이 목표를 끝내는 데 걸릴 것 같은 시간을 적어요.', durationHelper: 'Set a realistic time to accomplish this goal.',
startNow: '이 분위기로 들어가기', startNow: 'Begin Session',
startLoading: '입장 준비 중...', startLoading: 'Entering...',
atmosphereTitle: '어떤 분위기에서 들어갈까요?', atmosphereTitle: 'Atmosphere',
atmosphereBody: atmosphereBody:
'배경과 사운드는 하나의 atmosphere로 움직입니다. 지금 할 일의 온도에 맞는 분위기 하나만 고르면 바로 들어갈 수 있어요.', 'Background and sound play together. Choose an atmosphere to dive into deep focus.',
loadFailed: '세션 상태를 불러오지 못했어요. 새로 시작은 계속 할 수 있어요.', loadFailed: 'Failed to load session state. You can still start a new one.',
reviewEyebrow: 'Weekly Review', reviewEyebrow: 'Weekly Review',
reviewTitle: '이번 주 review를 잠깐 보고 갈까요?', reviewTitle: 'Take a quick look at your weekly review?',
reviewCta: '주간 review 보기', reviewCta: 'View Review',
reviewHelper: '다음 세션 전에 가볍게 보고 갈 수 있어요.', reviewHelper: 'A brief look back before you start.',
reviewTitlePro: '나에게 잘 맞았던 흐름을 다시 보고 갈까요?', reviewTitlePro: 'Revisit a flow that worked well for you?',
reviewCtaPro: '나에게 맞는 흐름 보기', reviewCtaPro: 'View My Flow',
reviewHelperPro: '가장 잘 맞았던 ritual carry-forward를 보고 돌아올 수 있어요.', reviewHelperPro: 'Check your best rituals and carry-forwards.',
reviewReturnEyebrow: '방금 본 review 기준', reviewReturnEyebrow: 'From your recent review',
reviewReturnTitleSteady: '이번 주에 잘 맞았던 흐름을 그대로 가져가 보세요.', reviewReturnTitleSteady: 'Keep the rhythm that worked well this week.',
reviewReturnTitleSmaller: '이번엔 목표를 더 작게 잡아보세요.', reviewReturnTitleSmaller: 'Try setting a smaller goal this time.',
reviewReturnTitleClosure: '이번엔 어디서 닫을지 먼저 정해보세요.', reviewReturnTitleClosure: 'Decide where to wrap up before you start.',
reviewReturnTitleStart: '이번 주는 시작 횟수 하나를 더 만드는 게 먼저예요.', reviewReturnTitleStart: 'Your focus right now: just start one more session.',
reviewReturnBodySteady: 'goal은 직접 정하되, 지금처럼 가볍게 들어가는 리듬을 유지해 보세요.', reviewReturnBodySteady: 'Set your own goal, but maintain the light entry rhythm.',
reviewReturnBodySmaller: '길이를 늘리기보다, 더 작은 goal과 더 구체적인 첫 한 조각으로 시작하면 이어가기 쉬워져요.', reviewReturnBodySmaller: 'Instead of extending time, a smaller goal makes it easier to keep going.',
reviewReturnBodyClosure: '큰 흐름보다 지금 블록을 어디서 마무리할지 먼저 떠올리면 끝까지 가져가기 쉬워져요.', reviewReturnBodyClosure: 'Think about where to close the block first to finish strong.',
reviewReturnBodyStart: '길이를 늘리기보다, 아주 작은 goal로 이번 주 첫 세션 하나를 더 여는 데 집중해 보세요.', reviewReturnBodyStart: 'Just aim to open one more short session to build momentum.',
reviewReturnRitualLabel: '추천 atmosphere · 숲 · Forest Birds', reviewReturnRitualLabel: 'Recommended Ritual · Forest · Forest Birds',
paywallLead: 'Calm Session OS PRO', paywallLead: 'Calm Session OS PRO',
paywallBody: 'Pro는 더 빠른 ritual과 더 깊은 review로 시작과 복귀를 가볍게 만듭니다.', paywallBody: 'Pro enables faster rituals and deeper reviews for seamless entry and return.',
}; };
const goalCardClass =
'w-full rounded-[2.2rem] border border-white/12 bg-[linear-gradient(160deg,rgba(9,13,20,0.52)_0%,rgba(9,13,20,0.24)_52%,rgba(9,13,20,0.5)_100%)] px-6 py-6 shadow-[0_26px_90px_rgba(3,7,18,0.34)] backdrop-blur-[24px] md:px-8 md:py-8';
const reviewCarryCopyByHint: Record< const reviewCarryCopyByHint: Record<
ReviewCarryHint, ReviewCarryHint,
{ title: string; body: string } { title: string; body: string }
@@ -103,7 +99,7 @@ export const FocusDashboardWidget = () => {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { plan, isPro, setPlan } = usePlanTier(); const { plan, isPro, setPlan } = usePlanTier();
const { sceneAssetMap } = useMediaCatalog(); const { sceneAssetMap } = useMediaCatalog();
const { review, summary: weeklySummary } = useFocusStats(); const { summary: weeklySummary } = useFocusStats();
const reviewEntryPreset = searchParams.get('entryPreset'); const reviewEntryPreset = searchParams.get('entryPreset');
const reviewEntryPresetConfig = useMemo(() => { const reviewEntryPresetConfig = useMemo(() => {
@@ -181,12 +177,9 @@ export const FocusDashboardWidget = () => {
const reviewReturnRitualLabel = const reviewReturnRitualLabel =
isPro && reviewEntryPresetConfig ? `추천 ritual · ${reviewEntryPresetConfig.label}` : null; isPro && reviewEntryPresetConfig ? `추천 ritual · ${reviewEntryPresetConfig.label}` : null;
const reviewTeaserTitle = isPro ? entryCopy.reviewTitlePro : entryCopy.reviewTitle; const reviewTeaserTitle = isPro ? entryCopy.reviewTitlePro : entryCopy.reviewTitle;
const reviewTeaserSummary = isPro ? review.carryForward.keepDoing : review.snapshotSummary;
const reviewTeaserHelper = isPro ? entryCopy.reviewHelperPro : entryCopy.reviewHelper;
const reviewTeaserCta = isPro ? entryCopy.reviewCtaPro : entryCopy.reviewCta;
const durationHelper = const durationHelper =
parsedDurationMinutes === null parsedDurationMinutes === null
? '이 목표를 끝내는 데 걸릴 것 같은 시간을 분 단위로 적어주세요.' ? 'Please enter the estimated duration in minutes.'
: entryCopy.durationHelper; : entryCopy.durationHelper;
const hasCurrentSession = Boolean(currentSession); const hasCurrentSession = Boolean(currentSession);
@@ -303,50 +296,51 @@ export const FocusDashboardWidget = () => {
!isCheckingSession && !currentSession && hasEnoughWeeklyData && !isReviewReturn; !isCheckingSession && !currentSession && hasEnoughWeeklyData && !isReviewReturn;
return ( return (
<div className="relative min-h-dvh overflow-hidden bg-slate-950 text-white selection:bg-white/20"> <div className="relative min-h-dvh overflow-hidden bg-black text-white selection:bg-white/20">
{/* Background Media */}
<div <div
className={cn( className={cn(
'absolute inset-0 bg-cover bg-center transition-transform duration-700 ease-out', 'absolute inset-0 bg-cover bg-center transition-transform duration-[1.5s] ease-[cubic-bezier(0.22,1,0.36,1)]',
isStartingSession ? 'scale-[1.04]' : 'scale-100', isStartingSession ? 'scale-[1.08] blur-[2px] brightness-75' : 'scale-100 blur-0 brightness-100',
)} )}
style={getSceneStageBackgroundStyle(activeScene, sceneAssetMap?.[activeScene.id])} style={getSceneStageBackgroundStyle(activeScene, sceneAssetMap?.[activeScene.id])}
/> />
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(2,6,23,0.18)_0%,rgba(2,6,23,0.28)_42%,rgba(2,6,23,0.54)_100%)]" /> {/* Immersive Overlay Gradients */}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.08),rgba(255,255,255,0)_42%)]" /> <div className="absolute inset-0 bg-[radial-gradient(circle_at_center,transparent_0%,rgba(0,0,0,0.6)_100%)] mix-blend-multiply pointer-events-none" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_left,rgba(6,10,20,0.24),rgba(6,10,20,0)_36%)]" /> <div className="absolute inset-0 bg-black/10 pointer-events-none" />
<header className="relative z-10 flex items-center justify-between px-5 py-5 md:px-8 md:py-7"> {/* Header */}
<p className="text-sm font-semibold uppercase tracking-[0.28em] text-white/56"> <header className="absolute top-0 inset-x-0 z-50 flex items-center justify-between px-8 py-8 md:px-12 md:py-10">
<div className="flex items-center gap-3 opacity-0 animate-fade-in delay-150">
<p className="text-[11px] font-bold uppercase tracking-[0.4em] text-white/50 drop-shadow-sm">
{entryCopy.eyebrow} {entryCopy.eyebrow}
</p> </p>
<PlanPill plan={plan} onClick={openPaywall} /> {plan === 'pro' && (
<span className="rounded-full bg-white/10 px-2 py-0.5 text-[9px] font-bold uppercase tracking-widest text-white/60 backdrop-blur-md">
PRO
</span>
)}
</div>
{plan !== 'pro' && (
<button
type="button"
onClick={openPaywall}
className="group flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-4 py-1.5 text-[10px] font-semibold uppercase tracking-widest text-white/50 backdrop-blur-md transition-all hover:bg-white/10 hover:text-white/80 opacity-0 animate-fade-in delay-150"
>
<span>Upgrade</span>
<span className="transition-transform group-hover:translate-x-0.5">&rarr;</span>
</button>
)}
</header> </header>
<main className="relative z-10 flex min-h-[calc(100dvh-84px)] items-start justify-center px-4 pb-10 pt-3 md:px-6 md:pb-12 md:pt-6"> {/* Main Content Area */}
<div className="w-full max-w-[86rem]"> <main className="relative z-10 flex h-[100dvh] flex-col">
{isCheckingSession ? ( {isCheckingSession ? (
<div className={cn(goalCardClass, 'space-y-4 text-center')}> <div className="flex h-full items-center justify-center">
<p className="text-[15px] text-white/72"> .</p> <p className="text-[15px] font-medium text-white/70 animate-pulse">Loading session...</p>
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="flex-1 flex flex-col h-full w-full">
{reviewReturnCopy ? (
<div className="rounded-[1.65rem] border border-white/12 bg-[linear-gradient(145deg,rgba(255,255,255,0.1)_0%,rgba(255,255,255,0.04)_100%)] px-5 py-4 backdrop-blur-xl">
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/42">
{entryCopy.reviewReturnEyebrow}
</p>
<p className="mt-2 text-[1rem] font-medium tracking-[-0.03em] text-white/90">
{reviewReturnCopy.title}
</p>
<p className="mt-2 max-w-[34rem] text-[13px] leading-[1.6] text-white/62">
{reviewReturnCopy.body}
</p>
{reviewReturnRitualLabel ? (
<p className="mt-3 text-[12px] text-white/46">{reviewReturnRitualLabel}</p>
) : null}
</div>
) : null}
<AppAtmosphereEntryShell <AppAtmosphereEntryShell
canStart={canStart} canStart={canStart}
durationDraft={durationDraft} durationDraft={durationDraft}
@@ -358,34 +352,46 @@ export const FocusDashboardWidget = () => {
goalInputRef={goalInputRef} goalInputRef={goalInputRef}
goalPlaceholder={entryCopy.goalPlaceholder} goalPlaceholder={entryCopy.goalPlaceholder}
isStartingSession={isStartingSession} isStartingSession={isStartingSession}
reviewEntry={ topAccessory={
shouldShowWeeklyReviewTeaser ? ( reviewReturnCopy ? (
<Link <div className="flex flex-col items-center text-center">
href="/stats" <div className="mb-3 inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1.5 text-[10px] font-bold uppercase tracking-[0.2em] text-white/60 backdrop-blur-md">
className="block transition" <span className="h-1.5 w-1.5 rounded-full bg-white/40" />
> {entryCopy.reviewReturnEyebrow}
<div className="flex flex-col gap-3"> </div>
<div className="min-w-0"> <p className="text-lg font-medium tracking-tight text-white/90">
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-white/42"> {reviewReturnCopy.title}
{entryCopy.reviewEyebrow}
</p> </p>
<p className="mt-2 text-[1rem] font-medium tracking-[-0.03em] text-white/88"> <p className="mt-1.5 max-w-md text-[13px] leading-relaxed text-white/50">
{reviewReturnCopy.body}
</p>
{reviewReturnRitualLabel && (
<p className="mt-3 text-[11px] font-medium text-white/40">
{reviewReturnRitualLabel}
</p>
)}
</div>
) : shouldShowWeeklyReviewTeaser ? (
<Link href="/stats" className="group inline-flex items-center gap-3 rounded-full border border-white/5 bg-white/5 py-2 pl-4 pr-3 backdrop-blur-md transition-all hover:bg-white/10 hover:border-white/10">
<span className="flex h-1.5 w-1.5 rounded-full bg-white/40 group-hover:bg-white/60 transition-colors" />
<span className="text-[13px] font-medium text-white/70 group-hover:text-white/90">
{reviewTeaserTitle} {reviewTeaserTitle}
</p>
<p className="mt-2 text-[13px] leading-[1.6] text-white/62">
{reviewTeaserSummary}
</p>
<p className="mt-2 text-[12px] text-white/44">{reviewTeaserHelper}</p>
</div>
<span className="inline-flex items-center text-[12px] font-medium tracking-[0.04em] text-white/74 transition group-hover:text-white">
{reviewTeaserCta}
</span> </span>
</div> <span className="text-white/40 transition-transform group-hover:translate-x-0.5">&rarr;</span>
</Link> </Link>
) : undefined ) : undefined
} }
errorAccessory={
sessionLookupError ? (
<div className="inline-flex items-center gap-2 rounded-full border border-red-500/20 bg-red-500/10 px-4 py-2 text-sm text-red-200 backdrop-blur-md">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{sessionLookupError}
</div>
) : undefined
}
selectedAtmosphere={selectedAtmosphere} selectedAtmosphere={selectedAtmosphere}
sessionLookupError={sessionLookupError}
startButtonLabel={entryCopy.startNow} startButtonLabel={entryCopy.startNow}
startButtonLoadingLabel={entryCopy.startLoading} startButtonLoadingLabel={entryCopy.startLoading}
atmosphereOptions={ATMOSPHERE_OPTIONS} atmosphereOptions={ATMOSPHERE_OPTIONS}
@@ -401,22 +407,22 @@ export const FocusDashboardWidget = () => {
/> />
</div> </div>
)} )}
</div>
</main> </main>
{paywallSource ? ( {/* Paywall Overlay */}
<div className="fixed inset-0 z-50 flex items-end justify-center p-4 sm:items-center"> {paywallSource && (
<div className="fixed inset-0 z-[100] flex items-end justify-center p-4 sm:items-center">
<button <button
type="button" type="button"
aria-label={copy.modal.closeAriaLabel} aria-label={copy.modal.closeAriaLabel}
onClick={() => setPaywallSource(null)} onClick={() => setPaywallSource(null)}
className="absolute inset-0 bg-slate-950/52 backdrop-blur-[3px]" className="absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity"
/> />
<div className="relative z-10 w-full max-w-md rounded-3xl border border-white/12 bg-[linear-gradient(165deg,rgba(15,23,42,0.94)_0%,rgba(2,6,23,0.98)_100%)] p-5 shadow-[0_24px_60px_rgba(2,6,23,0.36)]"> <div className="relative z-10 w-full max-w-md rounded-[2rem] border border-white/10 bg-zinc-900/90 p-6 shadow-2xl backdrop-blur-xl">
<p className="mb-3 text-[11px] uppercase tracking-[0.16em] text-white/42"> <p className="mb-2 text-[11px] font-medium uppercase tracking-[0.2em] text-white/50">
{entryCopy.paywallLead} {entryCopy.paywallLead}
</p> </p>
<p className="mb-4 text-sm text-white/62">{entryCopy.paywallBody}</p> <p className="mb-6 text-sm leading-relaxed text-white/80">{entryCopy.paywallBody}</p>
<PaywallSheetContent <PaywallSheetContent
onStartPro={() => { onStartPro={() => {
setPlan('pro'); setPlan('pro');
@@ -426,7 +432,7 @@ export const FocusDashboardWidget = () => {
/> />
</div> </div>
</div> </div>
) : null} )}
</div> </div>
); );
}; };