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:
parent
c9a2d2efbb
commit
19a6ff660c
16 changed files with 688 additions and 10 deletions
31
alembic/versions/022_add_creator_follows.py
Normal file
31
alembic/versions/022_add_creator_follows.py
Normal 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")
|
||||
|
|
@ -12,7 +12,7 @@ from fastapi import FastAPI
|
|||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
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:
|
||||
|
|
@ -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_highlights.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(ingest.router, prefix="/api/v1")
|
||||
app.include_router(pipeline.router, prefix="/api/v1")
|
||||
|
|
|
|||
|
|
@ -737,3 +737,28 @@ class HighlightCandidate(Base):
|
|||
# relationships
|
||||
key_moment: Mapped[KeyMoment] = 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()
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ from sqlalchemy import func, select
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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
|
||||
|
||||
logger = logging.getLogger("chrysopedia.creators")
|
||||
|
|
@ -175,6 +175,13 @@ async def get_creator(
|
|||
genre_breakdown[cat] = genre_breakdown.get(cat, 0) + 1
|
||||
|
||||
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(
|
||||
**creator_data.model_dump(),
|
||||
bio=creator.bio,
|
||||
|
|
@ -182,6 +189,8 @@ async def get_creator(
|
|||
featured=creator.featured,
|
||||
video_count=video_count,
|
||||
technique_count=len(techniques),
|
||||
moment_count=moment_count,
|
||||
techniques=techniques,
|
||||
genre_breakdown=genre_breakdown,
|
||||
follower_count=follower_count,
|
||||
)
|
||||
|
|
|
|||
116
backend/routers/follows.py
Normal file
116
backend/routers/follows.py
Normal 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,
|
||||
)
|
||||
|
|
@ -62,6 +62,7 @@ class CreatorDetail(CreatorRead):
|
|||
video_count: int = 0
|
||||
technique_count: int = 0
|
||||
moment_count: int = 0
|
||||
follower_count: int = 0
|
||||
techniques: list[CreatorTechniqueItem] = []
|
||||
genre_breakdown: dict[str, int] = {}
|
||||
|
||||
|
|
@ -705,3 +706,28 @@ class ChapterReorderRequest(BaseModel):
|
|||
class ChapterBulkApproveRequest(BaseModel):
|
||||
"""Bulk-approve chapters by IDs."""
|
||||
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
|
||||
|
|
|
|||
|
|
@ -2483,6 +2483,37 @@ a.app-footer__repo:hover {
|
|||
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 {
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ const AdminAuditLog = React.lazy(() => import("./pages/AdminAuditLog"));
|
|||
const ChatPage = React.lazy(() => import("./pages/ChatPage"));
|
||||
const ChapterReview = React.lazy(() => import("./pages/ChapterReview"));
|
||||
const HighlightQueue = React.lazy(() => import("./pages/HighlightQueue"));
|
||||
const CreatorTiers = React.lazy(() => import("./pages/CreatorTiers"));
|
||||
import AdminDropdown from "./components/AdminDropdown";
|
||||
import ImpersonationBanner from "./components/ImpersonationBanner";
|
||||
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/:videoId" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><ChapterReview /></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 */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export interface CreatorDetailResponse {
|
|||
avatar_url: string | null;
|
||||
technique_count: number;
|
||||
moment_count: number;
|
||||
follower_count: number;
|
||||
techniques: CreatorTechniqueItem[];
|
||||
genre_breakdown: Record<string, number>;
|
||||
}
|
||||
|
|
|
|||
39
frontend/src/api/follows.ts
Normal file
39
frontend/src/api/follows.ts
Normal 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`);
|
||||
}
|
||||
|
|
@ -15,3 +15,4 @@ export * from "./admin-techniques";
|
|||
export * from "./auth";
|
||||
export * from "./creator-dashboard";
|
||||
export * from "./consent";
|
||||
export * from "./follows";
|
||||
|
|
|
|||
|
|
@ -51,6 +51,14 @@ function SidebarNav() {
|
|||
</svg>
|
||||
Settings
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,12 @@ import { Link, useParams } from "react-router-dom";
|
|||
import {
|
||||
fetchCreator,
|
||||
updateCreatorProfile,
|
||||
followCreator,
|
||||
unfollowCreator,
|
||||
getFollowStatus,
|
||||
type CreatorDetailResponse,
|
||||
} from "../api";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import CreatorAvatar from "../components/CreatorAvatar";
|
||||
import { SocialIcon } from "../components/SocialIcons";
|
||||
import SortDropdown from "../components/SortDropdown";
|
||||
|
|
@ -40,12 +44,18 @@ const PLATFORM_OPTIONS = [
|
|||
|
||||
export default function CreatorDetail() {
|
||||
const { slug } = useParams<{ slug: string }>();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [creator, setCreator] = useState<CreatorDetailResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
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
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [editBio, setEditBio] = useState("");
|
||||
|
|
@ -89,6 +99,41 @@ export default function CreatorDetail() {
|
|||
};
|
||||
}, [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() {
|
||||
if (!creator) return;
|
||||
setEditBio(creator.bio ?? "");
|
||||
|
|
@ -202,13 +247,24 @@ export default function CreatorDetail() {
|
|||
<div className="creator-hero__name-row">
|
||||
<h1 className="creator-hero__name">{creator.name}</h1>
|
||||
{!editMode && (
|
||||
<button
|
||||
className="creator-hero__edit-btn"
|
||||
onClick={enterEditMode}
|
||||
title="Edit profile"
|
||||
>
|
||||
✎ Edit
|
||||
</button>
|
||||
<>
|
||||
{isAuthenticated && (
|
||||
<button
|
||||
className={`creator-hero__follow-btn${following ? " creator-hero__follow-btn--following" : ""}`}
|
||||
onClick={handleFollowToggle}
|
||||
disabled={followLoading}
|
||||
>
|
||||
{followLoading ? "…" : following ? "Following" : "Follow"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="creator-hero__edit-btn"
|
||||
onClick={enterEditMode}
|
||||
title="Edit profile"
|
||||
>
|
||||
✎ Edit
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -319,6 +375,10 @@ export default function CreatorDetail() {
|
|||
<span className="creator-detail__stats">
|
||||
{creator.moment_count} moment{creator.moment_count !== 1 ? "s" : ""}
|
||||
</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 && (
|
||||
<span className="creator-detail__topic-pills">
|
||||
{Object.entries(creator.genre_breakdown)
|
||||
|
|
|
|||
211
frontend/src/pages/CreatorTiers.module.css
Normal file
211
frontend/src/pages/CreatorTiers.module.css
Normal 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;
|
||||
}
|
||||
117
frontend/src/pages/CreatorTiers.tsx
Normal file
117
frontend/src/pages/CreatorTiers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"}
|
||||
Loading…
Add table
Reference in a new issue