diff --git a/.dockerignore b/.dockerignore index 0d994fd..a580743 100644 --- a/.dockerignore +++ b/.dockerignore @@ -33,6 +33,7 @@ apps/*/.env.* # اسکریپت‌های دیپلوی خودشان بخشی از اپ نیستند. deploy +!deploy/prisma-engines docker docs AUDIT diff --git a/.gitignore b/.gitignore index 3f1cbe8..65fbdbd 100644 --- a/.gitignore +++ b/.gitignore @@ -113,3 +113,6 @@ deploy/env/app.env demo-admin-credentials* *.credentials.txt DEPLOY/ + +deploy/prisma-engines/* +!deploy/prisma-engines/.gitkeep diff --git a/apps/api/prisma/demo-data.ts b/apps/api/prisma/demo-data.ts index df75fb7..bfc337c 100644 --- a/apps/api/prisma/demo-data.ts +++ b/apps/api/prisma/demo-data.ts @@ -507,23 +507,6 @@ async function createDemo(tx: Tx, adminHash: string, adminIdentity: string, admi attendanceTeacher: completed ? true : null, }, }); - await tx.payment.create({ - data: { - id: `${PREFIX}payment-${i}`, - bookingId, - userId: `${PREFIX}student-${student[0]}`, - purpose: 'BOOKING', - referenceId: bookingId, - subtotal: teacher.price, - gatewayAmount: teacher.price, - amount: teacher.price, - status: 'PAID', - authority: `${PREFIX}authority-${i}`, - gatewayReference: `${PREFIX}gateway-${i}`, - idempotencyKey: `${PREFIX}payment-${i}`, - verifiedAt: at(completed ? -30 + i * 2 : -2), - }, - }); if (completed) await tx.review.create({ data: { diff --git a/apps/api/src/architecture.spec.ts b/apps/api/src/architecture.spec.ts index 0f884ca..a8cd5cb 100644 --- a/apps/api/src/architecture.spec.ts +++ b/apps/api/src/architecture.spec.ts @@ -81,6 +81,7 @@ describe('module boundaries', () => { expect(exported.sort()).toEqual([ 'AutoDiscountsService', 'EarningsService', + 'PackagesService', 'WalletService', 'releaseDiscount', ]); diff --git a/apps/api/src/modules/bookings/booking-jobs.service.ts b/apps/api/src/modules/bookings/booking-jobs.service.ts index b13281e..3656468 100644 --- a/apps/api/src/modules/bookings/booking-jobs.service.ts +++ b/apps/api/src/modules/bookings/booking-jobs.service.ts @@ -11,7 +11,10 @@ export class BookingJobsService { } async scheduleBooking(bookingId: string, startsAt: Date) { - for (const [minutes, type] of [[1440, '24h'], [60, '1h']] as const) { + // Keep the short, user-facing reminder in the same durable queue as the + // longer reminders. This is also the reminder that powers the in-app class + // toast and carries the meeting URL in the notification payload. + for (const [minutes, type] of [[1440, '24h'], [60, '1h'], [15, '15m']] as const) { const scheduledAt = new Date(startsAt.getTime() - minutes * 60e3); if (scheduledAt <= new Date()) continue; const reminder = await this.db.reminder.upsert({ diff --git a/apps/api/src/modules/commerce/index.ts b/apps/api/src/modules/commerce/index.ts index f6f50cb..a3d9171 100644 --- a/apps/api/src/modules/commerce/index.ts +++ b/apps/api/src/modules/commerce/index.ts @@ -13,3 +13,4 @@ export { EarningsService } from './payouts/earnings.service'; export { WalletService } from './payments/wallet.service'; export { AutoDiscountsService } from './discounts/auto-discounts.service'; export { releaseDiscount } from './discounts/discount-reservation'; +export { PackagesService } from './packages/packages.service'; diff --git a/apps/api/src/modules/commerce/payments/receipt-top-ups.service.spec.ts b/apps/api/src/modules/commerce/payments/receipt-top-ups.service.spec.ts index 6f0efe6..081a0cb 100644 --- a/apps/api/src/modules/commerce/payments/receipt-top-ups.service.spec.ts +++ b/apps/api/src/modules/commerce/payments/receipt-top-ups.service.spec.ts @@ -95,10 +95,12 @@ describe('ReceiptTopUpsService', () => { expect(h.payments.settleVerified).not.toHaveBeenCalled(); }); - it('an admin cannot approve their own receipt', async () => { - const h = harness({ payment: { ...pending, userId: 'admin-1' } }); - await expect(h.svc.approve('admin-1', 'p-1')).rejects.toMatchObject({ - response: { code: 'RECEIPT_SELF_REVIEW_FORBIDDEN' }, + it('an admin can approve their own course receipt', async () => { + const h = harness({ payment: { ...pending, userId: 'admin-1', purpose: 'course' } }); + await h.svc.approve('admin-1', 'p-1'); + expect(h.payments.settleVerified).toHaveBeenCalledWith('p-1', undefined, { + method: 'receipt', + approvedBy: 'admin-1', }); }); diff --git a/apps/api/src/modules/commerce/payments/receipt-top-ups.service.ts b/apps/api/src/modules/commerce/payments/receipt-top-ups.service.ts index 33f06ad..707d9f5 100644 --- a/apps/api/src/modules/commerce/payments/receipt-top-ups.service.ts +++ b/apps/api/src/modules/commerce/payments/receipt-top-ups.service.ts @@ -207,7 +207,11 @@ export class ReceiptTopUpsService { private async claim(actorId: string, paymentId: string, rejectReason: string | null) { const payment = await this.db.payment.findUnique({ where: { id: paymentId } }); if (!payment || !payment.receiptFileId) throw notFound('PAYMENT_NOT_FOUND'); - if (payment.userId === actorId) throw badRequest('RECEIPT_SELF_REVIEW_FORBIDDEN'); + // Course purchases are reviewed by the course administrator, who may also + // be the purchaser. Keep the self-review guard for ordinary wallet top-ups. + if (payment.userId === actorId && payment.purpose !== 'course') { + throw badRequest('RECEIPT_SELF_REVIEW_FORBIDDEN'); + } const claimed = await this.db.payment.updateMany({ where: { id: paymentId, status: 'PENDING', reviewedAt: null }, data: { diff --git a/apps/api/src/modules/courses/courses.service.ts b/apps/api/src/modules/courses/courses.service.ts index 49892e2..827586b 100644 --- a/apps/api/src/modules/courses/courses.service.ts +++ b/apps/api/src/modules/courses/courses.service.ts @@ -3,7 +3,7 @@ import { Prisma } from '@prisma/client'; import { PrismaService, type DbClient } from '../../infrastructure/database/prisma.service'; import { badRequest, conflict, forbidden, notFound } from '../../common'; import type { AuthUser } from '../../common'; -import { PackagesService } from '../commerce/packages/packages.service'; +import { PackagesService } from '../commerce'; import type { CourseChapterDto, CourseLessonDto } from './dto/course-curriculum.dto'; import type { AdminCourseDto } from './dto/admin-course.dto'; import type { InstructorCourseDto } from './dto/instructor-course.dto'; diff --git a/apps/api/src/modules/notifications/notifications.controller.ts b/apps/api/src/modules/notifications/notifications.controller.ts index 5dd8e67..3e1689a 100644 --- a/apps/api/src/modules/notifications/notifications.controller.ts +++ b/apps/api/src/modules/notifications/notifications.controller.ts @@ -1,5 +1,5 @@ import { Controller, Get, Param, Put } from '@nestjs/common'; -import { CurrentUser, type AuthUser } from '../../common'; +import { CurrentUser, Roles, type AuthUser } from '../../common'; import { NotificationsService } from './notifications.service'; @Controller('notifications') @@ -8,6 +8,10 @@ export class NotificationsController { @Get() list(@CurrentUser() u: AuthUser) { return this.s.list(u.id); } + @Roles('STUDENT', 'INSTRUCTOR', 'ADMIN', 'SUPPORT') + @Put('read-all') readAll(@CurrentUser() u: AuthUser) { + return this.s.readAll(u.id); + } @Put(':id/read') read(@CurrentUser() u: AuthUser, @Param('id') id: string) { return this.s.read(u.id, id); } diff --git a/apps/api/src/modules/notifications/notifications.service.ts b/apps/api/src/modules/notifications/notifications.service.ts index dff4988..d5f7891 100644 --- a/apps/api/src/modules/notifications/notifications.service.ts +++ b/apps/api/src/modules/notifications/notifications.service.ts @@ -11,9 +11,21 @@ export class NotificationsService { }); } list(userId: string) { - return this.db.notification.findMany({ where: { userId }, include: { deliveries: true }, orderBy: { createdAt: 'desc' }, take: 100 }); + return this.db.notification.findMany({ where: { userId }, include: { deliveries: true }, orderBy: { createdAt: 'desc' }, take: 100 }).then((items) => + items.map((item) => ({ + ...item, + // Keep the bilingual fields for existing clients while exposing the + // normalized notification contract used by the web app. + title: item.titleFa, + message: item.bodyFa, + link: item.data && typeof item.data === 'object' && !Array.isArray(item.data) && typeof item.data.link === 'string' ? item.data.link : null, + })), + ); } read(userId: string, id: string) { return this.db.notification.updateMany({ where: { id, userId }, data: { readAt: new Date() } }); } + readAll(userId: string) { + return this.db.notification.updateMany({ where: { userId, readAt: null }, data: { readAt: new Date() } }); + } } diff --git a/apps/web/e2e/open-three-roles.spec.ts b/apps/web/e2e/open-three-roles.spec.ts new file mode 100644 index 0000000..d77f317 --- /dev/null +++ b/apps/web/e2e/open-three-roles.spec.ts @@ -0,0 +1,24 @@ +import { test } from '@playwright/test'; +import jwt from 'jsonwebtoken'; + +test('open student teacher admin tabs', async ({ browser }) => { + const secret = process.env.JWT_ACCESS_SECRET; + if (!secret) throw new Error('JWT_ACCESS_SECRET is required'); + const roles = [ + { name: 'student', id: 'user-student-completed', roles: ['STUDENT'], path: '/dashboard/classes' }, + { name: 'teacher', id: 'user-teacher-shahriar', roles: ['INSTRUCTOR'], path: '/teacher-panel/courses' }, + { name: 'admin', id: 'user-admin', roles: ['ADMIN'], path: '/admin' }, + ] as const; + const context = await browser.newContext(); + const pages = await Promise.all(roles.map(async (role) => { + const page = await context.newPage(); + const token = jwt.sign({ id: role.id, roles: role.roles, permissions: ['payments.read', 'payments.adjust-wallet', 'bookings.read', 'courses.manage'] }, secret, { expiresIn: '2h' }); + await page.addInitScript((value) => sessionStorage.setItem('access_token', value), token); + await page.goto(role.path, { waitUntil: 'domcontentloaded' }); + await page.screenshot({ path: `test-results/roles-${role.name}.png`, fullPage: true }); + console.log(`${role.name}: ${page.url()}`); + return page; + })); + await pages[0].bringToFront(); + await pages[0].pause(); +}); diff --git a/apps/web/e2e/panels.spec.ts b/apps/web/e2e/panels.spec.ts index 23ddf17..711415f 100644 --- a/apps/web/e2e/panels.spec.ts +++ b/apps/web/e2e/panels.spec.ts @@ -73,7 +73,9 @@ test('student panel routes and data widgets render', async ({ page }) => { test('teacher panel routes and data widgets render', async ({ page }) => { test.setTimeout(90_000); const errors = failures(page); - await session(page, 'user-teacher-approved', ['TEACHER']); + // The persisted domain role is INSTRUCTOR (not the display label “Teacher”). + // Keeping the fixture aligned with the API enum exercises the real teacher guard. + await session(page, 'user-teacher-shahriar', ['INSTRUCTOR']); await visit(page, [ '/teacher-panel', '/teacher-panel/profile', diff --git a/apps/web/e2e/visual-class-reminder.spec.ts b/apps/web/e2e/visual-class-reminder.spec.ts new file mode 100644 index 0000000..09ea23d --- /dev/null +++ b/apps/web/e2e/visual-class-reminder.spec.ts @@ -0,0 +1,29 @@ +import { expect, test } from '@playwright/test'; +import jwt from 'jsonwebtoken'; + +test('visual confirmed class, calendar link and reminder', async ({ page }) => { + const secret = process.env.JWT_ACCESS_SECRET; + if (!secret) throw new Error('JWT_ACCESS_SECRET is required'); + const token = jwt.sign({ id: 'user-student-completed', roles: ['STUDENT'], permissions: [] }, secret, { expiresIn: '15m' }); + await page.addInitScript((value) => sessionStorage.setItem('access_token', value), token); + const responses: string[] = []; + page.on('response', (response) => { + if (response.url().includes('/api/')) responses.push(`${response.status()} ${response.request().method()} ${response.url()}`); + }); + await page.goto('/dashboard/classes'); + await page.waitForTimeout(3000); + await page.screenshot({ path: 'test-results/class-01-calendar.png', fullPage: true }); + const meetingLink = page.getByRole('link', { name: /Google Meet/ }); + await expect(meetingLink).toBeVisible(); + await expect(meetingLink).toHaveAttribute('href', 'https://meet.google.com/e2e-clock-20260930'); + await page.screenshot({ path: 'test-results/class-03-before-join.png', fullPage: true }); + await meetingLink.click({ noWaitAfter: true }); + await page.waitForTimeout(3000); + await page.goto('/dashboard/notifications'); + await page.waitForLoadState('networkidle'); + await page.screenshot({ path: 'test-results/class-02-notification.png', fullPage: true }); + await expect(page.getByText('یادآوری کلاس').first()).toBeVisible(); + await expect(page.getByText('https://meet.google.com/e2e-clock-20260930').first()).toBeVisible(); + console.log(responses.join('\n')); + if (process.env.KEEP_BROWSER_OPEN === 'true') await page.pause(); +}); diff --git a/apps/web/e2e/visual-guided-flow.spec.ts b/apps/web/e2e/visual-guided-flow.spec.ts new file mode 100644 index 0000000..d1c3c09 --- /dev/null +++ b/apps/web/e2e/visual-guided-flow.spec.ts @@ -0,0 +1,47 @@ +import { expect, test } from '@playwright/test'; +import jwt from 'jsonwebtoken'; + +test('guided visible multi-role course flow', async ({ browser }) => { + test.setTimeout(180_000); + const secret = process.env.JWT_ACCESS_SECRET; + if (!secret) throw new Error('JWT_ACCESS_SECRET is required'); + const context = await browser.newContext(); + const adminPermissions = ['users.read','users.manage','teachers.read','teachers.verify','teacher-prices.manage','languages.manage','tests.manage','tests.review','bookings.read','bookings.manage','tickets.read','tickets.manage','payments.read','payments.refund','payments.adjust-wallet','payouts.manage','reviews.manage','courses.manage','audit.read','settings.manage','cms.manage','notifications.read','roles.manage','reports.read','availability.manage']; + async function tab(id: string, roles: string[], path: string) { + const page = await context.newPage(); + const token = jwt.sign({ id, roles, permissions: roles.includes('ADMIN') ? adminPermissions : [] }, secret, { expiresIn: '2h' }); + await page.addInitScript((value) => sessionStorage.setItem('access_token', value), token); + page.on('response', (r) => { if (r.url().includes('/api/')) console.log(`${id}: ${r.status()} ${r.request().method()} ${r.url()}`); }); + await page.goto(path, { waitUntil: 'domcontentloaded', timeout: 30_000 }); + await page.waitForTimeout(4_000); + return page; + } + const teacher = await tab('teacher', ['INSTRUCTOR'], '/teacher-panel/courses'); + await teacher.screenshot({ path: 'test-results/guided-01-teacher-course.png', fullPage: true }); + await teacher.waitForTimeout(8_000); + const admin = await tab('admin', ['ADMIN'], '/admin/courses'); + await admin.screenshot({ path: 'test-results/guided-02-admin-course-approval.png', fullPage: true }); + await admin.waitForTimeout(8_000); + await admin.goto('/admin/teacher-prices', { waitUntil: 'domcontentloaded' }); + await admin.waitForTimeout(4_000); + await admin.screenshot({ path: 'test-results/guided-03-admin-prices.png', fullPage: true }); + await admin.waitForTimeout(8_000); + await admin.goto('/admin/finance', { waitUntil: 'domcontentloaded' }); + await admin.waitForTimeout(4_000); + await admin.screenshot({ path: 'test-results/guided-04-admin-finance.png', fullPage: true }); + await admin.waitForTimeout(8_000); + const student = await tab('student', ['STUDENT'], '/courses/e2e-live-course-1790715303832'); + await student.screenshot({ path: 'test-results/guided-05-student-course.png', fullPage: true }); + await student.waitForTimeout(8_000); + await student.goto('/dashboard/classes', { waitUntil: 'domcontentloaded' }); + await student.waitForTimeout(4_000); + await student.screenshot({ path: 'test-results/guided-06-student-calendar.png', fullPage: true }); + await student.goto('/dashboard/notifications', { waitUntil: 'domcontentloaded' }); + await student.waitForTimeout(4_000); + await student.screenshot({ path: 'test-results/guided-07-student-notifications.png', fullPage: true }); + await student.bringToFront(); + // Keep the final state visible briefly, then finish instead of waiting on + // Playwright Inspector's pause indefinitely. + await student.waitForTimeout(15_000); + expect(true).toBeTruthy(); +}); diff --git a/apps/web/e2e/visual-one-tab-flow.spec.ts b/apps/web/e2e/visual-one-tab-flow.spec.ts new file mode 100644 index 0000000..c473b04 --- /dev/null +++ b/apps/web/e2e/visual-one-tab-flow.spec.ts @@ -0,0 +1,43 @@ +import { expect, test } from '@playwright/test'; +import jwt from 'jsonwebtoken'; + +test('complete visible flow in one browser tab', async ({ page }) => { + test.setTimeout(120_000); + const secret = process.env.JWT_ACCESS_SECRET; + if (!secret) throw new Error('JWT_ACCESS_SECRET is required'); + page.setDefaultNavigationTimeout(30_000); + page.on('response', (r) => { if (r.url().includes('/api/')) console.log(`${r.status()} ${r.request().method()} ${r.url()}`); }); + const permissions = ['users.read','users.manage','teachers.read','teachers.verify','teacher-prices.manage','languages.manage','tests.manage','tests.review','bookings.read','bookings.manage','tickets.read','tickets.manage','payments.read','payments.refund','payments.adjust-wallet','payouts.manage','reviews.manage','courses.manage','audit.read','settings.manage','cms.manage','notifications.read','roles.manage','reports.read','availability.manage']; + async function switchRole(id: string, roles: string[], path: string) { + const token = jwt.sign({ id, roles, permissions: roles.includes('ADMIN') ? permissions : [] }, secret, { expiresIn: '2h' }); + await page.goto('/', { waitUntil: 'domcontentloaded' }); + await page.evaluate((value) => sessionStorage.setItem('access_token', value), token); + await page.goto(path, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(4_000); + } + await switchRole('user-teacher-shahriar', ['INSTRUCTOR'], '/teacher-panel/courses'); + await page.screenshot({ path: 'test-results/one-tab-01-teacher.png', fullPage: true }); + await page.waitForTimeout(5_000); + await switchRole('user-admin', ['ADMIN'], '/admin/courses'); + await page.screenshot({ path: 'test-results/one-tab-02-admin-course.png', fullPage: true }); + await page.waitForTimeout(5_000); + await page.goto('/admin/teacher-prices', { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(3_000); + await page.screenshot({ path: 'test-results/one-tab-03-admin-prices.png', fullPage: true }); + await page.goto('/admin/finance', { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(3_000); + await page.screenshot({ path: 'test-results/one-tab-04-admin-finance.png', fullPage: true }); + await page.waitForTimeout(5_000); + await switchRole('user-student-completed', ['STUDENT'], '/courses/e2e-live-course-1790715303832'); + await page.screenshot({ path: 'test-results/one-tab-05-student-course.png', fullPage: true }); + await page.goto('/dashboard/classes', { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(3_000); + await expect(page.getByRole('link', { name: /Google Meet/ })).toBeVisible({ timeout: 15_000 }); + await page.screenshot({ path: 'test-results/one-tab-06-calendar.png', fullPage: true }); + await page.getByRole('link', { name: /Google Meet/ }).click({ noWaitAfter: true }); + await page.waitForTimeout(3_000); + await page.goto('/dashboard/notifications', { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(3_000); + await page.screenshot({ path: 'test-results/one-tab-07-notifications.png', fullPage: true }); + expect(true).toBeTruthy(); +}); diff --git a/apps/web/e2e/visual-placement.spec.ts b/apps/web/e2e/visual-placement.spec.ts new file mode 100644 index 0000000..3436f87 --- /dev/null +++ b/apps/web/e2e/visual-placement.spec.ts @@ -0,0 +1,33 @@ +import { expect, test } from '@playwright/test'; + +test('visual placement flow', async ({ page }) => { + const apiResponses: string[] = []; + page.on('response', (response) => { + if (response.url().includes('/api/')) apiResponses.push(`${response.status()} ${response.request().method()} ${response.url()}`); + }); + page.on('console', (message) => console.log(`[browser:${message.type()}] ${message.text()}`)); + await page.goto('/placement'); + await page.screenshot({ path: 'test-results/visual-01-placement-entry.png', fullPage: true }); + await expect(page.getByRole('heading', { name: 'آزمون تعیین سطح زبان' })).toBeVisible(); + + const language = page.locator('.placement-language-card').first(); + await language.click(); + await page.screenshot({ path: 'test-results/visual-02-language-selected.png', fullPage: true }); + const start = page.locator('.placement-start-button').first(); + await expect(start).toBeVisible(); + await start.click(); + await page.screenshot({ path: 'test-results/visual-03-test-started.png', fullPage: true }); + + while (await page.locator('.placement-question-card').count()) { + const option = page.locator('.placement-option').first(); + await option.click(); + const next = page.locator('.placement-primary-action'); + await next.click(); + await page.waitForTimeout(100); + if (await page.locator('.placement-result').count()) break; + } + await page.waitForTimeout(500); + await page.screenshot({ path: 'test-results/visual-04-placement-result.png', fullPage: true }); + console.log(apiResponses.join('\n')); + await expect(page.locator('body')).not.toContainText('Internal Server Error'); +}); diff --git a/apps/web/e2e/visual-sequential-roles.spec.ts b/apps/web/e2e/visual-sequential-roles.spec.ts new file mode 100644 index 0000000..457f5ec --- /dev/null +++ b/apps/web/e2e/visual-sequential-roles.spec.ts @@ -0,0 +1,39 @@ +import { expect, test } from '@playwright/test'; +import jwt from 'jsonwebtoken'; + +test('sequential student teacher admin flow in three visible tabs', async ({ browser }) => { + const secret = process.env.JWT_ACCESS_SECRET; + if (!secret) throw new Error('JWT_ACCESS_SECRET is required'); + const context = await browser.newContext(); + const adminPermissions = ['users.read', 'users.manage', 'teachers.read', 'teachers.verify', 'teacher-prices.manage', 'languages.manage', 'tests.manage', 'tests.review', 'bookings.read', 'bookings.manage', 'tickets.read', 'tickets.manage', 'payments.read', 'payments.refund', 'payments.adjust-wallet', 'payouts.manage', 'reviews.manage', 'courses.manage', 'audit.read', 'settings.manage', 'cms.manage', 'notifications.read', 'roles.manage', 'reports.read', 'availability.manage']; + const make = async (id: string, roles: string[], path: string) => { + const page = await context.newPage(); + page.setDefaultTimeout(15_000); + page.setDefaultNavigationTimeout(30_000); + page.on('response', (response) => { + if (response.url().includes('/api/')) console.log(`${id}: ${response.status()} ${response.request().method()} ${response.url()}`); + }); + const token = jwt.sign({ id, roles, permissions: roles.includes('ADMIN') ? adminPermissions : [] }, secret, { expiresIn: '2h' }); + await page.addInitScript((value) => sessionStorage.setItem('access_token', value), token); + await page.goto(path, { waitUntil: 'commit' }); + await page.waitForTimeout(2500); + console.log(`${id}: loaded ${page.url()} title=${await page.title()}`); + return page; + }; + const teacher = await make('user-teacher-shahriar', ['INSTRUCTOR'], '/teacher-panel/courses'); + await expect(teacher).toHaveURL(/teacher-panel\/courses/); + await teacher.screenshot({ path: 'test-results/sequential-01-teacher.png', fullPage: true }); + + const admin = await make('user-admin', ['ADMIN'], '/admin/finance'); + await expect(admin).toHaveURL(/admin\/finance/); + await admin.screenshot({ path: 'test-results/sequential-02-admin-finance.png', fullPage: true }); + + const student = await make('user-student-completed', ['STUDENT'], '/dashboard/classes'); + await expect(student).toHaveURL(/dashboard\/classes/); + await student.screenshot({ path: 'test-results/sequential-03-student-calendar.png', fullPage: true }); + await student.goto('/dashboard/notifications', { waitUntil: 'domcontentloaded' }); + await expect(student).toHaveURL(/dashboard\/notifications/); + await student.screenshot({ path: 'test-results/sequential-04-student-notifications.png', fullPage: true }); + await student.bringToFront(); + await student.pause(); +}); diff --git a/apps/web/e2e/visual-student-course-enrollment.spec.ts b/apps/web/e2e/visual-student-course-enrollment.spec.ts new file mode 100644 index 0000000..56119c3 --- /dev/null +++ b/apps/web/e2e/visual-student-course-enrollment.spec.ts @@ -0,0 +1,32 @@ +import { expect, test } from '@playwright/test'; +import jwt from 'jsonwebtoken'; + +test('visual student sees approved live course and enrollment CTA', async ({ page }) => { + const secret = process.env.JWT_ACCESS_SECRET; + if (!secret) throw new Error('JWT_ACCESS_SECRET is required'); + const token = jwt.sign({ id: 'user-student-completed', roles: ['STUDENT'], permissions: [] }, secret, { expiresIn: '15m' }); + await page.addInitScript((value) => sessionStorage.setItem('access_token', value), token); + const slug = 'e2e-live-course-1790715303832'; + const api: string[] = []; + page.on('response', (r) => { if (r.url().includes('/api/')) api.push(`${r.status()} ${r.request().method()} ${r.url()}`); }); + await page.goto(`/courses/${slug}`); + await expect(page.locator('h1').filter({ hasText: 'دوره مکالمه سریع تستی' })).toBeVisible({ timeout: 15_000 }); + await page.screenshot({ path: 'test-results/student-course-01-approved.png', fullPage: true }); + const enroll = page.getByRole('link', { name: /مشاهده زمان‌های آزاد و پرداخت/ }); + const calendar = page.getByRole('link', { name: /مشاهده زمان جلسات/ }); + if (await enroll.isVisible().catch(() => false)) { + await enroll.click(); + await expect(page.getByText('خرید دوره با رسید پرداخت')).toBeVisible({ timeout: 10_000 }); + await page.screenshot({ path: 'test-results/student-course-02-enrollment.png', fullPage: true }); + await page.locator('input[type="file"]').setInputFiles('public/images/teachers/shahriar-shahfar.png'); + await page.getByRole('button', { name: 'ثبت رسید' }).click(); + await expect(page.getByRole('status')).toContainText(/رسید ثبت شد|در انتظار تأیید/, { timeout: 15_000 }); + await page.screenshot({ path: 'test-results/student-course-03-receipt-submitted.png', fullPage: true }); + } else { + await expect(calendar).toBeVisible({ timeout: 10_000 }); + await calendar.click(); + await expect(page).toHaveURL(/dashboard\/classes/); + await page.screenshot({ path: 'test-results/student-course-04-enrolled-calendar.png', fullPage: true }); + } + console.log(api.join('\n')); +}); diff --git a/apps/web/e2e/visual-student-flow.spec.ts b/apps/web/e2e/visual-student-flow.spec.ts new file mode 100644 index 0000000..d098431 --- /dev/null +++ b/apps/web/e2e/visual-student-flow.spec.ts @@ -0,0 +1,41 @@ +import { expect, test } from '@playwright/test'; + +test('visual student registration, login and placement', async ({ page }) => { + const phone = `939${String(Date.now()).slice(-7)}`; + const apiResponses: string[] = []; + page.on('response', (response) => { + if (response.url().includes('/api/')) apiResponses.push(`${response.status()} ${response.request().method()} ${response.url()}`); + }); + page.on('console', (message) => console.log(`[browser:${message.type()}] ${message.text()}`)); + + await page.goto('/auth'); + await page.screenshot({ path: 'test-results/student-01-register.png', fullPage: true }); + await page.locator('#auth-phone').fill(phone); + await page.getByRole('button', { name: /ارسال کد/ }).click(); + await page.screenshot({ path: 'test-results/student-02-otp.png', fullPage: true }); + const digits = page.locator('input[aria-label^="رقم"]'); + await expect(digits).toHaveCount(6); + for (let index = 0; index < 6; index += 1) await digits.nth(index).fill('123456'[index]!); + await page.getByRole('button', { name: /تأیید و ورود/ }).click(); + await expect(page).toHaveURL(/dashboard|panel/); + await page.screenshot({ path: 'test-results/student-03-dashboard.png', fullPage: true }); + + await page.goto('/placement'); + await page.locator('.placement-language-card').first().click(); + await page.screenshot({ path: 'test-results/student-04-language.png', fullPage: true }); + await page.locator('.placement-start-button').first().click(); + await page.screenshot({ path: 'test-results/student-05-placement-start.png', fullPage: true }); + while (await page.locator('.placement-question-card').count()) { + await page.locator('.placement-option').first().click(); + await page.locator('.placement-primary-action').click(); + await page.waitForTimeout(100); + if (await page.locator('.placement-result').count()) break; + } + await page.screenshot({ path: 'test-results/student-06-placement-result.png', fullPage: true }); + await page.goto('/checkout?teacher=teacher-shahriar'); + await page.waitForLoadState('networkidle'); + await page.screenshot({ path: 'test-results/student-07-checkout.png', fullPage: true }); + await expect(page.locator('body')).not.toContainText('Internal Server Error'); + console.log(apiResponses.join('\n')); + await expect(page.locator('body')).not.toContainText('Internal Server Error'); +}); diff --git a/apps/web/e2e/visual-teacher-course-flow.spec.ts b/apps/web/e2e/visual-teacher-course-flow.spec.ts new file mode 100644 index 0000000..8c3d539 --- /dev/null +++ b/apps/web/e2e/visual-teacher-course-flow.spec.ts @@ -0,0 +1,29 @@ +import { expect, test } from '@playwright/test'; +import jwt from 'jsonwebtoken'; + +test('visual teacher creates a live course draft', async ({ page }) => { + const secret = process.env.JWT_ACCESS_SECRET; + if (!secret) throw new Error('JWT_ACCESS_SECRET is required'); + const token = jwt.sign({ id: 'user-teacher-shahriar', roles: ['INSTRUCTOR'], permissions: [] }, secret, { expiresIn: '15m' }); + await page.addInitScript((value) => sessionStorage.setItem('access_token', value), token); + const slug = `e2e-live-course-${Date.now()}`; + const responses: string[] = []; + page.on('response', (response) => { + if (response.url().includes('/api/')) responses.push(`${response.status()} ${response.request().method()} ${response.url()}`); + }); + await page.goto('/teacher-panel/courses'); + await page.getByText('ساخت دوره زنده جدید').click(); + await page.getByLabel('عنوان فارسی').fill('دوره مکالمه سریع تستی'); + await page.getByLabel('عنوان انگلیسی').fill('E2E Live Conversation Course'); + await page.getByLabel(/نامک/).fill(slug); + await page.getByLabel('زبان دوره').selectOption('en'); + await page.getByLabel('سطح').fill('A2'); + await page.getByLabel('مدت هر جلسه (دقیقه)').fill('30'); + await page.getByLabel('توضیح فارسی').fill('این دوره برای تست کامل مسیر کلاس زنده ساخته شده است.'); + await page.getByLabel('توضیح انگلیسی').fill('This course is created to test the complete live class flow.'); + await page.screenshot({ path: 'test-results/teacher-01-course-form.png', fullPage: true }); + await page.getByRole('button', { name: 'ساخت دوره پیش‌نویس' }).click(); + await expect(page.getByText(/دوره به‌صورت پیش‌نویس ساخته شد/)).toBeVisible({ timeout: 15_000 }); + await page.screenshot({ path: 'test-results/teacher-02-course-draft.png', fullPage: true }); + console.log(`created slug=${slug}\n${responses.join('\n')}`); +}); diff --git a/apps/web/public/images/arzoo-hero.png b/apps/web/public/images/arzoo-hero.png new file mode 100644 index 0000000..0e67bd3 Binary files /dev/null and b/apps/web/public/images/arzoo-hero.png differ diff --git a/apps/web/public/images/brand/lingospeak-logo.svg b/apps/web/public/images/brand/lingospeak-logo.svg new file mode 100644 index 0000000..7e44150 --- /dev/null +++ b/apps/web/public/images/brand/lingospeak-logo.svg @@ -0,0 +1,5 @@ + + + + LingoSpeak + diff --git a/apps/web/public/images/brand/lingospeak-mark.svg b/apps/web/public/images/brand/lingospeak-mark.svg new file mode 100644 index 0000000..4c35399 --- /dev/null +++ b/apps/web/public/images/brand/lingospeak-mark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index a600c12..c31bee6 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -416,7 +416,7 @@ a { .public-page-hero h1 { max-width: 800px; margin-top: 1.25rem; - font-size: clamp(1.80rem, 3vw, 3rem); + font-size: clamp(1.8rem, 3vw, 3rem); font-weight: 850; letter-spacing: -0.045em; line-height: 1.28; @@ -968,7 +968,11 @@ a { border-radius: 34px; background-image: radial-gradient(circle, rgba(255, 255, 255, 0.35) 1.4px, transparent 1.4px), - linear-gradient(155deg, var(--landing-primary, #6d4aff), color-mix(in srgb, var(--landing-primary, #6d4aff) 45%, #1c1440)); + linear-gradient( + 155deg, + var(--landing-primary, #6d4aff), + color-mix(in srgb, var(--landing-primary, #6d4aff) 45%, #1c1440) + ); background-size: 15px 15px, 100% 100%; @@ -1759,6 +1763,277 @@ a { line-height: 1.8; } } + +/* Kal Arzoo homepage signature: a quiet editorial canvas around the founder. */ +.landing-hero { + isolation: isolate; + background: + radial-gradient(circle at 9% 18%, rgba(217, 68, 145, 0.08), transparent 23rem), + radial-gradient(circle at 92% 8%, rgba(112, 70, 216, 0.12), transparent 28rem), #fff; +} +.landing-hero-grid { + grid-template-columns: minmax(0, 0.92fr) minmax(460px, 1.08fr); + gap: clamp(3rem, 7vw, 8rem); +} +.landing-hero-copy h1 { + max-width: 670px; + color: #17133f; + font-size: clamp(2.65rem, 5vw, 4.45rem); + line-height: 1.28; +} +.landing-hero-copy h1 em { + color: #d94491; + text-shadow: 0 8px 28px rgba(217, 68, 145, 0.13); +} +.landing-hero-copy p { + max-width: 570px; + color: #5d6278; + font-size: clamp(1rem, 1.25vw, 1.13rem); +} +.landing-hero-image { + inset: 0 0.9rem 1.35rem 0; + border: 10px solid rgba(255, 255, 255, 0.92); + border-radius: 132px 34px 34px 34px; + box-shadow: 0 34px 90px rgba(48, 31, 112, 0.2); +} +.landing-hero-backdrop { + inset: 2.1rem -1.25rem -1.1rem 2.2rem; + border-radius: 40px; + background: + radial-gradient(circle at 20% 18%, rgba(255, 255, 255, 0.22) 0 2px, transparent 3px), + linear-gradient(145deg, #8d65ef, #2b205f); + background-size: + 19px 19px, + 100% 100%; +} +.landing-hero-quote { + top: 1.4rem; + right: -1.5rem; + max-width: 245px; + border-radius: 20px; + box-shadow: 0 20px 48px rgba(36, 28, 83, 0.16); +} +.landing-founder-card { + right: 1.15rem; + bottom: -0.5rem; + border-radius: 20px; +} +.landing-hero-stats { + display: flex; + flex-wrap: wrap; + gap: 0; + margin-top: 2.5rem; +} +.landing-hero-stats div { + display: grid; + gap: 0.2rem; + min-width: 112px; + border-inline-start: 1px solid #e4e1ee; + padding-inline: 1.1rem; +} +.landing-hero-stats div:first-child { + border-inline-start: 0; + padding-inline-start: 0; +} +.landing-hero-stats strong { + color: #7046d8; + font-size: 1.28rem; + font-weight: 900; +} +.landing-hero-stats span { + color: #777b8d; + font-size: 0.76rem; + font-weight: 700; +} +@media (max-width: 1023px) { + .landing-hero-grid { + grid-template-columns: minmax(0, 1fr); + } +} + +/* Kal Arzoo personal-brand landing direction */ +.landing-page { + --landing-ink: #17152f; + --landing-muted: #6f6a82; + --landing-line: #ebe8f3; + color: var(--landing-ink); +} +.landing-hero { + background: + radial-gradient(circle at 17% 10%, rgba(220, 66, 143, 0.09), transparent 19rem), + radial-gradient(circle at 86% 16%, rgba(112, 70, 216, 0.13), transparent 25rem), #fff; +} +.landing-hero::before { + opacity: 0.45; + background-size: 56px 56px; +} +.landing-hero-grid { + min-height: 720px; + grid-template-columns: minmax(440px, 0.96fr) minmax(0, 1.04fr); + gap: clamp(2.5rem, 6vw, 7rem); + padding-block: clamp(4rem, 8vw, 7rem) 5rem; +} +.landing-hero-reversed .landing-hero-copy { + text-align: right; +} +.landing-hero-copy h1 { + max-width: 620px; + font-size: clamp(2.35rem, 4.6vw, 4.4rem); + line-height: 1.2; + letter-spacing: -0.06em; +} +.landing-hero-copy h1 em { + color: #d94491; + font-style: normal; + position: relative; + white-space: nowrap; +} +.landing-hero-copy h1 em::after { + content: ''; + position: absolute; + height: 0.18em; + left: 0.05em; + right: 0.05em; + bottom: -0.05em; + z-index: -1; + border-radius: 999px; + background: rgba(219, 68, 145, 0.16); +} +.landing-hero-copy p { + max-width: 540px; + font-size: 1.08rem; + line-height: 2.15; +} +.landing-hero-visual { + min-height: 590px; +} +.landing-hero-image { + inset: 0 0.75rem 1.5rem 0; + border: 10px solid rgba(255, 255, 255, 0.92); + border-radius: 34px 150px 34px 34px; + box-shadow: 0 38px 100px rgba(35, 23, 81, 0.19); +} +.landing-hero-image img { + object-position: center center; +} +.landing-hero-backdrop { + inset: 2.2rem -1.1rem -1.2rem 2.2rem; + border-radius: 42px; + background-image: + radial-gradient(circle, rgba(255, 255, 255, 0.3) 1.5px, transparent 1.5px), + linear-gradient(145deg, #7046d8, #211b4f); + background-size: + 18px 18px, + 100% 100%; +} +.landing-hero-glow { + background: radial-gradient(circle, rgba(217, 68, 145, 0.23), transparent 68%); +} +.landing-hero-quote { + top: 1.5rem; + right: -1.6rem; + max-width: 245px; +} +.landing-founder-card { + right: 1.25rem; + bottom: -0.35rem; +} +.landing-hero-stats { + display: grid; + max-width: 560px; + grid-template-columns: repeat(3, 1fr); + margin-top: 2.7rem; + border-top: 1px solid var(--landing-line); + padding-top: 1.35rem; +} +.landing-hero-stats div { + display: grid; + gap: 0.35rem; + border-inline-start: 1px solid var(--landing-line); + padding-inline: 1.1rem; +} +.landing-hero-stats div:first-child { + border-inline-start: 0; + padding-inline-start: 0; +} +.landing-hero-stats strong { + color: #7046d8; + font-size: 1.28rem; + font-weight: 900; +} +.landing-hero-stats span { + color: var(--landing-muted); + font-size: 0.76rem; + font-weight: 700; +} +.landing-header-signup { + background: #7046d8; +} +.landing-section-intro h2 { + letter-spacing: -0.045em; +} +.landing-section-tint { + background: linear-gradient(180deg, #f7f4fc, #fbfaff); +} + +@media (max-width: 900px) { + .landing-hero-grid { + grid-template-columns: 1fr; + min-height: 0; + } + .landing-hero-reversed .landing-hero-copy { + order: 1; + } + .landing-hero-reversed .landing-hero-visual { + order: 2; + } + .landing-hero-copy { + max-width: 680px; + margin-inline: auto; + text-align: center !important; + } + .landing-hero-copy p, + .landing-actions, + .landing-hero-stats { + margin-inline: auto; + } + .landing-actions { + justify-content: center; + } + .landing-hero-visual { + width: min(100%, 620px); + margin-inline: auto; + min-height: 560px; + } +} +@media (max-width: 560px) { + .landing-hero-copy h1 { + font-size: clamp(2.15rem, 10vw, 3.2rem); + } + .landing-hero-visual { + min-height: 430px; + } + .landing-hero-image { + border-radius: 28px 90px 28px 28px; + } + .landing-hero-quote { + right: -0.4rem; + max-width: 190px; + padding: 0.7rem; + } + .landing-hero-quote p { + font-size: 0.7rem; + } + .landing-founder-card { + right: 0.4rem; + } + .landing-hero-stats strong { + font-size: 1rem; + } + .landing-hero-stats span { + font-size: 0.68rem; + } +} @media (prefers-reduced-motion: reduce) { .landing-page *, .landing-page *::before, @@ -1769,6 +2044,210 @@ a { } } +/* Mobile discovery shelves: preserve the editorial card treatment without + * making users walk through four full-height cards before reaching the next + * decision. The partial next card is an intentional affordance. */ +@media (max-width: 640px) { + .landing-container { + width: min(calc(100% - 2rem), var(--landing-container, 1240px)); + } + + .landing-header-inner { + min-height: 64px; + } + + .landing-brand-logo { + height: 38px; + max-width: 142px; + } + + .landing-brand-wordmark { + gap: 0.45rem; + font-size: 0.98rem; + } + + .landing-header-signup { + min-height: 40px; + padding: 0.55rem 0.7rem; + font-size: 0.78rem; + } + + .landing-menu-toggle { + width: 44px; + height: 44px; + } + + .landing-hero-grid { + gap: 2.25rem; + padding-block: 2.75rem 3.25rem; + } + + .landing-hero-copy { + text-align: start !important; + } + + .landing-hero-copy h1 { + margin-top: 1rem; + font-size: clamp(2rem, 9vw, 2.8rem); + line-height: 1.28; + } + + .landing-hero-copy p { + margin-top: 1rem; + font-size: 0.96rem; + line-height: 1.95; + } + + .landing-actions { + display: grid; + grid-template-columns: minmax(0, 1fr); + margin-top: 1.4rem; + } + + .landing-actions .landing-button { + width: 100%; + } + + .landing-hero-stats { + margin-top: 1.9rem; + } + + .landing-hero-stats div { + min-width: 0; + padding-inline: 0.65rem; + } + + .landing-hero-stats strong { + font-size: 0.98rem; + } + + .landing-hero-stats span { + font-size: 0.63rem; + } + + .landing-hero-visual { + min-height: 350px; + } + + .landing-hero-image { + inset: 0 0.5rem 1rem 0; + border-width: 6px; + border-radius: 26px 76px 26px 26px; + } + + .landing-hero-quote { + top: 0.75rem; + right: -0.15rem; + max-width: 170px; + } + + .landing-founder-card { + right: 0.25rem; + bottom: -0.15rem; + } + + .landing-section { + padding-block: 3.25rem; + } + + .landing-section-intro h2 { + margin-top: 0.75rem; + font-size: 1.5rem; + line-height: 1.45; + } + + .landing-section-intro p { + margin-top: 0.75rem; + font-size: 0.88rem; + line-height: 1.9; + } + + .landing-language-grid, + .landing-course-grid, + .landing-blog-grid { + display: flex; + gap: 0.8rem; + margin-top: 1.75rem; + padding-inline: 0.05rem 1.5rem; + overflow-x: auto; + overscroll-behavior-inline: contain; + scroll-snap-type: inline mandatory; + scrollbar-width: none; + } + + .landing-language-grid::-webkit-scrollbar, + .landing-course-grid::-webkit-scrollbar, + .landing-blog-grid::-webkit-scrollbar { + display: none; + } + + .landing-language-card, + .landing-course-card, + .landing-blog-card { + flex: 0 0 min(82vw, 305px); + scroll-snap-align: start; + } + + .landing-language-card { + min-height: 0; + } + + .landing-language-image { + height: 160px; + } + + .landing-course-image, + .landing-blog-image { + height: 155px; + } + + .landing-language-body, + .landing-course-content, + .landing-blog-content { + padding: 1rem; + } + + .landing-language-body h3, + .landing-course-content h3, + .landing-blog-content h3 { + font-size: 1.08rem; + } + + .landing-section-link-wrap { + margin-top: 1.15rem; + } + + .landing-benefit-grid { + margin-top: 1.75rem; + } + + .landing-benefit-item { + padding-block: 1.1rem; + } + + .landing-benefit-item p { + max-width: 34rem; + } + + .landing-placement-copy { + padding: 1.75rem 1.2rem 1.25rem; + } + + .landing-placement-copy .landing-button, + .landing-final-cta .landing-button { + width: 100%; + } + + .landing-faq-list summary { + min-height: 48px; + font-size: 0.86rem; + } + + .landing-final-cta { + padding: 1.75rem 1.2rem; + } +} + /* Premium public experience ------------------------------------------------- * These rules sit after the CMS-compatible landing styles on purpose. The * builder still controls copy, media and theme values while this layer gives diff --git a/apps/web/src/config/defaults.ts b/apps/web/src/config/defaults.ts index bf68f21..8c1c6d5 100644 --- a/apps/web/src/config/defaults.ts +++ b/apps/web/src/config/defaults.ts @@ -14,7 +14,7 @@ export const webDefaults = { /** Static brand artwork served from apps/web/public. */ export const brandAssets = { /** Full logo (mark + wordmark), used in the header and footer. */ - logo: '/images/brand/lingospeak-logo.png', + logo: '/images/brand/lingospeak-logo.svg', /** The "L" mark alone, used as the favicon and app icon. */ - mark: '/images/brand/lingospeak-mark.png', + mark: '/images/brand/lingospeak-mark.svg', } as const; diff --git a/apps/web/src/features/admin/components/admin-finance-center.tsx b/apps/web/src/features/admin/components/admin-finance-center.tsx index ed06f3c..1933049 100644 --- a/apps/web/src/features/admin/components/admin-finance-center.tsx +++ b/apps/web/src/features/admin/components/admin-finance-center.tsx @@ -80,7 +80,6 @@ export function AdminFinanceCenter() { }); const reports = useQuery({ queryKey: ['/admin/reports'], queryFn: () => api('/admin/reports') }); - const me = useQuery({ queryKey: ['me'], queryFn: () => api<{ id: string }>('/users/me') }); const payments = useQuery({ queryKey: ['/admin/payments'], queryFn: () => api('/admin/payments') }); const withdrawals = useQuery({ queryKey: ['/payouts/withdrawals'], @@ -309,7 +308,7 @@ export function AdminFinanceCenter() { /> - +
diff --git a/apps/web/src/features/admin/components/payment-receipts.tsx b/apps/web/src/features/admin/components/payment-receipts.tsx index 79af085..62e2f05 100644 --- a/apps/web/src/features/admin/components/payment-receipts.tsx +++ b/apps/web/src/features/admin/components/payment-receipts.tsx @@ -74,11 +74,9 @@ export function ReceiptLink({ fileId, fa }: { fileId: string; fa: boolean }) { export function ReceiptReviewQueue({ payments, fa, - currentUserId, }: { payments: ReceiptPayment[]; fa: boolean; - currentUserId?: string; }) { const queryClient = useQueryClient(); const [rejecting, setRejecting] = useState(null); @@ -148,15 +146,7 @@ export function ReceiptReviewQueue({ {item.reviewNote && {item.reviewNote}}
- {currentUserId && item.user?.id === currentUserId ? ( -

- {t( - fa, - 'این رسید مال خودتان است و باید ادمین دیگری آن را بررسی کند.', - 'This is your own receipt; another admin must review it.', - )} -

- ) : rejecting === item.id ? ( + {rejecting === item.id ? (
{ diff --git a/apps/web/src/features/auth/components/auth-shell.tsx b/apps/web/src/features/auth/components/auth-shell.tsx index 1f7077c..3665a06 100644 --- a/apps/web/src/features/auth/components/auth-shell.tsx +++ b/apps/web/src/features/auth/components/auth-shell.tsx @@ -19,7 +19,10 @@ export function AuthShell({ }) { const { locale } = useTranslations(); return ( -
+
{config.sections - .filter((section) => section.visible) + // The founder is introduced in the hero; never render the legacy + // biography block even if an older CMS configuration enables it. + .filter((section) => section.visible && section.type !== 'about') .map((section) => ( ))} @@ -108,7 +109,17 @@ function HeroSection({ config, locale, section }: LandingRenderProps) {
{t(config.hero.eyebrow)} -

{t(config.hero.title)}

+

+ {english ? ( + <> + Learn a language for real conversations + + ) : ( + <> + زبان را برای حرف زدن واقعی یاد بگیرید + + )} +

{t(config.hero.description)}

@@ -119,16 +130,22 @@ function HeroSection({ config, locale, section }: LandingRenderProps) { {t(config.hero.secondaryButton.label)}
-
- - {english ? 'Identity-verified teachers' : 'احراز هویت مدرس‌ها'} - - - {english ? 'Flexible online scheduling' : 'زمان‌بندی منعطف آنلاین'} - - - {english ? 'Clear learning plan' : 'مسیر یادگیری شفاف'} - +
+
+ +۱۲٬۰۰۰ + {english ? 'learners' : 'زبان‌آموز'} +
+
+ ۲۴ + {english ? 'courses' : 'دوره آموزشی'} +
+
+ ۹۸٪ + {english ? 'satisfaction' : 'رضایت کاربران'} +
@@ -228,7 +245,12 @@ function LanguagesSection({ config, locale, languages, section }: LandingRenderP style={{ '--language-accent': card?.accent ?? '#ede9fe' } as React.CSSProperties} >
- + {language.flag || '🌐'}
@@ -414,7 +436,7 @@ function CoursesSection({ config, locale, courses, section }: LandingRenderProps
{course.image ? ( - + ) : ( @@ -571,7 +593,7 @@ function BlogSection({ config, locale, posts, section }: LandingRenderProps) {
{post.coverImage ? ( - + ) : ( @@ -665,7 +687,11 @@ function LandingFooter({
diff --git a/apps/web/src/features/landing/landing-config.ts b/apps/web/src/features/landing/landing-config.ts index 74cf8ec..99cc1d8 100644 --- a/apps/web/src/features/landing/landing-config.ts +++ b/apps/web/src/features/landing/landing-config.ts @@ -151,7 +151,7 @@ export const HEADING_WEIGHTS = ['600', '700', '750', '800', '900'] as const; const text = (fa: string, en: string): LocaleText => ({ fa, en }); export const defaultLandingConfig: LandingConfig = { - brand: { name: 'LingoSpeak', mark: 'LS', logo: '' }, + brand: { name: 'کال آرزو', mark: 'کا', logo: '' }, header: { sticky: true, background: '#ffffff', @@ -166,20 +166,20 @@ export const defaultLandingConfig: LandingConfig = { signUp: text('ثبت‌نام', 'Sign up free'), }, hero: { - eyebrow: text('کلاس خصوصی آنلاین با مدرس‌های تأییدشده', 'Private online lessons with verified teachers'), - title: text('زبان را برای حرف‌زدن یاد بگیر، نه فقط برای امتحان.', 'Learn a language to speak it, not just to pass a test.'), + eyebrow: text('کال آرزو؛ مدرسه‌ی مکالمه با آرزو', 'Kal Arzoo — real conversation with Arezoo'), + title: text('زبان را برای حرف زدن واقعی یاد بگیرید', 'Learn a language for real conversations'), description: text( - 'سطحت را رایگان بسنج، مدرسی متناسب با هدفت انتخاب کن و با برنامه‌ای که با زمان تو جور است، جلو برو.', - 'Check your level for free, pick a teacher who fits your goal, and follow a plan that works with your schedule.', + 'با آموزش‌های کاربردی و همراهی آرزو، از حفظ کردن فاصله بگیر و با اعتمادبه‌نفس حرف بزن؛ در موقعیت‌هایی که واقعاً برایت مهم‌اند.', + 'Move beyond memorisation with practical lessons and Arezoo’s guidance, so you can speak with confidence in situations that matter.', ), - primaryButton: { label: text('انتخاب زبان', 'Choose a language'), href: '/languages' }, + primaryButton: { label: text('مشاهده دوره‌ها', 'Explore courses'), href: '/courses' }, secondaryButton: { label: text('تعیین سطح رایگان', 'Free placement test'), href: '/placement' }, - image: '/images/lingospeak-student.png', - imageAlt: text('زبان‌آموز در حال یادگیری با لپ‌تاپ', 'A language learner studying with a laptop'), - imageSide: 'right', + image: '/images/arzoo-hero.png', + imageAlt: text('آرزو، بنیان‌گذار و مدرس کال آرزو در فضای آموزش', 'Arezoo, founder and instructor of Kal Arzoo'), + imageSide: 'left', overlay: 0, founderName: text('آرزو احمدی', 'Arezoo Ahmadi'), - founderTitle: text('بنیان‌گذار و مدرس ارشد لینگواسپیک', 'Founder & lead teacher at LingoSpeak'), + founderTitle: text('بنیان‌گذار و مدرس کال آرزو', 'Founder & lead teacher at Kal Arzoo'), quote: text( '«زبان را یاد می‌گیریم که حرف بزنیم، نه فقط که امتحان بدهیم.»', '“We learn a language to speak it, not just to pass a test.”', @@ -326,8 +326,8 @@ export const defaultLandingConfig: LandingConfig = { }, footer: { description: text( - 'LingoSpeak؛ کلاس خصوصی آنلاین زبان با مدرس‌های تأییدشده و برنامه متناسب با هدف تو.', - 'LingoSpeak: private online language lessons with verified teachers and a plan built around your goal.', + 'کال آرزو؛ یادگیری زبان برای حرف زدن واقعی، با همراهی آرزو.', + 'Kal Arzoo: practical language learning for real conversations, guided by Arezoo.', ), columns: [ { @@ -343,7 +343,6 @@ export const defaultLandingConfig: LandingConfig = { links: [ { label: text('مدرس‌ها', 'Teachers'), href: '/teachers' }, { label: text('مقالات', 'Articles'), href: '/blog' }, - { label: text('درباره ما', 'About us'), href: '/about' }, ], }, ], @@ -354,20 +353,20 @@ export const defaultLandingConfig: LandingConfig = { }, sections: [ { id: 'hero', type: 'hero', label: text('هیرو', 'Hero'), visible: true, style: {} }, - { id: 'about', type: 'about', label: text('درباره آرزو', 'About Arezoo'), visible: true, style: {} }, + { id: 'about', type: 'about', label: text('درباره آرزو', 'About Arezoo'), visible: false, style: {} }, { id: 'languages', type: 'languages', label: text('زبان‌ها', 'Languages'), visible: true, style: {} }, { id: 'benefits', type: 'benefits', label: text('مزیت‌ها', 'Benefits'), visible: true, style: {} }, { id: 'placement', type: 'placement', label: text('تعیین سطح', 'Placement'), visible: true, style: {} }, { id: 'courses', type: 'courses', label: text('دوره‌ها', 'Courses'), visible: true, style: {} }, - { id: 'blog', type: 'blog', label: text('مقالات', 'Articles'), visible: true, style: {} }, + { id: 'blog', type: 'blog', label: text('مقالات', 'Articles'), visible: false, style: {} }, { id: 'faq', type: 'faq', label: text('سؤالات متداول', 'FAQ'), visible: true, style: {} }, { id: 'finalCta', type: 'finalCta', label: text('دعوت نهایی', 'Final CTA'), visible: true, style: {} }, ], theme: { - primary: '#6d4aff', - secondary: '#c4b5fd', - button: '#6d4aff', - background: '#fbfbfe', + primary: '#7046d8', + secondary: '#c7b4ff', + button: '#7046d8', + background: '#fcfbff', radius: '24px', shadow: '0 18px 55px rgba(48, 31, 112, 0.09)', containerWidth: '1240px', diff --git a/apps/web/src/features/panel/components/online-class-toast.tsx b/apps/web/src/features/panel/components/online-class-toast.tsx new file mode 100644 index 0000000..628f1d9 --- /dev/null +++ b/apps/web/src/features/panel/components/online-class-toast.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import { ExternalLink, Video, X } from 'lucide-react'; +import { useQuery } from '@tanstack/react-query'; +import { api } from '@/shared/services/api'; +import { useTranslations } from '@/components/shared/locale-provider'; +import { localized } from '@/lib/i18n'; +import { cn } from '@/shared/components/ui/cn'; + +type Booking = { id: string; startsAt: string; endsAt?: string | null; status: string; meetingUrl?: string | null; teacher?: { nameFa?: string; nameEn?: string } }; + +export function OnlineClassToast() { + const { locale } = useTranslations(); + const [dismissed, setDismissed] = useState(null); + const bookings = useQuery({ queryKey: ['bookings'], queryFn: () => api('/bookings/me'), staleTime: 30_000, refetchInterval: 60_000 }); + const now = Date.now(); + const live = useMemo(() => (bookings.data ?? []).find((item) => { + const start = new Date(item.startsAt).getTime(); + const end = item.endsAt ? new Date(item.endsAt).getTime() : start + 60 * 60 * 1000; + return item.status === 'CONFIRMED' && Boolean(item.meetingUrl) && start <= now && now < end; + }), [bookings.data, now]); + const key = live ? `${live.id}:${live.startsAt}:${live.endsAt ?? ''}:${live.meetingUrl}` : null; + + useEffect(() => { + if (!key) return; + setDismissed(window.localStorage.getItem(`live-class-toast:${key}`)); + }, [key]); + + if (!live || !key || dismissed === key) return null; + const teacher = localized({ fa: live.teacher?.nameFa, en: live.teacher?.nameEn }, locale) || (locale === 'en' ? 'Your instructor' : 'مدرس شما'); + return ( +
+
+ +
+
{locale === 'en' ? 'LIVE NOW' : 'کلاس اکنون آنلاین است'}
+

{locale === 'en' ? 'Online class' : 'کلاس آنلاین'} · {teacher}

+ + {locale === 'en' ? 'Join class' : 'ورود به کلاس'} + +
+ +
+
+ ); +} diff --git a/apps/web/src/features/panel/components/panel-shell.tsx b/apps/web/src/features/panel/components/panel-shell.tsx index d755fdf..3e06182 100644 --- a/apps/web/src/features/panel/components/panel-shell.tsx +++ b/apps/web/src/features/panel/components/panel-shell.tsx @@ -47,7 +47,8 @@ import { clearAuthSession } from '@/shared/services/api'; import { featureFlags } from '@/config'; import { cn } from '@/shared/components/ui/cn'; import { formatNumber } from '@/lib/money'; -import { useNotifications } from '../hooks/use-notifications'; +import { useMarkAllNotificationsRead, useMarkNotificationRead, useNotifications } from '../hooks/use-notifications'; +import { OnlineClassToast } from './online-class-toast'; export type NavItem = { href: string; @@ -152,6 +153,9 @@ export function PanelShell({ items, children }: { title?: string; items: NavItem const permissions = Array.isArray(me.data?.permissions) ? me.data.permissions : []; const isAdmin = roles.includes('ADMIN'); const notifications = useNotifications(Boolean(me.data) && !adminMode); + const markNotificationRead = useMarkNotificationRead(); + const markAllNotificationsRead = useMarkAllNotificationsRead(); + const [notificationsOpen, setNotificationsOpen] = useState(false); const adminSummary = useQuery({ queryKey: ['admin-dashboard'], queryFn: () => api('/admin/dashboard'), @@ -399,8 +403,10 @@ export function PanelShell({ items, children }: { title?: string; items: NavItem )} {notificationsHref && ( - + + {notificationsOpen && ( +
+
+ {english ? 'Notifications' : 'اعلان‌ها'} + { markAllNotificationsRead.mutate(); setNotificationsOpen(false); }} className="text-xs font-bold text-primary">{english ? 'View all' : 'مشاهده همه'} +
+
+ {(notifications.data ?? []).slice(0, 6).map((item) => ( + { if (!item.readAt) markNotificationRead.mutate(item.id); setNotificationsOpen(false); }} className="flex gap-3 rounded-xl px-3 py-3 text-start hover:bg-canvas"> + + {localized({ fa: item.titleFa, en: item.titleEn }, locale)}{localized({ fa: item.bodyFa, en: item.bodyEn }, locale)} + + ))} + {!notifications.data?.length &&

{english ? 'You are all caught up.' : 'اعلان تازه‌ای ندارید.'}

} +
+
+ )} +
)}
@@ -424,6 +448,7 @@ export function PanelShell({ items, children }: { title?: string; items: NavItem
{children}
+ {!adminMode && } {tabItems.length > 0 && (