diff --git a/.gitignore b/.gitignore index 7055e8c..0a2da86 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ services/api/.data/ # E2E artifacts playwright-report/ test-results/ + +# Local MCP config +mcp.toml diff --git a/apps/admin/package.json b/apps/admin/package.json index 7736308..31dbd96 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -12,6 +12,7 @@ "typecheck": "echo 'admin typecheck not configured yet'" }, "dependencies": { + "@flix/design-system": "0.1.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.9.4" diff --git a/apps/admin/src/App.jsx b/apps/admin/src/App.jsx index 1df1285..b08eaf1 100644 --- a/apps/admin/src/App.jsx +++ b/apps/admin/src/App.jsx @@ -1,14 +1,24 @@ import { Navigate, Route, Routes } from 'react-router-dom'; import { ProtectedRoute } from './routes/ProtectedRoute.jsx'; import { LoginPage } from './pages/LoginPage.jsx'; -import { DashboardPage } from './pages/DashboardPage.jsx'; +import { DashboardHomePage } from './pages/DashboardHomePage.jsx'; +import { EventsPage } from './pages/EventsPage.jsx'; +import { NewEventPage } from './pages/NewEventPage.jsx'; +import { EditEventPage } from './pages/EditEventPage.jsx'; +import { LessonsPage } from './pages/LessonsPage.jsx'; +import { QuizzesPage } from './pages/QuizzesPage.jsx'; export const App = () => ( } /> }> - } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> diff --git a/apps/admin/src/main.jsx b/apps/admin/src/main.jsx index a4b4c16..5c9e56c 100644 --- a/apps/admin/src/main.jsx +++ b/apps/admin/src/main.jsx @@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; import { App } from './App.jsx'; import { AuthProvider } from './auth/AuthContext.jsx'; +import '@flix/design-system/tokens/theme.css'; import './styles.css'; ReactDOM.createRoot(document.getElementById('root')).render( diff --git a/apps/admin/src/pages/DashboardHomePage.jsx b/apps/admin/src/pages/DashboardHomePage.jsx new file mode 100644 index 0000000..b239a2b --- /dev/null +++ b/apps/admin/src/pages/DashboardHomePage.jsx @@ -0,0 +1,3 @@ +import { DashboardPage } from './DashboardPage.jsx'; + +export const DashboardHomePage = () => ; diff --git a/apps/admin/src/pages/DashboardPage.jsx b/apps/admin/src/pages/DashboardPage.jsx index 415734e..2794be8 100644 --- a/apps/admin/src/pages/DashboardPage.jsx +++ b/apps/admin/src/pages/DashboardPage.jsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; +import { NavLink, useNavigate, useParams } from 'react-router-dom'; import { useAuth } from '../auth/AuthContext.jsx'; import { createEvent, @@ -95,7 +96,18 @@ const buildEventUpdatePayload = (form) => ({ logoUrl: form.logoUrl ? form.logoUrl : null, }); -export const DashboardPage = () => { +const pageTitles = { + dashboard: 'Dashboard', + eventos: 'Eventos', + 'eventos-new': 'Eventos / Novo', + 'eventos-edit': 'Eventos / Editar', + aulas: 'Aulas', + quizzes: 'Quizzes', +}; + +export const DashboardPage = ({ section = 'dashboard' }) => { + const navigate = useNavigate(); + const { id: routeEventId } = useParams(); const { session, logout } = useAuth(); const token = session?.accessToken; @@ -119,6 +131,10 @@ export const DashboardPage = () => { const [status, setStatus] = useState(''); const [error, setError] = useState(''); + const isEventosSection = ['eventos', 'eventos-new', 'eventos-edit'].includes(section); + const isEventCreateMode = section === 'eventos-new'; + const isEventEditMode = section === 'eventos-edit'; + const selectedEvent = useMemo( () => events.find((event) => event.id === selectedEventId) ?? null, [events, selectedEventId], @@ -248,6 +264,26 @@ export const DashboardPage = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedEventId, selectedLessonId]); + useEffect(() => { + if (isEventCreateMode) { + setSelectedEventId(''); + setEventForm(defaultEventForm); + setBrandingPreview(null); + setBrandingRollback(null); + setBrandingError(''); + return; + } + + if (isEventEditMode && routeEventId) { + setSelectedEventId(routeEventId); + } + }, [isEventCreateMode, isEventEditMode, routeEventId]); + + useEffect(() => { + if (!isEventEditMode || !selectedEvent) return; + setEventForm(mapEventToForm(selectedEvent)); + }, [isEventEditMode, selectedEvent]); + const handleCreateEvent = async (event) => { event.preventDefault(); resetFeedback(); @@ -700,14 +736,29 @@ export const DashboardPage = () => {
-

Admin Content Operations

-

Manage events, lessons, materials, quizzes, and branding from one place.

+

{pageTitles[section] ?? 'Admin Content Operations'}

+

Fluxo administrativo orientado por rotas do workflow oficial.

+ +

Runtime

@@ -726,7 +777,31 @@ export const DashboardPage = () => {
-
+ {section === 'dashboard' ? ( +
+
+

Command Center

+

Use o menu para operar cada etapa com foco por contexto.

+
+ Abrir Eventos + Abrir Aulas + Abrir Quizzes +
+
+
+

Selecao Atual

+

+ Evento: {selectedEvent?.title ?? 'Nenhum'} +

+

+ Aula: {selectedLesson?.title ?? 'Nenhuma'} +

+
+
+ ) : null} + + {isEventosSection ? ( +

Events + Branding

@@ -919,26 +994,48 @@ export const DashboardPage = () => { )}
- - - - + {isEventCreateMode ? ( + <> + + + + ) : ( + <> + + + + + + )}
+
+ Novo evento + Painel eventos + {selectedEventId ? Editar selecionado : null} +
+
    {events.map((item) => (
  • @@ -946,7 +1043,29 @@ export const DashboardPage = () => { ))}
+
+

Selected Event Context

+ {selectedEvent ? ( +
+

+ {selectedEvent.title} +

+

Slug: {selectedEvent.slug}

+

Visibility: {selectedEvent.visibility}

+
+ ) : ( +

+ {isEventEditMode + ? 'Select an event or use a valid /eventos/:id/editar URL.' + : 'Select an event to continue with lesson and quiz flows.'} +

+ )} +
+
+ ) : null} + {section === 'aulas' ? ( +

Lessons

@@ -1025,9 +1144,7 @@ export const DashboardPage = () => { ))}
-
-

Materials

@@ -1076,7 +1193,11 @@ export const DashboardPage = () => { ))}
+
+ ) : null} + {section === 'quizzes' ? ( +

Quizzes

@@ -1146,7 +1267,21 @@ export const DashboardPage = () => { ) : null}
+
+

Lesson Context

+ {selectedLesson ? ( +
+

+ {selectedLesson.title} +

+

Slug: {selectedLesson.slug}

+
+ ) : ( +

Select event and lesson in /aulas before creating quizzes.

+ )} +
+ ) : null}
); }; diff --git a/apps/admin/src/pages/EditEventPage.jsx b/apps/admin/src/pages/EditEventPage.jsx new file mode 100644 index 0000000..c7df770 --- /dev/null +++ b/apps/admin/src/pages/EditEventPage.jsx @@ -0,0 +1,3 @@ +import { DashboardPage } from './DashboardPage.jsx'; + +export const EditEventPage = () => ; diff --git a/apps/admin/src/pages/EventsPage.jsx b/apps/admin/src/pages/EventsPage.jsx new file mode 100644 index 0000000..8aa8a2c --- /dev/null +++ b/apps/admin/src/pages/EventsPage.jsx @@ -0,0 +1,3 @@ +import { DashboardPage } from './DashboardPage.jsx'; + +export const EventsPage = () => ; diff --git a/apps/admin/src/pages/LessonsPage.jsx b/apps/admin/src/pages/LessonsPage.jsx new file mode 100644 index 0000000..0714309 --- /dev/null +++ b/apps/admin/src/pages/LessonsPage.jsx @@ -0,0 +1,3 @@ +import { DashboardPage } from './DashboardPage.jsx'; + +export const LessonsPage = () => ; diff --git a/apps/admin/src/pages/LoginPage.jsx b/apps/admin/src/pages/LoginPage.jsx index e96919d..f40c5fb 100644 --- a/apps/admin/src/pages/LoginPage.jsx +++ b/apps/admin/src/pages/LoginPage.jsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { useAuth } from '../auth/AuthContext.jsx'; +import { SignInCompletePattern } from '@flix/design-system/components'; export const LoginPage = () => { const navigate = useNavigate(); @@ -8,6 +9,7 @@ export const LoginPage = () => { const { login, isAuthenticated } = useAuth(); const [username, setUsername] = useState('admin'); const [password, setPassword] = useState('admin123'); + const [rememberMe, setRememberMe] = useState(false); const [error, setError] = useState(''); const [submitting, setSubmitting] = useState(false); @@ -15,8 +17,7 @@ export const LoginPage = () => { navigate('/dashboard', { replace: true }); } - const onSubmit = async (event) => { - event.preventDefault(); + const submitLogin = async () => { setSubmitting(true); setError(''); @@ -32,33 +33,32 @@ export const LoginPage = () => { navigate(from && from !== '/login' ? from : '/dashboard', { replace: true }); }; + const onSubmit = async (event) => { + event.preventDefault(); + await submitLogin(); + }; + return (
-

Flix Admin

-

Sign in to manage events, lessons, and operations.

- - - - - + {error ?

{error}

: null} - -
diff --git a/apps/admin/src/pages/NewEventPage.jsx b/apps/admin/src/pages/NewEventPage.jsx new file mode 100644 index 0000000..7a1c19c --- /dev/null +++ b/apps/admin/src/pages/NewEventPage.jsx @@ -0,0 +1,3 @@ +import { DashboardPage } from './DashboardPage.jsx'; + +export const NewEventPage = () => ; diff --git a/apps/admin/src/pages/QuizzesPage.jsx b/apps/admin/src/pages/QuizzesPage.jsx new file mode 100644 index 0000000..d5f260f --- /dev/null +++ b/apps/admin/src/pages/QuizzesPage.jsx @@ -0,0 +1,3 @@ +import { DashboardPage } from './DashboardPage.jsx'; + +export const QuizzesPage = () => ; diff --git a/apps/admin/src/styles.css b/apps/admin/src/styles.css index 7c72253..a7c6242 100644 --- a/apps/admin/src/styles.css +++ b/apps/admin/src/styles.css @@ -1,8 +1,8 @@ :root { - color-scheme: light; - font-family: "Space Grotesk", "Segoe UI", sans-serif; - background: radial-gradient(circle at top, #dbeafe 0%, #eff6ff 48%, #ffffff 100%); - color: #0f172a; + color-scheme: dark; + font-family: var(--fx-font-sans); + background: var(--fx-color-bg-primary); + color: var(--fx-color-text-primary); } * { @@ -12,12 +12,17 @@ body { margin: 0; min-height: 100vh; + background: + radial-gradient(circle at 10% -10%, rgb(229 9 20 / 20%), transparent 45%), + radial-gradient(circle at 100% 0%, rgb(255 255 255 / 6%), transparent 40%), + var(--fx-color-bg-primary); + color: var(--fx-color-text-primary); } .auth-layout, .dashboard-layout { min-height: 100vh; - padding: 1.5rem; + padding: var(--fx-space-6); } .auth-layout { @@ -27,11 +32,11 @@ body { .auth-card { width: min(420px, 100%); - background: #ffffff; - border: 1px solid #cbd5e1; - border-radius: 14px; - padding: 1.5rem; - box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08); + background: var(--fx-color-bg-elevated); + border: 1px solid var(--fx-color-border-default); + border-radius: var(--fx-radius-xl); + padding: var(--fx-space-6); + box-shadow: 0 12px 30px rgb(0 0 0 / 30%); } .auth-card h1 { @@ -40,39 +45,46 @@ body { } .auth-card p { - margin: 0.6rem 0 1.2rem; - color: #475569; + margin: var(--fx-space-3) 0 var(--fx-space-5); + color: var(--fx-color-text-secondary); } .auth-form { display: grid; - gap: 0.9rem; + gap: var(--fx-space-4); } .auth-form label { display: grid; - gap: 0.35rem; + gap: var(--fx-space-2); font-size: 0.9rem; - color: #334155; + color: var(--fx-color-text-secondary); } .auth-form input, .stack-form input, .stack-form select, .stack-form textarea { - border: 1px solid #94a3b8; - border-radius: 8px; - padding: 0.65rem 0.75rem; + border: 1px solid var(--fx-color-border-strong); + border-radius: var(--fx-radius-lg); + background: transparent; + color: var(--fx-color-text-primary); + padding: var(--fx-space-3) var(--fx-space-4); font: inherit; } button { border: 0; - border-radius: 9px; - background: #0ea5e9; - color: #f8fafc; - padding: 0.62rem 0.84rem; + border-radius: var(--fx-radius-lg); + background: var(--fx-color-brand-primary); + color: var(--fx-color-text-primary); + padding: var(--fx-space-3) var(--fx-space-4); cursor: pointer; + font-weight: 500; +} + +button:hover { + background: var(--fx-color-brand-hover); } button:disabled { @@ -81,19 +93,19 @@ button:disabled { } button.danger { - background: #dc2626; + background: var(--fx-color-error); } .auth-error, .feedback-error { margin: 0; - color: #dc2626; + color: var(--fx-color-error); font-size: 0.9rem; } .feedback-ok { margin: 0; - color: #15803d; + color: var(--fx-color-success); font-size: 0.9rem; } @@ -101,29 +113,49 @@ button.danger { max-width: 1200px; margin: 0 auto; display: grid; - gap: 1rem; + gap: var(--fx-space-4); } .dashboard-header { display: flex; justify-content: space-between; align-items: flex-start; - gap: 1rem; + gap: var(--fx-space-4); } .dashboard-header h1 { margin: 0; - font-size: 1.8rem; + font-family: var(--fx-font-display); + font-size: var(--fx-text-display); + line-height: 1; + letter-spacing: 0.5px; + text-transform: uppercase; } .dashboard-header p { - margin: 0.35rem 0 0; - color: #334155; + margin: var(--fx-space-2) 0 0; + color: var(--fx-color-text-secondary); +} + +.admin-nav { + display: flex; + flex-wrap: wrap; + gap: var(--fx-space-2); +} + +.admin-nav a { + border: 1px solid var(--fx-color-border-default); + background: var(--fx-color-bg-card); +} + +.admin-nav a.active { + border-color: var(--fx-color-brand-primary); + background: var(--fx-color-brand-primary); } .dashboard-grid { display: grid; - gap: 1rem; + gap: var(--fx-space-4); } .dashboard-grid.two-col { @@ -131,10 +163,10 @@ button.danger { } .dashboard-grid article { - background: #ffffff; - border: 1px solid #cbd5e1; - border-radius: 12px; - padding: 1rem; + background: var(--fx-color-bg-elevated); + border: 1px solid var(--fx-color-border-default); + border-radius: var(--fx-radius-xl); + padding: var(--fx-space-4); } .dashboard-grid h2, @@ -144,25 +176,25 @@ button.danger { .stack-form { display: grid; - gap: 0.7rem; - margin-bottom: 0.9rem; + gap: var(--fx-space-3); + margin-bottom: var(--fx-space-4); } .inline-fields { display: grid; - gap: 0.6rem; + gap: var(--fx-space-3); grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); } .inline-check { display: inline-flex; align-items: center; - gap: 0.4rem; + gap: var(--fx-space-2); } .inline-actions { display: flex; - gap: 0.5rem; + gap: var(--fx-space-2); flex-wrap: wrap; } @@ -172,44 +204,48 @@ button.danger { padding: 0; list-style: none; display: grid; - gap: 0.4rem; + gap: var(--fx-space-2); } .select-list button { width: 100%; text-align: left; - background: #e2e8f0; - color: #0f172a; + background: var(--fx-color-bg-card); + color: var(--fx-color-text-primary); + border: 1px solid var(--fx-color-border-default); } .select-list button.active { - background: #0284c7; - color: #f8fafc; + background: var(--fx-color-brand-primary); + color: var(--fx-color-text-primary); } .flat-list li { display: flex; justify-content: space-between; - gap: 1rem; - border: 1px solid #dbe4ee; - border-radius: 8px; - padding: 0.5rem 0.6rem; + gap: var(--fx-space-4); + border: 1px solid var(--fx-color-border-default); + border-radius: var(--fx-radius-lg); + background: var(--fx-color-bg-card); + padding: var(--fx-space-3); } .payload-preview { - border: 1px dashed #94a3b8; - background: #f8fafc; - border-radius: 8px; - padding: 0.65rem; + border: 1px dashed var(--fx-color-border-strong); + background: rgb(255 255 255 / 3%); + border-radius: var(--fx-radius-lg); + padding: var(--fx-space-3); } .branding-preview { - border: 1px dashed #60a5fa; - background: #eff6ff; - border-radius: 10px; - padding: 0.75rem; + border: 1px dashed var(--fx-color-border-strong); + background: + linear-gradient(180deg, rgb(0 0 0 / 48%) 0%, rgb(0 0 0 / 78%) 100%), + rgb(255 255 255 / 4%); + border-radius: var(--fx-radius-xl); + padding: var(--fx-space-4); display: grid; - gap: 0.35rem; + gap: var(--fx-space-2); } .branding-preview p { @@ -220,26 +256,27 @@ button.danger { display: inline-block; width: 16px; height: 16px; - border: 1px solid #1e293b; + border: 1px solid var(--fx-color-border-default); border-radius: 4px; - margin-left: 0.45rem; + margin-left: var(--fx-space-2); vertical-align: middle; } .muted { margin: 0; - color: #64748b; + color: var(--fx-color-text-tertiary); font-size: 0.9rem; } code { font-family: "JetBrains Mono", monospace; + color: var(--fx-color-text-secondary); } @media (max-width: 640px) { .auth-layout, .dashboard-layout { - padding: 1rem; + padding: var(--fx-space-4); } .dashboard-header { diff --git a/apps/web/package.json b/apps/web/package.json index 9967f60..d57e514 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,7 @@ "typecheck": "echo 'web typecheck not configured yet'" }, "dependencies": { + "@flix/design-system": "0.1.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.9.4" diff --git a/apps/web/src/App.jsx b/apps/web/src/App.jsx index ed26b15..82ce830 100644 --- a/apps/web/src/App.jsx +++ b/apps/web/src/App.jsx @@ -1,9 +1,11 @@ import { Navigate, Route, Routes } from 'react-router-dom'; import { CatalogPage } from './pages/CatalogPage.jsx'; +import { DesignSystemShowcasePage } from './pages/DesignSystemShowcasePage.jsx'; import { PlaybackPage } from './pages/PlaybackPage.jsx'; export const App = () => ( + } /> } /> } /> } /> diff --git a/apps/web/src/main.jsx b/apps/web/src/main.jsx index c74f1c7..1c204a5 100644 --- a/apps/web/src/main.jsx +++ b/apps/web/src/main.jsx @@ -2,6 +2,7 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; import { App } from './App.jsx'; +import '@flix/design-system/tokens/theme.css'; import './styles.css'; ReactDOM.createRoot(document.getElementById('root')).render( diff --git a/apps/web/src/pages/CatalogPage.jsx b/apps/web/src/pages/CatalogPage.jsx index 404d584..110b710 100644 --- a/apps/web/src/pages/CatalogPage.jsx +++ b/apps/web/src/pages/CatalogPage.jsx @@ -1,15 +1,10 @@ import { useEffect, useMemo, useState } from 'react'; import { Link, useParams } from 'react-router-dom'; +import { Card, LearnerCatalogPage, Text } from '@flix/design-system/components'; import { fetchCatalog } from '../services/api.js'; const eventKey = (eventSlug) => `flix.web.eventKey.${eventSlug}`; -const statusLabel = { - released: 'Released', - locked: 'Locked', - expired: 'Expired', -}; - export const CatalogPage = () => { const { eventSlug } = useParams(); const [catalog, setCatalog] = useState(null); @@ -19,7 +14,14 @@ export const CatalogPage = () => { const [loading, setLoading] = useState(true); const [error, setError] = useState(''); - const isPrivateBlocked = useMemo(() => error.includes('Private event access is required'), [error]); + const releasedLessons = useMemo( + () => catalog?.catalog?.items?.filter((lesson) => lesson.status === 'released') ?? [], + [catalog], + ); + const gatedLessons = useMemo( + () => catalog?.catalog?.items?.filter((lesson) => lesson.status !== 'released') ?? [], + [catalog], + ); const loadCatalog = async () => { setLoading(true); @@ -45,48 +47,43 @@ export const CatalogPage = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [eventSlug]); - return ( -
-
-

Flix Learner

-

Event: {eventSlug}

-
+ const releasedItems = releasedLessons.map((lesson) => ({ + id: lesson.id, + title: lesson.title, + status: lesson.status, + action: Open lesson, + })); -
-

Catalog Access

-
- setAccessKey(event.target.value)} - /> - -
- {isPrivateBlocked ?

Private event key required.

: null} - {error && !isPrivateBlocked ?

{error}

: null} -
+ const gatedItems = gatedLessons.map((lesson) => ({ + id: lesson.id, + title: lesson.title, + status: lesson.status, + action: Details, + })); - {catalog ? ( -
-

{catalog.event.title}

-

{catalog.event.description || 'No description'}

-
    - {catalog.catalog.items.map((lesson) => ( -
  • -
    - {lesson.title} -

    - Status: {statusLabel[lesson.status]} -

    -
    - Open lesson -
  • - ))} -
-
+ return ( + <> + {error ? ( +
+ + + {error} + + +
) : null} -
+ + + ); }; diff --git a/apps/web/src/pages/DesignSystemShowcasePage.jsx b/apps/web/src/pages/DesignSystemShowcasePage.jsx new file mode 100644 index 0000000..79abbc1 --- /dev/null +++ b/apps/web/src/pages/DesignSystemShowcasePage.jsx @@ -0,0 +1,177 @@ +import { + Card, + HomeHero, + HeroMobile, + ProfileMenuPattern, + Text, + UserProfilesPattern, + VideoPlayerChangingSoundPattern, + VideoPlayerEpisodesPreviewPattern, + VideoPlayerNextEpisodePattern, + VideoPlayerPattern, + VideoPlayerPlaybackSpeedPattern, + VideoPlayerScrollPreviewPattern, + VideoPlayerSubtitlesPattern, +} from '@flix/design-system/components'; + +const controlLabels = { + play: 'Play', + back10: 'Back 10 seconds', + forward10: 'Forward 10 seconds', + sound: 'Sound', + nextEpisode: 'Next episode', + listOfEpisodes: 'Episode list', + subtitles: 'Subtitles', + speed: 'Playback speed', + fullScreen: 'Full screen', +}; + +const episodeItems = [ + { + id: 'ep-1', + title: 'Chapter 1', + episodeLabel: 'Episode 1', + description: 'An opening with high tension and a fast pace.', + artworkUrl: 'https://images.unsplash.com/photo-1485846234645-a62644f84728?auto=format&fit=crop&w=1200&q=80', + playLabel: 'Play chapter 1', + }, + { + id: 'ep-2', + title: 'Chapter 2', + episodeLabel: 'Episode 2', + description: 'The conflict expands and the team reacts.', + artworkUrl: 'https://images.unsplash.com/photo-1517602302552-471fe67acf66?auto=format&fit=crop&w=1200&q=80', + playLabel: 'Play chapter 2', + }, +]; + +const playerProps = { + backdropImageUrl: 'https://images.unsplash.com/photo-1489515217757-5fd1be406fef?auto=format&fit=crop&w=1600&q=80', + progress: { + elapsedLabel: '15:52', + remainingLabel: '-27:18', + progressPercent: 64, + bufferPercent: 70, + markerPercent: 64, + }, + controlLabels, +}; + +export const DesignSystemShowcasePage = () => ( +
+ + Design System Showcase + + Página de referência para tokens, atoms, molecules, organisms e patterns implementados. + + + +
+ Hero Variants + + +
+ +
+ User Profile + + + +
+ +
+ Video Player Patterns + + + + + + + +
+
+); diff --git a/apps/web/src/pages/PlaybackPage.jsx b/apps/web/src/pages/PlaybackPage.jsx index 5b6c2f5..921d323 100644 --- a/apps/web/src/pages/PlaybackPage.jsx +++ b/apps/web/src/pages/PlaybackPage.jsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react'; import { Link, useParams } from 'react-router-dom'; +import { AccessKeyForm, Card, LearnerPlaybackPage, Text } from '@flix/design-system/components'; import { checkAccess, fetchMaterials, fetchPlayback, fetchQuiz, submitQuiz } from '../services/api.js'; const eventKey = (eventSlug) => `flix.web.eventKey.${eventSlug}`; @@ -62,8 +63,7 @@ export const PlaybackPage = () => { throw playbackOutcome.reason; } - const playbackResult = playbackOutcome.value; - setPlayback(playbackResult); + setPlayback(playbackOutcome.value); if (materialsOutcome.status === 'fulfilled') { setMaterials(materialsOutcome.value.items ?? []); @@ -74,12 +74,10 @@ export const PlaybackPage = () => { if (quizOutcome.status === 'fulfilled') { setQuiz(quizOutcome.value.item ?? null); + } else if (quizOutcome.reason?.status === 404 && quizOutcome.reason?.code === 'QUIZ_NOT_FOUND') { + setQuiz(null); } else { - if (quizOutcome.reason?.status === 404 && quizOutcome.reason?.code === 'QUIZ_NOT_FOUND') { - setQuiz(null); - } else { - setQuizError(quizOutcome.reason?.message ?? 'Failed to load quiz'); - } + setQuizError(quizOutcome.reason?.message ?? 'Failed to load quiz'); } if (accessKey) { @@ -139,149 +137,121 @@ export const PlaybackPage = () => { ? quiz.questions.filter((question) => Boolean(selectedAnswers[question.id])).length : 0; - return ( -
-
-

Lesson Playback

-

- Back to catalog -

-
+ const materialsNode = ( +
+ {loading ? Loading materials... : null} + {materialsError ? {materialsError} : null} + {!loading && !materialsError && Array.isArray(materials) && materials.length === 0 ? ( + No materials available for this lesson. + ) : null} -
-
- setAccessKey(event.target.value)} - /> - -
+ {!loading && Array.isArray(materials) && materials.length > 0 ? ( +
    + {materials.map((item) => ( +
  • +
    + {item.fileName} +

    + {item.mimeType} • {Math.round(item.sizeBytes / 1024)} KB +

    +
    + + Download + +
  • + ))} +
+ ) : null} +
+ ); - {error ?

{error}

: null} - {statusText ?

{statusText}

: null} + const quizNode = ( +
+ {loading ? Loading quiz... : null} + {quizError ? {quizError} : null} + {!loading && !quizError && !quiz ? No quiz available for this lesson. : null} - {access?.timing ? ( + {!loading && quiz && !quizResult ? ( +

- Release at: {access.timing.releaseAt} + {quiz.title} +

+

+ Answered {answeredQuestions}/{quiz.questions.length}

- ) : null} - - - {playback ? ( - <> -
-

{playback.lesson.title}

-

- Provider: {playback.player.provider} -

-

- Embed URL: {playback.player.embedUrl} -

- -
- {playback.navigation.previous ? ( - Previous - ) : ( - No previous lesson - )} - - {playback.navigation.next ? ( - Next - ) : ( - No next lesson - )} -
-
- -
-

Lesson Materials

- {loading ?

Loading materials...

: null} - {materialsError ?

{materialsError}

: null} - {!loading && !materialsError && Array.isArray(materials) && materials.length === 0 ? ( -

No materials available for this lesson.

- ) : null} - - {!loading && Array.isArray(materials) && materials.length > 0 ? ( -
    - {materials.map((item) => ( -
  • -
    - {item.fileName} -

    - {item.mimeType} • {Math.round(item.sizeBytes / 1024)} KB -

    -
    - - Download - -
  • - ))} -
- ) : null} -
- -
-

Lesson Quiz

- {loading ?

Loading quiz...

: null} - {quizError ?

{quizError}

: null} - {!loading && !quizError && !quiz ?

No quiz available for this lesson.

: null} - {!loading && quiz && !quizResult ? ( -
-

- {quiz.title} -

-

- Answered {answeredQuestions}/{quiz.questions.length} -

+ {quiz.questions.map((question) => ( +
+ {question.prompt} + {question.options.map((option) => ( + + ))} +
+ ))} + + {submitError ?

{submitError}

: null} + +
+ ) : null} - {quiz.questions.map((question) => ( -
- {question.prompt} - {question.options.map((option) => ( - - ))} -
- ))} + {quizResult ? ( +
+

+ Result: {quizResult.status === 'passed' ? 'Passed' : 'Failed'} +

+

+ Score: {quizResult.scorePercentage}% ({quizResult.correctAnswers}/ + {quizResult.totalQuestions} correct) +

+

+ Required pass score: {quizResult.passPercentage}% • Submitted at {quizResult.submittedAt} +

+ +
+ ) : null} +
+ ); - {submitError ?

{submitError}

: null} - -
- ) : null} + return ( +
+
+ + Lesson Playback + + Back to catalog + + + + + {error ? {error} : null} + {statusText ? {statusText} : null} + {access?.timing ? ( + Release at: {access.timing.releaseAt} + ) : null} + +
- {quizResult ? ( -
-

- Result: {quizResult.status === 'passed' ? 'Passed' : 'Failed'} -

-

- Score: {quizResult.scorePercentage}% ({quizResult.correctAnswers}/ - {quizResult.totalQuestions} correct) -

-

- Required pass score: {quizResult.passPercentage}% • Submitted at {quizResult.submittedAt} -

- -
- ) : null} - - + {playback ? ( + ) : null}
); diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 5ae7cd1..c08b09b 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -1,8 +1,8 @@ :root { - color-scheme: light; - font-family: "Space Grotesk", "Segoe UI", sans-serif; - background: radial-gradient(circle at top, #fef3c7 0%, #fffbeb 48%, #ffffff 100%); - color: #1f2937; + color-scheme: dark; + font-family: var(--fx-font-sans); + background: var(--fx-color-bg-primary); + color: var(--fx-color-text-primary); } * { box-sizing: border-box; } @@ -10,51 +10,106 @@ body { margin: 0; min-height: 100vh; + background: + radial-gradient(circle at 15% -20%, rgb(229 9 20 / 24%), transparent 55%), + radial-gradient(circle at 90% 0%, rgb(255 255 255 / 8%), transparent 35%), + var(--fx-color-bg-primary); + color: var(--fx-color-text-primary); } .web-layout { - max-width: 980px; + max-width: 1100px; margin: 0 auto; - padding: 1rem; + padding: var(--fx-space-6); display: grid; - gap: 0.9rem; + gap: var(--fx-space-4); } .web-header h1 { margin: 0; + font-family: var(--fx-font-display); + font-size: var(--fx-text-display); + line-height: 1; + letter-spacing: 0.5px; + text-transform: uppercase; + color: var(--fx-color-text-primary); +} + +.web-header p { + margin: var(--fx-space-2) 0 0; + color: var(--fx-color-text-secondary); } .panel { - background: #ffffff; - border: 1px solid #d1d5db; - border-radius: 12px; - padding: 1rem; + background: var(--fx-color-bg-elevated); + border: 1px solid var(--fx-color-border-default); + border-radius: var(--fx-radius-xl); + padding: var(--fx-space-5); } .panel h3 { margin-top: 0; + color: var(--fx-color-text-primary); +} + +.panel h2 { + margin-top: 0; + color: var(--fx-color-text-primary); +} + +.hero-panel { + border: 1px solid var(--fx-color-border-default); + border-radius: var(--fx-radius-xl); + padding: var(--fx-space-8) var(--fx-space-6); + background: + linear-gradient(110deg, rgb(0 0 0 / 70%) 0%, rgb(0 0 0 / 40%) 60%), + radial-gradient(circle at 80% 20%, rgb(229 9 20 / 35%) 0%, transparent 55%), + var(--fx-color-bg-elevated); +} + +.hero-panel h2 { + margin: var(--fx-space-2) 0; + font-size: var(--fx-text-title); + color: var(--fx-color-text-primary); +} + +.hero-eyebrow { + margin: 0; + color: var(--fx-color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.7px; + font-size: var(--fx-text-caption); } .inline-fields { display: grid; - gap: 0.5rem; + gap: var(--fx-space-2); grid-template-columns: 1fr auto; } .inline-fields input { - border: 1px solid #9ca3af; - border-radius: 8px; - padding: 0.55rem 0.65rem; + border: 1px solid var(--fx-color-border-strong); + border-radius: var(--fx-radius-lg); + background: transparent; + color: var(--fx-color-text-primary); + padding: var(--fx-space-3) var(--fx-space-4); } button, a { border: 0; - border-radius: 8px; - background: #0369a1; - color: #f8fafc; - padding: 0.55rem 0.75rem; + border-radius: var(--fx-radius-lg); + background: var(--fx-color-brand-primary); + color: var(--fx-color-text-primary); + padding: var(--fx-space-3) var(--fx-space-4); text-decoration: none; + font-weight: 500; + cursor: pointer; +} + +button:hover, +a:hover { + background: var(--fx-color-brand-hover); } .card-list { @@ -62,90 +117,165 @@ a { padding: 0; list-style: none; display: grid; - gap: 0.6rem; + gap: var(--fx-space-3); } .card-list li { - border: 1px solid #e5e7eb; - border-radius: 10px; - padding: 0.7rem; + border: 1px solid var(--fx-color-border-default); + border-radius: var(--fx-radius-xl); + background: var(--fx-color-bg-card); + padding: var(--fx-space-4); display: flex; justify-content: space-between; - gap: 0.8rem; + gap: var(--fx-space-3); align-items: center; } +.lesson-rail { + margin: 0; + padding: 0; + list-style: none; + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(240px, 1fr); + gap: var(--fx-space-3); + overflow-x: auto; +} + +.lesson-tile { + border: 1px solid var(--fx-color-border-default); + border-radius: var(--fx-radius-xl); + background: + linear-gradient(180deg, rgb(0 0 0 / 30%) 0%, rgb(0 0 0 / 75%) 100%), + var(--fx-color-bg-card); + padding: var(--fx-space-4); + min-height: 160px; + display: flex; + flex-direction: column; + justify-content: space-between; + gap: var(--fx-space-4); +} + .badge { border-radius: 999px; - padding: 0.15rem 0.5rem; - font-size: 0.8rem; - border: 1px solid #cbd5e1; + padding: 2px var(--fx-space-3); + font-size: var(--fx-text-caption); + border: 1px solid var(--fx-color-border-default); + color: var(--fx-color-text-primary); +} + +.badge.released { + background: rgb(70 211 105 / 24%); + border-color: var(--fx-color-success); } -.badge.released { background: #dcfce7; } -.badge.locked { background: #fef9c3; } -.badge.expired { background: #fee2e2; } +.badge.locked { + background: rgb(68 142 244 / 20%); +} + +.badge.expired { + background: rgb(235 57 66 / 20%); + border-color: var(--fx-color-error); +} .status-error { - color: #b91c1c; + color: var(--fx-color-error); } .status-warn { - color: #92400e; + color: var(--fx-color-text-secondary); } .inline-actions { display: flex; - gap: 0.6rem; + gap: var(--fx-space-3); align-items: center; } .quiz-shell { display: grid; - gap: 0.7rem; + gap: var(--fx-space-3); } .quiz-question { - border: 1px solid #d1d5db; - border-radius: 10px; - padding: 0.6rem 0.75rem; + border: 1px solid var(--fx-color-border-default); + border-radius: var(--fx-radius-xl); + padding: var(--fx-space-4); + background: var(--fx-color-bg-card); margin: 0; } .quiz-question legend { - padding: 0 0.2rem; + padding: 0 var(--fx-space-1); font-weight: 600; } .quiz-option { display: flex; align-items: center; - gap: 0.4rem; - margin-top: 0.35rem; + gap: var(--fx-space-2); + margin-top: var(--fx-space-2); } .quiz-result { display: grid; - gap: 0.25rem; + gap: var(--fx-space-1); } .quiz-result p { margin: 0; } +.player-shell { + display: grid; + gap: var(--fx-space-4); +} + +.player-frame { + width: 100%; + aspect-ratio: 16 / 9; + border: 1px solid var(--fx-color-border-default); + border-radius: var(--fx-radius-xl); + overflow: hidden; + background: var(--fx-color-bg-secondary); +} + +.player-frame iframe { + width: 100%; + height: 100%; + border: 0; +} + .muted { - color: #6b7280; + color: var(--fx-color-text-tertiary); } code { font-family: "JetBrains Mono", monospace; + color: var(--fx-color-text-secondary); } @media (max-width: 640px) { + .web-layout { + padding: var(--fx-space-4); + } + + .web-header h1 { + font-size: 36px; + } + .inline-fields { grid-template-columns: 1fr; } + .hero-panel { + padding: var(--fx-space-6) var(--fx-space-4); + } + + .lesson-rail { + grid-auto-columns: minmax(220px, 1fr); + } + .card-list li { flex-direction: column; align-items: flex-start; diff --git a/docs/design-system-canonical-policy.md b/docs/design-system-canonical-policy.md index db0a201..eca45f9 100644 --- a/docs/design-system-canonical-policy.md +++ b/docs/design-system-canonical-policy.md @@ -1,7 +1,9 @@ # Design System Canonical Policy ## Rule -- Canonical source for tokens and foundations is `packages/design-system`. +- Source of truth for visual design decisions is `design-system/netflix-design-system/Netflix Design System.pdf`. +- Source of truth for navigation/usability flows is `design-system/netflix-course-workflow.md`. +- Canonical implementation source for tokens and foundations in code is `packages/design-system`. - Legacy folder `design-system/` is reference-only and must not be imported by app code. ## Current Canonical Files diff --git a/docs/design-system-showcase.md b/docs/design-system-showcase.md new file mode 100644 index 0000000..38f115a --- /dev/null +++ b/docs/design-system-showcase.md @@ -0,0 +1,25 @@ +# Design System Showcase + +Rota de demonstração completa do design system: + +- URL: `/design-system` +- App: `apps/web` +- Página: `apps/web/src/pages/DesignSystemShowcasePage.jsx` + +## Cobertura atual + +- Tokens globais carregados por app (`@flix/design-system/tokens/theme.css`) +- Hero variants (`HomeHero`, `HeroMobile`) +- User profile anatomy (`UserProfilesPattern`, `ProfileMenuPattern`) +- Video player anatomy completo: + - `VideoPlayerPattern` + - `VideoPlayerChangingSoundPattern` + - `VideoPlayerPlaybackSpeedPattern` + - `VideoPlayerSubtitlesPattern` + - `VideoPlayerEpisodesPreviewPattern` + - `VideoPlayerNextEpisodePattern` + - `VideoPlayerScrollPreviewPattern` + +## Objetivo + +A página funciona como referência viva para inspeção visual, comparação de estados e uso correto de props sem conteúdo hardcoded dentro dos componentes de design system. diff --git a/docs/frontend-netflix-recovery-plan.md b/docs/frontend-netflix-recovery-plan.md new file mode 100644 index 0000000..55b9829 --- /dev/null +++ b/docs/frontend-netflix-recovery-plan.md @@ -0,0 +1,127 @@ +# Frontend Netflix Recovery Plan + +Date: 2026-02-12 +Owner: Product + Architecture + Frontend Guild +Status: Proposed + +## Source of Truth + +1. Design System (visual identity, typography, color, component style): `design-system/netflix-design-system/Netflix Design System.pdf` +2. Navigation and usability (flows, screens, UX behavior): `design-system/netflix-course-workflow.md` +3. `packages/design-system` is the implementation layer and must mirror the two sources above, never redefine them. + +## 1) Diagnosis (Evidence) + +1. Frontend flows exist, but visual implementation is not aligned with Netflix identity. +2. Learner and admin styles are currently custom light themes (`apps/web/src/styles.css`, `apps/admin/src/styles.css`), not token-driven Netflix UI. +3. Canonical design-system policy requires `packages/design-system` as source of truth (`docs/design-system-canonical-policy.md`), but app code is not consuming DS tokens/components. +4. Design-system package exists but is still scaffold-level for tooling (`packages/design-system/package.json` scripts are placeholders). +5. Product workflow/specification is detailed and must be treated as contract (`design-system/netflix-course-workflow.md`, `design-system/netflix-design-system/Netflix Design System.pdf`). + +## 2) Mandatory Recovery Goal + +Deliver both apps (`apps/web`, `apps/admin`) with atomic, reusable, Netflix-consistent UI by enforcing design-system-first implementation and blocking release on visual/runtime quality gates. + +## 3) Agent Call Plan + +1. `@sm` (Product/Scope Owner) +- Freeze MVP screen scope from workflow and define non-negotiable screens/states. +- Approve final acceptance checklist by flow. + +2. `@ux-design-expert` (UX/Visual Authority) +- Convert PDF + workflow into implementable UI spec (desktop/mobile, component states, spacing, typography, interactions). +- Approve fidelity before QA closes stories. + +3. `@architect` (Technical Gate) +- Enforce DS consumption rule and component architecture. +- Block merges that bypass tokens/components. + +4. `@dev` as `@design-system-engineer` +- Hardening `@flix/design-system` package (build/test/typecheck real scripts). +- Build atomic layer: tokens -> primitives -> composed components. + +5. `@dev` as `@frontend-web` +- Migrate learner app pages to DS components and Netflix layout patterns. + +6. `@dev` as `@frontend-admin` +- Migrate admin app pages to DS-driven form/table/layout system. + +7. `@qa` / `@e2e` +- Add visual + functional gates (smoke, regression, key responsive breakpoints). + +## 4) Execution Plan (Hard Timeline) + +## Phase 0 - Contract Freeze (2026-02-12 to 2026-02-13) +1. Build a screen inventory from workflow and existing routes. +2. Mark each screen/state as `Must`, `Should`, `Later`. +3. Lock acceptance criteria per screen (including loading/empty/error states). + +Deliverables +- `docs/frontend-screen-contract.md` +- Updated story checklist with explicit screen/state matrix. + +## Phase 1 - Atomic Design System Foundation (2026-02-13 to 2026-02-16) +1. Normalize DS package scripts/build pipeline in `packages/design-system`. +2. Publish atomic primitives: +- Typography, color, spacing, radius, elevation, motion. +- Layout primitives (`Page`, `Section`, `Grid`, `Stack`). +- Form primitives (`Input`, `Select`, `Textarea`, `Checkbox`, `Button`). +- Content primitives (`Card`, `Badge`, `Tabs`, `Modal`, `Toast`, `Table`). +3. Add strict lint/test rule: app code cannot hardcode colors/fonts outside DS. + +Deliverables +- DS package consumable by both apps. +- Token usage verifier integrated in CI. + +## Phase 2 - Learner App Migration (2026-02-16 to 2026-02-19) +1. Rebuild catalog and playback pages with Netflix structure: +- Hero, rail/cards, release badges, access-state messaging, quiz/material sections. +2. Keep existing API integrations intact while replacing view layer. +3. Validate responsive behavior (mobile first + desktop cinematic layout). + +Deliverables +- `apps/web` UI fully DS-based. +- No raw palette/typography leakage. + +## Phase 3 - Admin App Migration (2026-02-19 to 2026-02-22) +1. Rebuild login and dashboard operation surfaces with DS components. +2. Recompose event/lesson/material/quiz flows as consistent admin patterns. +3. Integrate branding generation UX with clear preview/rollback and status feedback. + +Deliverables +- `apps/admin` UI fully DS-based. +- Form validation and feedback visually standardized. + +## Phase 4 - Closure Gates (2026-02-22 to 2026-02-24) +1. Run full quality gates: +- Unit/integration/e2e. +- Visual review checklist per contract. +- Responsive acceptance (mobile/tablet/desktop). +2. Promote stories only with evidence links. +3. Release decision with explicit GO/NO-GO minutes. + +Deliverables +- `docs/frontend-visual-qa-checklist.md` +- Updated readiness report with frontend fidelity evidence. + +## 5) Non-Negotiable Merge Gates + +1. No merge if app CSS hardcodes brand colors/typography outside DS tokens. +2. No merge if required workflow screens/states are missing. +3. No merge if smoke e2e fails on admin or learner critical paths. +4. No merge if mobile layout breaks at small breakpoint. +5. No merge if UX authority (`@ux-design-expert`) and architecture gate (`@architect`) did not approve. + +## 6) Definition of Done (Frontend) + +1. All MVP screens from workflow implemented in web/admin with Netflix identity. +2. Both apps consume `@flix/design-system` tokens/components as default path. +3. Accessibility baseline respected (focus states, contrast, keyboard nav). +4. Functional tests and smoke e2e passing. +5. Documentation updated with exact evidence per story. + +## 7) Immediate Next Action (Today) + +1. Open a dedicated branch `feat/frontend-netflix-recovery`. +2. Execute Phase 0 and publish screen contract in docs. +3. Start Phase 1 by hardening `packages/design-system` scripts and CI gates. diff --git a/docs/frontend-screen-contract.md b/docs/frontend-screen-contract.md new file mode 100644 index 0000000..7f47d70 --- /dev/null +++ b/docs/frontend-screen-contract.md @@ -0,0 +1,33 @@ +# Frontend Screen Contract (Workflow vs Current Apps) + +Date: 2026-02-12 +Sources: +- Visual design: `design-system/netflix-design-system/Netflix Design System.pdf` +- Navigation/usability: `design-system/netflix-course-workflow.md` + +## Admin App + +1. `/admin/login` - Must - Implemented (needs Netflix visual fidelity uplift) +2. `/admin/dashboard` - Must - Implemented (needs information architecture split by workflow menu) +3. `/admin/eventos` - Must - Implemented (phase 1 routing with dedicated section) +4. `/admin/eventos/novo` - Must - Implemented (dedicated create mode and reset form behavior) +5. `/admin/eventos/:id/editar` - Must - Implemented (dedicated edit mode with route-id selection) +6. `/admin/aulas` - Must - Implemented (phase 1 routing with dedicated section) +7. `/admin/quizzes` - Should - Implemented (phase 1 routing with dedicated section) +8. `/admin/usuarios` - Later - Not planned for MVP + +## Learner App + +1. `/events/:eventSlug` - Must - Implemented (catalog) +2. `/events/:eventSlug/lessons/:lessonSlug` - Must - Implemented (playback + materials + quiz) +3. Private access key gating state - Must - Implemented +4. Locked lesson state - Must - Implemented +5. Expired lesson state - Must - Implemented +6. Hero/featured cinematic landing composition - Must - Implemented (phase 1 visual rebuild) +7. Netflix row/card browsing pattern - Must - Implemented (phase 1 rails/tile pattern) + +## Execution Priorities + +1. P0: route split for admin workflow screens to match markdown navigation contract. (Done 2026-02-12) +2. P1: learner catalog/playback visual rebuild to Netflix card/hero patterns. (Done 2026-02-12 phase 1) +3. P2: admin visual system and component consistency uplift (split section component into dedicated page files). (Done 2026-02-12 phase 1) diff --git a/docs/frontend-war-room.md b/docs/frontend-war-room.md new file mode 100644 index 0000000..d60b901 --- /dev/null +++ b/docs/frontend-war-room.md @@ -0,0 +1,51 @@ +# Frontend War Room (YOLO Execution) + +Date: 2026-02-12 +Mode: Execution-first (no more discovery-only cycles) + +## Command Center + +1. Visual source of truth: `design-system/netflix-design-system/Netflix Design System.pdf` +2. Navigation/usability source of truth: `design-system/netflix-course-workflow.md` +3. Code implementation source: `packages/design-system` + `apps/web` + `apps/admin` + +## Parallel Lanes + +1. Lane A - Design System Core (`@dev` as DS Engineer) +- Harden package scripts (build/test/typecheck). +- Expose stable primitives and tokens. +- Deliverable gate: apps consume DS tokens without hardcoded theme values. + +2. Lane B - Learner UI Migration (`@dev` as Frontend Web) +- Catalog/playback/materials/quiz surfaces using Netflix layout language. +- Deliverable gate: all learner states from workflow (`loading`, `empty`, `error`, gated access). + +3. Lane C - Admin UI Migration (`@dev` as Frontend Admin) +- Login + dashboard + CRUD forms + branding flows with unified components. +- Deliverable gate: consistent form/table/feedback patterns. + +4. Lane D - Quality Gates (`@qa` / `@architect`) +- Token usage checks, unit/integration, smoke e2e, responsive checks. +- Deliverable gate: no release if any lane fails. + +## Daily Gate (Blocking) + +1. `npm run build --workspace @flix/web` +2. `npm run build --workspace @flix/admin` +3. `node scripts/verify-token-usage.mjs apps/web/src` +4. `node scripts/verify-token-usage.mjs apps/admin/src` +5. `npm run e2e:smoke:gate` + +Any failure blocks merge. + +## Current Sprint Cutline + +1. P0: Remove all light-theme placeholder styling and switch to tokenized Netflix baseline. +2. P1: Map workflow screens to implemented routes and fill missing screens/states. +3. P2: Raise visual fidelity (spacing/typography/components) to PDF baseline. + +## Status Snapshot + +1. P0 in progress. +2. P1 pending. +3. P2 pending. diff --git a/e2e/playwright.config.mjs b/e2e/playwright.config.mjs index aa89e65..bee50da 100644 --- a/e2e/playwright.config.mjs +++ b/e2e/playwright.config.mjs @@ -7,6 +7,12 @@ const configDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(configDir, '..'); const dbFilePath = `${repoRoot}/services/api/.data/flix.e2e.sqlite`; const dbUrl = `file:${dbFilePath}`; +const e2eApiPort = 3901; +const e2eAdminPort = 4274; +const e2eWebPort = 4273; +const e2eApiUrl = `http://127.0.0.1:${e2eApiPort}`; +const e2eAdminUrl = `http://127.0.0.1:${e2eAdminPort}`; +const e2eWebUrl = `http://127.0.0.1:${e2eWebPort}`; export default defineConfig({ testDir: './tests', @@ -17,7 +23,7 @@ export default defineConfig({ timeout: 45_000, reporter: [['list'], ['html', { open: 'never', outputFolder: 'playwright-report' }]], use: { - baseURL: 'http://127.0.0.1:4173', + baseURL: e2eWebUrl, trace: 'retain-on-failure', screenshot: 'only-on-failure', video: 'retain-on-failure', @@ -25,23 +31,23 @@ export default defineConfig({ webServer: [ { command: - `sh -c "cd .. && DATABASE_URL=${dbUrl} npm run db:reset --workspace @flix/api && DATABASE_URL=${dbUrl} API_PORT=3001 PERSISTENCE_ADAPTER=sqlite CORS_ORIGIN=http://127.0.0.1:4173,http://127.0.0.1:4174,http://localhost:4173,http://localhost:4174 node services/api/src/server.js"`, - url: 'http://127.0.0.1:3001/health', - reuseExistingServer: !isCi, + `sh -c "cd .. && DATABASE_URL=${dbUrl} npm run db:reset --workspace @flix/api && DATABASE_URL=${dbUrl} API_PORT=${e2eApiPort} PERSISTENCE_ADAPTER=sqlite CORS_ORIGIN=${e2eWebUrl},${e2eAdminUrl},http://localhost:${e2eWebPort},http://localhost:${e2eAdminPort} node services/api/src/server.js"`, + url: `${e2eApiUrl}/health`, + reuseExistingServer: false, timeout: 120_000, }, { command: - 'sh -c "cd .. && VITE_API_BASE_URL=http://127.0.0.1:3001 npm run dev --workspace @flix/admin -- --host 127.0.0.1 --port 4174"', - url: 'http://127.0.0.1:4174/login', - reuseExistingServer: !isCi, + `sh -c "cd .. && VITE_API_BASE_URL=${e2eApiUrl} npm run dev --workspace @flix/admin -- --host 127.0.0.1 --port ${e2eAdminPort}"`, + url: `${e2eAdminUrl}/login`, + reuseExistingServer: false, timeout: 120_000, }, { command: - 'sh -c "cd .. && VITE_API_BASE_URL=http://127.0.0.1:3001 npm run dev --workspace @flix/web -- --host 127.0.0.1 --port 4173"', - url: 'http://127.0.0.1:4173/events/flix-mvp-launch-event', - reuseExistingServer: !isCi, + `sh -c "cd .. && VITE_API_BASE_URL=${e2eApiUrl} npm run dev --workspace @flix/web -- --host 127.0.0.1 --port ${e2eWebPort}"`, + url: `${e2eWebUrl}/events/flix-mvp-launch-event`, + reuseExistingServer: false, timeout: 120_000, }, ], diff --git a/e2e/tests/admin-smoke.spec.mjs b/e2e/tests/admin-smoke.spec.mjs index 1717384..67d774f 100644 --- a/e2e/tests/admin-smoke.spec.mjs +++ b/e2e/tests/admin-smoke.spec.mjs @@ -1,10 +1,13 @@ import { expect, test } from '@playwright/test'; test('admin happy path smoke: login and create event', async ({ page }) => { - await page.goto('http://127.0.0.1:4174/login'); + await page.goto('http://127.0.0.1:4274/login'); await page.getByRole('button', { name: 'Sign in' }).click(); - await expect(page.getByRole('heading', { name: 'Admin Content Operations' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'Eventos', exact: true })).toBeVisible(); + + await page.getByRole('link', { name: 'Eventos', exact: true }).click(); + await expect(page).toHaveURL(/\/eventos/); const randomSuffix = `${Date.now()}`; await page.getByPlaceholder('Event title').fill(`E2E Event ${randomSuffix}`); diff --git a/e2e/tests/learner-smoke.spec.mjs b/e2e/tests/learner-smoke.spec.mjs index efc54c5..6a306e9 100644 --- a/e2e/tests/learner-smoke.spec.mjs +++ b/e2e/tests/learner-smoke.spec.mjs @@ -3,10 +3,10 @@ import { expect, test } from '@playwright/test'; test('learner happy path smoke: catalog to playback', async ({ page }) => { await page.goto('/events/flix-mvp-launch-event'); - await expect(page.getByRole('heading', { name: 'Flix Learner' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Flix', exact: true })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Catalog Access' })).toBeVisible(); - const lessonLink = page.locator('a[href="/events/flix-mvp-launch-event/lessons/kickoff-do-mvp"]'); + const lessonLink = page.locator('a[href*="/events/flix-mvp-launch-event/lessons/"]').first(); for (let attempt = 1; attempt <= 3; attempt += 1) { await page.getByRole('button', { name: 'Load catalog' }).click(); try { diff --git a/package-lock.json b/package-lock.json index 7bcc030..4e643c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "name": "@flix/admin", "version": "0.1.0", "dependencies": { + "@flix/design-system": "0.1.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.9.4" @@ -38,6 +39,7 @@ "name": "@flix/web", "version": "0.1.0", "dependencies": { + "@flix/design-system": "0.1.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router-dom": "^7.9.4" diff --git a/packages/design-system/docs/README.md b/packages/design-system/docs/README.md index 00f50bd..925d7b2 100644 --- a/packages/design-system/docs/README.md +++ b/packages/design-system/docs/README.md @@ -2,6 +2,23 @@ This package is the canonical design-system location for the monorepo. -Reference source (non-canonical): -- ../../../design-system/ -- ../../../docs/design-system-extraction.md +Source of truth +- Visual design source: `../../../design-system/netflix-design-system/Netflix Design System.pdf` +- Navigation/usability source: `../../../design-system/netflix-course-workflow.md` + +Component architecture (Atomic Design) +- Atoms + - `Button`, `Input`, `Text`, `Badge`, `Card`, `HeroBannerControlButton` +- Molecules + - `FormField`, `AccessKeyForm`, `LessonTile`, `StatTile`, `HeroBannerActionsPattern`, `HeroBannerUtilitiesPattern` +- Organisms + - `AppTopNav`, `HeroBanner`, `HomeHero`, `LessonRail`, `PlaybackPanel`, `AdminHeader`, `FaqQuestionsPattern` +- Templates + - `LearnerCatalogTemplate`, `LearnerPlaybackTemplate`, `AdminDashboardTemplate`, `AdminContentTemplate` +- Pages + - `LearnerCatalogPage`, `LearnerPlaybackPage`, `AdminDashboardPage`, `AdminEventsPage`, `AdminLessonsPage`, `AdminQuizzesPage` + +Public exports +- `@flix/design-system` -> tokens + all component layers +- `@flix/design-system/tokens` -> tokens only +- `@flix/design-system/tokens/theme.css` -> css variables diff --git a/packages/design-system/package.json b/packages/design-system/package.json index 86d7f3c..f8b3490 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -14,6 +14,10 @@ "import": "./src/tokens/index.ts", "types": "./src/tokens/index.ts" }, + "./components": { + "import": "./src/components/index.ts", + "types": "./src/components/index.ts" + }, "./tokens/theme.css": { "import": "./src/tokens/theme.css" } diff --git a/packages/design-system/src/assets/icons/Account.svg b/packages/design-system/src/assets/icons/Account.svg new file mode 100644 index 0000000..253b7f0 --- /dev/null +++ b/packages/design-system/src/assets/icons/Account.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/design-system/src/assets/icons/ArrowDown.svg b/packages/design-system/src/assets/icons/ArrowDown.svg new file mode 100644 index 0000000..3f678d2 --- /dev/null +++ b/packages/design-system/src/assets/icons/ArrowDown.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/design-system/src/assets/icons/ArrowLeft.svg b/packages/design-system/src/assets/icons/ArrowLeft.svg new file mode 100644 index 0000000..a9aaf38 --- /dev/null +++ b/packages/design-system/src/assets/icons/ArrowLeft.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/design-system/src/assets/icons/ArrowRight.svg b/packages/design-system/src/assets/icons/ArrowRight.svg new file mode 100644 index 0000000..58ff954 --- /dev/null +++ b/packages/design-system/src/assets/icons/ArrowRight.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/design-system/src/assets/icons/ArrowRightNarrow.svg b/packages/design-system/src/assets/icons/ArrowRightNarrow.svg new file mode 100644 index 0000000..0aadb3b --- /dev/null +++ b/packages/design-system/src/assets/icons/ArrowRightNarrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/design-system/src/assets/icons/ArrowUp.svg b/packages/design-system/src/assets/icons/ArrowUp.svg new file mode 100644 index 0000000..8660eb3 --- /dev/null +++ b/packages/design-system/src/assets/icons/ArrowUp.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/design-system/src/assets/icons/CircleError.svg b/packages/design-system/src/assets/icons/CircleError.svg new file mode 100644 index 0000000..2dfdd88 --- /dev/null +++ b/packages/design-system/src/assets/icons/CircleError.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/design-system/src/assets/icons/Cross.svg b/packages/design-system/src/assets/icons/Cross.svg new file mode 100644 index 0000000..d00bcb0 --- /dev/null +++ b/packages/design-system/src/assets/icons/Cross.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/design-system/src/assets/icons/Facebook.svg b/packages/design-system/src/assets/icons/Facebook.svg new file mode 100644 index 0000000..34d55f4 --- /dev/null +++ b/packages/design-system/src/assets/icons/Facebook.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/design-system/src/assets/icons/Flag.svg b/packages/design-system/src/assets/icons/Flag.svg new file mode 100644 index 0000000..661bf10 --- /dev/null +++ b/packages/design-system/src/assets/icons/Flag.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/design-system/src/assets/icons/Info.svg b/packages/design-system/src/assets/icons/Info.svg new file mode 100644 index 0000000..1d0cad3 --- /dev/null +++ b/packages/design-system/src/assets/icons/Info.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/design-system/src/assets/icons/Instagram.svg b/packages/design-system/src/assets/icons/Instagram.svg new file mode 100644 index 0000000..56cb2cb --- /dev/null +++ b/packages/design-system/src/assets/icons/Instagram.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/design-system/src/assets/icons/Notification.svg b/packages/design-system/src/assets/icons/Notification.svg new file mode 100644 index 0000000..9aca984 --- /dev/null +++ b/packages/design-system/src/assets/icons/Notification.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/design-system/src/assets/icons/Pensil.svg b/packages/design-system/src/assets/icons/Pensil.svg new file mode 100644 index 0000000..33abc46 --- /dev/null +++ b/packages/design-system/src/assets/icons/Pensil.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/design-system/src/assets/icons/Person.svg b/packages/design-system/src/assets/icons/Person.svg new file mode 100644 index 0000000..0c8fa7d --- /dev/null +++ b/packages/design-system/src/assets/icons/Person.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/design-system/src/assets/icons/Play.svg b/packages/design-system/src/assets/icons/Play.svg new file mode 100644 index 0000000..1ee2d86 --- /dev/null +++ b/packages/design-system/src/assets/icons/Play.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/design-system/src/assets/icons/PlusThin.svg b/packages/design-system/src/assets/icons/PlusThin.svg new file mode 100644 index 0000000..0da3227 --- /dev/null +++ b/packages/design-system/src/assets/icons/PlusThin.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/design-system/src/assets/icons/PlusWide.svg b/packages/design-system/src/assets/icons/PlusWide.svg new file mode 100644 index 0000000..849b945 --- /dev/null +++ b/packages/design-system/src/assets/icons/PlusWide.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/design-system/src/assets/icons/Question.svg b/packages/design-system/src/assets/icons/Question.svg new file mode 100644 index 0000000..19b0f8d --- /dev/null +++ b/packages/design-system/src/assets/icons/Question.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/design-system/src/assets/icons/Search.svg b/packages/design-system/src/assets/icons/Search.svg new file mode 100644 index 0000000..3264bde --- /dev/null +++ b/packages/design-system/src/assets/icons/Search.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/design-system/src/assets/icons/ThumbDown.svg b/packages/design-system/src/assets/icons/ThumbDown.svg new file mode 100644 index 0000000..0b2e182 --- /dev/null +++ b/packages/design-system/src/assets/icons/ThumbDown.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/design-system/src/assets/icons/ThumbUp.svg b/packages/design-system/src/assets/icons/ThumbUp.svg new file mode 100644 index 0000000..fda459a --- /dev/null +++ b/packages/design-system/src/assets/icons/ThumbUp.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/design-system/src/assets/icons/Twitter.svg b/packages/design-system/src/assets/icons/Twitter.svg new file mode 100644 index 0000000..c2f88e4 --- /dev/null +++ b/packages/design-system/src/assets/icons/Twitter.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/design-system/src/assets/icons/YouTube.svg b/packages/design-system/src/assets/icons/YouTube.svg new file mode 100644 index 0000000..1afde7b --- /dev/null +++ b/packages/design-system/src/assets/icons/YouTube.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/design-system/src/assets/icons/hero-banner-preview/HeroBannerPreviewMuteDefault.svg b/packages/design-system/src/assets/icons/hero-banner-preview/HeroBannerPreviewMuteDefault.svg new file mode 100644 index 0000000..cdea430 --- /dev/null +++ b/packages/design-system/src/assets/icons/hero-banner-preview/HeroBannerPreviewMuteDefault.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/design-system/src/assets/icons/hero-banner-preview/HeroBannerPreviewMuteHover.svg b/packages/design-system/src/assets/icons/hero-banner-preview/HeroBannerPreviewMuteHover.svg new file mode 100644 index 0000000..61364f5 --- /dev/null +++ b/packages/design-system/src/assets/icons/hero-banner-preview/HeroBannerPreviewMuteHover.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/design-system/src/assets/icons/hero-banner-preview/HeroBannerPreviewRepeatArrowDefault.svg b/packages/design-system/src/assets/icons/hero-banner-preview/HeroBannerPreviewRepeatArrowDefault.svg new file mode 100644 index 0000000..c226e6e --- /dev/null +++ b/packages/design-system/src/assets/icons/hero-banner-preview/HeroBannerPreviewRepeatArrowDefault.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/design-system/src/assets/icons/hero-banner-preview/HeroBannerPreviewRepeatArrowHover.svg b/packages/design-system/src/assets/icons/hero-banner-preview/HeroBannerPreviewRepeatArrowHover.svg new file mode 100644 index 0000000..e37b2d9 --- /dev/null +++ b/packages/design-system/src/assets/icons/hero-banner-preview/HeroBannerPreviewRepeatArrowHover.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/design-system/src/assets/icons/hero-banner-preview/HeroBannerPreviewSoundDefault.svg b/packages/design-system/src/assets/icons/hero-banner-preview/HeroBannerPreviewSoundDefault.svg new file mode 100644 index 0000000..8015917 --- /dev/null +++ b/packages/design-system/src/assets/icons/hero-banner-preview/HeroBannerPreviewSoundDefault.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/design-system/src/assets/icons/hero-banner-preview/HeroBannerPreviewSoundHover.svg b/packages/design-system/src/assets/icons/hero-banner-preview/HeroBannerPreviewSoundHover.svg new file mode 100644 index 0000000..64a1a4a --- /dev/null +++ b/packages/design-system/src/assets/icons/hero-banner-preview/HeroBannerPreviewSoundHover.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/design-system/src/assets/icons/index.ts b/packages/design-system/src/assets/icons/index.ts new file mode 100644 index 0000000..e797b56 --- /dev/null +++ b/packages/design-system/src/assets/icons/index.ts @@ -0,0 +1,214 @@ +export const iconAssets = { + account: new URL('./Account.svg', import.meta.url).href, + arrowDown: new URL('./ArrowDown.svg', import.meta.url).href, + arrowLeft: new URL('./ArrowLeft.svg', import.meta.url).href, + arrowRight: new URL('./ArrowRight.svg', import.meta.url).href, + arrowRightNarrow: new URL('./ArrowRightNarrow.svg', import.meta.url).href, + arrowUp: new URL('./ArrowUp.svg', import.meta.url).href, + circleError: new URL('./CircleError.svg', import.meta.url).href, + cross: new URL('./Cross.svg', import.meta.url).href, + facebook: new URL('./Facebook.svg', import.meta.url).href, + flag: new URL('./Flag.svg', import.meta.url).href, + heroBannerPreviewMuteDefault: new URL('./hero-banner-preview/HeroBannerPreviewMuteDefault.svg', import.meta.url).href, + heroBannerPreviewMuteHover: new URL('./hero-banner-preview/HeroBannerPreviewMuteHover.svg', import.meta.url).href, + heroBannerPreviewRepeatArrowDefault: new URL( + './hero-banner-preview/HeroBannerPreviewRepeatArrowDefault.svg', + import.meta.url, + ).href, + heroBannerPreviewRepeatArrowHover: new URL( + './hero-banner-preview/HeroBannerPreviewRepeatArrowHover.svg', + import.meta.url, + ).href, + heroBannerPreviewSoundDefault: new URL('./hero-banner-preview/HeroBannerPreviewSoundDefault.svg', import.meta.url).href, + heroBannerPreviewSoundHover: new URL('./hero-banner-preview/HeroBannerPreviewSoundHover.svg', import.meta.url).href, + info: new URL('./Info.svg', import.meta.url).href, + instagram: new URL('./Instagram.svg', import.meta.url).href, + notification: new URL('./Notification.svg', import.meta.url).href, + pensil: new URL('./Pensil.svg', import.meta.url).href, + person: new URL('./Person.svg', import.meta.url).href, + play: new URL('./Play.svg', import.meta.url).href, + plusThin: new URL('./PlusThin.svg', import.meta.url).href, + plusWide: new URL('./PlusWide.svg', import.meta.url).href, + presetLeavingSoon: new URL('./presets-labels/LeavingSoon.svg', import.meta.url).href, + presetNewSeason: new URL('./presets-labels/NewSeason.svg', import.meta.url).href, + presetRecentlyAdded: new URL('./presets-labels/RecentlyAdded.svg', import.meta.url).href, + presetTop10: new URL('./presets-labels/Top10.svg', import.meta.url).href, + presetTop10Number: new URL('./presets-labels/Top10Number.svg', import.meta.url).href, + question: new URL('./Question.svg', import.meta.url).href, + ratingG: new URL('./maturity-ratings/G.svg', import.meta.url).href, + ratingNc17: new URL('./maturity-ratings/NC-17.svg', import.meta.url).href, + ratingPg: new URL('./maturity-ratings/PG.svg', import.meta.url).href, + ratingPg13: new URL('./maturity-ratings/PG-13.svg', import.meta.url).href, + ratingR: new URL('./maturity-ratings/R.svg', import.meta.url).href, + ratingTv14: new URL('./maturity-ratings/TV-14.svg', import.meta.url).href, + ratingTvG: new URL('./maturity-ratings/TV-G.svg', import.meta.url).href, + ratingTvMa: new URL('./maturity-ratings/TV-MA.svg', import.meta.url).href, + ratingTvPg: new URL('./maturity-ratings/TV-PG.svg', import.meta.url).href, + ratingTvY: new URL('./maturity-ratings/TV-Y.svg', import.meta.url).href, + ratingTvY7: new URL('./maturity-ratings/TV-Y7.svg', import.meta.url).href, + moviePreviewAddDefault: new URL('./movie-preview/MoviePreviewAddDefault.svg', import.meta.url).href, + moviePreviewAddHover: new URL('./movie-preview/MoviePreviewAddHover.svg', import.meta.url).href, + moviePreviewArrowDownDefault: new URL('./movie-preview/MoviePreviewArrowDownDefault.svg', import.meta.url).href, + moviePreviewArrowDownHover: new URL('./movie-preview/MoviePreviewArrowDownHover.svg', import.meta.url).href, + moviePreviewArrowDownInactive: new URL('./movie-preview/MoviePreviewArrowDownInactive.svg', import.meta.url).href, + moviePreviewMuteDefault: new URL('./movie-preview/MoviePreviewMuteDefault.svg', import.meta.url).href, + moviePreviewMuteHover: new URL('./movie-preview/MoviePreviewMuteHover.svg', import.meta.url).href, + moviePreviewPlayDefault: new URL('./movie-preview/MoviePreviewPlayDefault.svg', import.meta.url).href, + moviePreviewPlayHover: new URL('./movie-preview/MoviePreviewPlayHover.svg', import.meta.url).href, + moviePreviewThumbUpDefault: new URL('./movie-preview/MoviePreviewThumbUpDefault.svg', import.meta.url).href, + moviePreviewThumbUpHover: new URL('./movie-preview/MoviePreviewThumbUpHover.svg', import.meta.url).href, + moviePreviewThumbsGroupDefault: new URL('./movie-preview/MoviePreviewThumbsGroupDefault.svg', import.meta.url).href, + moviePreviewThumbsGroupHover: new URL('./movie-preview/MoviePreviewThumbsGroupHover.svg', import.meta.url).href, + search: new URL('./Search.svg', import.meta.url).href, + videoPlayer10secBackDefault: new URL('./video-player/VideoPlayer10secBackDefault.svg', import.meta.url).href, + videoPlayer10secBackHover: new URL('./video-player/VideoPlayer10secBackHover.svg', import.meta.url).href, + videoPlayer10secForwardDefault: new URL('./video-player/VideoPlayer10secForwardDefault.svg', import.meta.url).href, + videoPlayer10secForwardHover: new URL('./video-player/VideoPlayer10secForwardHover.svg', import.meta.url).href, + videoPlayerFullScreenDefault: new URL('./video-player/VideoPlayerFullScreenDefault.svg', import.meta.url).href, + videoPlayerFullScreenHover: new URL('./video-player/VideoPlayerFullScreenHover.svg', import.meta.url).href, + videoPlayerListOfEpisodesDefault: new URL('./video-player/VideoPlayerListOfEpisodesDefault.svg', import.meta.url).href, + videoPlayerListOfEpisodesHover: new URL('./video-player/VideoPlayerListOfEpisodesHover.svg', import.meta.url).href, + videoPlayerMuteDefault: new URL('./video-player/VideoPlayerMuteDefault.svg', import.meta.url).href, + videoPlayerMuteHover: new URL('./video-player/VideoPlayerMuteHover.svg', import.meta.url).href, + videoPlayerNextEpisodeDefault: new URL('./video-player/VideoPlayerNextEpisodeDefault.svg', import.meta.url).href, + videoPlayerNextEpisodeHover: new URL('./video-player/VideoPlayerNextEpisodeHover.svg', import.meta.url).href, + videoPlayerPlayDefault: new URL('./video-player/VideoPlayerPlayDefault.svg', import.meta.url).href, + videoPlayerPlayHover: new URL('./video-player/VideoPlayerPlayHover.svg', import.meta.url).href, + videoPlayerSoundDefault: new URL('./video-player/VideoPlayerSoundDefault.svg', import.meta.url).href, + videoPlayerSoundHover: new URL('./video-player/VideoPlayerSoundHover.svg', import.meta.url).href, + videoPlayerSpeedDefault: new URL('./video-player/VideoPlayerSpeedDefault.svg', import.meta.url).href, + videoPlayerSpeedHover: new URL('./video-player/VideoPlayerSpeedHover.svg', import.meta.url).href, + videoPlayerSubtitlesDefault: new URL('./video-player/VideoPlayerSubtitlesDefault.svg', import.meta.url).href, + videoPlayerSubtitlesHover: new URL('./video-player/VideoPlayerSubtitlesHover.svg', import.meta.url).href, + videoQuality4k: new URL('./video-quality/4K.svg', import.meta.url).href, + videoQualityDolbyVision: new URL('./video-quality/DolbyVision.svg', import.meta.url).href, + videoQualityHd: new URL('./video-quality/HD.svg', import.meta.url).href, + videoQualityHdr: new URL('./video-quality/HDR.svg', import.meta.url).href, + videoQualityUltraHd4k: new URL('./video-quality/UltraHD4K.svg', import.meta.url).href, + thumbDown: new URL('./ThumbDown.svg', import.meta.url).href, + thumbUp: new URL('./ThumbUp.svg', import.meta.url).href, + twitter: new URL('./Twitter.svg', import.meta.url).href, + youTube: new URL('./YouTube.svg', import.meta.url).href, +} as const; + +export type IconName = keyof typeof iconAssets; + +export const getIconAsset = (name: IconName): string => iconAssets[name]; + +type IconSetState = 'default' | 'hover' | 'inactive'; + +export const moviePreviewIconSets = { + add: { + default: iconAssets.moviePreviewAddDefault, + hover: iconAssets.moviePreviewAddHover, + }, + arrowDown: { + default: iconAssets.moviePreviewArrowDownDefault, + hover: iconAssets.moviePreviewArrowDownHover, + inactive: iconAssets.moviePreviewArrowDownInactive, + }, + mute: { + default: iconAssets.moviePreviewMuteDefault, + hover: iconAssets.moviePreviewMuteHover, + }, + play: { + default: iconAssets.moviePreviewPlayDefault, + hover: iconAssets.moviePreviewPlayHover, + }, + thumbUp: { + default: iconAssets.moviePreviewThumbUpDefault, + hover: iconAssets.moviePreviewThumbUpHover, + }, + thumbsGroup: { + default: iconAssets.moviePreviewThumbsGroupDefault, + hover: iconAssets.moviePreviewThumbsGroupHover, + }, +} as const; + +export type MoviePreviewIconSetName = keyof typeof moviePreviewIconSets; + +export const getMoviePreviewIconSetAsset = ( + setName: MoviePreviewIconSetName, + state: IconSetState, +): string => { + const set = moviePreviewIconSets[setName] as Partial> & { default: string }; + return set[state] ?? set.default; +}; + +export const heroBannerPreviewIconSets = { + mute: { + default: iconAssets.heroBannerPreviewMuteDefault, + hover: iconAssets.heroBannerPreviewMuteHover, + }, + repeatArrow: { + default: iconAssets.heroBannerPreviewRepeatArrowDefault, + hover: iconAssets.heroBannerPreviewRepeatArrowHover, + }, + sound: { + default: iconAssets.heroBannerPreviewSoundDefault, + hover: iconAssets.heroBannerPreviewSoundHover, + }, +} as const; + +export type HeroBannerPreviewIconSetName = keyof typeof heroBannerPreviewIconSets; + +export const getHeroBannerPreviewIconSetAsset = ( + setName: HeroBannerPreviewIconSetName, + state: IconSetState, +): string => { + const set = heroBannerPreviewIconSets[setName] as Partial> & { default: string }; + return set[state] ?? set.default; +}; + +export const videoPlayerIconSets = { + play: { + default: iconAssets.videoPlayerPlayDefault, + hover: iconAssets.videoPlayerPlayHover, + }, + back10: { + default: iconAssets.videoPlayer10secBackDefault, + hover: iconAssets.videoPlayer10secBackHover, + }, + forward10: { + default: iconAssets.videoPlayer10secForwardDefault, + hover: iconAssets.videoPlayer10secForwardHover, + }, + sound: { + default: iconAssets.videoPlayerSoundDefault, + hover: iconAssets.videoPlayerSoundHover, + }, + mute: { + default: iconAssets.videoPlayerMuteDefault, + hover: iconAssets.videoPlayerMuteHover, + }, + nextEpisode: { + default: iconAssets.videoPlayerNextEpisodeDefault, + hover: iconAssets.videoPlayerNextEpisodeHover, + }, + listOfEpisodes: { + default: iconAssets.videoPlayerListOfEpisodesDefault, + hover: iconAssets.videoPlayerListOfEpisodesHover, + }, + subtitles: { + default: iconAssets.videoPlayerSubtitlesDefault, + hover: iconAssets.videoPlayerSubtitlesHover, + }, + speed: { + default: iconAssets.videoPlayerSpeedDefault, + hover: iconAssets.videoPlayerSpeedHover, + }, + fullScreen: { + default: iconAssets.videoPlayerFullScreenDefault, + hover: iconAssets.videoPlayerFullScreenHover, + }, +} as const; + +export type VideoPlayerIconSetName = keyof typeof videoPlayerIconSets; + +export const getVideoPlayerIconSetAsset = ( + setName: VideoPlayerIconSetName, + state: IconSetState, +): string => { + const set = videoPlayerIconSets[setName] as Partial> & { default: string }; + return set[state] ?? set.default; +}; diff --git a/packages/design-system/src/assets/icons/maturity-ratings/G.svg b/packages/design-system/src/assets/icons/maturity-ratings/G.svg new file mode 100644 index 0000000..3a485a1 --- /dev/null +++ b/packages/design-system/src/assets/icons/maturity-ratings/G.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/design-system/src/assets/icons/maturity-ratings/NC-17.svg b/packages/design-system/src/assets/icons/maturity-ratings/NC-17.svg new file mode 100644 index 0000000..4e221b7 --- /dev/null +++ b/packages/design-system/src/assets/icons/maturity-ratings/NC-17.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/design-system/src/assets/icons/maturity-ratings/PG-13.svg b/packages/design-system/src/assets/icons/maturity-ratings/PG-13.svg new file mode 100644 index 0000000..aa280c1 --- /dev/null +++ b/packages/design-system/src/assets/icons/maturity-ratings/PG-13.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/design-system/src/assets/icons/maturity-ratings/PG.svg b/packages/design-system/src/assets/icons/maturity-ratings/PG.svg new file mode 100644 index 0000000..94801d9 --- /dev/null +++ b/packages/design-system/src/assets/icons/maturity-ratings/PG.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/design-system/src/assets/icons/maturity-ratings/R.svg b/packages/design-system/src/assets/icons/maturity-ratings/R.svg new file mode 100644 index 0000000..f171124 --- /dev/null +++ b/packages/design-system/src/assets/icons/maturity-ratings/R.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/design-system/src/assets/icons/maturity-ratings/TV-14.svg b/packages/design-system/src/assets/icons/maturity-ratings/TV-14.svg new file mode 100644 index 0000000..029862a --- /dev/null +++ b/packages/design-system/src/assets/icons/maturity-ratings/TV-14.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/design-system/src/assets/icons/maturity-ratings/TV-G.svg b/packages/design-system/src/assets/icons/maturity-ratings/TV-G.svg new file mode 100644 index 0000000..ace188a --- /dev/null +++ b/packages/design-system/src/assets/icons/maturity-ratings/TV-G.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/design-system/src/assets/icons/maturity-ratings/TV-MA.svg b/packages/design-system/src/assets/icons/maturity-ratings/TV-MA.svg new file mode 100644 index 0000000..d71c50e --- /dev/null +++ b/packages/design-system/src/assets/icons/maturity-ratings/TV-MA.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/design-system/src/assets/icons/maturity-ratings/TV-PG.svg b/packages/design-system/src/assets/icons/maturity-ratings/TV-PG.svg new file mode 100644 index 0000000..5640140 --- /dev/null +++ b/packages/design-system/src/assets/icons/maturity-ratings/TV-PG.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/design-system/src/assets/icons/maturity-ratings/TV-Y.svg b/packages/design-system/src/assets/icons/maturity-ratings/TV-Y.svg new file mode 100644 index 0000000..e069e3b --- /dev/null +++ b/packages/design-system/src/assets/icons/maturity-ratings/TV-Y.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/design-system/src/assets/icons/maturity-ratings/TV-Y7.svg b/packages/design-system/src/assets/icons/maturity-ratings/TV-Y7.svg new file mode 100644 index 0000000..3e1b327 --- /dev/null +++ b/packages/design-system/src/assets/icons/maturity-ratings/TV-Y7.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/design-system/src/assets/icons/movie-preview/MoviePreviewAddDefault.svg b/packages/design-system/src/assets/icons/movie-preview/MoviePreviewAddDefault.svg new file mode 100644 index 0000000..7b50eb3 --- /dev/null +++ b/packages/design-system/src/assets/icons/movie-preview/MoviePreviewAddDefault.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/design-system/src/assets/icons/movie-preview/MoviePreviewAddHover.svg b/packages/design-system/src/assets/icons/movie-preview/MoviePreviewAddHover.svg new file mode 100644 index 0000000..73dfc6a --- /dev/null +++ b/packages/design-system/src/assets/icons/movie-preview/MoviePreviewAddHover.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/design-system/src/assets/icons/movie-preview/MoviePreviewArrowDownDefault.svg b/packages/design-system/src/assets/icons/movie-preview/MoviePreviewArrowDownDefault.svg new file mode 100644 index 0000000..0f03376 --- /dev/null +++ b/packages/design-system/src/assets/icons/movie-preview/MoviePreviewArrowDownDefault.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/design-system/src/assets/icons/movie-preview/MoviePreviewArrowDownHover.svg b/packages/design-system/src/assets/icons/movie-preview/MoviePreviewArrowDownHover.svg new file mode 100644 index 0000000..89b5861 --- /dev/null +++ b/packages/design-system/src/assets/icons/movie-preview/MoviePreviewArrowDownHover.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/design-system/src/assets/icons/movie-preview/MoviePreviewArrowDownInactive.svg b/packages/design-system/src/assets/icons/movie-preview/MoviePreviewArrowDownInactive.svg new file mode 100644 index 0000000..193390b --- /dev/null +++ b/packages/design-system/src/assets/icons/movie-preview/MoviePreviewArrowDownInactive.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/design-system/src/assets/icons/movie-preview/MoviePreviewMuteDefault.svg b/packages/design-system/src/assets/icons/movie-preview/MoviePreviewMuteDefault.svg new file mode 100644 index 0000000..70d5252 --- /dev/null +++ b/packages/design-system/src/assets/icons/movie-preview/MoviePreviewMuteDefault.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/design-system/src/assets/icons/movie-preview/MoviePreviewMuteHover.svg b/packages/design-system/src/assets/icons/movie-preview/MoviePreviewMuteHover.svg new file mode 100644 index 0000000..7f39be7 --- /dev/null +++ b/packages/design-system/src/assets/icons/movie-preview/MoviePreviewMuteHover.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/design-system/src/assets/icons/movie-preview/MoviePreviewPlayDefault.svg b/packages/design-system/src/assets/icons/movie-preview/MoviePreviewPlayDefault.svg new file mode 100644 index 0000000..dd28cff --- /dev/null +++ b/packages/design-system/src/assets/icons/movie-preview/MoviePreviewPlayDefault.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/design-system/src/assets/icons/movie-preview/MoviePreviewPlayHover.svg b/packages/design-system/src/assets/icons/movie-preview/MoviePreviewPlayHover.svg new file mode 100644 index 0000000..fd22bb8 --- /dev/null +++ b/packages/design-system/src/assets/icons/movie-preview/MoviePreviewPlayHover.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/design-system/src/assets/icons/movie-preview/MoviePreviewThumbUpDefault.svg b/packages/design-system/src/assets/icons/movie-preview/MoviePreviewThumbUpDefault.svg new file mode 100644 index 0000000..6c2de27 --- /dev/null +++ b/packages/design-system/src/assets/icons/movie-preview/MoviePreviewThumbUpDefault.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/design-system/src/assets/icons/movie-preview/MoviePreviewThumbUpHover.svg b/packages/design-system/src/assets/icons/movie-preview/MoviePreviewThumbUpHover.svg new file mode 100644 index 0000000..d1522bc --- /dev/null +++ b/packages/design-system/src/assets/icons/movie-preview/MoviePreviewThumbUpHover.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/design-system/src/assets/icons/movie-preview/MoviePreviewThumbsGroupDefault.svg b/packages/design-system/src/assets/icons/movie-preview/MoviePreviewThumbsGroupDefault.svg new file mode 100644 index 0000000..c1fe448 --- /dev/null +++ b/packages/design-system/src/assets/icons/movie-preview/MoviePreviewThumbsGroupDefault.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/design-system/src/assets/icons/movie-preview/MoviePreviewThumbsGroupHover.svg b/packages/design-system/src/assets/icons/movie-preview/MoviePreviewThumbsGroupHover.svg new file mode 100644 index 0000000..0a3807e --- /dev/null +++ b/packages/design-system/src/assets/icons/movie-preview/MoviePreviewThumbsGroupHover.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/design-system/src/assets/icons/presets-labels/LeavingSoon.svg b/packages/design-system/src/assets/icons/presets-labels/LeavingSoon.svg new file mode 100644 index 0000000..8fa3604 --- /dev/null +++ b/packages/design-system/src/assets/icons/presets-labels/LeavingSoon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/design-system/src/assets/icons/presets-labels/NewSeason.svg b/packages/design-system/src/assets/icons/presets-labels/NewSeason.svg new file mode 100644 index 0000000..146950c --- /dev/null +++ b/packages/design-system/src/assets/icons/presets-labels/NewSeason.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/design-system/src/assets/icons/presets-labels/RecentlyAdded.svg b/packages/design-system/src/assets/icons/presets-labels/RecentlyAdded.svg new file mode 100644 index 0000000..b324309 --- /dev/null +++ b/packages/design-system/src/assets/icons/presets-labels/RecentlyAdded.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/design-system/src/assets/icons/presets-labels/Top10.svg b/packages/design-system/src/assets/icons/presets-labels/Top10.svg new file mode 100644 index 0000000..7b79160 --- /dev/null +++ b/packages/design-system/src/assets/icons/presets-labels/Top10.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/design-system/src/assets/icons/presets-labels/Top10Number.svg b/packages/design-system/src/assets/icons/presets-labels/Top10Number.svg new file mode 100644 index 0000000..6af140d --- /dev/null +++ b/packages/design-system/src/assets/icons/presets-labels/Top10Number.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/design-system/src/assets/icons/video-player/VideoPlayer10secBackDefault.svg b/packages/design-system/src/assets/icons/video-player/VideoPlayer10secBackDefault.svg new file mode 100644 index 0000000..aa3b865 --- /dev/null +++ b/packages/design-system/src/assets/icons/video-player/VideoPlayer10secBackDefault.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/design-system/src/assets/icons/video-player/VideoPlayer10secBackHover.svg b/packages/design-system/src/assets/icons/video-player/VideoPlayer10secBackHover.svg new file mode 100644 index 0000000..b3d7a1d --- /dev/null +++ b/packages/design-system/src/assets/icons/video-player/VideoPlayer10secBackHover.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/design-system/src/assets/icons/video-player/VideoPlayer10secForwardDefault.svg b/packages/design-system/src/assets/icons/video-player/VideoPlayer10secForwardDefault.svg new file mode 100644 index 0000000..528e287 --- /dev/null +++ b/packages/design-system/src/assets/icons/video-player/VideoPlayer10secForwardDefault.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/design-system/src/assets/icons/video-player/VideoPlayer10secForwardHover.svg b/packages/design-system/src/assets/icons/video-player/VideoPlayer10secForwardHover.svg new file mode 100644 index 0000000..af27270 --- /dev/null +++ b/packages/design-system/src/assets/icons/video-player/VideoPlayer10secForwardHover.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/design-system/src/assets/icons/video-player/VideoPlayerFullScreenDefault.svg b/packages/design-system/src/assets/icons/video-player/VideoPlayerFullScreenDefault.svg new file mode 100644 index 0000000..64bdd8d --- /dev/null +++ b/packages/design-system/src/assets/icons/video-player/VideoPlayerFullScreenDefault.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/design-system/src/assets/icons/video-player/VideoPlayerFullScreenHover.svg b/packages/design-system/src/assets/icons/video-player/VideoPlayerFullScreenHover.svg new file mode 100644 index 0000000..5cb32dc --- /dev/null +++ b/packages/design-system/src/assets/icons/video-player/VideoPlayerFullScreenHover.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/design-system/src/assets/icons/video-player/VideoPlayerListOfEpisodesDefault.svg b/packages/design-system/src/assets/icons/video-player/VideoPlayerListOfEpisodesDefault.svg new file mode 100644 index 0000000..61cb7bb --- /dev/null +++ b/packages/design-system/src/assets/icons/video-player/VideoPlayerListOfEpisodesDefault.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/packages/design-system/src/assets/icons/video-player/VideoPlayerListOfEpisodesHover.svg b/packages/design-system/src/assets/icons/video-player/VideoPlayerListOfEpisodesHover.svg new file mode 100644 index 0000000..e43b946 --- /dev/null +++ b/packages/design-system/src/assets/icons/video-player/VideoPlayerListOfEpisodesHover.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/design-system/src/assets/icons/video-player/VideoPlayerMuteDefault.svg b/packages/design-system/src/assets/icons/video-player/VideoPlayerMuteDefault.svg new file mode 100644 index 0000000..4211bf8 --- /dev/null +++ b/packages/design-system/src/assets/icons/video-player/VideoPlayerMuteDefault.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/design-system/src/assets/icons/video-player/VideoPlayerMuteHover.svg b/packages/design-system/src/assets/icons/video-player/VideoPlayerMuteHover.svg new file mode 100644 index 0000000..32bdce7 --- /dev/null +++ b/packages/design-system/src/assets/icons/video-player/VideoPlayerMuteHover.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/design-system/src/assets/icons/video-player/VideoPlayerNextEpisodeDefault.svg b/packages/design-system/src/assets/icons/video-player/VideoPlayerNextEpisodeDefault.svg new file mode 100644 index 0000000..87f4a7a --- /dev/null +++ b/packages/design-system/src/assets/icons/video-player/VideoPlayerNextEpisodeDefault.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/design-system/src/assets/icons/video-player/VideoPlayerNextEpisodeHover.svg b/packages/design-system/src/assets/icons/video-player/VideoPlayerNextEpisodeHover.svg new file mode 100644 index 0000000..a2b8ecc --- /dev/null +++ b/packages/design-system/src/assets/icons/video-player/VideoPlayerNextEpisodeHover.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/design-system/src/assets/icons/video-player/VideoPlayerPlayDefault.svg b/packages/design-system/src/assets/icons/video-player/VideoPlayerPlayDefault.svg new file mode 100644 index 0000000..e4600e9 --- /dev/null +++ b/packages/design-system/src/assets/icons/video-player/VideoPlayerPlayDefault.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/design-system/src/assets/icons/video-player/VideoPlayerPlayHover.svg b/packages/design-system/src/assets/icons/video-player/VideoPlayerPlayHover.svg new file mode 100644 index 0000000..132f7f0 --- /dev/null +++ b/packages/design-system/src/assets/icons/video-player/VideoPlayerPlayHover.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/design-system/src/assets/icons/video-player/VideoPlayerSoundDefault.svg b/packages/design-system/src/assets/icons/video-player/VideoPlayerSoundDefault.svg new file mode 100644 index 0000000..af2cab1 --- /dev/null +++ b/packages/design-system/src/assets/icons/video-player/VideoPlayerSoundDefault.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/design-system/src/assets/icons/video-player/VideoPlayerSoundHover.svg b/packages/design-system/src/assets/icons/video-player/VideoPlayerSoundHover.svg new file mode 100644 index 0000000..e63554f --- /dev/null +++ b/packages/design-system/src/assets/icons/video-player/VideoPlayerSoundHover.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/design-system/src/assets/icons/video-player/VideoPlayerSpeedDefault.svg b/packages/design-system/src/assets/icons/video-player/VideoPlayerSpeedDefault.svg new file mode 100644 index 0000000..9809600 --- /dev/null +++ b/packages/design-system/src/assets/icons/video-player/VideoPlayerSpeedDefault.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/design-system/src/assets/icons/video-player/VideoPlayerSpeedHover.svg b/packages/design-system/src/assets/icons/video-player/VideoPlayerSpeedHover.svg new file mode 100644 index 0000000..1114083 --- /dev/null +++ b/packages/design-system/src/assets/icons/video-player/VideoPlayerSpeedHover.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/design-system/src/assets/icons/video-player/VideoPlayerSubtitlesDefault.svg b/packages/design-system/src/assets/icons/video-player/VideoPlayerSubtitlesDefault.svg new file mode 100644 index 0000000..154443f --- /dev/null +++ b/packages/design-system/src/assets/icons/video-player/VideoPlayerSubtitlesDefault.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/design-system/src/assets/icons/video-player/VideoPlayerSubtitlesHover.svg b/packages/design-system/src/assets/icons/video-player/VideoPlayerSubtitlesHover.svg new file mode 100644 index 0000000..2a735eb --- /dev/null +++ b/packages/design-system/src/assets/icons/video-player/VideoPlayerSubtitlesHover.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/packages/design-system/src/assets/icons/video-quality/4K.svg b/packages/design-system/src/assets/icons/video-quality/4K.svg new file mode 100644 index 0000000..86faf3b --- /dev/null +++ b/packages/design-system/src/assets/icons/video-quality/4K.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/design-system/src/assets/icons/video-quality/DolbyVision.svg b/packages/design-system/src/assets/icons/video-quality/DolbyVision.svg new file mode 100644 index 0000000..cc0a4a9 --- /dev/null +++ b/packages/design-system/src/assets/icons/video-quality/DolbyVision.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/design-system/src/assets/icons/video-quality/HD.svg b/packages/design-system/src/assets/icons/video-quality/HD.svg new file mode 100644 index 0000000..42f8783 --- /dev/null +++ b/packages/design-system/src/assets/icons/video-quality/HD.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/design-system/src/assets/icons/video-quality/HDR.svg b/packages/design-system/src/assets/icons/video-quality/HDR.svg new file mode 100644 index 0000000..7cd0706 --- /dev/null +++ b/packages/design-system/src/assets/icons/video-quality/HDR.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/design-system/src/assets/icons/video-quality/UltraHD4K.svg b/packages/design-system/src/assets/icons/video-quality/UltraHD4K.svg new file mode 100644 index 0000000..efb596f --- /dev/null +++ b/packages/design-system/src/assets/icons/video-quality/UltraHD4K.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/design-system/src/assets/index.ts b/packages/design-system/src/assets/index.ts new file mode 100644 index 0000000..838008a --- /dev/null +++ b/packages/design-system/src/assets/index.ts @@ -0,0 +1 @@ +export * from './icons'; diff --git a/packages/design-system/src/components/atoms/Badge.tsx b/packages/design-system/src/components/atoms/Badge.tsx new file mode 100644 index 0000000..1d2d08d --- /dev/null +++ b/packages/design-system/src/components/atoms/Badge.tsx @@ -0,0 +1,37 @@ +import type { CSSProperties, HTMLAttributes, ReactNode } from 'react'; + +type BadgeVariant = 'released' | 'locked' | 'expired' | 'neutral'; + +type BadgeProps = HTMLAttributes & { + children: ReactNode; + variant?: BadgeVariant; +}; + +const variants: Record = { + released: { borderColor: 'var(--fx-color-success)', background: 'rgb(70 211 105 / 24%)' }, + locked: { borderColor: 'var(--fx-color-border-default)', background: 'rgb(68 142 244 / 20%)' }, + expired: { borderColor: 'var(--fx-color-error)', background: 'rgb(235 57 66 / 20%)' }, + neutral: { borderColor: 'var(--fx-color-border-default)', background: 'var(--fx-color-bg-card)' }, +}; + +export const Badge = ({ children, variant = 'neutral', style, ...props }: BadgeProps) => ( + + {children} + +); diff --git a/packages/design-system/src/components/atoms/Button.tsx b/packages/design-system/src/components/atoms/Button.tsx new file mode 100644 index 0000000..864a6c7 --- /dev/null +++ b/packages/design-system/src/components/atoms/Button.tsx @@ -0,0 +1,144 @@ +import type { ButtonHTMLAttributes, CSSProperties, ReactNode } from 'react'; + +type ButtonVariant = + | 'primary' + | 'primaryPressed' + | 'secondary' + | 'secondaryPressed' + | 'ghost' + | 'danger' + | 'text' + | 'textMuted' + | 'play' + | 'moreInfo'; +type ButtonSize = 'sm' | 'md' | 'lg'; + +type ButtonProps = ButtonHTMLAttributes & { + children: ReactNode; + variant?: ButtonVariant; + size?: ButtonSize; + fullWidth?: boolean; + leadingIcon?: ReactNode; + trailingIcon?: ReactNode; +}; + +const variants: Record = { + primary: { + background: 'var(--fx-color-brand-primary)', + color: 'var(--fx-color-text-primary)', + border: '1px solid var(--fx-color-brand-primary)', + }, + secondary: { + background: 'var(--fx-color-bg-card)', + color: 'var(--fx-color-text-primary)', + border: '1px solid var(--fx-color-border-default)', + }, + secondaryPressed: { + background: 'var(--fx-color-bg-hover)', + color: 'var(--fx-color-text-primary)', + border: '1px solid var(--fx-color-border-default)', + }, + primaryPressed: { + background: 'var(--fx-color-brand-hover)', + color: 'var(--fx-color-text-primary)', + border: '1px solid var(--fx-color-brand-hover)', + }, + ghost: { + background: 'transparent', + color: 'var(--fx-color-text-secondary)', + border: '1px solid var(--fx-color-border-default)', + }, + danger: { + background: 'var(--fx-color-error)', + color: 'var(--fx-color-text-primary)', + border: '1px solid var(--fx-color-error)', + }, + text: { + background: 'transparent', + color: 'var(--fx-color-text-primary)', + border: '1px solid transparent', + }, + textMuted: { + background: 'transparent', + color: 'var(--fx-color-text-tertiary)', + border: '1px solid transparent', + }, + play: { + background: 'var(--fx-color-text-primary)', + color: 'var(--fx-color-bg-primary)', + border: '1px solid var(--fx-color-text-primary)', + }, + moreInfo: { + background: 'var(--fx-color-bg-overlay-strong)', + color: 'var(--fx-color-text-primary)', + border: '1px solid transparent', + }, +}; + +const sizes: Record = { + sm: { + minHeight: 'var(--fx-size-button-height-sm)', + padding: '0 var(--fx-size-button-padding-x-sm)', + fontSize: 'var(--fx-typo-medium-body-size)', + lineHeight: 'var(--fx-typo-medium-body-line)', + }, + md: { + minHeight: 'var(--fx-size-button-height-md)', + padding: '0 var(--fx-size-button-padding-x-md)', + fontSize: 'var(--fx-typo-medium-body-size)', + lineHeight: 'var(--fx-typo-medium-body-line)', + }, + lg: { + minHeight: 'var(--fx-size-button-height-lg)', + padding: '0 var(--fx-size-button-padding-x-lg)', + fontSize: 'var(--fx-typo-medium-headline2-size)', + lineHeight: 'var(--fx-typo-medium-headline2-line)', + }, +}; + +const base: CSSProperties = { + borderRadius: 'var(--fx-radius-button)', + fontFamily: 'var(--fx-font-sans)', + fontWeight: 'var(--fx-font-weight-medium)', + letterSpacing: 'var(--fx-typo-medium-body-spacing)', + cursor: 'pointer', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + gap: 'var(--fx-space-2)', + whiteSpace: 'nowrap', +}; + +const disabledStyle: CSSProperties = { + opacity: 'var(--fx-opacity-disabled)', + cursor: 'not-allowed', +}; + +export const Button = ({ + children, + variant = 'primary', + size = 'md', + fullWidth = false, + leadingIcon, + trailingIcon, + disabled, + style, + ...props +}: ButtonProps) => ( + +); diff --git a/packages/design-system/src/components/atoms/Card.tsx b/packages/design-system/src/components/atoms/Card.tsx new file mode 100644 index 0000000..5edd8e2 --- /dev/null +++ b/packages/design-system/src/components/atoms/Card.tsx @@ -0,0 +1,20 @@ +import type { HTMLAttributes, ReactNode } from 'react'; + +type CardProps = HTMLAttributes & { + children: ReactNode; +}; + +export const Card = ({ children, style, ...props }: CardProps) => ( +
+ {children} +
+); diff --git a/packages/design-system/src/components/atoms/Dropdown.tsx b/packages/design-system/src/components/atoms/Dropdown.tsx new file mode 100644 index 0000000..1c6db51 --- /dev/null +++ b/packages/design-system/src/components/atoms/Dropdown.tsx @@ -0,0 +1,320 @@ +import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react'; + +export type DropdownOption = { + value: string; + label: string; + disabled?: boolean; +}; + +type DropdownSurface = 'black' | 'elevated'; +type DropdownVariant = 'compact' | 'browse'; + +type DropdownProps = { + options: DropdownOption[]; + value?: string; + defaultValue?: string; + onChange?: (value: string, option: DropdownOption) => void; + placeholder?: string; + disabled?: boolean; + surface?: DropdownSurface; + variant?: DropdownVariant; + width?: CSSProperties['width']; + maxMenuHeight?: number; + ariaLabel?: string; + className?: string; + style?: CSSProperties; +}; + +const triggerBase: CSSProperties = { + width: '100%', + borderRadius: '0', + border: '1px solid var(--fx-color-text-primary)', + color: 'var(--fx-color-text-primary)', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 'var(--fx-space-2)', + cursor: 'pointer', + outline: 'none', + fontFamily: 'var(--fx-font-sans)', + fontSize: 'var(--fx-typo-regular-headline2-size)', + lineHeight: 'var(--fx-typo-regular-headline2-line)', + fontWeight: 'var(--fx-typo-regular-headline2-weight)', + letterSpacing: 'var(--fx-typo-regular-headline2-spacing)', +}; + +const triggerVariants: Record = { + compact: { + height: '26px', + padding: '0 var(--fx-space-2)', + }, + browse: { + height: '37px', + padding: '0 var(--fx-space-3)', + }, +}; + +const triggerSurfaces: Record = { + black: { + background: 'var(--fx-color-bg-primary)', + }, + elevated: { + background: 'var(--fx-color-bg-card)', + }, +}; + +const menuStyle: CSSProperties = { + position: 'absolute', + top: 'calc(100% - 1px)', + left: 0, + right: 0, + border: '1px solid var(--fx-color-text-primary)', + background: 'var(--fx-color-bg-primary)', + zIndex: 50, + overflowY: 'auto', +}; + +const optionStyle: CSSProperties = { + width: '100%', + border: 0, + background: 'transparent', + color: 'var(--fx-color-text-primary)', + textAlign: 'left', + padding: '2px var(--fx-space-2)', + cursor: 'pointer', + fontFamily: 'var(--fx-font-sans)', + fontSize: 'var(--fx-typo-regular-headline2-size)', + lineHeight: 'var(--fx-typo-regular-headline2-line)', + fontWeight: 'var(--fx-typo-regular-headline2-weight)', + letterSpacing: 'var(--fx-typo-regular-headline2-spacing)', + display: 'block', +}; + +const getNextEnabledIndex = (options: DropdownOption[], start: number, direction: 1 | -1): number => { + let index = start; + for (let i = 0; i < options.length; i += 1) { + index = (index + direction + options.length) % options.length; + if (!options[index]?.disabled) { + return index; + } + } + return -1; +}; + +export const Dropdown = ({ + options, + value, + defaultValue, + onChange, + placeholder = 'Select an option', + disabled = false, + surface = 'black', + variant = 'compact', + width, + maxMenuHeight = 520, + ariaLabel, + className, + style, +}: DropdownProps) => { + const isControlled = value !== undefined; + const [internalValue, setInternalValue] = useState(defaultValue); + const [open, setOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(-1); + const [focusVisible, setFocusVisible] = useState(false); + + const rootRef = useRef(null); + const optionRefs = useRef>([]); + + const selectedValue = isControlled ? value : internalValue; + const resolvedWidth = width ?? (variant === 'browse' ? 245 : 177); + const selectedIndex = useMemo( + () => options.findIndex((option) => option.value === selectedValue), + [options, selectedValue], + ); + const selectedOption = selectedIndex >= 0 ? options[selectedIndex] : undefined; + + useEffect(() => { + if (!open) { + return; + } + const onPointerDown = (event: MouseEvent | TouchEvent) => { + const node = rootRef.current; + if (node && !node.contains(event.target as Node)) { + setOpen(false); + setActiveIndex(-1); + } + }; + document.addEventListener('mousedown', onPointerDown); + document.addEventListener('touchstart', onPointerDown); + return () => { + document.removeEventListener('mousedown', onPointerDown); + document.removeEventListener('touchstart', onPointerDown); + }; + }, [open]); + + useEffect(() => { + if (!open) { + return; + } + const initialIndex = selectedIndex >= 0 && !options[selectedIndex]?.disabled + ? selectedIndex + : getNextEnabledIndex(options, -1, 1); + setActiveIndex(initialIndex); + }, [open, selectedIndex, options]); + + useEffect(() => { + if (!open || activeIndex < 0) { + return; + } + optionRefs.current[activeIndex]?.scrollIntoView({ block: 'nearest' }); + }, [activeIndex, open]); + + const commitSelection = (index: number) => { + const option = options[index]; + if (!option || option.disabled) { + return; + } + if (!isControlled) { + setInternalValue(option.value); + } + onChange?.(option.value, option); + setOpen(false); + setActiveIndex(index); + }; + + const onTriggerKeyDown = (event: KeyboardEvent) => { + if (disabled) { + return; + } + if (event.key === 'ArrowDown') { + event.preventDefault(); + if (!open) { + setOpen(true); + return; + } + setActiveIndex((current) => getNextEnabledIndex(options, current < 0 ? -1 : current, 1)); + return; + } + if (event.key === 'ArrowUp') { + event.preventDefault(); + if (!open) { + setOpen(true); + return; + } + setActiveIndex((current) => getNextEnabledIndex(options, current < 0 ? 0 : current, -1)); + return; + } + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + if (!open) { + setOpen(true); + } else if (activeIndex >= 0) { + commitSelection(activeIndex); + } + return; + } + if (event.key === 'Escape') { + setOpen(false); + setActiveIndex(-1); + } + }; + + const onListKeyDown = (event: KeyboardEvent) => { + if (event.key === 'ArrowDown') { + event.preventDefault(); + setActiveIndex((current) => getNextEnabledIndex(options, current < 0 ? -1 : current, 1)); + return; + } + if (event.key === 'ArrowUp') { + event.preventDefault(); + setActiveIndex((current) => getNextEnabledIndex(options, current < 0 ? 0 : current, -1)); + return; + } + if (event.key === 'Home') { + event.preventDefault(); + setActiveIndex(getNextEnabledIndex(options, -1, 1)); + return; + } + if (event.key === 'End') { + event.preventDefault(); + setActiveIndex(getNextEnabledIndex(options, 0, -1)); + return; + } + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + if (activeIndex >= 0) { + commitSelection(activeIndex); + } + return; + } + if (event.key === 'Escape' || event.key === 'Tab') { + setOpen(false); + setActiveIndex(-1); + } + }; + + return ( +
+ + + {open ? ( +
+ {options.map((option, index) => { + const isSelected = option.value === selectedValue; + const isActive = index === activeIndex; + return ( + + ); + })} +
+ ) : null} +
+ ); +}; diff --git a/packages/design-system/src/components/atoms/FaqToggleIcon.tsx b/packages/design-system/src/components/atoms/FaqToggleIcon.tsx new file mode 100644 index 0000000..3988226 --- /dev/null +++ b/packages/design-system/src/components/atoms/FaqToggleIcon.tsx @@ -0,0 +1,38 @@ +import type { CSSProperties } from 'react'; + +type FaqToggleIconProps = { + expanded: boolean; + size?: number | string; + color?: string; + strokeWidth?: number; + style?: CSSProperties; +}; + +export const FaqToggleIcon = ({ + expanded, + size = 'var(--fx-size-pattern-faq-icon-size)', + color = 'var(--fx-color-text-primary)', + strokeWidth = 2, + style, +}: FaqToggleIconProps) => ( + +); diff --git a/packages/design-system/src/components/atoms/HeroBannerControlButton.tsx b/packages/design-system/src/components/atoms/HeroBannerControlButton.tsx new file mode 100644 index 0000000..eec1e70 --- /dev/null +++ b/packages/design-system/src/components/atoms/HeroBannerControlButton.tsx @@ -0,0 +1,49 @@ +import type { ButtonHTMLAttributes, CSSProperties } from 'react'; +import { useState } from 'react'; +import { getHeroBannerPreviewIconSetAsset, type HeroBannerPreviewIconSetName } from '../../assets/icons'; + +type HeroBannerControlButtonProps = Omit, 'children'> & { + iconSet: HeroBannerPreviewIconSetName; + label: string; + size?: number | string; +}; + +const buttonStyle: CSSProperties = { + border: 'var(--fx-size-border-default) solid var(--fx-color-hero-control-border)', + background: 'var(--fx-color-hero-control-background)', + color: 'var(--fx-color-text-primary)', + padding: 0, + width: 'var(--fx-size-pattern-hero-control-size)', + height: 'var(--fx-size-pattern-hero-control-size)', + borderRadius: '9999px', + cursor: 'pointer', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + lineHeight: 0, + flexShrink: 0, +}; + +const iconStyle: CSSProperties = { + width: 'var(--fx-size-pattern-header-action-icon-size)', + height: 'var(--fx-size-pattern-header-action-icon-size)', + objectFit: 'contain', +}; + +export const HeroBannerControlButton = ({ iconSet, label, size, style, ...props }: HeroBannerControlButtonProps) => { + const [hovered, setHovered] = useState(false); + const iconSrc = getHeroBannerPreviewIconSetAsset(iconSet, hovered ? 'hover' : 'default'); + + return ( + + ); +}; diff --git a/packages/design-system/src/components/atoms/Icon.tsx b/packages/design-system/src/components/atoms/Icon.tsx new file mode 100644 index 0000000..44422d5 --- /dev/null +++ b/packages/design-system/src/components/atoms/Icon.tsx @@ -0,0 +1,28 @@ +import type { CSSProperties, ImgHTMLAttributes } from 'react'; +import { getIconAsset, type IconName } from '../../assets/icons'; + +type IconProps = Omit, 'src' | 'alt'> & { + name: IconName; + size?: number | string; + alt?: string; +}; + +const wrapperStyle: CSSProperties = { + display: 'inline-flex', + lineHeight: 0, + flexShrink: 0, +}; + +export const Icon = ({ name, size = 16, alt = '', style, ...props }: IconProps) => ( + + {alt} + +); diff --git a/packages/design-system/src/components/atoms/Input.tsx b/packages/design-system/src/components/atoms/Input.tsx new file mode 100644 index 0000000..6417e1a --- /dev/null +++ b/packages/design-system/src/components/atoms/Input.tsx @@ -0,0 +1,108 @@ +import { useMemo, useState, type CSSProperties, type InputHTMLAttributes } from 'react'; + +type InputState = 'default' | 'focused' | 'error'; + +type InputProps = InputHTMLAttributes & { + state?: InputState; + errorMessage?: string; + helperText?: string; +}; + +const baseInputStyle: CSSProperties = { + width: '100%', + height: 'var(--fx-size-input-height)', + borderRadius: 'var(--fx-radius-input)', + border: 'var(--fx-size-border-default) solid var(--fx-color-border-strong)', + background: 'var(--fx-color-bg-input)', + color: 'var(--fx-color-text-primary)', + padding: '0 var(--fx-space-4)', + fontFamily: 'var(--fx-font-sans)', + fontSize: 'var(--fx-typo-regular-headline2-size)', + lineHeight: 'var(--fx-typo-regular-headline2-line)', + fontWeight: 'var(--fx-typo-regular-headline2-weight)', + letterSpacing: 'var(--fx-typo-regular-headline2-spacing)', + outline: 'none', + transition: 'box-shadow 150ms ease, border-color 150ms ease', +}; + +const helperStyle: CSSProperties = { + fontFamily: 'var(--fx-font-sans)', + fontSize: 'var(--fx-typo-medium-caption1-size)', + lineHeight: 'var(--fx-typo-medium-caption1-line)', + fontWeight: 'var(--fx-typo-medium-caption1-weight)', + letterSpacing: 'var(--fx-typo-medium-caption1-spacing)', + marginTop: 'var(--fx-space-1)', + display: 'inline-flex', + gap: 'var(--fx-space-1)', + alignItems: 'center', +}; + +const ErrorIcon = () => ( + +); + +export const Input = ({ state, errorMessage, helperText, style, onFocus, onBlur, ...props }: InputProps) => { + const [isFocused, setIsFocused] = useState(false); + const { width, maxWidth, minWidth, ...styleOverrides } = style ?? {}; + + const resolvedState = useMemo(() => { + if (state) { + return state; + } + if (errorMessage) { + return 'error'; + } + if (isFocused) { + return 'focused'; + } + return 'default'; + }, [errorMessage, isFocused, state]); + + const inputStyle: CSSProperties = { + ...baseInputStyle, + borderColor: resolvedState === 'error' ? 'var(--fx-color-error)' : 'var(--fx-color-border-strong)', + boxShadow: resolvedState === 'focused' ? '0 0 0 var(--fx-size-focus-ring) var(--fx-color-text-primary)' : 'none', + opacity: props.disabled ? 0.6 : 1, + ...styleOverrides, + }; + + return ( +
+ { + setIsFocused(true); + onFocus?.(event); + }} + onBlur={(event) => { + setIsFocused(false); + onBlur?.(event); + }} + /> + {errorMessage ? ( + + + {errorMessage} + + ) : null} + {!errorMessage && helperText ? ( + {helperText} + ) : null} +
+ ); +}; diff --git a/packages/design-system/src/components/atoms/ProfileAvatar.tsx b/packages/design-system/src/components/atoms/ProfileAvatar.tsx new file mode 100644 index 0000000..42f10bf --- /dev/null +++ b/packages/design-system/src/components/atoms/ProfileAvatar.tsx @@ -0,0 +1,107 @@ +import type { CSSProperties, ReactNode } from 'react'; +import type { IconName } from '../../assets/icons'; +import { Icon } from './Icon'; + +export type ProfileAvatarSize = 'sm' | 'md' | 'lg'; + +type ProfileAvatarProps = { + imageUrl?: string; + imageAlt?: string; + size?: ProfileAvatarSize; + selected?: boolean; + addState?: boolean; + addIconName?: IconName; + interactive?: boolean; + ariaLabel?: string; + onClick?: () => void; + style?: CSSProperties; +}; + +const sizeMap: Record = { + sm: 'var(--fx-size-pattern-profile-avatar-sm-size)', + md: 'var(--fx-size-pattern-profile-avatar-md-size)', + lg: 'var(--fx-size-pattern-profile-avatar-lg-size)', +}; + +const radiusMap: Record = { + sm: 'var(--fx-size-pattern-profile-avatar-sm-radius)', + md: 'var(--fx-size-pattern-profile-avatar-sm-radius)', + lg: 'var(--fx-size-pattern-profile-avatar-lg-radius)', +}; + +const baseStyle: CSSProperties = { + border: 0, + padding: 0, + margin: 0, + cursor: 'pointer', + background: 'transparent', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + lineHeight: 0, + overflow: 'hidden', +}; + +export const ProfileAvatar = ({ + imageUrl, + imageAlt = '', + size = 'md', + selected = false, + addState = false, + addIconName = 'plusWide', + interactive = true, + ariaLabel, + onClick, + style, +}: ProfileAvatarProps): ReactNode => { + const dimension = sizeMap[size]; + + const visual = imageUrl ? ( + {imageAlt} + ) : ( + + ); + + const sharedStyle: CSSProperties = { + ...baseStyle, + width: dimension, + height: dimension, + borderRadius: radiusMap[size], + outline: selected ? '3px solid var(--fx-color-profile-avatar-selection-border)' : 'none', + outlineOffset: selected ? '-3px' : '0', + background: addState ? 'var(--fx-color-profile-avatar-add-background)' : 'transparent', + cursor: interactive ? 'pointer' : 'default', + ...style, + }; + + if (!interactive) { + return ( + + ); + } + + return ( + + ); +}; diff --git a/packages/design-system/src/components/atoms/Text.tsx b/packages/design-system/src/components/atoms/Text.tsx new file mode 100644 index 0000000..6a8d631 --- /dev/null +++ b/packages/design-system/src/components/atoms/Text.tsx @@ -0,0 +1,301 @@ +import type { CSSProperties, HTMLAttributes, ReactNode } from 'react'; + +type TextTone = 'primary' | 'secondary' | 'tertiary' | 'error'; +type TextVariant = + | 'regular-caption2' + | 'regular-caption1' + | 'regular-small-body' + | 'regular-small-body-normal' + | 'regular-body' + | 'regular-headline2' + | 'regular-headline1' + | 'regular-title4' + | 'regular-title3' + | 'regular-title2' + | 'regular-title1' + | 'regular-large-title' + | 'medium-caption2' + | 'medium-caption1' + | 'medium-small-body' + | 'medium-body' + | 'medium-body-condensed' + | 'medium-headline2' + | 'medium-headline1' + | 'medium-title4' + | 'medium-title3' + | 'medium-title3-condensed' + | 'medium-title2' + | 'medium-title1' + | 'medium-large-title' + | 'bold-title2' + | 'bold-title1' + | 'bold-large-title' + | 'display-small' + | 'display-medium' + | 'display-large' + | 'display-xlarge'; + +type TextProps = HTMLAttributes & { + as?: 'p' | 'span' | 'strong' | 'h1' | 'h2' | 'h3' | 'h4'; + children: ReactNode; + tone?: TextTone; + variant?: TextVariant; +}; + +const tones: Record = { + primary: 'var(--fx-color-text-primary)', + secondary: 'var(--fx-color-text-secondary)', + tertiary: 'var(--fx-color-text-tertiary)', + error: 'var(--fx-color-error)', +}; + +const variants: Record = { + 'regular-caption2': { + fontFamily: 'var(--fx-font-sans)', + fontSize: 'var(--fx-typo-regular-caption2-size)', + lineHeight: 'var(--fx-typo-regular-caption2-line)', + fontWeight: 'var(--fx-typo-regular-caption2-weight)', + letterSpacing: 'var(--fx-typo-regular-caption2-spacing)', + }, + 'regular-caption1': { + fontFamily: 'var(--fx-font-sans)', + fontSize: 'var(--fx-typo-regular-caption1-size)', + lineHeight: 'var(--fx-typo-regular-caption1-line)', + fontWeight: 'var(--fx-typo-regular-caption1-weight)', + letterSpacing: 'var(--fx-typo-regular-caption1-spacing)', + }, + 'regular-small-body': { + fontFamily: 'var(--fx-font-sans)', + fontSize: 'var(--fx-typo-regular-small-body-size)', + lineHeight: 'var(--fx-typo-regular-small-body-line)', + fontWeight: 'var(--fx-typo-regular-small-body-weight)', + letterSpacing: 'var(--fx-typo-regular-small-body-spacing)', + }, + 'regular-small-body-normal': { + fontFamily: 'var(--fx-font-sans)', + fontSize: 'var(--fx-typo-regular-small-body-normal-size)', + lineHeight: 'var(--fx-typo-regular-small-body-normal-line)', + fontWeight: 'var(--fx-typo-regular-small-body-normal-weight)', + letterSpacing: 'var(--fx-typo-regular-small-body-normal-spacing)', + }, + 'regular-body': { + fontFamily: 'var(--fx-font-sans)', + fontSize: 'var(--fx-typo-regular-body-size)', + lineHeight: 'var(--fx-typo-regular-body-line)', + fontWeight: 'var(--fx-typo-regular-body-weight)', + letterSpacing: 'var(--fx-typo-regular-body-spacing)', + }, + 'regular-headline2': { + fontFamily: 'var(--fx-font-sans)', + fontSize: 'var(--fx-typo-regular-headline2-size)', + lineHeight: 'var(--fx-typo-regular-headline2-line)', + fontWeight: 'var(--fx-typo-regular-headline2-weight)', + letterSpacing: 'var(--fx-typo-regular-headline2-spacing)', + }, + 'regular-headline1': { + fontFamily: 'var(--fx-font-sans)', + fontSize: 'var(--fx-typo-regular-headline1-size)', + lineHeight: 'var(--fx-typo-regular-headline1-line)', + fontWeight: 'var(--fx-typo-regular-headline1-weight)', + letterSpacing: 'var(--fx-typo-regular-headline1-spacing)', + }, + 'regular-title4': { + fontFamily: 'var(--fx-font-sans)', + fontSize: 'var(--fx-typo-regular-title4-size)', + lineHeight: 'var(--fx-typo-regular-title4-line)', + fontWeight: 'var(--fx-typo-regular-title4-weight)', + letterSpacing: 'var(--fx-typo-regular-title4-spacing)', + }, + 'regular-title3': { + fontFamily: 'var(--fx-font-sans)', + fontSize: 'var(--fx-typo-regular-title3-size)', + lineHeight: 'var(--fx-typo-regular-title3-line)', + fontWeight: 'var(--fx-typo-regular-title3-weight)', + letterSpacing: 'var(--fx-typo-regular-title3-spacing)', + }, + 'regular-title2': { + fontFamily: 'var(--fx-font-sans)', + fontSize: 'var(--fx-typo-regular-title2-size)', + lineHeight: 'var(--fx-typo-regular-title2-line)', + fontWeight: 'var(--fx-typo-regular-title2-weight)', + letterSpacing: 'var(--fx-typo-regular-title2-spacing)', + }, + 'regular-title1': { + fontFamily: 'var(--fx-font-sans)', + fontSize: 'var(--fx-typo-regular-title1-size)', + lineHeight: 'var(--fx-typo-regular-title1-line)', + fontWeight: 'var(--fx-typo-regular-title1-weight)', + letterSpacing: 'var(--fx-typo-regular-title1-spacing)', + }, + 'regular-large-title': { + fontFamily: 'var(--fx-font-sans)', + fontSize: 'var(--fx-typo-regular-large-title-size)', + lineHeight: 'var(--fx-typo-regular-large-title-line)', + fontWeight: 'var(--fx-typo-regular-large-title-weight)', + letterSpacing: 'var(--fx-typo-regular-large-title-spacing)', + }, + 'medium-caption2': { + fontFamily: 'var(--fx-font-sans)', + fontSize: 'var(--fx-typo-medium-caption2-size)', + lineHeight: 'var(--fx-typo-medium-caption2-line)', + fontWeight: 'var(--fx-typo-medium-caption2-weight)', + letterSpacing: 'var(--fx-typo-medium-caption2-spacing)', + }, + 'medium-caption1': { + fontFamily: 'var(--fx-font-sans)', + fontSize: 'var(--fx-typo-medium-caption1-size)', + lineHeight: 'var(--fx-typo-medium-caption1-line)', + fontWeight: 'var(--fx-typo-medium-caption1-weight)', + letterSpacing: 'var(--fx-typo-medium-caption1-spacing)', + }, + 'medium-small-body': { + fontFamily: 'var(--fx-font-sans)', + fontSize: 'var(--fx-typo-medium-small-body-size)', + lineHeight: 'var(--fx-typo-medium-small-body-line)', + fontWeight: 'var(--fx-typo-medium-small-body-weight)', + letterSpacing: 'var(--fx-typo-medium-small-body-spacing)', + }, + 'medium-body': { + fontFamily: 'var(--fx-font-sans)', + fontSize: 'var(--fx-typo-medium-body-size)', + lineHeight: 'var(--fx-typo-medium-body-line)', + fontWeight: 'var(--fx-typo-medium-body-weight)', + letterSpacing: 'var(--fx-typo-medium-body-spacing)', + }, + 'medium-body-condensed': { + fontFamily: 'var(--fx-font-sans)', + fontSize: 'var(--fx-typo-medium-body-condensed-size)', + lineHeight: 'var(--fx-typo-medium-body-condensed-line)', + fontWeight: 'var(--fx-typo-medium-body-condensed-weight)', + letterSpacing: 'var(--fx-typo-medium-body-condensed-spacing)', + }, + 'medium-headline2': { + fontFamily: 'var(--fx-font-sans)', + fontSize: 'var(--fx-typo-medium-headline2-size)', + lineHeight: 'var(--fx-typo-medium-headline2-line)', + fontWeight: 'var(--fx-typo-medium-headline2-weight)', + letterSpacing: 'var(--fx-typo-medium-headline2-spacing)', + }, + 'medium-headline1': { + fontFamily: 'var(--fx-font-sans)', + fontSize: 'var(--fx-typo-medium-headline1-size)', + lineHeight: 'var(--fx-typo-medium-headline1-line)', + fontWeight: 'var(--fx-typo-medium-headline1-weight)', + letterSpacing: 'var(--fx-typo-medium-headline1-spacing)', + }, + 'medium-title4': { + fontFamily: 'var(--fx-font-sans)', + fontSize: 'var(--fx-typo-medium-title4-size)', + lineHeight: 'var(--fx-typo-medium-title4-line)', + fontWeight: 'var(--fx-typo-medium-title4-weight)', + letterSpacing: 'var(--fx-typo-medium-title4-spacing)', + }, + 'medium-title3': { + fontFamily: 'var(--fx-font-sans)', + fontSize: 'var(--fx-typo-medium-title3-size)', + lineHeight: 'var(--fx-typo-medium-title3-line)', + fontWeight: 'var(--fx-typo-medium-title3-weight)', + letterSpacing: 'var(--fx-typo-medium-title3-spacing)', + }, + 'medium-title3-condensed': { + fontFamily: 'var(--fx-font-sans)', + fontSize: 'var(--fx-typo-medium-title3-condensed-size)', + lineHeight: 'var(--fx-typo-medium-title3-condensed-line)', + fontWeight: 'var(--fx-typo-medium-title3-condensed-weight)', + letterSpacing: 'var(--fx-typo-medium-title3-condensed-spacing)', + }, + 'medium-title2': { + fontFamily: 'var(--fx-font-sans)', + fontSize: 'var(--fx-typo-medium-title2-size)', + lineHeight: 'var(--fx-typo-medium-title2-line)', + fontWeight: 'var(--fx-typo-medium-title2-weight)', + letterSpacing: 'var(--fx-typo-medium-title2-spacing)', + }, + 'medium-title1': { + fontFamily: 'var(--fx-font-sans)', + fontSize: 'var(--fx-typo-medium-title1-size)', + lineHeight: 'var(--fx-typo-medium-title1-line)', + fontWeight: 'var(--fx-typo-medium-title1-weight)', + letterSpacing: 'var(--fx-typo-medium-title1-spacing)', + }, + 'medium-large-title': { + fontFamily: 'var(--fx-font-sans)', + fontSize: 'var(--fx-typo-medium-large-title-size)', + lineHeight: 'var(--fx-typo-medium-large-title-line)', + fontWeight: 'var(--fx-typo-medium-large-title-weight)', + letterSpacing: 'var(--fx-typo-medium-large-title-spacing)', + }, + 'bold-title2': { + fontFamily: 'var(--fx-font-sans)', + fontSize: 'var(--fx-typo-bold-title2-size)', + lineHeight: 'var(--fx-typo-bold-title2-line)', + fontWeight: 'var(--fx-typo-bold-title2-weight)', + letterSpacing: 'var(--fx-typo-bold-title2-spacing)', + }, + 'bold-title1': { + fontFamily: 'var(--fx-font-sans)', + fontSize: 'var(--fx-typo-bold-title1-size)', + lineHeight: 'var(--fx-typo-bold-title1-line)', + fontWeight: 'var(--fx-typo-bold-title1-weight)', + letterSpacing: 'var(--fx-typo-bold-title1-spacing)', + }, + 'bold-large-title': { + fontFamily: 'var(--fx-font-sans)', + fontSize: 'var(--fx-typo-bold-large-title-size)', + lineHeight: 'var(--fx-typo-bold-large-title-line)', + fontWeight: 'var(--fx-typo-bold-large-title-weight)', + letterSpacing: 'var(--fx-typo-bold-large-title-spacing)', + }, + 'display-small': { + fontFamily: 'var(--fx-font-display)', + fontSize: 'var(--fx-typo-display-small-size)', + lineHeight: 'var(--fx-typo-display-small-line)', + fontWeight: 'var(--fx-typo-display-small-weight)', + letterSpacing: 'var(--fx-typo-display-small-spacing)', + }, + 'display-medium': { + fontFamily: 'var(--fx-font-display)', + fontSize: 'var(--fx-typo-display-medium-size)', + lineHeight: 'var(--fx-typo-display-medium-line)', + fontWeight: 'var(--fx-typo-display-medium-weight)', + letterSpacing: 'var(--fx-typo-display-medium-spacing)', + }, + 'display-large': { + fontFamily: 'var(--fx-font-display)', + fontSize: 'var(--fx-typo-display-large-size)', + lineHeight: 'var(--fx-typo-display-large-line)', + fontWeight: 'var(--fx-typo-display-large-weight)', + letterSpacing: 'var(--fx-typo-display-large-spacing)', + }, + 'display-xlarge': { + fontFamily: 'var(--fx-font-display)', + fontSize: 'var(--fx-typo-display-xlarge-size)', + lineHeight: 'var(--fx-typo-display-xlarge-line)', + fontWeight: 'var(--fx-typo-display-xlarge-weight)', + letterSpacing: 'var(--fx-typo-display-xlarge-spacing)', + }, +}; + +export const Text = ({ + as = 'p', + children, + tone = 'primary', + variant = 'regular-body', + style, + ...props +}: TextProps) => { + const Comp = as; + return ( + + {children} + + ); +}; diff --git a/packages/design-system/src/components/atoms/VideoPlayerIconButton.tsx b/packages/design-system/src/components/atoms/VideoPlayerIconButton.tsx new file mode 100644 index 0000000..bef94ff --- /dev/null +++ b/packages/design-system/src/components/atoms/VideoPlayerIconButton.tsx @@ -0,0 +1,43 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { getVideoPlayerIconSetAsset, type VideoPlayerIconSetName } from '../../assets/icons'; + +type VideoPlayerIconButtonProps = { + icon: VideoPlayerIconSetName; + state?: 'default' | 'hover'; + size?: number | string; + ariaLabel: string; + onClick?: () => void; + style?: CSSProperties; +}; + +const buttonStyle: CSSProperties = { + border: 0, + background: 'transparent', + padding: 0, + margin: 0, + cursor: 'pointer', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + lineHeight: 0, +}; + +export const VideoPlayerIconButton = ({ + icon, + state = 'default', + size = 'var(--fx-size-pattern-video-icon-control)', + ariaLabel, + onClick, + style, +}: VideoPlayerIconButtonProps): ReactNode => ( + +); diff --git a/packages/design-system/src/components/atoms/index.ts b/packages/design-system/src/components/atoms/index.ts new file mode 100644 index 0000000..23225e5 --- /dev/null +++ b/packages/design-system/src/components/atoms/index.ts @@ -0,0 +1,11 @@ +export * from './Badge'; +export * from './Button'; +export * from './Card'; +export * from './Dropdown'; +export * from './FaqToggleIcon'; +export * from './HeroBannerControlButton'; +export * from './Icon'; +export * from './Input'; +export * from './ProfileAvatar'; +export * from './Text'; +export * from './VideoPlayerIconButton'; diff --git a/packages/design-system/src/components/index.ts b/packages/design-system/src/components/index.ts new file mode 100644 index 0000000..5e1aa18 --- /dev/null +++ b/packages/design-system/src/components/index.ts @@ -0,0 +1,5 @@ +export * from './atoms'; +export * from './molecules'; +export * from './organisms'; +export * from './templates'; +export * from './pages'; diff --git a/packages/design-system/src/components/molecules/AccessKeyForm.tsx b/packages/design-system/src/components/molecules/AccessKeyForm.tsx new file mode 100644 index 0000000..07f0092 --- /dev/null +++ b/packages/design-system/src/components/molecules/AccessKeyForm.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from 'react'; +import { Button } from '../atoms/Button'; +import { Input } from '../atoms/Input'; + +type AccessKeyFormProps = { + value: string; + onChange: (next: string) => void; + onSubmit: () => void; + loading?: boolean; + submitLabel?: string; +}; + +export const AccessKeyForm = ({ value, onChange, onSubmit, loading, submitLabel = 'Load catalog' }: AccessKeyFormProps): ReactNode => ( +
+ onChange(event.target.value)} + /> + +
+); diff --git a/packages/design-system/src/components/molecules/BrowseLanguagePattern.tsx b/packages/design-system/src/components/molecules/BrowseLanguagePattern.tsx new file mode 100644 index 0000000..6a5498c --- /dev/null +++ b/packages/design-system/src/components/molecules/BrowseLanguagePattern.tsx @@ -0,0 +1,65 @@ +import type { CSSProperties } from 'react'; +import { Dropdown, type DropdownOption } from '../atoms/Dropdown'; + +type BrowseLanguagePatternProps = { + originalLanguageOptions: DropdownOption[]; + languageOptions: DropdownOption[]; + suggestionOptions: DropdownOption[]; + originalLanguageValue?: string; + languageValue?: string; + suggestionValue?: string; + onOriginalLanguageChange?: (value: string, option: DropdownOption) => void; + onLanguageChange?: (value: string, option: DropdownOption) => void; + onSuggestionChange?: (value: string, option: DropdownOption) => void; + style?: CSSProperties; +}; + +const rowStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: 'var(--fx-space-2)', + flexWrap: 'wrap', +}; + +export const BrowseLanguagePattern = ({ + originalLanguageOptions, + languageOptions, + suggestionOptions, + originalLanguageValue, + languageValue, + suggestionValue, + onOriginalLanguageChange, + onLanguageChange, + onSuggestionChange, + style, +}: BrowseLanguagePatternProps) => ( +
+ + + +
+); diff --git a/packages/design-system/src/components/molecules/ContinueWatchingCard.tsx b/packages/design-system/src/components/molecules/ContinueWatchingCard.tsx new file mode 100644 index 0000000..8a78f60 --- /dev/null +++ b/packages/design-system/src/components/molecules/ContinueWatchingCard.tsx @@ -0,0 +1,92 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { Icon } from '../atoms/Icon'; + +type ContinueWatchingCardProps = { + imageUrl: string; + imageAlt?: string; + progress: number; + progressLabel?: string; + presetIconName?: 'presetTop10' | 'presetRecentlyAdded' | 'presetLeavingSoon' | 'presetNewSeason'; + onClick?: () => void; + style?: CSSProperties; +}; + +const cardStyle: CSSProperties = { + width: 'var(--fx-size-pattern-movie-card-standard-width)', + border: 0, + padding: 0, + background: 'transparent', + cursor: 'pointer', + display: 'grid', + gap: 'var(--fx-space-2)', + textAlign: 'left', +}; + +const imageWrapperStyle: CSSProperties = { + position: 'relative', + width: 'var(--fx-size-pattern-movie-card-standard-width)', + height: 'var(--fx-size-pattern-movie-card-standard-height)', + borderRadius: 'var(--fx-radius-sm)', + overflow: 'hidden', + background: 'var(--fx-color-bg-hover)', +}; + +const imageStyle: CSSProperties = { + width: '100%', + height: '100%', + objectFit: 'cover', + display: 'block', +}; + +const progressTrackStyle: CSSProperties = { + position: 'relative', + width: 'var(--fx-size-pattern-movie-progress-track-width)', + height: 'var(--fx-size-pattern-movie-progress-track-height)', + background: 'var(--fx-color-movie-card-progress-track)', + borderRadius: '9999px', + overflow: 'hidden', +}; + +export const ContinueWatchingCard = ({ + imageUrl, + imageAlt = '', + progress, + progressLabel, + presetIconName, + onClick, + style, +}: ContinueWatchingCardProps): ReactNode => { + const safeProgress = Math.max(0, Math.min(100, progress)); + + return ( + + ); +}; diff --git a/packages/design-system/src/components/molecules/EmailGetStartedPattern.tsx b/packages/design-system/src/components/molecules/EmailGetStartedPattern.tsx new file mode 100644 index 0000000..d2a9e6f --- /dev/null +++ b/packages/design-system/src/components/molecules/EmailGetStartedPattern.tsx @@ -0,0 +1,82 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { Button } from '../atoms/Button'; +import { Input } from '../atoms/Input'; + +type EmailGetStartedPatternProps = { + emailValue: string; + onEmailChange: (next: string) => void; + onSubmit: () => void; + emailPlaceholder?: string; + buttonLabel?: string; + disabled?: boolean; + showArrow?: boolean; + style?: CSSProperties; +}; + +const rowStyle: CSSProperties = { + display: 'flex', + gap: 'var(--fx-space-1)', + alignItems: 'center', + flexWrap: 'wrap', +}; + +const inputStyle: CSSProperties = { + width: 'var(--fx-size-pattern-get-started-input-width)', + maxWidth: '100%', +}; + +const buttonStyle: CSSProperties = { + width: 'var(--fx-size-pattern-get-started-button-width)', + minHeight: 'var(--fx-size-pattern-get-started-button-height)', + borderRadius: 'var(--fx-radius-input)', + background: 'var(--fx-color-brand-primary)', + borderColor: 'var(--fx-color-brand-primary)', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + gap: 'var(--fx-space-2)', + fontSize: 'var(--fx-typo-medium-headline2-size)', + lineHeight: 'var(--fx-typo-medium-headline2-line)', + fontWeight: 'var(--fx-typo-medium-headline2-weight)', + letterSpacing: 'var(--fx-typo-medium-headline2-spacing)', +}; + +export const EmailGetStartedPattern = ({ + emailValue, + onEmailChange, + onSubmit, + emailPlaceholder = 'Email address', + buttonLabel = 'Get Started', + disabled, + showArrow = true, + style, +}: EmailGetStartedPatternProps): ReactNode => ( +
+ onEmailChange(event.target.value)} + style={inputStyle} + /> + +
+); diff --git a/packages/design-system/src/components/molecules/EpisodeListItem.tsx b/packages/design-system/src/components/molecules/EpisodeListItem.tsx new file mode 100644 index 0000000..7c48acb --- /dev/null +++ b/packages/design-system/src/components/molecules/EpisodeListItem.tsx @@ -0,0 +1,86 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { Text } from '../atoms/Text'; + +type EpisodeListItemProps = { + itemAriaLabel?: string; + episodeNumber: number; + title: string; + durationLabel: string; + description: string; + imageUrl: string; + imageAlt?: string; + active?: boolean; + onClick?: () => void; + style?: CSSProperties; +}; + +const itemStyle: CSSProperties = { + width: '100%', + border: 0, + padding: 'var(--fx-space-4)', + margin: 0, + display: 'grid', + gridTemplateColumns: 'var(--fx-size-pattern-episode-thumb-width) auto', + gap: 'var(--fx-space-4)', + background: 'transparent', + textAlign: 'left', + cursor: 'pointer', + borderRadius: 'var(--fx-radius-sm)', +}; + +const thumbStyle: CSSProperties = { + width: 'var(--fx-size-pattern-episode-thumb-width)', + height: 'var(--fx-size-pattern-episode-thumb-height)', + borderRadius: 'var(--fx-radius-sm)', + objectFit: 'cover', + display: 'block', + background: 'var(--fx-color-bg-hover)', +}; + +const headerStyle: CSSProperties = { + display: 'flex', + alignItems: 'baseline', + justifyContent: 'space-between', + gap: 'var(--fx-space-3)', +}; + +export const EpisodeListItem = ({ + itemAriaLabel, + episodeNumber, + title, + durationLabel, + description, + imageUrl, + imageAlt = '', + active = false, + onClick, + style, +}: EpisodeListItemProps): ReactNode => ( + +); diff --git a/packages/design-system/src/components/molecules/FaqAccordionItem.tsx b/packages/design-system/src/components/molecules/FaqAccordionItem.tsx new file mode 100644 index 0000000..5ec9748 --- /dev/null +++ b/packages/design-system/src/components/molecules/FaqAccordionItem.tsx @@ -0,0 +1,108 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { useState } from 'react'; +import { FaqToggleIcon } from '../atoms/FaqToggleIcon'; +import { Text } from '../atoms/Text'; + +type FaqAnswerContent = ReactNode | string | string[]; + +export type FaqAccordionItemProps = { + id: string; + question: string; + answer: FaqAnswerContent; + expanded: boolean; + onToggle: () => void; + style?: CSSProperties; +}; + +const itemStyle: CSSProperties = { + background: 'var(--fx-color-faq-item-background-default)', +}; + +const triggerStyle: CSSProperties = { + width: '100%', + minHeight: 'var(--fx-size-pattern-faq-item-min-height)', + padding: 'var(--fx-space-4) var(--fx-size-pattern-faq-item-padding-x)', + border: 0, + background: 'transparent', + color: 'var(--fx-color-text-primary)', + textAlign: 'left', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 'var(--fx-space-4)', + cursor: 'pointer', +}; + +const answerStyle: CSSProperties = { + borderTop: 'var(--fx-size-border-default) solid var(--fx-color-faq-divider)', + background: 'var(--fx-color-faq-answer-background)', + padding: 'var(--fx-space-6) var(--fx-size-pattern-faq-item-padding-x)', + display: 'grid', + gap: 'var(--fx-size-pattern-faq-answer-gap)', +}; + +const textStyle: CSSProperties = { + margin: 0, + fontSize: 'var(--fx-size-pattern-faq-text-size)', + lineHeight: 'var(--fx-size-pattern-faq-text-line-height)', +}; + +const renderAnswer = (answer: FaqAnswerContent) => { + if (Array.isArray(answer)) { + return answer.map((paragraph, index) => ( + + {paragraph} + + )); + } + + if (typeof answer === 'string') { + return ( + + {answer} + + ); + } + + return answer; +}; + +export const FaqAccordionItem = ({ id, question, answer, expanded, onToggle, style }: FaqAccordionItemProps) => { + const [hovered, setHovered] = useState(false); + const triggerId = `faq-trigger-${id}`; + const panelId = `faq-panel-${id}`; + + return ( +
+ + + {expanded ? ( +
+ {renderAnswer(answer)} +
+ ) : null} +
+ ); +}; diff --git a/packages/design-system/src/components/molecules/FormField.tsx b/packages/design-system/src/components/molecules/FormField.tsx new file mode 100644 index 0000000..022fe0d --- /dev/null +++ b/packages/design-system/src/components/molecules/FormField.tsx @@ -0,0 +1,41 @@ +import type { ReactNode } from 'react'; +import { Input } from '../atoms/Input'; +import { Text } from '../atoms/Text'; + +type FormFieldProps = { + label: string; + value: string; + onChange: (next: string) => void; + placeholder?: string; + type?: string; + required?: boolean; + hint?: string; +}; + +export const FormField = ({ + label, + value, + onChange, + placeholder, + type = 'text', + required, + hint, +}: FormFieldProps): ReactNode => ( + +); diff --git a/packages/design-system/src/components/molecules/HeroBannerActionsPattern.tsx b/packages/design-system/src/components/molecules/HeroBannerActionsPattern.tsx new file mode 100644 index 0000000..beb76b6 --- /dev/null +++ b/packages/design-system/src/components/molecules/HeroBannerActionsPattern.tsx @@ -0,0 +1,59 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { Button } from '../atoms/Button'; +import { Icon } from '../atoms/Icon'; + +type HeroBannerActionsPatternProps = { + primaryLabel: string; + secondaryLabel: string; + onPrimaryClick?: () => void; + onSecondaryClick?: () => void; + primaryLeadingIconName?: 'videoPlayerPlayDefault' | 'play'; + secondaryLeadingIconName?: 'info' | 'question'; + style?: CSSProperties; +}; + +const rowStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: 'var(--fx-space-2)', + flexWrap: 'wrap', +}; + +export const HeroBannerActionsPattern = ({ + primaryLabel, + secondaryLabel, + onPrimaryClick, + onSecondaryClick, + primaryLeadingIconName = 'videoPlayerPlayDefault', + secondaryLeadingIconName = 'info', + style, +}: HeroBannerActionsPatternProps): ReactNode => ( +
+ + +
+); diff --git a/packages/design-system/src/components/molecules/HeroBannerRatingPattern.tsx b/packages/design-system/src/components/molecules/HeroBannerRatingPattern.tsx new file mode 100644 index 0000000..ef193d6 --- /dev/null +++ b/packages/design-system/src/components/molecules/HeroBannerRatingPattern.tsx @@ -0,0 +1,40 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { Icon } from '../atoms/Icon'; +import { Text } from '../atoms/Text'; + +type HeroBannerRatingPatternProps = { + ratingLabel: string; + leadingIconName?: 'heroBannerPreviewRepeatArrowDefault' | 'heroBannerPreviewRepeatArrowHover'; + style?: CSSProperties; +}; + +const wrapperStyle: CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + gap: 'var(--fx-space-2)', +}; + +const ratingBoxStyle: CSSProperties = { + minWidth: 'var(--fx-size-pattern-video-rating-width)', + minHeight: 'var(--fx-size-pattern-video-rating-height)', + background: 'var(--fx-color-bg-hover)', + display: 'inline-flex', + alignItems: 'center', + padding: '0 var(--fx-space-4)', + borderLeft: 'var(--fx-size-border-default) solid var(--fx-color-text-primary)', +}; + +export const HeroBannerRatingPattern = ({ + ratingLabel, + leadingIconName = 'heroBannerPreviewRepeatArrowDefault', + style, +}: HeroBannerRatingPatternProps): ReactNode => ( +
+ +
+ + {ratingLabel} + +
+
+); diff --git a/packages/design-system/src/components/molecules/HeroBannerUtilitiesPattern.tsx b/packages/design-system/src/components/molecules/HeroBannerUtilitiesPattern.tsx new file mode 100644 index 0000000..503b041 --- /dev/null +++ b/packages/design-system/src/components/molecules/HeroBannerUtilitiesPattern.tsx @@ -0,0 +1,62 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { HeroBannerControlButton } from '../atoms/HeroBannerControlButton'; +import { Text } from '../atoms/Text'; + +type HeroBannerUtilitiesPatternProps = { + ratingLabel: string; + muteControlLabel: string; + audioDescriptionControlLabel: string; + replayControlLabel: string; + onMuteToggle?: () => void; + onAudioDescriptionToggle?: () => void; + onReplayToggle?: () => void; + style?: CSSProperties; +}; + +const rowStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 'var(--fx-space-4)', + flexWrap: 'wrap', +}; + +const controlsStyle: CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + gap: 'var(--fx-space-2)', +}; + +const ratingStyle: CSSProperties = { + minWidth: 'var(--fx-size-pattern-hero-rating-width)', + minHeight: 'var(--fx-size-pattern-hero-rating-height)', + padding: '0 var(--fx-space-4)', + borderLeft: 'var(--fx-size-border-default) solid var(--fx-color-text-primary)', + background: 'var(--fx-color-hero-rating-background)', + display: 'inline-flex', + alignItems: 'center', +}; + +export const HeroBannerUtilitiesPattern = ({ + ratingLabel, + muteControlLabel, + audioDescriptionControlLabel, + replayControlLabel, + onMuteToggle, + onAudioDescriptionToggle, + onReplayToggle, + style, +}: HeroBannerUtilitiesPatternProps): ReactNode => ( +
+
+ + + +
+
+ + {ratingLabel} + +
+
+); diff --git a/packages/design-system/src/components/molecules/LessonTile.tsx b/packages/design-system/src/components/molecules/LessonTile.tsx new file mode 100644 index 0000000..5cecd23 --- /dev/null +++ b/packages/design-system/src/components/molecules/LessonTile.tsx @@ -0,0 +1,31 @@ +import type { ReactNode } from 'react'; +import { Badge } from '../atoms/Badge'; +import { Card } from '../atoms/Card'; +import { Text } from '../atoms/Text'; + +type LessonTileProps = { + title: string; + status: 'released' | 'locked' | 'expired'; + action?: ReactNode; +}; + +export const LessonTile = ({ title, status, action }: LessonTileProps): ReactNode => ( + +
+ {title} +
+ {status} +
+
+ {action} +
+); diff --git a/packages/design-system/src/components/molecules/MovieBlockCard.tsx b/packages/design-system/src/components/molecules/MovieBlockCard.tsx new file mode 100644 index 0000000..411c53d --- /dev/null +++ b/packages/design-system/src/components/molecules/MovieBlockCard.tsx @@ -0,0 +1,73 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { Icon } from '../atoms/Icon'; + +type MovieBlockCardSize = 'small' | 'standard' | 'medium'; + +type MovieBlockCardProps = { + imageUrl: string; + imageAlt?: string; + size?: MovieBlockCardSize; + presetIconName?: 'presetTop10' | 'presetRecentlyAdded' | 'presetLeavingSoon' | 'presetNewSeason'; + onClick?: () => void; + style?: CSSProperties; +}; + +const sizeStyles: Record = { + small: { + width: 'var(--fx-size-pattern-movie-card-small-width)', + height: 'var(--fx-size-pattern-movie-card-small-height)', + }, + standard: { + width: 'var(--fx-size-pattern-movie-card-standard-width)', + height: 'var(--fx-size-pattern-movie-card-standard-height)', + }, + medium: { + width: 'var(--fx-size-pattern-movie-card-medium-width)', + height: 'var(--fx-size-pattern-movie-card-medium-height)', + }, +}; + +const cardBaseStyle: CSSProperties = { + border: 0, + margin: 0, + padding: 0, + borderRadius: 'var(--fx-radius-sm)', + overflow: 'hidden', + position: 'relative', + background: 'var(--fx-color-bg-hover)', + cursor: 'pointer', + display: 'inline-flex', +}; + +const imageStyle: CSSProperties = { + width: '100%', + height: '100%', + objectFit: 'cover', + display: 'block', +}; + +export const MovieBlockCard = ({ + imageUrl, + imageAlt = '', + size = 'standard', + presetIconName, + onClick, + style, +}: MovieBlockCardProps): ReactNode => ( + +); diff --git a/packages/design-system/src/components/molecules/MovieInfoPattern.tsx b/packages/design-system/src/components/molecules/MovieInfoPattern.tsx new file mode 100644 index 0000000..2035090 --- /dev/null +++ b/packages/design-system/src/components/molecules/MovieInfoPattern.tsx @@ -0,0 +1,129 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { Icon } from '../atoms/Icon'; +import { Text } from '../atoms/Text'; +import type { IconName } from '../../assets/icons'; + +type MovieInfoDetail = { + label: string; + value?: string; +}; + +type MovieInfoPatternProps = { + statusLabel?: string; + seasonsLabel?: string; + yearLabel?: string; + qualityIconName?: IconName; + qualityIconAlt?: string; + audioLabel?: string; + ratingIconName?: IconName; + ratingIconAlt?: string; + presetIconName?: IconName; + presetIconAlt?: string; + details?: MovieInfoDetail[]; + style?: CSSProperties; +}; + +const layoutStyle: CSSProperties = { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'flex-start', + gap: 'var(--fx-space-12)', + flexWrap: 'wrap', +}; + +const leftColumnStyle: CSSProperties = { + display: 'grid', + gap: 'var(--fx-space-3)', + minWidth: 'var(--fx-size-pattern-movie-info-left-min-width)', +}; + +const summaryRowStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: 'var(--fx-space-3)', + flexWrap: 'wrap', +}; + +const rightColumnStyle: CSSProperties = { + display: 'grid', + gap: 'var(--fx-space-8)', + minWidth: 'var(--fx-size-pattern-movie-info-right-min-width)', +}; + +const detailRowStyle: CSSProperties = { + display: 'flex', + alignItems: 'baseline', + gap: 'var(--fx-space-2)', + flexWrap: 'wrap', +}; + +const defaultDetails: MovieInfoDetail[] = [{ label: 'Cast:' }, { label: 'Genres:' }, { label: 'This show is:' }]; + +export const MovieInfoPattern = ({ + statusLabel = 'New', + seasonsLabel = '3 Seasons', + yearLabel = '2024', + qualityIconName = 'videoQualityHd', + qualityIconAlt = 'HD', + audioLabel = 'AD', + ratingIconName = 'ratingTvMa', + ratingIconAlt = 'TV-MA', + presetIconName = 'presetTop10', + presetIconAlt = 'Top 10', + details = defaultDetails, + style, +}: MovieInfoPatternProps): ReactNode => ( +
+
+
+ + {statusLabel} + + + {seasonsLabel} + + + {yearLabel} + + + + {audioLabel} + +
+ + + + +
+ +
+ {details.map((detail) => ( +
+ + {detail.label} + + {detail.value ? ( + + {detail.value} + + ) : null} +
+ ))} +
+
+); diff --git a/packages/design-system/src/components/molecules/MoviePreviewDetailsPattern.tsx b/packages/design-system/src/components/molecules/MoviePreviewDetailsPattern.tsx new file mode 100644 index 0000000..11951cf --- /dev/null +++ b/packages/design-system/src/components/molecules/MoviePreviewDetailsPattern.tsx @@ -0,0 +1,46 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { Icon } from '../atoms/Icon'; +import { Text } from '../atoms/Text'; + +type MoviePreviewDetailsPatternProps = { + ratingIconName?: 'ratingTvMa' | 'ratingTv14' | 'ratingTvPg' | 'ratingTvG'; + yearLabel?: string; + onAddClick?: () => void; + style?: CSSProperties; +}; + +const wrapperStyle: CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + gap: 'var(--fx-space-2)', + flexWrap: 'wrap', +}; + +const iconButtonStyle: CSSProperties = { + border: 0, + background: 'transparent', + padding: 0, + cursor: 'pointer', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + lineHeight: 0, +}; + +export const MoviePreviewDetailsPattern = ({ + ratingIconName = 'ratingTvMa', + yearLabel = '2021', + onAddClick, + style, +}: MoviePreviewDetailsPatternProps): ReactNode => ( +
+ + + + {yearLabel} + + +
+); diff --git a/packages/design-system/src/components/molecules/MoviePreviewPattern.tsx b/packages/design-system/src/components/molecules/MoviePreviewPattern.tsx new file mode 100644 index 0000000..9739c4a --- /dev/null +++ b/packages/design-system/src/components/molecules/MoviePreviewPattern.tsx @@ -0,0 +1,62 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { Icon } from '../atoms/Icon'; + +type MoviePreviewPatternProps = { + onPlayClick?: () => void; + onAddClick?: () => void; + onThumbUpClick?: () => void; + onExpandClick?: () => void; + style?: CSSProperties; +}; + +const wrapperStyle: CSSProperties = { + width: 'var(--fx-size-pattern-video-movie-preview-width)', + maxWidth: '100%', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + gap: 'var(--fx-space-4)', +}; + +const controlsStyle: CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + gap: 'var(--fx-space-1)', +}; + +const iconButtonStyle: CSSProperties = { + border: 0, + background: 'transparent', + padding: 0, + cursor: 'pointer', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + lineHeight: 0, +}; + +export const MoviePreviewPattern = ({ + onPlayClick, + onAddClick, + onThumbUpClick, + onExpandClick, + style, +}: MoviePreviewPatternProps): ReactNode => ( +
+
+ + + +
+ + +
+); diff --git a/packages/design-system/src/components/molecules/PlayAddThumbUpPattern.tsx b/packages/design-system/src/components/molecules/PlayAddThumbUpPattern.tsx new file mode 100644 index 0000000..b695811 --- /dev/null +++ b/packages/design-system/src/components/molecules/PlayAddThumbUpPattern.tsx @@ -0,0 +1,63 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { Button } from '../atoms/Button'; +import { Icon } from '../atoms/Icon'; + +type PlayAddThumbUpPatternProps = { + playLabel: string; + playControlLabel: string; + addControlLabel: string; + thumbUpControlLabel: string; + onPlayClick?: () => void; + onAddClick?: () => void; + onThumbUpClick?: () => void; + style?: CSSProperties; +}; + +const rowStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: 'var(--fx-space-2)', + flexWrap: 'wrap', +}; + +const iconButtonStyle: CSSProperties = { + border: 0, + background: 'transparent', + padding: 0, + cursor: 'pointer', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + lineHeight: 0, +}; + +export const PlayAddThumbUpPattern = ({ + playLabel, + playControlLabel, + addControlLabel, + thumbUpControlLabel, + onPlayClick, + onAddClick, + onThumbUpClick, + style, +}: PlayAddThumbUpPatternProps): ReactNode => ( +
+ + + +
+); diff --git a/packages/design-system/src/components/molecules/PlayMoreInfoPattern.tsx b/packages/design-system/src/components/molecules/PlayMoreInfoPattern.tsx new file mode 100644 index 0000000..aa2dc8c --- /dev/null +++ b/packages/design-system/src/components/molecules/PlayMoreInfoPattern.tsx @@ -0,0 +1,41 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { Button } from '../atoms/Button'; +import { Icon } from '../atoms/Icon'; + +type PlayMoreInfoPatternProps = { + onPlayClick?: () => void; + onMoreInfoClick?: () => void; + style?: CSSProperties; +}; + +const rowStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: 'var(--fx-space-2)', + flexWrap: 'wrap', +}; + +export const PlayMoreInfoPattern = ({ onPlayClick, onMoreInfoClick, style }: PlayMoreInfoPatternProps): ReactNode => ( +
+ + +
+); diff --git a/packages/design-system/src/components/molecules/ProfileAvatarTrigger.tsx b/packages/design-system/src/components/molecules/ProfileAvatarTrigger.tsx new file mode 100644 index 0000000..a2990f9 --- /dev/null +++ b/packages/design-system/src/components/molecules/ProfileAvatarTrigger.tsx @@ -0,0 +1,58 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { Icon } from '../atoms/Icon'; +import { ProfileAvatar } from '../atoms/ProfileAvatar'; + +type ProfileAvatarTriggerProps = { + avatarImageUrl?: string; + avatarImageAlt?: string; + avatarAriaLabel: string; + showCaret?: boolean; + onClick?: () => void; + style?: CSSProperties; +}; + +const rootStyle: CSSProperties = { + width: 'var(--fx-size-pattern-profile-compact-trigger-width)', + minHeight: 'var(--fx-size-pattern-profile-compact-trigger-height)', + borderRadius: 'var(--fx-radius-lg)', + border: '1px dashed var(--fx-color-brand-primary-active)', + background: 'var(--fx-color-bg-elevated)', + padding: 'var(--fx-space-8)', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 'var(--fx-space-6)', +}; + +const triggerStyle: CSSProperties = { + border: 0, + background: 'transparent', + padding: 0, + margin: 0, + display: 'inline-flex', + alignItems: 'center', + gap: 'var(--fx-space-6)', + cursor: 'pointer', + color: 'var(--fx-color-text-primary)', +}; + +export const ProfileAvatarTrigger = ({ + avatarImageUrl, + avatarImageAlt = '', + avatarAriaLabel, + showCaret = true, + onClick, + style, +}: ProfileAvatarTriggerProps): ReactNode => ( +
+ +
+); diff --git a/packages/design-system/src/components/molecules/ProfileMenuItem.tsx b/packages/design-system/src/components/molecules/ProfileMenuItem.tsx new file mode 100644 index 0000000..c76f1d5 --- /dev/null +++ b/packages/design-system/src/components/molecules/ProfileMenuItem.tsx @@ -0,0 +1,43 @@ +import type { CSSProperties, ReactNode } from 'react'; +import type { IconName } from '../../assets/icons'; +import { Icon } from '../atoms/Icon'; + +type ProfileMenuItemProps = { + label: ReactNode; + iconName?: IconName; + muted?: boolean; + ariaLabel: string; + onClick?: () => void; + style?: CSSProperties; +}; + +const itemStyle: CSSProperties = { + width: '100%', + border: 0, + background: 'transparent', + color: 'var(--fx-color-text-primary)', + display: 'grid', + gridTemplateColumns: '20px auto', + alignItems: 'center', + gap: 'var(--fx-space-3)', + textAlign: 'left', + cursor: 'pointer', + padding: 0, + margin: 0, + fontFamily: 'var(--fx-font-sans)', + fontSize: 'var(--fx-typo-regular-small-body-normal-size)', + lineHeight: 'var(--fx-typo-regular-small-body-normal-line)', + letterSpacing: 'var(--fx-typo-regular-small-body-normal-spacing)', +}; + +export const ProfileMenuItem = ({ label, iconName, muted = false, ariaLabel, onClick, style }: ProfileMenuItemProps): ReactNode => ( + +); diff --git a/packages/design-system/src/components/molecules/SignInCompletePattern.tsx b/packages/design-system/src/components/molecules/SignInCompletePattern.tsx new file mode 100644 index 0000000..3c2f324 --- /dev/null +++ b/packages/design-system/src/components/molecules/SignInCompletePattern.tsx @@ -0,0 +1,194 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { Button } from '../atoms/Button'; +import { Input } from '../atoms/Input'; +import { Text } from '../atoms/Text'; + +type SignInCompletePatternProps = { + emailValue: string; + passwordValue: string; + rememberMe?: boolean; + onEmailChange: (next: string) => void; + onPasswordChange: (next: string) => void; + onSubmit: () => void; + onUseSignInCode?: () => void; + onForgotPassword?: () => void; + onRememberMeChange?: (checked: boolean) => void; + onSignUp?: () => void; + onLearnMore?: () => void; + title?: string; + submitLabel?: string; + useCodeLabel?: string; + forgotPasswordLabel?: string; + rememberMeLabel?: string; + signUpPrefix?: string; + signUpLabel?: string; + recaptchaCopy?: string; + learnMoreLabel?: string; + controlWidth?: CSSProperties['width']; + submitting?: boolean; + style?: CSSProperties; +}; + +const layoutStyle: CSSProperties = { + display: 'grid', + gap: 'var(--fx-space-4)', +}; + +const controlWidthStyle: CSSProperties = { + width: 'var(--fx-size-pattern-signin-control-width)', + maxWidth: '100%', +}; + +const linkButtonStyle: CSSProperties = { + border: 0, + background: 'transparent', + padding: 0, + cursor: 'pointer', + textAlign: 'left', + fontFamily: 'var(--fx-font-sans)', + fontSize: 'var(--fx-typo-regular-headline2-size)', + lineHeight: 'var(--fx-typo-regular-headline2-line)', + fontWeight: 'var(--fx-typo-regular-headline2-weight)', + letterSpacing: 'var(--fx-typo-regular-headline2-spacing)', +}; + +const checkboxButtonStyle: CSSProperties = { + width: 'var(--fx-size-pattern-signin-checkbox-size)', + height: 'var(--fx-size-pattern-signin-checkbox-size)', + borderRadius: 'var(--fx-radius-sm)', + border: 'var(--fx-size-border-default) solid var(--fx-color-border-strong)', + background: 'transparent', + padding: 0, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', +}; + +const rowStyle: CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + gap: 'var(--fx-space-3)', +}; + +const footnoteStyle: CSSProperties = { + ...controlWidthStyle, + color: 'var(--fx-color-text-tertiary)', +}; + +export const SignInCompletePattern = ({ + emailValue, + passwordValue, + rememberMe = false, + onEmailChange, + onPasswordChange, + onSubmit, + onUseSignInCode, + onForgotPassword, + onRememberMeChange, + onSignUp, + onLearnMore, + title = 'Sign In', + submitLabel = 'Sign In', + useCodeLabel = 'Use a Sign-In Code', + forgotPasswordLabel = 'Forgot Password?', + rememberMeLabel = 'Remember me', + signUpPrefix = 'New to Flix?', + signUpLabel = 'Sign up now.', + recaptchaCopy = "This page is protected by Google reCAPTCHA to ensure you're not a bot.", + learnMoreLabel = 'Learn more.', + controlWidth = 'var(--fx-size-pattern-signin-control-width)', + submitting = false, + style, +}: SignInCompletePatternProps): ReactNode => ( +
+ + {title} + + + onEmailChange(event.target.value)} + style={{ ...controlWidthStyle, width: controlWidth }} + /> + onPasswordChange(event.target.value)} + style={{ ...controlWidthStyle, width: controlWidth }} + /> + + + + + OR + + + + + + +
+ + + {rememberMeLabel} + +
+ + + {signUpPrefix}{' '} + + + + + {recaptchaCopy}{' '} + + +
+); diff --git a/packages/design-system/src/components/molecules/SignInEmailPasswordPattern.tsx b/packages/design-system/src/components/molecules/SignInEmailPasswordPattern.tsx new file mode 100644 index 0000000..a0b1c06 --- /dev/null +++ b/packages/design-system/src/components/molecules/SignInEmailPasswordPattern.tsx @@ -0,0 +1,76 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { Button } from '../atoms/Button'; +import { Input } from '../atoms/Input'; + +type SignInEmailPasswordPatternProps = { + emailValue: string; + passwordValue: string; + onEmailChange: (next: string) => void; + onPasswordChange: (next: string) => void; + onSubmit: () => void; + emailPlaceholder?: string; + passwordPlaceholder?: string; + buttonLabel?: string; + disabled?: boolean; + controlWidth?: CSSProperties['width']; + style?: CSSProperties; +}; + +const layoutStyle: CSSProperties = { + display: 'grid', + gap: 'var(--fx-space-4)', +}; + +const controlWidthStyle: CSSProperties = { + width: 'var(--fx-size-pattern-signin-control-width)', + maxWidth: '100%', +}; + +const submitStyle: CSSProperties = { + ...controlWidthStyle, + minHeight: 'var(--fx-size-pattern-signin-button-height)', + borderRadius: 'var(--fx-radius-input)', + background: 'var(--fx-color-brand-primary)', + borderColor: 'var(--fx-color-brand-primary)', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: 'var(--fx-typo-medium-body-size)', + lineHeight: 'var(--fx-typo-medium-body-line)', + fontWeight: 'var(--fx-typo-medium-body-weight)', + letterSpacing: 'var(--fx-typo-medium-body-spacing)', +}; + +export const SignInEmailPasswordPattern = ({ + emailValue, + passwordValue, + onEmailChange, + onPasswordChange, + onSubmit, + emailPlaceholder = 'Email or phone number', + passwordPlaceholder = 'Password', + buttonLabel = 'Sign In', + disabled, + controlWidth = 'var(--fx-size-pattern-signin-control-width)', + style, +}: SignInEmailPasswordPatternProps): ReactNode => ( +
+ onEmailChange(event.target.value)} + style={{ ...controlWidthStyle, width: controlWidth }} + /> + onPasswordChange(event.target.value)} + style={{ ...controlWidthStyle, width: controlWidth }} + /> + +
+); diff --git a/packages/design-system/src/components/molecules/StatTile.tsx b/packages/design-system/src/components/molecules/StatTile.tsx new file mode 100644 index 0000000..cde3362 --- /dev/null +++ b/packages/design-system/src/components/molecules/StatTile.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from 'react'; +import { Card } from '../atoms/Card'; +import { Text } from '../atoms/Text'; + +type StatTileProps = { + label: string; + value: string | number; +}; + +export const StatTile = ({ label, value }: StatTileProps): ReactNode => ( + + + {label} + + + {value} + + +); diff --git a/packages/design-system/src/components/molecules/Top10EntryCard.tsx b/packages/design-system/src/components/molecules/Top10EntryCard.tsx new file mode 100644 index 0000000..7611dd8 --- /dev/null +++ b/packages/design-system/src/components/molecules/Top10EntryCard.tsx @@ -0,0 +1,67 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { Icon } from '../atoms/Icon'; +import { Text } from '../atoms/Text'; + +type Top10EntryCardProps = { + ariaLabel?: string; + rank: number; + imageUrl: string; + imageAlt?: string; + onClick?: () => void; + style?: CSSProperties; +}; + +const rootStyle: CSSProperties = { + position: 'relative', + width: 'var(--fx-size-pattern-top10-card-width)', + height: 'var(--fx-size-pattern-top10-card-height)', + border: 0, + padding: 0, + margin: 0, + background: 'transparent', + cursor: 'pointer', + display: 'block', +}; + +const rankStyle: CSSProperties = { + position: 'absolute', + left: 0, + bottom: 0, + zIndex: 0, + fontSize: 'var(--fx-size-pattern-top10-rank-size)', + lineHeight: 0.9, + color: 'var(--fx-color-text-primary)', + opacity: 0.28, + fontWeight: 'var(--fx-font-weight-bold)', +}; + +const posterStyle: CSSProperties = { + position: 'absolute', + left: 'var(--fx-size-pattern-top10-poster-left)', + top: 0, + width: 'var(--fx-size-pattern-top10-poster-width)', + height: 'var(--fx-size-pattern-top10-poster-height)', + borderRadius: 'var(--fx-radius-sm)', + objectFit: 'cover', + zIndex: 1, + background: 'var(--fx-color-bg-hover)', +}; + +export const Top10EntryCard = ({ ariaLabel, rank, imageUrl, imageAlt = '', onClick, style }: Top10EntryCardProps): ReactNode => ( + +); diff --git a/packages/design-system/src/components/molecules/VideoEpisodesPreview.tsx b/packages/design-system/src/components/molecules/VideoEpisodesPreview.tsx new file mode 100644 index 0000000..250e7af --- /dev/null +++ b/packages/design-system/src/components/molecules/VideoEpisodesPreview.tsx @@ -0,0 +1,55 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { VideoNextEpisodeCard } from './VideoNextEpisodeCard'; + +export type VideoEpisodePreviewItem = { + id: string; + title: ReactNode; + episodeLabel: ReactNode; + description?: ReactNode; + artworkUrl?: string; + artworkAlt?: string; + playLabel: string; + onPlayClick?: () => void; +}; + +type VideoEpisodesPreviewProps = { + items: VideoEpisodePreviewItem[]; + horizontal?: boolean; + style?: CSSProperties; +}; + +const rootStyle: CSSProperties = { + display: 'grid', + gap: 'var(--fx-space-4)', +}; + +export const VideoEpisodesPreview = ({ items, horizontal = false, style }: VideoEpisodesPreviewProps): ReactNode => ( +
+ {items.map((item) => ( + + ))} +
+); diff --git a/packages/design-system/src/components/molecules/VideoNextEpisodeCard.tsx b/packages/design-system/src/components/molecules/VideoNextEpisodeCard.tsx new file mode 100644 index 0000000..3d38d43 --- /dev/null +++ b/packages/design-system/src/components/molecules/VideoNextEpisodeCard.tsx @@ -0,0 +1,56 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { Text } from '../atoms/Text'; +import { VideoPlayerIconButton } from '../atoms/VideoPlayerIconButton'; + +type VideoNextEpisodeCardProps = { + title: ReactNode; + episodeLabel: ReactNode; + description?: ReactNode; + artworkUrl?: string; + artworkAlt?: string; + playLabel: string; + onPlayClick?: () => void; + style?: CSSProperties; +}; + +const rootStyle: CSSProperties = { + width: 'min(100%, var(--fx-size-pattern-player-next-episode-card-width))', + minHeight: 'var(--fx-size-pattern-player-next-episode-card-height)', + borderRadius: 'var(--fx-radius-sm)', + overflow: 'hidden', + background: 'var(--fx-color-player-surface)', + display: 'grid', + gridTemplateRows: '200px auto', +}; + +export const VideoNextEpisodeCard = ({ + title, + episodeLabel, + description, + artworkUrl, + artworkAlt = '', + playLabel, + onPlayClick, + style, +}: VideoNextEpisodeCardProps): ReactNode => ( +
+ {artworkUrl ? ( + {artworkAlt} + ) : ( +
+ )} + +
+ {episodeLabel} + {title} + {description ? {description} : null} + +
+
+); diff --git a/packages/design-system/src/components/molecules/VideoOptionMenu.tsx b/packages/design-system/src/components/molecules/VideoOptionMenu.tsx new file mode 100644 index 0000000..1aac646 --- /dev/null +++ b/packages/design-system/src/components/molecules/VideoOptionMenu.tsx @@ -0,0 +1,70 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { Text } from '../atoms/Text'; + +export type VideoOptionItem = { + id: string; + label: ReactNode; + description?: ReactNode; + selected?: boolean; + onSelect?: () => void; +}; + +type VideoOptionMenuProps = { + title: ReactNode; + options: VideoOptionItem[]; + style?: CSSProperties; +}; + +const panelStyle: CSSProperties = { + width: 'min(100%, var(--fx-size-pattern-player-option-panel-width))', + maxHeight: 'var(--fx-size-pattern-player-option-panel-height)', + background: 'var(--fx-color-player-surface)', + borderRadius: 'var(--fx-radius-input)', + overflow: 'hidden', + display: 'grid', + alignContent: 'start', +}; + +const headingStyle: CSSProperties = { + padding: 'var(--fx-space-4) var(--fx-space-6)', + borderBottom: '1px solid var(--fx-color-player-panel-muted)', +}; + +const itemStyle: CSSProperties = { + width: '100%', + border: 0, + background: 'transparent', + color: 'var(--fx-color-text-primary)', + textAlign: 'left', + cursor: 'pointer', + padding: 'var(--fx-space-4) var(--fx-space-6)', + display: 'grid', + gap: 'var(--fx-space-1)', +}; + +export const VideoOptionMenu = ({ title, options, style }: VideoOptionMenuProps): ReactNode => ( +
+
+ {title} +
+ +
+ {options.map((option) => ( + + ))} +
+
+); diff --git a/packages/design-system/src/components/molecules/VideoPlayerControlsPattern.tsx b/packages/design-system/src/components/molecules/VideoPlayerControlsPattern.tsx new file mode 100644 index 0000000..a432e66 --- /dev/null +++ b/packages/design-system/src/components/molecules/VideoPlayerControlsPattern.tsx @@ -0,0 +1,59 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { VideoPlayerIconButton } from '../atoms/VideoPlayerIconButton'; + +type VideoPlayerControlsPatternProps = { + labels: { + play: string; + back10: string; + forward10: string; + sound: string; + }; + iconState?: 'default' | 'hover'; + onPlayClick?: () => void; + onBackClick?: () => void; + onForwardClick?: () => void; + onSoundClick?: () => void; + style?: CSSProperties; +}; + +const rowStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: 'var(--fx-space-4)', + flexWrap: 'wrap', +}; + +const iconButtonStyle: CSSProperties = { + border: 0, + background: 'transparent', + padding: 0, + cursor: 'pointer', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + lineHeight: 0, +}; + +export const VideoPlayerControlsPattern = ({ + labels, + iconState = 'default', + onPlayClick, + onBackClick, + onForwardClick, + onSoundClick, + style, +}: VideoPlayerControlsPatternProps): ReactNode => ( +
+ + + + +
+); diff --git a/packages/design-system/src/components/molecules/VideoPlayerUtilitiesPattern.tsx b/packages/design-system/src/components/molecules/VideoPlayerUtilitiesPattern.tsx new file mode 100644 index 0000000..6e71693 --- /dev/null +++ b/packages/design-system/src/components/molecules/VideoPlayerUtilitiesPattern.tsx @@ -0,0 +1,80 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { VideoPlayerIconButton } from '../atoms/VideoPlayerIconButton'; + +type VideoPlayerUtilitiesPatternProps = { + labels: { + nextEpisode: string; + listOfEpisodes: string; + subtitles: string; + speed: string; + fullScreen: string; + }; + iconState?: 'default' | 'hover'; + onNextEpisodeClick?: () => void; + onListOfEpisodesClick?: () => void; + onSubtitlesClick?: () => void; + onSpeedClick?: () => void; + onFullScreenClick?: () => void; + style?: CSSProperties; +}; + +const rowStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: 'var(--fx-space-4)', + flexWrap: 'wrap', +}; + +const iconButtonStyle: CSSProperties = { + border: 0, + background: 'transparent', + padding: 0, + cursor: 'pointer', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + lineHeight: 0, +}; + +export const VideoPlayerUtilitiesPattern = ({ + labels, + iconState = 'default', + onNextEpisodeClick, + onListOfEpisodesClick, + onSubtitlesClick, + onSpeedClick, + onFullScreenClick, + style, +}: VideoPlayerUtilitiesPatternProps): ReactNode => ( +
+ + + + + +
+); diff --git a/packages/design-system/src/components/molecules/VideoProgressIndicator.tsx b/packages/design-system/src/components/molecules/VideoProgressIndicator.tsx new file mode 100644 index 0000000..304b443 --- /dev/null +++ b/packages/design-system/src/components/molecules/VideoProgressIndicator.tsx @@ -0,0 +1,87 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { Text } from '../atoms/Text'; + +type VideoProgressIndicatorProps = { + elapsedLabel: string; + remainingLabel: string; + progressPercent: number; + bufferPercent?: number; + markerPercent?: number; + dense?: boolean; + style?: CSSProperties; +}; + +const clamp = (value: number): number => Math.max(0, Math.min(100, value)); + +const rootStyle: CSSProperties = { + width: '100%', + display: 'grid', + gap: 'var(--fx-space-2)', +}; + +const railStyle: CSSProperties = { + position: 'relative', + width: '100%', + background: 'var(--fx-color-text-tertiary)', + borderRadius: '9999px', + overflow: 'hidden', +}; + +const labelsStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 'var(--fx-space-4)', +}; + +export const VideoProgressIndicator = ({ + elapsedLabel, + remainingLabel, + progressPercent, + bufferPercent = 0, + markerPercent, + dense = false, + style, +}: VideoProgressIndicatorProps): ReactNode => { + const current = clamp(progressPercent); + const buffered = clamp(bufferPercent); + const marker = markerPercent === undefined ? undefined : clamp(markerPercent); + + const trackHeight = dense ? 'var(--fx-size-pattern-movie-progress-track-height)' : 'var(--fx-size-pattern-player-progress-track-height)'; + const thumbSize = dense ? 8 : 'var(--fx-size-pattern-player-progress-thumb-size)'; + + return ( +
+
+
+ +
+ {elapsedLabel} + {remainingLabel} +
+
+ ); +}; diff --git a/packages/design-system/src/components/molecules/VideoVolumeSlider.tsx b/packages/design-system/src/components/molecules/VideoVolumeSlider.tsx new file mode 100644 index 0000000..1ecabe2 --- /dev/null +++ b/packages/design-system/src/components/molecules/VideoVolumeSlider.tsx @@ -0,0 +1,83 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { VideoPlayerIconButton } from '../atoms/VideoPlayerIconButton'; + +type VideoVolumeSliderProps = { + volumePercent: number; + muted?: boolean; + iconLabel: string; + onIconClick?: () => void; + onChange?: (next: number) => void; + style?: CSSProperties; +}; + +const clamp = (value: number): number => Math.max(0, Math.min(100, value)); + +const rootStyle: CSSProperties = { + display: 'inline-flex', + alignItems: 'flex-end', + gap: 'var(--fx-space-3)', +}; + +const railShellStyle: CSSProperties = { + width: 'var(--fx-size-pattern-player-volume-rail-width)', + height: 'var(--fx-size-pattern-player-volume-rail-height)', + borderRadius: 'var(--fx-radius-sm)', + background: 'var(--fx-color-player-surface)', + display: 'grid', + placeItems: 'center', +}; + +const railStyle: CSSProperties = { + width: 'var(--fx-size-pattern-player-volume-bar-width)', + height: '98px', + background: 'var(--fx-color-text-tertiary)', + position: 'relative', +}; + +export const VideoVolumeSlider = ({ volumePercent, muted = false, iconLabel, onIconClick, onChange, style }: VideoVolumeSliderProps): ReactNode => { + const effective = muted ? 0 : clamp(volumePercent); + + return ( +
+
+ +
+ + +
+ ); +}; diff --git a/packages/design-system/src/components/molecules/index.ts b/packages/design-system/src/components/molecules/index.ts new file mode 100644 index 0000000..d9655a3 --- /dev/null +++ b/packages/design-system/src/components/molecules/index.ts @@ -0,0 +1,30 @@ +export * from './AccessKeyForm'; +export * from './BrowseLanguagePattern'; +export * from './ContinueWatchingCard'; +export * from './EmailGetStartedPattern'; +export * from './EpisodeListItem'; +export * from './FaqAccordionItem'; +export * from './FormField'; +export * from './HeroBannerActionsPattern'; +export * from './HeroBannerRatingPattern'; +export * from './HeroBannerUtilitiesPattern'; +export * from './LessonTile'; +export * from './MovieInfoPattern'; +export * from './MovieBlockCard'; +export * from './MoviePreviewDetailsPattern'; +export * from './MoviePreviewPattern'; +export * from './PlayAddThumbUpPattern'; +export * from './PlayMoreInfoPattern'; +export * from './ProfileAvatarTrigger'; +export * from './ProfileMenuItem'; +export * from './SignInEmailPasswordPattern'; +export * from './SignInCompletePattern'; +export * from './StatTile'; +export * from './Top10EntryCard'; +export * from './VideoPlayerControlsPattern'; +export * from './VideoEpisodesPreview'; +export * from './VideoNextEpisodeCard'; +export * from './VideoOptionMenu'; +export * from './VideoProgressIndicator'; +export * from './VideoPlayerUtilitiesPattern'; +export * from './VideoVolumeSlider'; diff --git a/packages/design-system/src/components/organisms/AdminHeader.tsx b/packages/design-system/src/components/organisms/AdminHeader.tsx new file mode 100644 index 0000000..1339e68 --- /dev/null +++ b/packages/design-system/src/components/organisms/AdminHeader.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from 'react'; +import { Button } from '../atoms/Button'; +import { Text } from '../atoms/Text'; + +type AdminHeaderProps = { + title: string; + subtitle: string; + onLogout: () => void; +}; + +export const AdminHeader = ({ title, subtitle, onLogout }: AdminHeaderProps): ReactNode => ( +
+
+ + {title} + + + {subtitle} + +
+ +
+); diff --git a/packages/design-system/src/components/organisms/AppTopNav.tsx b/packages/design-system/src/components/organisms/AppTopNav.tsx new file mode 100644 index 0000000..f37573c --- /dev/null +++ b/packages/design-system/src/components/organisms/AppTopNav.tsx @@ -0,0 +1,41 @@ +import type { ReactNode } from 'react'; +import { Text } from '../atoms/Text'; + +type NavItem = { label: string; href: string; active?: boolean }; + +type AppTopNavProps = { + brand: string; + items: NavItem[]; + rightSlot?: ReactNode; +}; + +export const AppTopNav = ({ brand, items, rightSlot }: AppTopNavProps) => ( + +); diff --git a/packages/design-system/src/components/organisms/ContinueWatchingPattern.tsx b/packages/design-system/src/components/organisms/ContinueWatchingPattern.tsx new file mode 100644 index 0000000..80d40d3 --- /dev/null +++ b/packages/design-system/src/components/organisms/ContinueWatchingPattern.tsx @@ -0,0 +1,54 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { Text } from '../atoms/Text'; +import { ContinueWatchingCard } from '../molecules/ContinueWatchingCard'; + +export type ContinueWatchingItem = { + id: string; + imageUrl: string; + imageAlt?: string; + progress: number; + progressLabel?: string; + presetIconName?: 'presetTop10' | 'presetRecentlyAdded' | 'presetLeavingSoon' | 'presetNewSeason'; + onClick?: () => void; +}; + +type ContinueWatchingPatternProps = { + title: string; + items: ContinueWatchingItem[]; + style?: CSSProperties; +}; + +const panelStyle: CSSProperties = { + width: 'var(--fx-size-pattern-continue-watching-width)', + maxWidth: '100%', + minHeight: 'var(--fx-size-pattern-continue-watching-height)', + borderRadius: 'var(--fx-radius-lg)', + background: 'var(--fx-color-bg-elevated)', + padding: 'var(--fx-space-6)', + display: 'grid', + gap: 'var(--fx-space-5)', +}; + +const listStyle: CSSProperties = { + display: 'grid', + gap: 'var(--fx-space-5)', +}; + +export const ContinueWatchingPattern = ({ title, items, style }: ContinueWatchingPatternProps): ReactNode => ( +
+ {title} +
+ {items.map((item) => ( + + ))} +
+
+); diff --git a/packages/design-system/src/components/organisms/FaqQuestionsPattern.tsx b/packages/design-system/src/components/organisms/FaqQuestionsPattern.tsx new file mode 100644 index 0000000..cdc4a6f --- /dev/null +++ b/packages/design-system/src/components/organisms/FaqQuestionsPattern.tsx @@ -0,0 +1,77 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { useMemo, useState } from 'react'; +import { FaqAccordionItem } from '../molecules/FaqAccordionItem'; + +export type FaqQuestionItem = { + id: string; + question: string; + answer: ReactNode | string | string[]; +}; + +type FaqQuestionsPatternProps = { + items: FaqQuestionItem[]; + defaultExpandedId?: string | null; + expandedIds?: string[]; + allowMultipleExpanded?: boolean; + onExpandedIdsChange?: (ids: string[]) => void; + style?: CSSProperties; +}; + +const layoutStyle: CSSProperties = { + width: '100%', + maxWidth: 'var(--fx-size-pattern-faq-max-width)', + margin: 0, + display: 'grid', + gap: 'var(--fx-size-pattern-faq-item-gap)', +}; + +export const FaqQuestionsPattern = ({ + items, + defaultExpandedId = null, + expandedIds, + allowMultipleExpanded = false, + onExpandedIdsChange, + style, +}: FaqQuestionsPatternProps) => { + const [internalExpandedIds, setInternalExpandedIds] = useState( + defaultExpandedId ? [defaultExpandedId] : [], + ); + const activeExpandedIds = expandedIds ?? internalExpandedIds; + const expandedSet = useMemo(() => new Set(activeExpandedIds), [activeExpandedIds]); + + const updateExpandedIds = (nextIds: string[]) => { + if (!expandedIds) { + setInternalExpandedIds(nextIds); + } + onExpandedIdsChange?.(nextIds); + }; + + const toggleItem = (id: string) => { + if (expandedSet.has(id)) { + updateExpandedIds(activeExpandedIds.filter((currentId) => currentId !== id)); + return; + } + + if (allowMultipleExpanded) { + updateExpandedIds([...activeExpandedIds, id]); + return; + } + + updateExpandedIds([id]); + }; + + return ( +
+ {items.map((item) => ( + toggleItem(item.id)} + /> + ))} +
+ ); +}; diff --git a/packages/design-system/src/components/organisms/HeroBanner.tsx b/packages/design-system/src/components/organisms/HeroBanner.tsx new file mode 100644 index 0000000..54c563b --- /dev/null +++ b/packages/design-system/src/components/organisms/HeroBanner.tsx @@ -0,0 +1,237 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { HeroBannerActionsPattern } from '../molecules/HeroBannerActionsPattern'; +import { HeroBannerUtilitiesPattern } from '../molecules/HeroBannerUtilitiesPattern'; +import { Text } from '../atoms/Text'; + +export type HeroBannerSize = 'large' | 'medium' | 'small1' | 'small2' | 'small3'; + +type HeroBannerActionConfig = { + primaryLabel: string; + secondaryLabel: string; + onPrimaryClick?: () => void; + onSecondaryClick?: () => void; +}; + +type HeroBannerUtilityConfig = { + ratingLabel: string; + muteControlLabel: string; + audioDescriptionControlLabel: string; + replayControlLabel: string; + onMuteToggle?: () => void; + onAudioDescriptionToggle?: () => void; + onReplayToggle?: () => void; +}; + +type HeroBannerProps = { + size?: HeroBannerSize; + backgroundImageUrl?: string; + backgroundImageAlt?: string; + eyebrow?: string; + title?: string; + description?: string; + supportingText?: string; + badgeLabel?: string; + actions?: HeroBannerActionConfig; + utilities?: HeroBannerUtilityConfig; + style?: CSSProperties; +}; + +const variantDimensions: Record = { + large: { + width: 'var(--fx-size-pattern-hero-large-width)', + minHeight: 'var(--fx-size-pattern-hero-large-height)', + }, + medium: { + width: 'var(--fx-size-pattern-hero-medium-width)', + minHeight: 'var(--fx-size-pattern-hero-medium-height)', + }, + small1: { + width: 'var(--fx-size-pattern-hero-small1-width)', + minHeight: 'var(--fx-size-pattern-hero-small1-height)', + }, + small2: { + width: 'var(--fx-size-pattern-hero-small2-width)', + minHeight: 'var(--fx-size-pattern-hero-small2-height)', + }, + small3: { + width: 'var(--fx-size-pattern-hero-small3-width)', + minHeight: 'var(--fx-size-pattern-hero-small3-height)', + }, +}; + +const variantTypography: Record = { + large: { titleSize: 'var(--fx-size-pattern-hero-title-size)', descriptionSize: 'var(--fx-size-pattern-hero-description-size)', contentGap: 'var(--fx-space-4)' }, + medium: { titleSize: '28px', descriptionSize: '16px', contentGap: 'var(--fx-space-3)' }, + small1: { titleSize: '24px', descriptionSize: '15px', contentGap: 'var(--fx-space-3)' }, + small2: { titleSize: 'var(--fx-size-pattern-hero-title-size)', descriptionSize: 'var(--fx-size-pattern-hero-description-size)', contentGap: 'var(--fx-space-4)' }, + small3: { titleSize: 'var(--fx-size-pattern-hero-title-size)', descriptionSize: 'var(--fx-size-pattern-hero-description-size)', contentGap: 'var(--fx-space-4)' }, +}; + +const rootStyle: CSSProperties = { + maxWidth: '100%', + overflow: 'hidden', + background: 'var(--fx-color-bg-elevated)', +}; + +const mediaStyle: CSSProperties = { + position: 'relative', + width: '100%', + height: 'var(--fx-size-pattern-hero-media-height)', + overflow: 'hidden', + background: 'var(--fx-color-bg-secondary)', +}; + +const imageStyle: CSSProperties = { + width: '100%', + height: '100%', + objectFit: 'cover', + display: 'block', +}; + +const mediaOverlayStyle: CSSProperties = { + position: 'absolute', + inset: 0, + background: + 'linear-gradient(180deg, rgb(0 0 0 / 0%) 0%, var(--fx-color-hero-media-overlay-bottom) 100%), linear-gradient(90deg, var(--fx-color-hero-media-overlay-left) 0%, rgb(0 0 0 / 0%) 65%)', + pointerEvents: 'none', +}; + +const contentStyle: CSSProperties = { + padding: 'var(--fx-space-4) var(--fx-size-pattern-hero-content-padding-x) var(--fx-space-6)', + display: 'grid', + gap: 'var(--fx-space-4)', + background: 'var(--fx-color-bg-elevated)', +}; + +const badgeStyle: CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + minWidth: 'var(--fx-size-pattern-hero-badge-size)', + minHeight: 'var(--fx-size-pattern-hero-badge-size)', + padding: '0 var(--fx-space-2)', + borderRadius: 'var(--fx-radius-input)', + background: 'var(--fx-color-brand-primary-active)', +}; + +const titleStyle: CSSProperties = { + margin: 0, + fontSize: 'var(--fx-size-pattern-hero-title-size)', + lineHeight: 'var(--fx-size-pattern-hero-title-line-height)', +}; + +const descriptionStyle: CSSProperties = { + margin: 0, + maxWidth: 'var(--fx-size-pattern-hero-description-max-width)', + fontSize: 'var(--fx-size-pattern-hero-description-size)', + lineHeight: 'var(--fx-size-pattern-hero-description-line-height)', +}; + +const supportingTextStyle: CSSProperties = { + margin: 0, + maxWidth: 'var(--fx-size-pattern-hero-supporting-max-width)', + fontSize: 'var(--fx-size-pattern-hero-supporting-size)', + lineHeight: 'var(--fx-size-pattern-hero-supporting-line-height)', +}; + +const imageOnlyVariants = new Set(['small2', 'small3']); + +export const HeroBanner = ({ + size = 'large', + backgroundImageUrl, + backgroundImageAlt = '', + eyebrow, + title, + description, + supportingText, + badgeLabel, + actions, + utilities, + style, +}: HeroBannerProps): ReactNode => { + const showContent = !imageOnlyVariants.has(size); + const typeScale = variantTypography[size]; + + return ( +
+
+ {backgroundImageUrl ? {backgroundImageAlt} : null} + {!imageOnlyVariants.has(size) ? + + {showContent ? ( +
+ {badgeLabel ? ( + + + {badgeLabel} + + + ) : null} + + {eyebrow ? ( + + {eyebrow} + + ) : null} + + {title ? ( + + {title} + + ) : null} + + {description ? ( + + {description} + + ) : null} + + {actions ? ( + + ) : null} + + {utilities ? ( + + ) : null} + + {supportingText ? ( + + {supportingText} + + ) : null} +
+ ) : null} +
+ ); +}; diff --git a/packages/design-system/src/components/organisms/HeroMobile.tsx b/packages/design-system/src/components/organisms/HeroMobile.tsx new file mode 100644 index 0000000..a4d5d31 --- /dev/null +++ b/packages/design-system/src/components/organisms/HeroMobile.tsx @@ -0,0 +1,190 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { Icon } from '../atoms/Icon'; +import { Text } from '../atoms/Text'; +import { PlayAddThumbUpPattern } from '../molecules/PlayAddThumbUpPattern'; + +type HeroMobileProps = { + backgroundImageUrl: string; + backgroundImageAlt?: string; + previewImageUrl?: string; + previewImageAlt?: string; + eyebrow?: ReactNode; + title: ReactNode; + titleAs?: 'h1' | 'h2' | 'h3'; + playLabel: string; + playControlLabel: string; + addControlLabel: string; + thumbUpControlLabel: string; + muteControlLabel: string; + closeControlLabel: string; + onPlayClick?: () => void; + onAddClick?: () => void; + onThumbUpClick?: () => void; + onMuteClick?: () => void; + onCloseClick?: () => void; + style?: CSSProperties; +}; + +const rootStyle: CSSProperties = { + position: 'relative', + width: '100%', + minHeight: 'var(--fx-size-pattern-hero-mobile-height)', + overflow: 'hidden', + background: 'var(--fx-color-bg-secondary)', +}; + +const backgroundImageStyle: CSSProperties = { + width: '100%', + height: '100%', + minHeight: 'var(--fx-size-pattern-hero-mobile-height)', + objectFit: 'cover', + display: 'block', +}; + +const overlayStyle: CSSProperties = { + position: 'absolute', + inset: 0, + pointerEvents: 'none', + background: + 'linear-gradient(180deg, rgb(24 24 24 / 0%) 48.5%, var(--fx-color-hero-mobile-overlay-bottom) 100%)', +}; + +const previewImageWrapperStyle: CSSProperties = { + position: 'absolute', + left: 'var(--fx-size-pattern-hero-mobile-content-left)', + top: 'var(--fx-size-pattern-hero-mobile-preview-top)', + width: 'var(--fx-size-pattern-hero-mobile-preview-width)', + height: 'var(--fx-size-pattern-hero-mobile-preview-height)', + overflow: 'hidden', +}; + +const previewImageStyle: CSSProperties = { + width: '100%', + height: '100%', + objectFit: 'cover', + display: 'block', +}; + +const closeButtonStyle: CSSProperties = { + position: 'absolute', + top: 'var(--fx-size-pattern-hero-mobile-close-top)', + right: 'var(--fx-size-pattern-hero-mobile-close-right)', + width: 'var(--fx-size-pattern-hero-mobile-close-size)', + height: 'var(--fx-size-pattern-hero-mobile-close-size)', + border: 0, + borderRadius: '9999px', + background: 'var(--fx-color-hero-mobile-close-background)', + padding: 0, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + lineHeight: 0, + zIndex: 2, +}; + +const contentStyle: CSSProperties = { + position: 'absolute', + left: 'var(--fx-size-pattern-hero-mobile-content-left)', + right: 'var(--fx-size-pattern-hero-mobile-content-right)', + bottom: 'var(--fx-size-pattern-hero-mobile-content-bottom)', + zIndex: 1, + display: 'grid', + gap: 'var(--fx-size-pattern-hero-mobile-content-gap)', +}; + +const titleStyle: CSSProperties = { + margin: 0, + maxWidth: 'var(--fx-size-pattern-hero-mobile-title-max-width)', + fontSize: 'var(--fx-size-pattern-hero-mobile-title-size)', + lineHeight: 'var(--fx-size-pattern-hero-mobile-title-line-height)', + fontWeight: 'var(--fx-size-pattern-hero-mobile-title-weight)', + letterSpacing: 'var(--fx-size-pattern-hero-mobile-title-letter-spacing)', + textTransform: 'uppercase', +}; + +const actionsDockStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 'var(--fx-space-4)', +}; + +const muteButtonStyle: CSSProperties = { + border: 0, + padding: 0, + background: 'transparent', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + lineHeight: 0, + cursor: 'pointer', + width: 'var(--fx-size-pattern-hero-mobile-mute-size)', + height: 'var(--fx-size-pattern-hero-mobile-mute-size)', + flexShrink: 0, +}; + +export const HeroMobile = ({ + backgroundImageUrl, + backgroundImageAlt = '', + previewImageUrl, + previewImageAlt = '', + eyebrow, + title, + titleAs = 'h1', + playLabel, + playControlLabel, + addControlLabel, + thumbUpControlLabel, + muteControlLabel, + closeControlLabel, + onPlayClick, + onAddClick, + onThumbUpClick, + onMuteClick, + onCloseClick, + style, +}: HeroMobileProps): ReactNode => ( +
+ {backgroundImageAlt} +
+); diff --git a/packages/design-system/src/components/organisms/HomeHero.tsx b/packages/design-system/src/components/organisms/HomeHero.tsx new file mode 100644 index 0000000..ec51de6 --- /dev/null +++ b/packages/design-system/src/components/organisms/HomeHero.tsx @@ -0,0 +1,188 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { HomePageHeader, type HomeNavItem } from './HomePageHeader'; +import { HeroBannerActionsPattern } from '../molecules/HeroBannerActionsPattern'; +import { HeroBannerRatingPattern } from '../molecules/HeroBannerRatingPattern'; +import { Text } from '../atoms/Text'; +import type { IconName } from '../../assets/icons'; + +type HomeHeroActions = { + primaryLabel: string; + secondaryLabel: string; + onPrimaryClick?: () => void; + onSecondaryClick?: () => void; +}; + +type HomeHeroRating = { + label: string; + repeatControlIconName?: 'heroBannerPreviewRepeatArrowDefault' | 'heroBannerPreviewRepeatArrowHover'; +}; + +type HomeHeroProps = { + backgroundImageUrl: string; + backgroundImageAlt?: string; + headerItems: HomeNavItem[]; + brandLabel: string; + title: ReactNode; + titleAs?: 'h1' | 'h2'; + titleWidth?: CSSProperties['maxWidth']; + eyebrow?: ReactNode; + description?: ReactNode; + actions?: HomeHeroActions; + rating?: HomeHeroRating; + profileIconName?: IconName; + searchControlLabel: string; + notificationsControlLabel: string; + profileControlLabel: string; + onSearchClick?: () => void; + onNotificationsClick?: () => void; + onProfileClick?: () => void; + style?: CSSProperties; +}; + +const rootStyle: CSSProperties = { + position: 'relative', + width: '100%', + minHeight: 'var(--fx-size-pattern-home-hero-height)', + overflow: 'hidden', + background: 'var(--fx-color-bg-secondary)', +}; + +const backgroundMediaStyle: CSSProperties = { + position: 'absolute', + inset: 0, + zIndex: 0, +}; + +const backgroundImageStyle: CSSProperties = { + width: '100%', + height: '100%', + objectFit: 'cover', + display: 'block', +}; + +const overlaysStyle: CSSProperties = { + position: 'absolute', + inset: 0, + pointerEvents: 'none', + background: [ + 'linear-gradient(180deg, var(--fx-color-home-hero-overlay-top) 0%, rgb(0 0 0 / 0%) 22%)', + 'linear-gradient(90deg, var(--fx-color-home-hero-overlay-left) 0%, rgb(0 0 0 / 0%) 58%)', + 'linear-gradient(180deg, rgb(20 20 20 / 0%) 58%, var(--fx-color-home-hero-overlay-bottom) 100%)', + ].join(', '), +}; + +const contentLayerStyle: CSSProperties = { + position: 'relative', + zIndex: 1, + minHeight: 'var(--fx-size-pattern-home-hero-height)', +}; + +const contentStyle: CSSProperties = { + display: 'grid', + gap: 'var(--fx-space-4)', + paddingTop: 'var(--fx-size-pattern-home-hero-content-top)', + paddingLeft: 'var(--fx-size-pattern-home-hero-content-left)', + maxWidth: 'var(--fx-size-pattern-home-hero-content-max-width)', +}; + +const titleStyle: CSSProperties = { + margin: 0, + fontSize: 'var(--fx-size-pattern-home-hero-title-size)', + lineHeight: 'var(--fx-size-pattern-home-hero-title-line-height)', + maxWidth: 'var(--fx-size-pattern-home-hero-content-max-width)', +}; + +const descriptionStyle: CSSProperties = { + margin: 0, + fontSize: 'var(--fx-size-pattern-home-hero-description-size)', + lineHeight: 'var(--fx-size-pattern-home-hero-description-line-height)', + maxWidth: 'var(--fx-size-pattern-home-hero-description-max-width)', +}; + +const ratingDockStyle: CSSProperties = { + position: 'absolute', + right: 'var(--fx-size-pattern-home-hero-rating-dock-right)', + bottom: 'var(--fx-size-pattern-home-hero-rating-dock-bottom)', + height: 'var(--fx-size-pattern-home-hero-rating-dock-height)', + paddingLeft: 'var(--fx-space-2)', + background: 'var(--fx-color-home-hero-utility-background)', + display: 'inline-flex', + alignItems: 'center', +}; + +export const HomeHero = ({ + backgroundImageUrl, + backgroundImageAlt = '', + headerItems, + brandLabel, + title, + titleAs = 'h1', + titleWidth, + eyebrow, + description, + actions, + rating, + profileIconName, + searchControlLabel, + notificationsControlLabel, + profileControlLabel, + onSearchClick, + onNotificationsClick, + onProfileClick, + style, +}: HomeHeroProps): ReactNode => ( +
+
+ {backgroundImageAlt} + + +
+ + +
+ {eyebrow ? ( + + {eyebrow} + + ) : null} + + + {title} + + + {description ? ( + + {description} + + ) : null} + + {actions ? ( + + ) : null} +
+ + {rating ? ( +
+ +
+ ) : null} +
+
+); diff --git a/packages/design-system/src/components/organisms/HomePageHeader.tsx b/packages/design-system/src/components/organisms/HomePageHeader.tsx new file mode 100644 index 0000000..284c7cc --- /dev/null +++ b/packages/design-system/src/components/organisms/HomePageHeader.tsx @@ -0,0 +1,130 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { Icon } from '../atoms/Icon'; +import type { IconName } from '../../assets/icons'; + +export type HomeNavItem = { + label: string; + href: string; + active?: boolean; +}; + +type HomePageHeaderProps = { + items: HomeNavItem[]; + brandLabel: string; + profileIconName?: IconName; + searchControlLabel: string; + notificationsControlLabel: string; + profileControlLabel: string; + onSearchClick?: () => void; + onNotificationsClick?: () => void; + onProfileClick?: () => void; + style?: CSSProperties; +}; + +const headerStyle: CSSProperties = { + width: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 'var(--fx-space-6)', + padding: 'var(--fx-space-4) var(--fx-space-8)', + flexWrap: 'wrap', +}; + +const leftStyle: CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + gap: 'var(--fx-space-8)', + flexWrap: 'wrap', +}; + +const brandStyle: CSSProperties = { + margin: 0, + color: 'var(--fx-color-brand-primary)', + fontFamily: 'var(--fx-font-sans)', + fontSize: 'var(--fx-size-pattern-header-home-logo-size)', + lineHeight: '1', + fontWeight: 'var(--fx-font-weight-bold)', + letterSpacing: 'var(--fx-size-pattern-header-logo-letter-spacing)', + textTransform: 'uppercase', +}; + +const navStyle: CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + gap: 'var(--fx-space-5)', + flexWrap: 'wrap', +}; + +const rightStyle: CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + gap: 'var(--fx-space-4)', +}; + +const iconButtonStyle: CSSProperties = { + border: 0, + background: 'transparent', + padding: 0, + cursor: 'pointer', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + lineHeight: 0, +}; + +const profileButtonStyle: CSSProperties = { + ...iconButtonStyle, + gap: 'var(--fx-space-2)', +}; + +export const HomePageHeader = ({ + items, + brandLabel, + profileIconName = 'account', + searchControlLabel, + notificationsControlLabel, + profileControlLabel, + onSearchClick, + onNotificationsClick, + onProfileClick, + style, +}: HomePageHeaderProps): ReactNode => ( +
+
+

{brandLabel}

+ +
+ +
+ + + +
+
+); diff --git a/packages/design-system/src/components/organisms/LandingPageHeader.tsx b/packages/design-system/src/components/organisms/LandingPageHeader.tsx new file mode 100644 index 0000000..f61b6e1 --- /dev/null +++ b/packages/design-system/src/components/organisms/LandingPageHeader.tsx @@ -0,0 +1,78 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { Button } from '../atoms/Button'; +import { Dropdown, type DropdownOption } from '../atoms/Dropdown'; + +type LandingPageHeaderProps = { + languageOptions: DropdownOption[]; + languageValue?: string; + onLanguageChange?: (value: string, option: DropdownOption) => void; + onSignInClick?: () => void; + signInLabel?: string; + brandLabel?: string; + style?: CSSProperties; +}; + +const headerStyle: CSSProperties = { + width: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 'var(--fx-space-6)', + padding: 'var(--fx-space-6) var(--fx-space-8)', + flexWrap: 'wrap', +}; + +const brandStyle: CSSProperties = { + margin: 0, + color: 'var(--fx-color-brand-primary)', + fontFamily: 'var(--fx-font-sans)', + fontSize: 'var(--fx-size-pattern-header-landing-logo-size)', + lineHeight: '1', + fontWeight: 'var(--fx-font-weight-bold)', + letterSpacing: 'var(--fx-size-pattern-header-logo-letter-spacing)', + textTransform: 'uppercase', +}; + +const actionsStyle: CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + gap: 'var(--fx-space-4)', +}; + +const languageControlStyle: CSSProperties = { + width: 'var(--fx-size-pattern-header-language-width)', +}; + +const signInButtonStyle: CSSProperties = { + minWidth: 'var(--fx-size-pattern-header-signin-width)', + minHeight: 'var(--fx-size-pattern-header-signin-height)', + borderRadius: 'var(--fx-radius-input)', +}; + +export const LandingPageHeader = ({ + languageOptions, + languageValue, + onLanguageChange, + onSignInClick, + signInLabel = 'Sign In', + brandLabel = 'NETFLIX', + style, +}: LandingPageHeaderProps): ReactNode => ( +
+

{brandLabel}

+ +
+ + +
+
+); diff --git a/packages/design-system/src/components/organisms/LessonRail.tsx b/packages/design-system/src/components/organisms/LessonRail.tsx new file mode 100644 index 0000000..c0726a8 --- /dev/null +++ b/packages/design-system/src/components/organisms/LessonRail.tsx @@ -0,0 +1,38 @@ +import type { ReactNode } from 'react'; +import { LessonTile } from '../molecules/LessonTile'; + +type LessonRailItem = { + id: string; + title: string; + status: 'released' | 'locked' | 'expired'; + action?: ReactNode; +}; + +type LessonRailProps = { + title: string; + items: LessonRailItem[]; +}; + +export const LessonRail = ({ title, items }: LessonRailProps): ReactNode => ( +
+

{title}

+
    + {items.map((item) => ( +
  • + +
  • + ))} +
+
+); diff --git a/packages/design-system/src/components/organisms/ListOfEpisodesPattern.tsx b/packages/design-system/src/components/organisms/ListOfEpisodesPattern.tsx new file mode 100644 index 0000000..a7a095f --- /dev/null +++ b/packages/design-system/src/components/organisms/ListOfEpisodesPattern.tsx @@ -0,0 +1,64 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { Text } from '../atoms/Text'; +import { EpisodeListItem } from '../molecules/EpisodeListItem'; + +export type EpisodeListEntry = { + id: string; + itemAriaLabel?: string; + episodeNumber: number; + title: string; + durationLabel: string; + description: string; + imageUrl: string; + imageAlt?: string; + active?: boolean; + onClick?: () => void; +}; + +type ListOfEpisodesPatternProps = { + title: string; + items: EpisodeListEntry[]; + style?: CSSProperties; +}; + +const rootStyle: CSSProperties = { + width: 'var(--fx-size-pattern-list-of-episodes-width)', + maxWidth: '100%', + minHeight: 'var(--fx-size-pattern-list-of-episodes-height)', + borderRadius: 'var(--fx-radius-lg)', + background: 'var(--fx-color-bg-elevated)', + padding: 'var(--fx-space-6)', + display: 'grid', + gap: 'var(--fx-space-4)', +}; + +const rowDividerStyle: CSSProperties = { + height: 'var(--fx-size-border-default)', + width: '100%', + background: 'var(--fx-color-border-default)', +}; + +export const ListOfEpisodesPattern = ({ title, items, style }: ListOfEpisodesPatternProps): ReactNode => ( +
+ {title} + +
+ {items.map((item, index) => ( +
+ + {index < items.length - 1 ? + ))} +
+
+); diff --git a/packages/design-system/src/components/organisms/MovieBlocksPattern.tsx b/packages/design-system/src/components/organisms/MovieBlocksPattern.tsx new file mode 100644 index 0000000..4f9b714 --- /dev/null +++ b/packages/design-system/src/components/organisms/MovieBlocksPattern.tsx @@ -0,0 +1,62 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { Text } from '../atoms/Text'; +import { MovieBlockCard } from '../molecules/MovieBlockCard'; + +export type MovieBlockCardEntry = { + id: string; + imageUrl: string; + imageAlt?: string; + presetIconName?: 'presetTop10' | 'presetRecentlyAdded' | 'presetLeavingSoon' | 'presetNewSeason'; + onClick?: () => void; +}; + +export type MovieBlockSection = { + id: string; + title: string; + items: MovieBlockCardEntry[]; +}; + +type MovieBlocksPatternProps = { + sections: MovieBlockSection[]; + style?: CSSProperties; +}; + +const rootStyle: CSSProperties = { + width: 'var(--fx-size-pattern-movie-blocks-width)', + maxWidth: '100%', + minHeight: 'var(--fx-size-pattern-movie-blocks-height)', + borderRadius: 'var(--fx-radius-lg)', + background: 'var(--fx-color-bg-elevated)', + padding: 'var(--fx-space-6)', + display: 'grid', + gap: 'var(--fx-space-6)', +}; + +const railStyle: CSSProperties = { + display: 'grid', + gridAutoFlow: 'column', + gridAutoColumns: 'var(--fx-size-pattern-movie-card-standard-width)', + gap: 'var(--fx-space-2)', + overflowX: 'auto', +}; + +export const MovieBlocksPattern = ({ sections, style }: MovieBlocksPatternProps): ReactNode => ( +
+ {sections.map((section) => ( +
+ {section.title} +
+ {section.items.map((item) => ( + + ))} +
+
+ ))} +
+); diff --git a/packages/design-system/src/components/organisms/MoviePreviewDesktopPattern.tsx b/packages/design-system/src/components/organisms/MoviePreviewDesktopPattern.tsx new file mode 100644 index 0000000..19c8e8d --- /dev/null +++ b/packages/design-system/src/components/organisms/MoviePreviewDesktopPattern.tsx @@ -0,0 +1,114 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { Text } from '../atoms/Text'; +import { EpisodeListItem } from '../molecules/EpisodeListItem'; +import { MoviePreviewPattern } from '../molecules/MoviePreviewPattern'; +import { MoviePreviewDetailsPattern } from '../molecules/MoviePreviewDetailsPattern'; + +type EpisodeListEntry = { + id: string; + itemAriaLabel?: string; + episodeNumber: number; + title: string; + durationLabel: string; + description: string; + imageUrl: string; + imageAlt?: string; + active?: boolean; + onClick?: () => void; +}; + +type MoviePreviewDesktopPatternProps = { + heroImageUrl: string; + heroImageAlt?: string; + title: ReactNode; + description: ReactNode; + yearLabel: string; + ratingIconName?: 'ratingTvMa' | 'ratingTv14' | 'ratingTvPg' | 'ratingTvG'; + sideTitle: string; + sideItem: EpisodeListEntry; + onPlayClick?: () => void; + onAddClick?: () => void; + onThumbUpClick?: () => void; + onExpandClick?: () => void; + style?: CSSProperties; +}; + +const rootStyle: CSSProperties = { + width: 'var(--fx-size-pattern-movie-preview-desktop-width)', + maxWidth: '100%', + minHeight: 'var(--fx-size-pattern-movie-preview-desktop-height)', + borderRadius: 'var(--fx-radius-lg)', + background: 'var(--fx-color-bg-elevated)', + padding: 'var(--fx-space-6)', + display: 'grid', + gridTemplateColumns: 'var(--fx-size-pattern-movie-preview-left-width) var(--fx-size-pattern-movie-preview-right-width)', + gap: 'var(--fx-space-10)', +}; + +const mediaStyle: CSSProperties = { + width: 'var(--fx-size-pattern-movie-preview-left-width)', + height: 'var(--fx-size-pattern-movie-preview-left-media-height)', + objectFit: 'cover', + display: 'block', + borderTopLeftRadius: 'var(--fx-radius-sm)', + borderTopRightRadius: 'var(--fx-radius-sm)', +}; + +export const MoviePreviewDesktopPattern = ({ + heroImageUrl, + heroImageAlt = '', + title, + description, + yearLabel, + ratingIconName = 'ratingTv14', + sideTitle, + sideItem, + onPlayClick, + onAddClick, + onThumbUpClick, + onExpandClick, + style, +}: MoviePreviewDesktopPatternProps): ReactNode => ( +
+
+ {heroImageAlt} +
+ {title} + {description} + + +
+
+ +
+ {sideTitle} + +
+
+); diff --git a/packages/design-system/src/components/organisms/MoviesCardsPattern.tsx b/packages/design-system/src/components/organisms/MoviesCardsPattern.tsx new file mode 100644 index 0000000..4c766fb --- /dev/null +++ b/packages/design-system/src/components/organisms/MoviesCardsPattern.tsx @@ -0,0 +1,149 @@ +import type { CSSProperties, ReactNode } from 'react'; +import { Text } from '../atoms/Text'; +import { ContinueWatchingCard } from '../molecules/ContinueWatchingCard'; +import { MovieBlockCard } from '../molecules/MovieBlockCard'; +import { Top10EntryCard } from '../molecules/Top10EntryCard'; + +type ContinueWatchingItem = { + id: string; + imageUrl: string; + imageAlt?: string; + progress: number; + progressLabel?: string; + presetIconName?: 'presetTop10' | 'presetRecentlyAdded' | 'presetLeavingSoon' | 'presetNewSeason'; + onClick?: () => void; +}; + +type MovieBlockCardEntry = { + id: string; + imageUrl: string; + imageAlt?: string; + presetIconName?: 'presetTop10' | 'presetRecentlyAdded' | 'presetLeavingSoon' | 'presetNewSeason'; + onClick?: () => void; +}; + +type Top10Item = { + id: string; + rank: number; + ariaLabel?: string; + imageUrl: string; + imageAlt?: string; + onClick?: () => void; +}; + +type MoviesCardsPatternProps = { + title: string; + standardCards: MovieBlockCardEntry[]; + mediumCards?: MovieBlockCardEntry[]; + smallCards?: MovieBlockCardEntry[]; + top10Cards?: Top10Item[]; + continueWatchingCards?: ContinueWatchingItem[]; + style?: CSSProperties; +}; + +const rootStyle: CSSProperties = { + width: 'var(--fx-size-pattern-movies-cards-width)', + maxWidth: '100%', + minHeight: 'var(--fx-size-pattern-movies-cards-height)', + borderRadius: 'var(--fx-radius-lg)', + background: 'var(--fx-color-bg-elevated)', + padding: 'var(--fx-space-6)', + display: 'grid', + gap: 'var(--fx-space-6)', +}; + +const rowStyle: CSSProperties = { + display: 'grid', + gridAutoFlow: 'column', + gap: 'var(--fx-space-3)', + overflowX: 'auto', + alignItems: 'start', +}; + +export const MoviesCardsPattern = ({ + title, + standardCards, + mediumCards, + smallCards, + top10Cards, + continueWatchingCards, + style, +}: MoviesCardsPatternProps): ReactNode => ( +
+ {title} + +
+ {standardCards.map((item) => ( + + ))} +
+ + {mediumCards?.length ? ( +
+ {mediumCards.map((item) => ( + + ))} +
+ ) : null} + + {smallCards?.length ? ( +
+ {smallCards.map((item) => ( + + ))} +
+ ) : null} + + {top10Cards?.length ? ( +
+ {top10Cards.map((item) => ( + + ))} +
+ ) : null} + + {continueWatchingCards?.length ? ( +
+ {continueWatchingCards.map((item) => ( + + ))} +
+ ) : null} +
+); diff --git a/packages/design-system/src/components/organisms/PlaybackPanel.tsx b/packages/design-system/src/components/organisms/PlaybackPanel.tsx new file mode 100644 index 0000000..d19e705 --- /dev/null +++ b/packages/design-system/src/components/organisms/PlaybackPanel.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from 'react'; +import { Card } from '../atoms/Card'; +import { Text } from '../atoms/Text'; + +type PlaybackPanelProps = { + title: string; + embedUrl: string; + provider: string; +}; + +export const PlaybackPanel = ({ title, embedUrl, provider }: PlaybackPanelProps): ReactNode => ( + + + {title} + +
+