From ea5ed7277b2ec32d128b2da60cab9d07ba5d0f57 Mon Sep 17 00:00:00 2001 From: Vadosina Date: Tue, 13 Oct 2026 08:58:02 +0300 Subject: [PATCH 1/5] feat(ui): add mobile drawer navigation --- src/app/account/layout.tsx | 2 +- src/components/account/account-ui.tsx | 16 +- .../account/connections/connections.tsx | 2 +- src/components/account/progress/progress.tsx | 2 +- src/components/account/sessions/sessions.tsx | 2 +- .../account/settings/appearance.tsx | 4 +- .../account/settings/avatar-form.tsx | 4 +- .../account/settings/display-name-form.tsx | 2 +- .../account/settings/email-form.tsx | 10 +- .../account/settings/enable-totp-form.tsx | 26 +- .../account/settings/passkey-modal.tsx | 20 +- .../account/settings/recovery-codes-list.tsx | 5 +- .../account/settings/recovery-codes-modal.tsx | 4 +- src/components/account/settings/settings.tsx | 2 +- .../account/subscription/subscription.tsx | 2 +- src/components/layout/mobile-nav.tsx | 256 ++++++++++++------ src/components/layout/user-navigation.tsx | 28 +- 17 files changed, 257 insertions(+), 130 deletions(-) diff --git a/src/app/account/layout.tsx b/src/app/account/layout.tsx index 4fa37af..f578970 100644 --- a/src/app/account/layout.tsx +++ b/src/app/account/layout.tsx @@ -8,7 +8,7 @@ export default function AccountLayout({ children }: { children: ReactNode }) { return (
-
+
{children}
diff --git a/src/components/account/account-ui.tsx b/src/components/account/account-ui.tsx index fca9fc9..9670a29 100644 --- a/src/components/account/account-ui.tsx +++ b/src/components/account/account-ui.tsx @@ -16,7 +16,7 @@ export function AccountHeader({ return (
-

+

{title}

{description && ( @@ -25,7 +25,9 @@ export function AccountHeader({

)}
- {action &&
{action}
} + {action && ( +
{action}
+ )}
) } @@ -97,11 +99,11 @@ export function SettingsRow({ return (
-
+
{icon && ( {icon} @@ -109,7 +111,7 @@ export function SettingsRow({ )}
-

{title}

+

{title}

{badge}
{description && ( @@ -120,7 +122,7 @@ export function SettingsRow({
{children && ( -
+
{children}
)} @@ -130,7 +132,7 @@ export function SettingsRow({ export function SettingsRowSkeleton() { return ( -
+
diff --git a/src/components/account/connections/connections.tsx b/src/components/account/connections/connections.tsx index b883251..0eaebac 100644 --- a/src/components/account/connections/connections.tsx +++ b/src/components/account/connections/connections.tsx @@ -62,7 +62,7 @@ export function Connections() { return ( <> -
+
+
1 return ( -
+
{THEMES.map(option => { const isActive = isMounted && theme === option.value @@ -43,7 +43,7 @@ export function AppearanceForm() { aria-checked={isActive} onClick={() => setTheme(option.value)} className={cn( - 'flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm transition-colors', + 'flex flex-1 items-center justify-center gap-1.5 rounded-full px-2.5 py-2 text-sm transition-colors sm:flex-none sm:px-3 sm:py-1.5', isActive ? 'bg-card font-medium text-foreground shadow-card' : 'text-muted-foreground hover:text-foreground' diff --git a/src/components/account/settings/avatar-form.tsx b/src/components/account/settings/avatar-form.tsx index 3238709..2b7e14b 100644 --- a/src/components/account/settings/avatar-form.tsx +++ b/src/components/account/settings/avatar-form.tsx @@ -61,7 +61,7 @@ export function AvatarForm({ user }: AvatarFormProps) { } return ( -
+
@@ -79,7 +79,7 @@ export function AvatarForm({ user }: AvatarFormProps) {
) } @@ -120,7 +118,7 @@ export function SettingsRow({ {children && ( -
+
{children}
)} @@ -132,13 +130,13 @@ export function SettingsRowSkeleton() { return (
-
+
-
-
+
+
-
+
) } diff --git a/src/components/account/connections/connection-error.tsx b/src/components/account/connections/connection-error.tsx index ee050c5..23fd51a 100644 --- a/src/components/account/connections/connection-error.tsx +++ b/src/components/account/connections/connection-error.tsx @@ -86,8 +86,8 @@ export function ConnectionError() { if (!isVisible || !errorInfo) return null return ( -
- +
+
diff --git a/src/components/account/settings/passkey-modal.tsx b/src/components/account/settings/passkey-modal.tsx index 3ca180f..218b4e5 100644 --- a/src/components/account/settings/passkey-modal.tsx +++ b/src/components/account/settings/passkey-modal.tsx @@ -102,12 +102,12 @@ export function PasskeyModal() {
-

+

{passkey.name}

- + Добавлен:{' '} {formatDate( @@ -116,7 +116,7 @@ export function PasskeyModal() {
- + Последнее использование:{' '} diff --git a/src/components/account/subscription/subscription-status.tsx b/src/components/account/subscription/subscription-status.tsx index af6bc73..ded7c9f 100644 --- a/src/components/account/subscription/subscription-status.tsx +++ b/src/components/account/subscription/subscription-status.tsx @@ -64,13 +64,13 @@ export function SubscriptionStatus({ subscription }: SubscriptionStatusProps) { : [] return ( -
+
-

+

TeaCoder Premium

diff --git a/src/components/auth/auth-wrapper.tsx b/src/components/auth/auth-wrapper.tsx index e18e41e..8eb3535 100644 --- a/src/components/auth/auth-wrapper.tsx +++ b/src/components/auth/auth-wrapper.tsx @@ -69,9 +69,9 @@ export function AuthWrapper({
-
+
-

+

{heading}

{description && ( @@ -81,7 +81,7 @@ export function AuthWrapper({ )}
-
+
{isShowSocial && isLoading ? (
) : showChoice ? ( -
+
) : ( -
+
{hasChoice && ( Прогресс - + -
+
-

+

{index >= 0 ? `Урок ${index + 1} из ${lessons.length}` : 'Урок'} @@ -271,7 +271,7 @@ export function LessonContent({ id }: { id: string }) { )} {lesson.description && ( -

+
{lesson.description}
)} diff --git a/src/components/payment/payment-status.tsx b/src/components/payment/payment-status.tsx index 2571a66..2b2b25b 100644 --- a/src/components/payment/payment-status.tsx +++ b/src/components/payment/payment-status.tsx @@ -284,10 +284,10 @@ export function PaymentStatus({ id }: { id: string }) { className={cn( 'pointer-events-none absolute inset-0 -z-10 transition-colors duration-700', phase === 'success' - ? 'bg-[radial-gradient(60%_45%_at_50%_0%,hsl(var(--success)/0.14),transparent_70%)]' + ? 'bg-[radial-gradient(60%_45%_at_50%_0%,color-mix(in_oklab,var(--success)_14%,transparent),transparent_70%)]' : phase === 'failed' || query.isError - ? 'bg-[radial-gradient(60%_45%_at_50%_0%,hsl(var(--destructive)/0.12),transparent_70%)]' - : 'bg-[radial-gradient(60%_45%_at_50%_0%,hsl(var(--primary)/0.14),transparent_70%)]' + ? 'bg-[radial-gradient(60%_45%_at_50%_0%,color-mix(in_oklab,var(--destructive)_12%,transparent),transparent_70%)]' + : 'bg-[radial-gradient(60%_45%_at_50%_0%,color-mix(in_oklab,var(--primary)_14%,transparent),transparent_70%)]' )} />
@@ -300,7 +300,7 @@ export function PaymentStatus({ id }: { id: string }) {
-
+
{content}
@@ -337,7 +337,7 @@ function StatusCard({ return (
{tone === 'pending' && ( diff --git a/src/components/premium/premium.tsx b/src/components/premium/premium.tsx index ad4ad4b..1b5058b 100644 --- a/src/components/premium/premium.tsx +++ b/src/components/premium/premium.tsx @@ -47,13 +47,13 @@ export function Premium() { return ( <>
-
+
-

+

Premium

{premium ? (

- + {formatPrice( premium.prices.standard, premium.currency diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx index 3f885f9..e8fbcdc 100644 --- a/src/components/ui/accordion.tsx +++ b/src/components/ui/accordion.tsx @@ -35,7 +35,7 @@ function AccordionTrigger({ svg]:rotate-180', + 'flex flex-1 items-start justify-between gap-4 rounded-md py-5 text-left text-base font-medium outline-hidden transition-colors hover:text-brand focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180', className )} {...props} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 6932ec5..d659c55 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -6,7 +6,7 @@ import { type ButtonHTMLAttributes, type ReactNode, forwardRef } from 'react' import { cn } from '@/src/lib/utils' const buttonVariants = cva( - 'inline-flex select-none items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium transition-[background-color,color,border-color,transform] duration-200 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background active:scale-[0.97] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 [&>svg:last-child:not(:first-child)]:transition-transform [&>svg:last-child:not(:first-child)]:duration-200 hover:[&>svg:last-child:not(:first-child)]:translate-x-0.5', + 'inline-flex select-none items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium transition-[background-color,color,border-color,transform] duration-200 ease-out focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background active:scale-[0.97] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 [&>svg:last-child:not(:first-child)]:transition-transform [&>svg:last-child:not(:first-child)]:duration-200 [&>svg:last-child:not(:first-child)]:hover:translate-x-0.5', { variants: { variant: { @@ -18,7 +18,7 @@ const buttonVariants = cva( highlight: 'bg-highlight text-inverse hover:bg-highlight/85', // Translucent pill for dark panels - glass: 'border border-inverse-foreground/15 bg-inverse-foreground/[0.06] text-inverse-foreground backdrop-blur-md hover:bg-inverse-foreground/[0.12]', + glass: 'border border-inverse-foreground/15 bg-inverse-foreground/6 text-inverse-foreground backdrop-blur-md hover:bg-inverse-foreground/12', // Light button for use on top of primary-filled blocks contrast: 'bg-primary-foreground text-primary hover:bg-primary-foreground/90', diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index 5044a70..018b9e2 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -32,7 +32,7 @@ const DropdownMenuSubTrigger = forwardRef< svg]:size-4 [&>svg]:shrink-0', + 'relative flex cursor-pointer select-none items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm outline-hidden transition-colors focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0', inset && 'pl-8', className )} @@ -105,7 +105,7 @@ const DropdownMenuCheckboxItem = forwardRef< >( {children} - + ) @@ -122,7 +122,7 @@ const SelectContent = forwardRef< {children} @@ -168,7 +168,7 @@ const SelectItem = forwardRef< ) { return (

) diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx index a9fb761..0563f16 100644 --- a/src/components/ui/switch.tsx +++ b/src/components/ui/switch.tsx @@ -15,7 +15,7 @@ const Switch = forwardRef< >(({ className, ...props }, ref) => ( diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx index 4156509..b412bc7 100644 --- a/src/components/ui/tabs.tsx +++ b/src/components/ui/tabs.tsx @@ -56,9 +56,9 @@ TabsList.displayName = 'TabsList' function getVariantStyles(tabVariant: TabsListVariant) { switch (tabVariant) { case 'line': - return '-mb-px items-center justify-center whitespace-nowrap border-b-2 border-transparent px-3 pb-2.5 text-sm font-medium transition-colors text-muted-foreground hover:text-foreground data-[state=active]:border-foreground data-[state=active]:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50' + return '-mb-px items-center justify-center whitespace-nowrap border-b-2 border-transparent px-3 pb-2.5 text-sm font-medium transition-colors text-muted-foreground hover:text-foreground data-[state=active]:border-foreground data-[state=active]:text-foreground data-disabled:pointer-events-none data-disabled:opacity-50' case 'solid': - return 'inline-flex items-center justify-center whitespace-nowrap rounded-full px-4 py-1.5 text-sm font-medium transition-colors text-muted-foreground hover:text-foreground data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow-card data-[disabled]:pointer-events-none data-[disabled]:opacity-50' + return 'inline-flex items-center justify-center whitespace-nowrap rounded-full px-4 py-1.5 text-sm font-medium transition-colors text-muted-foreground hover:text-foreground data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow-card data-disabled:pointer-events-none data-disabled:opacity-50' } } @@ -86,7 +86,7 @@ const TabsContent = forwardRef< >(({ className, ...props }, forwardedRef) => ( )) diff --git a/src/lib/fonts.ts b/src/lib/fonts.ts index cf9ad6f..8db5032 100644 --- a/src/lib/fonts.ts +++ b/src/lib/fonts.ts @@ -56,7 +56,7 @@ export const onest = localFont({ src: '../assets/fonts/Onest/Onest.woff2', weight: '100 900', style: 'normal', - variable: '--font-display', + variable: '--font-onest', display: 'swap' }) @@ -73,7 +73,7 @@ export const garamond = localFont({ style: 'italic' } ], - variable: '--font-serif', + variable: '--font-garamond', display: 'swap', fallback: ['Georgia', 'serif'] }) diff --git a/tailwind.config.ts b/tailwind.config.ts deleted file mode 100644 index ac5fa96..0000000 --- a/tailwind.config.ts +++ /dev/null @@ -1,167 +0,0 @@ -import type { Config } from 'tailwindcss' - -const config: Config = { - darkMode: ['class'], - content: [ - './src/pages/**/*.{js,ts,jsx,tsx,mdx}', - './src/components/**/*.{js,ts,jsx,tsx,mdx}', - './src/app/**/*.{js,ts,jsx,tsx,mdx}' - ], - theme: { - extend: { - fontFamily: { - sans: ['var(--font-geist-sans)', 'system-ui', 'sans-serif'], - display: ['var(--font-display)', 'system-ui', 'sans-serif'], - serif: ['var(--font-serif)', 'Georgia', 'serif'], - mono: [ - 'ui-monospace', - 'SFMono-Regular', - 'Menlo', - 'Consolas', - 'monospace' - ] - }, - fontSize: { - // Fluid display scale for headings (Onest, tight tracking) - 'display-2xl': [ - 'clamp(2.5rem, 1.3rem + 3.6vw, 4.5rem)', - { lineHeight: '1.04', letterSpacing: '-0.035em' } - ], - 'display-xl': [ - 'clamp(2rem, 1.3rem + 2.6vw, 3.5rem)', - { lineHeight: '1.08', letterSpacing: '-0.03em' } - ], - 'display-lg': [ - 'clamp(1.75rem, 1.3rem + 1.6vw, 2.75rem)', - { lineHeight: '1.1', letterSpacing: '-0.03em' } - ], - 'display-md': [ - 'clamp(1.375rem, 1.2rem + 0.7vw, 1.875rem)', - { lineHeight: '1.2', letterSpacing: '-0.02em' } - ] - }, - colors: { - border: 'hsl(var(--border))', - input: 'hsl(var(--input))', - ring: 'hsl(var(--ring))', - background: 'hsl(var(--background))', - foreground: 'hsl(var(--foreground))', - brand: { - DEFAULT: 'hsl(var(--brand))', - soft: 'hsl(var(--brand-soft))' - }, - highlight: 'hsl(var(--highlight))', - primary: { - DEFAULT: 'hsl(var(--primary))', - foreground: 'hsl(var(--primary-foreground))' - }, - secondary: { - DEFAULT: 'hsl(var(--secondary))', - foreground: 'hsl(var(--secondary-foreground))' - }, - destructive: { - DEFAULT: 'hsl(var(--destructive))', - foreground: 'hsl(var(--destructive-foreground))', - soft: 'hsl(var(--destructive-soft))' - }, - success: { - DEFAULT: 'hsl(var(--success))', - soft: 'hsl(var(--success-soft))' - }, - warning: { - DEFAULT: 'hsl(var(--warning))', - soft: 'hsl(var(--warning-soft))' - }, - inverse: { - DEFAULT: 'hsl(var(--inverse))', - foreground: 'hsl(var(--inverse-foreground))' - }, - muted: { - DEFAULT: 'hsl(var(--muted))', - foreground: 'hsl(var(--muted-foreground))' - }, - accent: { - DEFAULT: 'hsl(var(--accent))', - foreground: 'hsl(var(--accent-foreground))' - }, - popover: { - DEFAULT: 'hsl(var(--popover))', - foreground: 'hsl(var(--popover-foreground))' - }, - card: { - DEFAULT: 'hsl(var(--card))', - foreground: 'hsl(var(--card-foreground))' - }, - chart: { - '1': 'hsl(var(--chart-1))', - '2': 'hsl(var(--chart-2))', - '3': 'hsl(var(--chart-3))', - '4': 'hsl(var(--chart-4))', - '5': 'hsl(var(--chart-5))' - } - }, - borderRadius: { - // sm 8 / md 10 / lg 12 / xl 16 / 2xl 20 / 3xl 28 (radius = 12px) - sm: 'calc(var(--radius) - 4px)', - md: 'calc(var(--radius) - 2px)', - lg: 'var(--radius)', - xl: 'calc(var(--radius) + 4px)', - '2xl': 'calc(var(--radius) + 8px)', - '3xl': 'calc(var(--radius) + 16px)' - }, - boxShadow: { - // Hairline + soft lift used by cards instead of plain borders - card: '0 0 0 1px hsl(var(--border) / 0.7), 0 1px 2px hsl(var(--foreground) / 0.04)', - 'card-hover': - '0 0 0 1px hsl(var(--border)), 0 8px 24px -12px hsl(var(--foreground) / 0.18)', - popover: - '0 0 0 1px hsl(var(--border)), 0 16px 40px -16px hsl(var(--foreground) / 0.25)', - highlight: 'inset 0 1px 0 hsl(0 0% 100% / 0.16)' - }, - transitionTimingFunction: { - out: 'cubic-bezier(0.22, 1, 0.36, 1)' - }, - keyframes: { - 'accordion-down': { - from: { height: '0' }, - to: { height: 'var(--radix-accordion-content-height)' } - }, - 'accordion-up': { - from: { height: 'var(--radix-accordion-content-height)' }, - to: { height: '0' } - }, - 'collapsible-down': { - from: { height: '0' }, - to: { height: 'var(--radix-collapsible-content-height)' } - }, - 'collapsible-up': { - from: { height: 'var(--radix-collapsible-content-height)' }, - to: { height: '0' } - }, - 'fade-up': { - from: { opacity: '0', transform: 'translateY(8px)' }, - to: { opacity: '1', transform: 'translateY(0)' } - }, - marquee: { - from: { transform: 'translateX(0)' }, - to: { transform: 'translateX(-50%)' } - }, - steam: { - '0%, 100%': { transform: 'translateY(0) scaleY(1)' }, - '50%': { transform: 'translateY(-6%) scaleY(1.04)' } - } - }, - animation: { - 'accordion-down': 'accordion-down 0.2s ease-out', - 'accordion-up': 'accordion-up 0.2s ease-out', - 'collapsible-down': 'collapsible-down 0.2s ease-out', - 'collapsible-up': 'collapsible-up 0.2s ease-out', - 'fade-up': 'fade-up 0.5s cubic-bezier(0.22, 1, 0.36, 1) both', - marquee: 'marquee var(--marquee-duration, 60s) linear infinite', - steam: 'steam 3.2s ease-in-out infinite' - } - } - }, - plugins: [require('tailwindcss-animate')] -} -export default config From 555c8c85973914c95615cd8be297588d341497a6 Mon Sep 17 00:00:00 2001 From: Vadosina Date: Tue, 13 Oct 2026 09:57:23 +0300 Subject: [PATCH 5/5] feat(courses): show discounted price --- generated/model/courseLibraryResponseItem.ts | 2 +- generated/model/courseListResponseItem.ts | 2 +- generated/model/courseResponse.ts | 2 +- src/components/course/course-aside.tsx | 44 ++++++++++++----- src/components/course/course-card.tsx | 34 +++++++++---- src/components/course/course-details.tsx | 3 ++ src/components/course/course-outcomes.tsx | 29 +++++++++++ src/components/course/course-price.tsx | 52 ++++++++++++++++++++ src/lib/utils/format-price.ts | 24 +++++++++ 9 files changed, 167 insertions(+), 25 deletions(-) create mode 100644 src/components/course/course-outcomes.tsx create mode 100644 src/components/course/course-price.tsx diff --git a/generated/model/courseLibraryResponseItem.ts b/generated/model/courseLibraryResponseItem.ts index eab99a5..a7abc82 100644 --- a/generated/model/courseLibraryResponseItem.ts +++ b/generated/model/courseLibraryResponseItem.ts @@ -47,7 +47,7 @@ export type CourseLibraryResponseItem = { shortDescription: string | null; thumbnail: string | null; price: number | null; - originalPrice: number | null; + discountPrice: number | null; /** Как открываются закрытые уроки и материалы: `FREE` - курс полностью бесплатный, `PREMIUM` - по премиум-подписке или покупке курса, `PURCHASE` - только покупкой. Купленный курс открыт навсегда в любом режиме. */ accessMode: CourseLibraryResponseItemAccessMode; /** Есть ли у курса материалы (исходный код). Скачать - `POST /courses/{slug}/materials/link`. */ diff --git a/generated/model/courseListResponseItem.ts b/generated/model/courseListResponseItem.ts index 73ae762..4e54304 100644 --- a/generated/model/courseListResponseItem.ts +++ b/generated/model/courseListResponseItem.ts @@ -47,7 +47,7 @@ export type CourseListResponseItem = { shortDescription: string | null; thumbnail: string | null; price: number | null; - originalPrice: number | null; + discountPrice: number | null; /** Как открываются закрытые уроки и материалы: `FREE` - курс полностью бесплатный, `PREMIUM` - по премиум-подписке или покупке курса, `PURCHASE` - только покупкой. Купленный курс открыт навсегда в любом режиме. */ accessMode: CourseListResponseItemAccessMode; /** Есть ли у курса материалы (исходный код). Скачать - `POST /courses/{slug}/materials/link`. */ diff --git a/generated/model/courseResponse.ts b/generated/model/courseResponse.ts index 4f9447b..fcb683b 100644 --- a/generated/model/courseResponse.ts +++ b/generated/model/courseResponse.ts @@ -50,7 +50,7 @@ export interface CourseResponse { thumbnail: string | null; youtubeUrl: string | null; price: number | null; - originalPrice: number | null; + discountPrice: number | null; /** Что вы узнаете на курсе: пункты списка в порядке показа. Пустой массив, если не заполнено. */ learningOutcomes: string[]; /** Как открываются закрытые уроки и материалы: `FREE` - курс полностью бесплатный, `PREMIUM` - по премиум-подписке или покупке курса, `PURCHASE` - только покупкой. Купленный курс открыт навсегда в любом режиме. */ diff --git a/src/components/course/course-aside.tsx b/src/components/course/course-aside.tsx index fa33d5a..d52e12f 100644 --- a/src/components/course/course-aside.tsx +++ b/src/components/course/course-aside.tsx @@ -24,13 +24,15 @@ import { useSession } from '@/lib/auth/auth-provider' import { useAppConfig } from '@/lib/config/useAppConfig' import type { ProgramLesson } from '@/lib/courses/program' import { ROUTES } from '@/lib/routes' -import { LESSON, formatPrice, pluralize } from '@/lib/utils' +import { LESSON, formatPrice, getPayablePrice, pluralize } from '@/lib/utils' import { PaymentDialog } from '../billing/payment-dialog' import { Button } from '../ui/button' import { Progress } from '../ui/progress' import { Skeleton } from '../ui/skeleton' +import { CoursePrice } from './course-price' + interface CourseAsideProps { course: CourseResponse lessonCount: number @@ -59,10 +61,9 @@ export function CourseAside({ const [isPaymentOpen, setIsPaymentOpen] = useState(false) const currency = config?.features.premium.currency + const payable = getPayablePrice(course.price, course.discountPrice) const price = - course.price !== null && currency - ? formatPrice(course.price, currency) - : null + payable !== null && currency ? formatPrice(payable, currency) : null const courseUrl = ROUTES.COURSES.SINGLE(course.slug) const isStarted = completedCount > 0 @@ -168,10 +169,10 @@ export function CourseAside({
-
+
@@ -181,7 +182,7 @@ export function CourseAside({ Пройдено {completedCount} из {lessonCount} - + {Math.round(percentage)}%
@@ -260,13 +261,22 @@ export function CourseAside({ function AccessSummary({ course, - price, + currency, hasAccess }: { course: CourseResponse - price: string | null + currency: string | undefined hasAccess: boolean }) { + const price = ( + + ) + const hasPrice = course.price !== null && Boolean(currency) + if (course.accessMode === 'FREE') { return (

@@ -292,8 +302,8 @@ function AccessSummary({

По подписке

- {price && ( -

+ {hasPrice && ( +

или навсегда за {price}

)} @@ -302,8 +312,16 @@ function AccessSummary({ } return ( -

- {price ?? 'Покупка'} +

+ {hasPrice ? ( + + ) : ( + 'Покупка' + )}

) } diff --git a/src/components/course/course-card.tsx b/src/components/course/course-card.tsx index b0f62f5..cbb8972 100644 --- a/src/components/course/course-card.tsx +++ b/src/components/course/course-card.tsx @@ -5,7 +5,9 @@ import type { CourseListResponseItem } from '@/generated/model' import { getAppConfig } from '@/lib/config/app-config' import { ROUTES } from '@/lib/routes' -import { LESSON, formatPrice, pluralize } from '@/lib/utils' +import { LESSON, pluralize } from '@/lib/utils' + +import { CoursePrice } from './course-price' interface CourseCardProps { course: CourseListResponseItem @@ -40,7 +42,7 @@ export async function CourseCard({ course }: CourseCardProps) {

{course.title}

@@ -57,6 +59,7 @@ export async function CourseCard({ course }: CourseCardProps) {
@@ -68,12 +71,25 @@ export async function CourseCard({ course }: CourseCardProps) { interface AccessLabelProps { accessMode: CourseListResponseItem['accessMode'] price: number | null + discountPrice: number | null currency: string | undefined } -function AccessLabel({ accessMode, price, currency }: AccessLabelProps) { - const formattedPrice = - price !== null && currency ? formatPrice(price, currency) : null +function AccessLabel({ + accessMode, + price, + discountPrice, + currency +}: AccessLabelProps) { + const priceLabel = ( + + ) + const hasPrice = price !== null && Boolean(currency) if (accessMode === 'FREE') { return Бесплатно @@ -83,10 +99,10 @@ function AccessLabel({ accessMode, price, currency }: AccessLabelProps) { return ( По подписке - {formattedPrice && ( + {hasPrice && ( {' '} - или {formattedPrice} + или {priceLabel} )} @@ -94,8 +110,8 @@ function AccessLabel({ accessMode, price, currency }: AccessLabelProps) { } return ( - - {formattedPrice ?? 'Покупка'} + + {hasPrice ? priceLabel : 'Покупка'} ) } diff --git a/src/components/course/course-details.tsx b/src/components/course/course-details.tsx index 13c9afe..e253686 100644 --- a/src/components/course/course-details.tsx +++ b/src/components/course/course-details.tsx @@ -20,6 +20,7 @@ import { LESSON, SECTION, cn, pluralize } from '@/lib/utils' import { Button } from '../ui/button' import { CourseAside } from './course-aside' +import { CourseOutcomes } from './course-outcomes' import { CourseProgram } from './course-program' const DESCRIPTION_PREVIEW_LENGTH = 520 @@ -87,6 +88,8 @@ export function CourseDetails({ course, lessons }: CourseDetailsProps) {

)} + + {course.fullDescription && (

diff --git a/src/components/course/course-outcomes.tsx b/src/components/course/course-outcomes.tsx new file mode 100644 index 0000000..fda309e --- /dev/null +++ b/src/components/course/course-outcomes.tsx @@ -0,0 +1,29 @@ +import { Check } from 'lucide-react' + +interface CourseOutcomesProps { + outcomes: string[] +} + +export function CourseOutcomes({ outcomes }: CourseOutcomesProps) { + if (outcomes.length === 0) return null + + return ( +
+

+ Чему вы научитесь +

+
    + {outcomes.map((outcome, index) => ( +
  • + + + + + {outcome} + +
  • + ))} +
+
+ ) +} diff --git a/src/components/course/course-price.tsx b/src/components/course/course-price.tsx new file mode 100644 index 0000000..325fed4 --- /dev/null +++ b/src/components/course/course-price.tsx @@ -0,0 +1,52 @@ +import { + cn, + formatPrice, + getDiscountPercent, + getPayablePrice +} from '@/lib/utils' + +interface CoursePriceProps { + price: number | null + discountPrice: number | null + currency: string | undefined + className?: string + priceClassName?: string +} + +/** Payable price; while a discount lasts the full price is struck through. */ +export function CoursePrice({ + price, + discountPrice, + currency, + className, + priceClassName +}: CoursePriceProps) { + const payable = getPayablePrice(price, discountPrice) + + if (price === null || payable === null || !currency) return null + + const discount = getDiscountPercent(price, discountPrice) + + return ( + + + {formatPrice(payable, currency)} + + {discount !== null && ( + <> + + {formatPrice(price, currency)} + + + −{discount}% + + + )} + + ) +} diff --git a/src/lib/utils/format-price.ts b/src/lib/utils/format-price.ts index fd97191..92b29d1 100644 --- a/src/lib/utils/format-price.ts +++ b/src/lib/utils/format-price.ts @@ -5,3 +5,27 @@ export function formatPrice(amount: number, currency: string) { maximumFractionDigits: 0 }).format(amount) } + +/** `price` is the full price; `discountPrice` is what is charged while a discount lasts. */ +export function getPayablePrice( + price: number | null, + discountPrice: number | null +): number | null { + if (price === null) return null + + return discountPrice !== null && discountPrice > 0 && discountPrice < price + ? discountPrice + : price +} + +/** Discount in whole percent, or `null` when there is no discount. */ +export function getDiscountPercent( + price: number | null, + discountPrice: number | null +): number | null { + const payable = getPayablePrice(price, discountPrice) + + if (price === null || payable === null || payable >= price) return null + + return Math.round((1 - payable / price) * 100) +}