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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
],
"importOrderSeparation": true,
"importOrderSortSpecifiers": true,
"tailwindStylesheet": "./src/assets/styles/globals.css",
"plugins": [
"@trivago/prettier-plugin-sort-imports",
"prettier-plugin-tailwindcss"
Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Instructions for agents (Claude Code and others) working in this repository. `CL

## Project

An educational platform for web development. Next.js 16 (App Router), React 19, TypeScript strict, Tailwind 3 + reworked shadcn/ui, TanStack Query 5, axios, react-hook-form + zod, next-themes, framer-motion, sonner, lucide-react. Package manager: **bun**. The backend is a separate repository (`../elysia-backend`); the API defaults to `http://localhost:8000`.
An educational platform for web development. Next.js 16 (App Router), React 19, TypeScript strict, Tailwind 4 (CSS-first config, OKLCH tokens) + reworked shadcn/ui, TanStack Query 5, axios, react-hook-form + zod, next-themes, framer-motion, sonner, lucide-react. Package manager: **bun**. The backend is a separate repository (`../elysia-backend`); the API defaults to `http://localhost:8000`.

## Commands

Expand Down Expand Up @@ -85,7 +85,7 @@ Don't touch, move or rename `analytics.auth.*` calls or event names. Metrica loa
### Code

- TypeScript strict, no `any`. Types come from `generated/model`.
- Prettier: tabs, single quotes, no semicolons, width 80, sorted imports and Tailwind classes.
- Prettier: tabs, single quotes, no semicolons, width 80, sorted imports and Tailwind classes (the plugin reads `src/assets/styles/globals.css`).
- Comments in English, only where the "why" isn't obvious.
- UI text in Russian.
- Keep every state: skeletons, empty, error, `isLoading`, `disabled`, toasts (`sonner`).
Expand Down
2 changes: 1 addition & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ The design should feel **calm, modern, editorial, and technical**. Combine gener

## 2. Color

All UI colors must use the existing design tokens defined in the project's theme configuration. Do not introduce hardcoded colors or use arbitrary Tailwind color classes in components.
All UI colors must use the existing design tokens, declared as OKLCH variables in `src/assets/styles/globals.css` and exposed to Tailwind through `@theme`. Do not introduce hardcoded colors or use arbitrary Tailwind color classes in components.

| Token | Purpose |
| ----------------------------------- | ------------------------------------------ |
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# TeaCoder - фронтенд

Next.js 16 (App Router), React 19, TanStack Query 5, axios, react-hook-form + zod, Tailwind 3 + shadcn/ui. Клиент API генерируется orval из OpenAPI-спеки бэкенда.
Next.js 16 (App Router), React 19, TanStack Query 5, axios, react-hook-form + zod, Tailwind 4 + shadcn/ui. Клиент API генерируется orval из OpenAPI-спеки бэкенда.

## Запуск

Expand Down
201 changes: 83 additions & 118 deletions bun.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion generated/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3598,7 +3598,7 @@ export function useGetBillingSubscriptionQuery<TData = Awaited<ReturnType<typeof


/**
* Включает или выключает автопродление. Включить можно только при действующей подписке и сохранённом способе оплаты (`paymentMethod` в ответе; сохраняется при оплате премиума через ЮKassa или картой через Робокассу) - иначе 409. Выключение останавливает и повторные попытки автосписания. Выключение всегда проходит, оплаченный период действует до конца. Повторный запрос с тем же значением ничего не меняет.
* Включает или выключает автопродление. Автопродление включается само при оплате премиума через ЮKassa любым способом или банковской картой через Робокассу, если способ оплаты удалось сохранить. Вручную включить можно только при действующей подписке и сохранённом способе оплаты (`paymentMethod` в ответе; сохраняется при оплате премиума через ЮKassa или картой через Робокассу) - иначе 409. Выключение останавливает и повторные попытки автосписания. Выключение всегда проходит, оплаченный период действует до конца. Повторный запрос с тем же значением ничего не меняет.
* @summary Автопродление подписки
*/
export const patchBillingSubscriptionMutation = (
Expand Down
1 change: 1 addition & 0 deletions generated/model/courseLibraryResponseItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export type CourseLibraryResponseItem = {
shortDescription: string | null;
thumbnail: string | null;
price: number | null;
discountPrice: number | null;
/** Как открываются закрытые уроки и материалы: `FREE` - курс полностью бесплатный, `PREMIUM` - по премиум-подписке или покупке курса, `PURCHASE` - только покупкой. Купленный курс открыт навсегда в любом режиме. */
accessMode: CourseLibraryResponseItemAccessMode;
/** Есть ли у курса материалы (исходный код). Скачать - `POST /courses/{slug}/materials/link`. */
Expand Down
1 change: 1 addition & 0 deletions generated/model/courseListResponseItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export type CourseListResponseItem = {
shortDescription: string | null;
thumbnail: string | null;
price: number | null;
discountPrice: number | null;
/** Как открываются закрытые уроки и материалы: `FREE` - курс полностью бесплатный, `PREMIUM` - по премиум-подписке или покупке курса, `PURCHASE` - только покупкой. Купленный курс открыт навсегда в любом режиме. */
accessMode: CourseListResponseItemAccessMode;
/** Есть ли у курса материалы (исходный код). Скачать - `POST /courses/{slug}/materials/link`. */
Expand Down
3 changes: 3 additions & 0 deletions generated/model/courseResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ export interface CourseResponse {
thumbnail: string | null;
youtubeUrl: string | null;
price: number | null;
discountPrice: number | null;
/** Что вы узнаете на курсе: пункты списка в порядке показа. Пустой массив, если не заполнено. */
learningOutcomes: string[];
/** Как открываются закрытые уроки и материалы: `FREE` - курс полностью бесплатный, `PREMIUM` - по премиум-подписке или покупке курса, `PURCHASE` - только покупкой. Купленный курс открыт навсегда в любом режиме. */
accessMode: CourseResponseAccessMode;
/** Есть ли у курса материалы (исходный код). Скачать - `POST /courses/{slug}/materials/link`. */
Expand Down
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,10 +59,11 @@
"react-turnstile": "^1.1.4",
"sonner": "^1.7.4",
"tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.4.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/canvas-confetti": "^1.9.0",
"@types/node": "^20",
Expand All @@ -72,8 +73,8 @@
"orval": "8.38.0",
"postcss": "^8",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^3.4.1",
"prettier-plugin-tailwindcss": "^0.8.1",
"tailwindcss": "^4",
"typescript": "^5"
},
"overrides": {
Expand Down
11 changes: 5 additions & 6 deletions postcss.config.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
plugins: {
'@tailwindcss/postcss': {}
}
}

export default config;
export default config
48 changes: 9 additions & 39 deletions src/app/(public)/courses/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@ import { getCoursesBySlugLessonsQuery } from '@/generated/api'

import { CourseDetails } from '@/components/course/course-details'

import { getAppConfig } from '@/lib/config/app-config'
import { getCourse } from '@/lib/courses/get-course'
import { getMediaSource } from '@/lib/utils'

export async function generateMetadata({
params
Expand All @@ -15,37 +13,19 @@ export async function generateMetadata({
}): Promise<Metadata> {
const { slug } = await params

const [course, config] = await Promise.all([
getCourse(slug),
getAppConfig()
])

const thumbnail = getMediaSource(
course.thumbnail,
'courses',
config?.features.orion.url
)
const course = await getCourse(slug)
const images = course.thumbnail
? [{ url: course.thumbnail, alt: course.title }]
: undefined

return {
title: course.title,
description: course.shortDescription,
openGraph: {
images: [
{
url: thumbnail,
alt: course.title
}
]
},
openGraph: { images },
twitter: {
title: course.title,
description: course.shortDescription ?? '',
images: [
{
url: thumbnail,
alt: course.title
}
]
images
}
}
}
Expand All @@ -57,20 +37,10 @@ export default async function CoursePage({
}) {
const { slug } = await params

const [course, lessons, config] = await Promise.all([
const [course, lessons] = await Promise.all([
getCourse(slug),
getCoursesBySlugLessonsQuery(slug),
getAppConfig()
getCoursesBySlugLessonsQuery(slug)
])

// Resolved on the server: the client hook has no storage URL during SSR
const coverSrc = getMediaSource(
course.thumbnail,
'courses',
config?.features.orion.url
)

return (
<CourseDetails course={course} lessons={lessons} coverSrc={coverSrc} />
)
return <CourseDetails course={course} lessons={lessons} />
}
5 changes: 2 additions & 3 deletions src/app/(public)/courses/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { getCoursesQuery } from '@/generated/api'

import { CourseCard } from '@/components/course/course-card'

import { getCourseLabel } from '@/lib/utils'
import { COURSE, pluralize } from '@/lib/utils'

export const metadata: Metadata = {
title: 'Курсы',
Expand All @@ -29,8 +29,7 @@ export default async function CoursesPage() {
className='-z-10 object-cover object-[70%_50%]'
/>
<p className='font-mono text-xs uppercase tracking-[0.18em] text-highlight'>
Каталог · {courses.length}{' '}
{getCourseLabel(courses.length)}
Каталог · {pluralize(courses.length, COURSE)}
</p>
<h1 className='mt-4 font-display text-display-xl font-medium'>
Курсы
Expand Down
3 changes: 1 addition & 2 deletions src/app/(public)/document/cookie-policy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@ import Link from 'next/link'

import { LegalDocument } from '@/components/legal/legal-document'

import { ROUTES } from '@/constants/routes'

import { env } from '@/lib/config/env'
import { ROUTES } from '@/lib/routes'

export const metadata: Metadata = {
title: 'Политика использования файлов cookie'
Expand Down
3 changes: 1 addition & 2 deletions src/app/(public)/document/privacy-policy/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@ import Link from 'next/link'

import { LegalDocument } from '@/components/legal/legal-document'

import { ROUTES } from '@/constants/routes'

import { env } from '@/lib/config/env'
import { ROUTES } from '@/lib/routes'

export const metadata: Metadata = {
title: 'Политика конфиденциальности'
Expand Down
3 changes: 1 addition & 2 deletions src/app/(public)/document/terms-of-use/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@ import Link from 'next/link'

import { LegalDocument } from '@/components/legal/legal-document'

import { ROUTES } from '@/constants/routes'

import { env } from '@/lib/config/env'
import { ROUTES } from '@/lib/routes'

export const metadata: Metadata = {
title: 'Пользовательское соглашение'
Expand Down
14 changes: 3 additions & 11 deletions src/app/(public)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,9 @@ import { Header } from '@/components/layout/header'
export default function PublicLayout({ children }: { children: ReactNode }) {
return (
<div className='relative min-h-screen'>
{/* <div className='pointer-events-none fixed inset-0'>
<div className='absolute inset-0 bg-gradient-to-b from-background via-background/90 to-background' />
<div className='absolute right-0 top-0 h-[500px] w-[500px] bg-blue-600/10 blur-[100px]' />
<div className='absolute bottom-0 left-0 h-[500px] w-[500px] bg-purple-500/10 blur-[100px]' />
</div> */}

<div className='relative z-10'>
<Header />
{children}
<Footer />
</div>
<Header />
{children}
<Footer />
</div>
)
}
23 changes: 6 additions & 17 deletions src/app/(public)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,42 +9,31 @@ import { Manifesto } from '@/components/home/manifesto'
import { Popular } from '@/components/home/popular'
import { TelegramCTA } from '@/components/home/telegram-cta'

import { getAppConfig } from '@/lib/config/app-config'
import { pickPopularCourses } from '@/lib/courses/popular-courses'
import { getMediaSource } from '@/lib/utils'

export const metadata: Metadata = {
title: 'Образовательная платформа по веб разработке'
}

export default async function HomePage() {
const [allCourses, config] = await Promise.all([
getCoursesQuery(),
getAppConfig()
])

const allCourses = await getCoursesQuery()
const courses = pickPopularCourses(allCourses)
// Popular courses lead the showcase, the rest follow
const ordered = [

const covers = [
...courses,
...allCourses.filter(course => !courses.includes(course))
]
const covers = ordered
.filter(course => course.thumbnail)
.map(course => ({
slug: course.slug,
title: course.title,
lessons: course.lessons,
src: getMediaSource(
course.thumbnail,
'courses',
config?.features.orion.url
)
src: course.thumbnail!
}))
.filter(cover => cover.src !== '')

return (
<Fragment>
<Hero covers={covers} />
<Hero />
<Manifesto />
<Popular courses={courses} />
<Bento covers={covers} />
Expand Down
4 changes: 2 additions & 2 deletions src/app/(public)/premium/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { CourseCard } from '@/components/course/course-card'
import { FAQSection } from '@/components/premium/faq'
import { Premium } from '@/components/premium/premium'

import { getCourseLabel } from '@/lib/utils'
import { COURSE, pluralize } from '@/lib/utils'

export const metadata: Metadata = {
title: 'Подписка'
Expand Down Expand Up @@ -44,7 +44,7 @@ export default async function PremiumPage() {
Курсы в подписке
</h2>
<p className='text-muted-foreground'>
{included.length} {getCourseLabel(included.length)}
{pluralize(included.length, COURSE)}
</p>
</div>
<div className='mt-10 grid gap-5 sm:grid-cols-2 lg:grid-cols-3'>
Expand Down
2 changes: 1 addition & 1 deletion src/app/account/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export default function AccountLayout({ children }: { children: ReactNode }) {
return (
<AccountGuard>
<Header />
<div className='page-container grid gap-8 pb-24 pt-6 sm:pt-10 lg:grid-cols-[260px_minmax(0,1fr)] lg:gap-12'>
<div className='page-container grid gap-6 pb-16 pt-4 sm:pb-24 sm:pt-10 lg:grid-cols-[260px_minmax(0,1fr)] lg:gap-12'>
<UserNavigation />
<main className='min-w-0'>{children}</main>
</div>
Expand Down
Loading
Loading