feat: Follow system + tier config page (M022/S02)

- CreatorFollow model + pure SQL migration 022
- Follow router: POST/DELETE /follows/{creator_id}, GET /follows/me, GET /follows/{id}/status
- follower_count on creator detail endpoint
- Follow button on CreatorDetail (authenticated users only)
- CreatorTiers page with Free/Pro/Premium cards, Coming Soon modals
- Tiers link in creator sidebar nav
- Route /creator/tiers (protected)
This commit is contained in:
jlightner 2026-04-04 07:34:03 +00:00
parent c9a2d2efbb
commit 19a6ff660c
16 changed files with 688 additions and 10 deletions

View file

@ -0,0 +1,31 @@
"""Add creator_follows table for user follow system.
Revision ID: 022_add_creator_follows
Revises: 021_add_highlight_trim_columns
"""
from alembic import op
revision = "022_add_creator_follows"
down_revision = "021_add_highlight_trim_columns"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
CREATE TABLE IF NOT EXISTS creator_follows (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
creator_id UUID NOT NULL REFERENCES creators(id) ON DELETE CASCADE,
created_at TIMESTAMP NOT NULL DEFAULT now(),
CONSTRAINT uq_creator_follow_user_creator UNIQUE (user_id, creator_id)
)
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_creator_follows_user_id ON creator_follows (user_id)")
op.execute("CREATE INDEX IF NOT EXISTS ix_creator_follows_creator_id ON creator_follows (creator_id)")
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS creator_follows")

View file

@ -12,7 +12,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from config import get_settings from config import get_settings
from routers import admin, auth, chat, consent, creator_chapters, creator_dashboard, creator_highlights, creators, health, highlights, ingest, pipeline, reports, search, stats, techniques, topics, videos from routers import admin, auth, chat, consent, creator_chapters, creator_dashboard, creator_highlights, creators, follows, health, highlights, ingest, pipeline, reports, search, stats, techniques, topics, videos
def _setup_logging() -> None: def _setup_logging() -> None:
@ -86,6 +86,7 @@ app.include_router(creator_dashboard.router, prefix="/api/v1")
app.include_router(creator_chapters.router, prefix="/api/v1") app.include_router(creator_chapters.router, prefix="/api/v1")
app.include_router(creator_highlights.router, prefix="/api/v1") app.include_router(creator_highlights.router, prefix="/api/v1")
app.include_router(creators.router, prefix="/api/v1") app.include_router(creators.router, prefix="/api/v1")
app.include_router(follows.router, prefix="/api/v1")
app.include_router(highlights.router, prefix="/api/v1") app.include_router(highlights.router, prefix="/api/v1")
app.include_router(ingest.router, prefix="/api/v1") app.include_router(ingest.router, prefix="/api/v1")
app.include_router(pipeline.router, prefix="/api/v1") app.include_router(pipeline.router, prefix="/api/v1")

View file

@ -737,3 +737,28 @@ class HighlightCandidate(Base):
# relationships # relationships
key_moment: Mapped[KeyMoment] = sa_relationship() key_moment: Mapped[KeyMoment] = sa_relationship()
source_video: Mapped[SourceVideo] = sa_relationship() source_video: Mapped[SourceVideo] = sa_relationship()
# ── Follow System ────────────────────────────────────────────────────────────
class CreatorFollow(Base):
"""A user following a creator."""
__tablename__ = "creator_follows"
__table_args__ = (
UniqueConstraint("user_id", "creator_id", name="uq_creator_follow_user_creator"),
)
id: Mapped[uuid.UUID] = _uuid_pk()
user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True,
)
creator_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("creators.id", ondelete="CASCADE"), nullable=False, index=True,
)
created_at: Mapped[datetime] = mapped_column(
default=_now, server_default=func.now()
)
# relationships
user: Mapped[User] = sa_relationship()
creator: Mapped[Creator] = sa_relationship()

View file

@ -12,7 +12,7 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from database import get_session from database import get_session
from models import Creator, KeyMoment, SourceVideo, TechniquePage from models import Creator, CreatorFollow, KeyMoment, SourceVideo, TechniquePage
from schemas import CreatorBrowseItem, CreatorDetail, CreatorRead, CreatorTechniqueItem from schemas import CreatorBrowseItem, CreatorDetail, CreatorRead, CreatorTechniqueItem
logger = logging.getLogger("chrysopedia.creators") logger = logging.getLogger("chrysopedia.creators")
@ -175,6 +175,13 @@ async def get_creator(
genre_breakdown[cat] = genre_breakdown.get(cat, 0) + 1 genre_breakdown[cat] = genre_breakdown.get(cat, 0) + 1
creator_data = CreatorRead.model_validate(creator) creator_data = CreatorRead.model_validate(creator)
# Follower count
follower_count = (await db.execute(
select(func.count()).select_from(CreatorFollow)
.where(CreatorFollow.creator_id == creator.id)
)).scalar() or 0
return CreatorDetail( return CreatorDetail(
**creator_data.model_dump(), **creator_data.model_dump(),
bio=creator.bio, bio=creator.bio,
@ -182,6 +189,8 @@ async def get_creator(
featured=creator.featured, featured=creator.featured,
video_count=video_count, video_count=video_count,
technique_count=len(techniques), technique_count=len(techniques),
moment_count=moment_count,
techniques=techniques, techniques=techniques,
genre_breakdown=genre_breakdown, genre_breakdown=genre_breakdown,
follower_count=follower_count,
) )

116
backend/routers/follows.py Normal file
View file

@ -0,0 +1,116 @@
"""Follow system — users follow creators.
Endpoints:
POST /follows/{creator_id} follow a creator
DELETE /follows/{creator_id} unfollow a creator
GET /follows/{creator_id}/status check if following
GET /follows/me list followed creators
"""
from __future__ import annotations
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import delete, func, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from auth import get_current_user
from database import get_session
from models import Creator, CreatorFollow, User
from schemas import FollowResponse, FollowStatusResponse, FollowedCreatorItem
router = APIRouter(prefix="/follows", tags=["follows"])
async def _follower_count(session: AsyncSession, creator_id: uuid.UUID) -> int:
result = await session.execute(
select(func.count()).select_from(CreatorFollow).where(
CreatorFollow.creator_id == creator_id
)
)
return result.scalar_one()
@router.post("/{creator_id}", response_model=FollowResponse)
async def follow_creator(
creator_id: uuid.UUID,
current_user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""Follow a creator. Idempotent — following twice is a no-op."""
# Verify creator exists
result = await session.execute(select(Creator).where(Creator.id == creator_id))
if result.scalar_one_or_none() is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Creator not found")
# Upsert — idempotent
stmt = pg_insert(CreatorFollow).values(
user_id=current_user.id,
creator_id=creator_id,
).on_conflict_do_nothing(constraint="uq_creator_follow_user_creator")
await session.execute(stmt)
await session.commit()
count = await _follower_count(session, creator_id)
return FollowResponse(followed=True, creator_id=creator_id, follower_count=count)
@router.delete("/{creator_id}", response_model=FollowResponse)
async def unfollow_creator(
creator_id: uuid.UUID,
current_user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""Unfollow a creator. Idempotent — unfollowing when not following is a no-op."""
await session.execute(
delete(CreatorFollow).where(
CreatorFollow.user_id == current_user.id,
CreatorFollow.creator_id == creator_id,
)
)
await session.commit()
count = await _follower_count(session, creator_id)
return FollowResponse(followed=False, creator_id=creator_id, follower_count=count)
@router.get("/me", response_model=list[FollowedCreatorItem])
async def my_follows(
current_user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""List all creators the current user follows."""
result = await session.execute(
select(
CreatorFollow.creator_id,
Creator.name.label("creator_name"),
Creator.slug.label("creator_slug"),
CreatorFollow.created_at.label("followed_at"),
)
.join(Creator, CreatorFollow.creator_id == Creator.id)
.where(CreatorFollow.user_id == current_user.id)
.order_by(CreatorFollow.created_at.desc())
)
return [FollowedCreatorItem(**row._mapping) for row in result.all()]
@router.get("/{creator_id}/status", response_model=FollowStatusResponse)
async def follow_status(
creator_id: uuid.UUID,
current_user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)],
):
"""Check if the current user follows a specific creator."""
result = await session.execute(
select(CreatorFollow.id).where(
CreatorFollow.user_id == current_user.id,
CreatorFollow.creator_id == creator_id,
)
)
return FollowStatusResponse(
following=result.scalar_one_or_none() is not None,
creator_id=creator_id,
)

View file

@ -62,6 +62,7 @@ class CreatorDetail(CreatorRead):
video_count: int = 0 video_count: int = 0
technique_count: int = 0 technique_count: int = 0
moment_count: int = 0 moment_count: int = 0
follower_count: int = 0
techniques: list[CreatorTechniqueItem] = [] techniques: list[CreatorTechniqueItem] = []
genre_breakdown: dict[str, int] = {} genre_breakdown: dict[str, int] = {}
@ -705,3 +706,28 @@ class ChapterReorderRequest(BaseModel):
class ChapterBulkApproveRequest(BaseModel): class ChapterBulkApproveRequest(BaseModel):
"""Bulk-approve chapters by IDs.""" """Bulk-approve chapters by IDs."""
chapter_ids: list[uuid.UUID] chapter_ids: list[uuid.UUID]
# ── Follow System ────────────────────────────────────────────────────────────
class FollowResponse(BaseModel):
"""Response after follow/unfollow action."""
followed: bool
creator_id: uuid.UUID
follower_count: int
class FollowStatusResponse(BaseModel):
"""Whether the current user is following a creator."""
following: bool
creator_id: uuid.UUID
class FollowedCreatorItem(BaseModel):
"""A creator the current user follows."""
model_config = ConfigDict(from_attributes=True)
creator_id: uuid.UUID
creator_name: str
creator_slug: str
followed_at: datetime

View file

@ -2483,6 +2483,37 @@ a.app-footer__repo:hover {
gap: 0.75rem; gap: 0.75rem;
} }
.creator-hero__follow-btn {
background: transparent;
border: 1px solid var(--color-accent);
color: var(--color-accent);
font-size: 0.8rem;
font-weight: 600;
padding: 0.3rem 0.85rem;
border-radius: 20px;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.creator-hero__follow-btn:hover {
background: var(--color-accent);
color: var(--color-bg-page);
}
.creator-hero__follow-btn--following {
background: var(--color-accent-subtle);
border-color: var(--color-accent);
color: var(--color-accent);
}
.creator-hero__follow-btn--following:hover {
background: var(--color-error-bg);
border-color: var(--color-error);
color: var(--color-error);
}
.creator-hero__follow-btn:disabled {
opacity: 0.5;
cursor: default;
}
.creator-hero__edit-btn { .creator-hero__edit-btn {
background: none; background: none;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);

View file

@ -24,6 +24,7 @@ const AdminAuditLog = React.lazy(() => import("./pages/AdminAuditLog"));
const ChatPage = React.lazy(() => import("./pages/ChatPage")); const ChatPage = React.lazy(() => import("./pages/ChatPage"));
const ChapterReview = React.lazy(() => import("./pages/ChapterReview")); const ChapterReview = React.lazy(() => import("./pages/ChapterReview"));
const HighlightQueue = React.lazy(() => import("./pages/HighlightQueue")); const HighlightQueue = React.lazy(() => import("./pages/HighlightQueue"));
const CreatorTiers = React.lazy(() => import("./pages/CreatorTiers"));
import AdminDropdown from "./components/AdminDropdown"; import AdminDropdown from "./components/AdminDropdown";
import ImpersonationBanner from "./components/ImpersonationBanner"; import ImpersonationBanner from "./components/ImpersonationBanner";
import AppFooter from "./components/AppFooter"; import AppFooter from "./components/AppFooter";
@ -205,6 +206,7 @@ function AppShell() {
<Route path="/creator/chapters" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><ChapterReview /></Suspense></ProtectedRoute>} /> <Route path="/creator/chapters" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><ChapterReview /></Suspense></ProtectedRoute>} />
<Route path="/creator/chapters/:videoId" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><ChapterReview /></Suspense></ProtectedRoute>} /> <Route path="/creator/chapters/:videoId" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><ChapterReview /></Suspense></ProtectedRoute>} />
<Route path="/creator/highlights" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><HighlightQueue /></Suspense></ProtectedRoute>} /> <Route path="/creator/highlights" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><HighlightQueue /></Suspense></ProtectedRoute>} />
<Route path="/creator/tiers" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><CreatorTiers /></Suspense></ProtectedRoute>} />
{/* Fallback */} {/* Fallback */}
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />

View file

@ -49,6 +49,7 @@ export interface CreatorDetailResponse {
avatar_url: string | null; avatar_url: string | null;
technique_count: number; technique_count: number;
moment_count: number; moment_count: number;
follower_count: number;
techniques: CreatorTechniqueItem[]; techniques: CreatorTechniqueItem[];
genre_breakdown: Record<string, number>; genre_breakdown: Record<string, number>;
} }

View file

@ -0,0 +1,39 @@
import { request, BASE } from "./client";
// ── Types ────────────────────────────────────────────────────────────────────
export interface FollowResponse {
followed: boolean;
creator_id: string;
follower_count: number;
}
export interface FollowStatusResponse {
following: boolean;
creator_id: string;
}
export interface FollowedCreatorItem {
creator_id: string;
creator_name: string;
creator_slug: string;
followed_at: string;
}
// ── Functions ────────────────────────────────────────────────────────────────
export async function followCreator(creatorId: string): Promise<FollowResponse> {
return request<FollowResponse>(`${BASE}/follows/${creatorId}`, { method: "POST" });
}
export async function unfollowCreator(creatorId: string): Promise<FollowResponse> {
return request<FollowResponse>(`${BASE}/follows/${creatorId}`, { method: "DELETE" });
}
export async function getFollowStatus(creatorId: string): Promise<FollowStatusResponse> {
return request<FollowStatusResponse>(`${BASE}/follows/${creatorId}/status`);
}
export async function getMyFollows(): Promise<FollowedCreatorItem[]> {
return request<FollowedCreatorItem[]>(`${BASE}/follows/me`);
}

View file

@ -15,3 +15,4 @@ export * from "./admin-techniques";
export * from "./auth"; export * from "./auth";
export * from "./creator-dashboard"; export * from "./creator-dashboard";
export * from "./consent"; export * from "./consent";
export * from "./follows";

View file

@ -51,6 +51,14 @@ function SidebarNav() {
</svg> </svg>
Settings Settings
</NavLink> </NavLink>
<NavLink to="/creator/tiers" className={linkClass}>
<svg className={styles.sidebarIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
Tiers
</NavLink>
</nav> </nav>
); );
} }

View file

@ -10,8 +10,12 @@ import { Link, useParams } from "react-router-dom";
import { import {
fetchCreator, fetchCreator,
updateCreatorProfile, updateCreatorProfile,
followCreator,
unfollowCreator,
getFollowStatus,
type CreatorDetailResponse, type CreatorDetailResponse,
} from "../api"; } from "../api";
import { useAuth } from "../context/AuthContext";
import CreatorAvatar from "../components/CreatorAvatar"; import CreatorAvatar from "../components/CreatorAvatar";
import { SocialIcon } from "../components/SocialIcons"; import { SocialIcon } from "../components/SocialIcons";
import SortDropdown from "../components/SortDropdown"; import SortDropdown from "../components/SortDropdown";
@ -40,12 +44,18 @@ const PLATFORM_OPTIONS = [
export default function CreatorDetail() { export default function CreatorDetail() {
const { slug } = useParams<{ slug: string }>(); const { slug } = useParams<{ slug: string }>();
const { isAuthenticated } = useAuth();
const [creator, setCreator] = useState<CreatorDetailResponse | null>(null); const [creator, setCreator] = useState<CreatorDetailResponse | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false); const [notFound, setNotFound] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [sort, setSort] = useSortPreference("newest"); const [sort, setSort] = useSortPreference("newest");
// Follow state
const [following, setFollowing] = useState(false);
const [followLoading, setFollowLoading] = useState(false);
const [followerCount, setFollowerCount] = useState(0);
// Edit mode state // Edit mode state
const [editMode, setEditMode] = useState(false); const [editMode, setEditMode] = useState(false);
const [editBio, setEditBio] = useState(""); const [editBio, setEditBio] = useState("");
@ -89,6 +99,41 @@ export default function CreatorDetail() {
}; };
}, [slug]); }, [slug]);
// Check follow status when creator loads and user is authenticated
useEffect(() => {
if (!creator || !isAuthenticated) {
setFollowing(false);
return;
}
setFollowerCount(creator.follower_count);
let cancelled = false;
void (async () => {
try {
const status = await getFollowStatus(creator.id);
if (!cancelled) setFollowing(status.following);
} catch {
// Non-critical — leave as not following
}
})();
return () => { cancelled = true; };
}, [creator?.id, isAuthenticated]); // eslint-disable-line react-hooks/exhaustive-deps
async function handleFollowToggle() {
if (!creator || followLoading) return;
setFollowLoading(true);
try {
const res = following
? await unfollowCreator(creator.id)
: await followCreator(creator.id);
setFollowing(res.followed);
setFollowerCount(res.follower_count);
} catch {
// Silently fail — button state stays as-is
} finally {
setFollowLoading(false);
}
}
function enterEditMode() { function enterEditMode() {
if (!creator) return; if (!creator) return;
setEditBio(creator.bio ?? ""); setEditBio(creator.bio ?? "");
@ -202,13 +247,24 @@ export default function CreatorDetail() {
<div className="creator-hero__name-row"> <div className="creator-hero__name-row">
<h1 className="creator-hero__name">{creator.name}</h1> <h1 className="creator-hero__name">{creator.name}</h1>
{!editMode && ( {!editMode && (
<button <>
className="creator-hero__edit-btn" {isAuthenticated && (
onClick={enterEditMode} <button
title="Edit profile" className={`creator-hero__follow-btn${following ? " creator-hero__follow-btn--following" : ""}`}
> onClick={handleFollowToggle}
Edit disabled={followLoading}
</button> >
{followLoading ? "…" : following ? "Following" : "Follow"}
</button>
)}
<button
className="creator-hero__edit-btn"
onClick={enterEditMode}
title="Edit profile"
>
Edit
</button>
</>
)} )}
</div> </div>
@ -319,6 +375,10 @@ export default function CreatorDetail() {
<span className="creator-detail__stats"> <span className="creator-detail__stats">
{creator.moment_count} moment{creator.moment_count !== 1 ? "s" : ""} {creator.moment_count} moment{creator.moment_count !== 1 ? "s" : ""}
</span> </span>
<span className="creator-detail__stats-sep">·</span>
<span className="creator-detail__stats">
{followerCount} follower{followerCount !== 1 ? "s" : ""}
</span>
{Object.keys(creator.genre_breakdown).length > 0 && ( {Object.keys(creator.genre_breakdown).length > 0 && (
<span className="creator-detail__topic-pills"> <span className="creator-detail__topic-pills">
{Object.entries(creator.genre_breakdown) {Object.entries(creator.genre_breakdown)

View file

@ -0,0 +1,211 @@
.layout {
display: flex;
gap: 2rem;
max-width: 64rem;
margin: 0 auto;
padding: 2rem 1rem;
}
.content {
flex: 1;
min-width: 0;
}
.pageTitle {
font-size: 1.5rem;
font-weight: 700;
margin: 0 0 0.25rem;
color: var(--color-text-primary);
}
.subtitle {
color: var(--color-text-secondary);
font-size: 0.9rem;
margin: 0 0 2rem;
}
/* Tier grid */
.tierGrid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.25rem;
}
@media (max-width: 900px) {
.tierGrid {
grid-template-columns: 1fr;
}
.layout {
flex-direction: column;
gap: 1rem;
}
}
.tierCard {
position: relative;
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 1.5rem;
display: flex;
flex-direction: column;
transition: border-color 0.2s ease;
}
.tierCard:hover {
border-color: var(--color-text-muted);
}
.tierCardActive {
border-color: var(--color-accent);
box-shadow: 0 0 0 1px var(--color-accent-subtle);
}
.activeBadge {
position: absolute;
top: -0.5rem;
right: 1rem;
background: var(--color-accent);
color: var(--color-bg-page);
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.15rem 0.5rem;
border-radius: 10px;
}
.tierName {
font-size: 1.1rem;
font-weight: 700;
color: var(--color-text-primary);
margin: 0 0 0.75rem;
}
.tierPrice {
display: flex;
align-items: baseline;
gap: 0.25rem;
margin-bottom: 0.75rem;
}
.priceAmount {
font-size: 2rem;
font-weight: 800;
color: var(--color-text-primary);
font-variant-numeric: tabular-nums;
}
.pricePeriod {
font-size: 0.85rem;
color: var(--color-text-muted);
}
.tierDescription {
font-size: 0.85rem;
color: var(--color-text-secondary);
line-height: 1.5;
margin: 0 0 1.25rem;
flex: 1;
}
.featureList {
list-style: none;
padding: 0;
margin: 0 0 1.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.featureItem {
font-size: 0.8rem;
color: var(--color-text-secondary);
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.featureCheck {
color: var(--color-accent);
font-weight: 700;
flex-shrink: 0;
}
.tierCta {
width: 100%;
padding: 0.6rem;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
border: none;
}
.tierCtaActive {
background: var(--color-accent-subtle);
color: var(--color-accent);
cursor: default;
}
.tierCtaDisabled {
background: var(--color-bg-surface-hover);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
}
.tierCtaDisabled:hover {
border-color: var(--color-accent);
color: var(--color-accent);
}
/* Coming Soon modal */
.modalOverlay {
position: fixed;
inset: 0;
background: var(--color-overlay);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modalContent {
background: var(--color-bg-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 2rem;
max-width: 24rem;
text-align: center;
}
.modalTitle {
font-size: 1.25rem;
font-weight: 700;
color: var(--color-accent);
margin: 0 0 0.75rem;
}
.modalText {
font-size: 0.85rem;
color: var(--color-text-secondary);
line-height: 1.6;
margin: 0 0 1.5rem;
}
.modalClose {
background: var(--color-accent);
color: var(--color-bg-page);
border: none;
padding: 0.5rem 1.5rem;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s ease;
}
.modalClose:hover {
opacity: 0.85;
}

View file

@ -0,0 +1,117 @@
import { useState } from "react";
import { SidebarNav } from "./CreatorDashboard";
import { useDocumentTitle } from "../hooks/useDocumentTitle";
import styles from "./CreatorTiers.module.css";
const TIERS = [
{
name: "Free",
price: "$0",
period: "forever",
description: "All your techniques, tutorials, and knowledge — free for everyone to discover.",
features: [
"Full technique page listings",
"Search & discovery visibility",
"Creator profile & bio",
"Follower count display",
],
cta: "Current Plan",
active: true,
},
{
name: "Pro",
price: "$9",
period: "/month",
description: "Gated downloads, sample packs, and presets. Monetize your production knowledge.",
features: [
"Everything in Free",
"Gated file downloads",
"Sample pack distribution",
"Preset file hosting",
"Download analytics",
],
cta: "Coming Soon",
active: false,
},
{
name: "Premium",
price: "$29",
period: "/month",
description: "Priority placement, exclusive content, and direct fan engagement tools.",
features: [
"Everything in Pro",
"Priority search placement",
"Exclusive content sections",
"Fan messaging (DMs)",
"Revenue analytics dashboard",
"Custom branding options",
],
cta: "Coming Soon",
active: false,
},
];
export default function CreatorTiers() {
useDocumentTitle("Tier Configuration — Chrysopedia");
const [showModal, setShowModal] = useState(false);
return (
<div className={styles.layout}>
<SidebarNav />
<div className={styles.content}>
<h1 className={styles.pageTitle}>Tier Configuration</h1>
<p className={styles.subtitle}>
Choose how you want to share your knowledge. Payment tiers are coming soon.
</p>
<div className={styles.tierGrid}>
{TIERS.map((tier) => (
<div
key={tier.name}
className={`${styles.tierCard} ${tier.active ? styles.tierCardActive : ""}`}
>
{tier.active && <span className={styles.activeBadge}>Active</span>}
<h2 className={styles.tierName}>{tier.name}</h2>
<div className={styles.tierPrice}>
<span className={styles.priceAmount}>{tier.price}</span>
<span className={styles.pricePeriod}>{tier.period}</span>
</div>
<p className={styles.tierDescription}>{tier.description}</p>
<ul className={styles.featureList}>
{tier.features.map((f) => (
<li key={f} className={styles.featureItem}>
<span className={styles.featureCheck}></span>
{f}
</li>
))}
</ul>
<button
className={`${styles.tierCta} ${tier.active ? styles.tierCtaActive : styles.tierCtaDisabled}`}
onClick={() => { if (!tier.active) setShowModal(true); }}
disabled={tier.active}
>
{tier.cta}
</button>
</div>
))}
</div>
{/* Coming Soon modal */}
{showModal && (
<div className={styles.modalOverlay} onClick={() => setShowModal(false)}>
<div className={styles.modalContent} onClick={(e) => e.stopPropagation()}>
<h3 className={styles.modalTitle}>Coming Soon</h3>
<p className={styles.modalText}>
Paid tiers with file distribution, gated downloads, and premium features
are currently in development. We'll notify you when they're ready.
</p>
<button className={styles.modalClose} onClick={() => setShowModal(false)}>
Got it
</button>
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/admin-pipeline.ts","./src/api/admin-techniques.ts","./src/api/auth.ts","./src/api/chat.ts","./src/api/client.ts","./src/api/consent.ts","./src/api/creator-dashboard.ts","./src/api/creators.ts","./src/api/index.ts","./src/api/reports.ts","./src/api/search.ts","./src/api/stats.ts","./src/api/techniques.ts","./src/api/topics.ts","./src/api/videos.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/AudioWaveform.tsx","./src/components/CategoryIcons.tsx","./src/components/ChapterMarkers.tsx","./src/components/ConfirmModal.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ImpersonationBanner.tsx","./src/components/PlayerControls.tsx","./src/components/ProtectedRoute.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SocialIcons.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/components/ToggleSwitch.tsx","./src/components/TranscriptSidebar.tsx","./src/components/VideoPlayer.tsx","./src/context/AuthContext.tsx","./src/hooks/useCountUp.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useMediaSync.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminAuditLog.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/AdminUsers.tsx","./src/pages/ChapterReview.tsx","./src/pages/ChatPage.tsx","./src/pages/ConsentDashboard.tsx","./src/pages/CreatorDashboard.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorSettings.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/Register.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/pages/WatchPage.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"errors":true,"version":"5.6.3"} {"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/admin-pipeline.ts","./src/api/admin-techniques.ts","./src/api/auth.ts","./src/api/chat.ts","./src/api/client.ts","./src/api/consent.ts","./src/api/creator-dashboard.ts","./src/api/creators.ts","./src/api/follows.ts","./src/api/highlights.ts","./src/api/index.ts","./src/api/reports.ts","./src/api/search.ts","./src/api/stats.ts","./src/api/techniques.ts","./src/api/topics.ts","./src/api/videos.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/AudioWaveform.tsx","./src/components/CategoryIcons.tsx","./src/components/ChapterMarkers.tsx","./src/components/ConfirmModal.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ImpersonationBanner.tsx","./src/components/PlayerControls.tsx","./src/components/ProtectedRoute.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SocialIcons.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/components/ToggleSwitch.tsx","./src/components/TranscriptSidebar.tsx","./src/components/VideoPlayer.tsx","./src/context/AuthContext.tsx","./src/hooks/useCountUp.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useMediaSync.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminAuditLog.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/AdminUsers.tsx","./src/pages/ChapterReview.tsx","./src/pages/ChatPage.tsx","./src/pages/ConsentDashboard.tsx","./src/pages/CreatorDashboard.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorSettings.tsx","./src/pages/CreatorTiers.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/HighlightQueue.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/Register.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/pages/WatchPage.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"version":"5.6.3"}