diff --git a/angular.json b/angular.json index 532abd0..ee8fced 100644 --- a/angular.json +++ b/angular.json @@ -154,7 +154,8 @@ }, "serve": { "options": { - "allowedHosts": ["novo.market", "dexarmarket.ru", "localhost"] + "allowedHosts": ["novo.market", "dexarmarket.ru", "localhost"], + "proxyConfig": "proxy.conf.json" }, "builder": "@angular/build:dev-server", "configurations": { diff --git a/docs/API_CHANGES_REQUIRED.md b/docs/API_CHANGES_REQUIRED.md index 8f86647..22dc479 100644 --- a/docs/API_CHANGES_REQUIRED.md +++ b/docs/API_CHANGES_REQUIRED.md @@ -1,168 +1,726 @@ -# Backend API Changes Required +# Complete Backend API Documentation -## Cart Quantity Support - -### 1. Add Quantity to Cart Items - -**Current GET /cart Response:** -```json -[ - { - "itemID": 123, - "name": "Product Name", - "price": 100, - "currency": "RUB", - ...other item fields - } -] -``` - -**NEW Required Response:** -```json -[ - { - "itemID": 123, - "name": "Product Name", - "price": 100, - "currency": "RUB", - "quantity": 2, // <-- ADD THIS FIELD - ...other item fields - } -] -``` - -### 2. POST /cart - Add Item to Cart - -**Current Request:** -```json -{ - "itemID": 123 -} -``` - -**NEW Request (with optional quantity):** -```json -{ - "itemID": 123, - "quantity": 1 // Optional, defaults to 1 if not provided -} -``` - -**Behavior:** -- If item already exists in cart, **increment** the quantity by the provided amount -- If item doesn't exist, add it with the specified quantity - -### 3. PATCH /cart - Update Item Quantity (NEW ENDPOINT) - -**Request:** -```json -{ - "itemID": 123, - "quantity": 5 // New quantity value (not increment, but absolute value) -} -``` - -**Response:** -```json -{ - "message": "Cart updated successfully" -} -``` - -**Behavior:** -- Set the quantity to the exact value provided -- If quantity is 0 or negative, remove the item from cart - -### 4. Payment Endpoints - Include Quantity - -**POST /payment/create** - -Update the items array to include quantity: - -**Current:** -```json -{ - "amount": 1000, - "currency": "RUB", - "items": [ - { - "itemID": 123, - "price": 500, - "name": "Product Name" - } - ] -} -``` - -**NEW:** -```json -{ - "amount": 1000, - "currency": "RUB", - "items": [ - { - "itemID": 123, - "price": 500, - "name": "Product Name", - "quantity": 2 // <-- ADD THIS FIELD - } - ] -} -``` - -### 5. Email Purchase Confirmation - -**POST /purchase-email** - -Update items to include quantity: - -**NEW:** -```json -{ - "email": "user@example.com", - "telegramUserId": "123456", - "items": [ - { - "itemID": 123, - "name": "Product Name", - "price": 500, - "currency": "RUB", - "quantity": 2 // <-- ADD THIS FIELD - } - ] -} -``` - -## Future: Filters & Sorting (To Be Discussed) - -### GET /category/{categoryID} - -Add query parameters for filtering and sorting: - -**Proposed Query Parameters:** -- `sort`: Sort order (e.g., `price_asc`, `price_desc`, `rating_desc`, `name_asc`) -- `minPrice`: Minimum price filter -- `maxPrice`: Maximum price filter -- `minRating`: Minimum rating filter (1-5) -- `count`: Number of items per page (already exists) -- `skip`: Offset for pagination (already exists) - -**Example:** -``` -GET /category/5?sort=price_asc&minPrice=100&maxPrice=500&minRating=4&count=20&skip=0 -``` - -**Response:** Same as current (array of items) +> **Last updated:** February 2026 +> **Frontend:** Angular 21 · Dual-brand (Dexar + Novo) +> **Covers:** Catalog, Cart, Payments, Reviews, Regions, Auth, i18n, BackOffice --- -## Summary +## Base URLs -**Required NOW:** -1. Add `quantity` field to cart item responses -2. Support `quantity` parameter in POST /cart -3. Create new PATCH /cart endpoint for updating quantities -4. Include `quantity` in payment and email endpoints +| Brand | Dev | Production | +|--------|----------------------------------|----------------------------------| +| Dexar | `https://api.dexarmarket.ru:445` | `https://api.dexarmarket.ru:445` | +| Novo | `https://api.novo.market:444` | `https://api.novo.market:444` | -**Future (After Discussion):** -- Sorting and filtering query parameters for category items endpoint +--- + +## Global HTTP Headers + +The frontend **automatically attaches** two custom headers to **every API request** via an interceptor. The backend should read these headers and use them to filter/translate responses accordingly. + +| Header | Example Value | Description | +|---------------|---------------|------------------------------------------------------------| +| `X-Region` | `moscow` | Region ID selected by the user. **Absent** = global (all). | +| `X-Language` | `ru` | Active UI language: `ru`, `en`, or `hy`. | + +### Backend behavior + +- **`X-Region`**: If present, filter items/categories to only those available in that region. If absent, return everything (global catalog). +- **`X-Language`**: If present, return translated `name`, `description`, etc. for categories/items when translations exist. If absent or `ru`, use russians defaults. + +### CORS requirements for these headers + +``` +Access-Control-Allow-Headers: Content-Type, X-Region, X-Language +``` + +--- + +## 1. Health Check + +### `GET /ping` + +Simple health check. + +**Response `200`:** +```json +{ "message": "pong" } +``` + +--- + +## 2. Catalog — Categories + +### `GET /category` + +Returns all top-level categories. Respects `X-Region` and `X-Language` headers. + +**Response `200`:** +```json +[ + { + "categoryID": 1, + "name": "Электроника", + "parentID": 0, + "icon": "https://...", + "wideBanner": "https://...", + "itemCount": 42, + "priority": 10, + + "id": "cat_abc123", + "visible": true, + "img": "https://...", + "projectId": "proj_xyz", + "subcategories": [ + { + "id": "sub_001", + "name": "Смартфоны", + "visible": true, + "priority": 5, + "img": "https://...", + "categoryId": "cat_abc123", + "parentId": "cat_abc123", + "itemCount": 20, + "hasItems": true, + "subcategories": [] + } + ] + } +] +``` + +**Category object:** + +| Field | Type | Required | Description | +|------------------|---------------|----------|----------------------------------------------------| +| `categoryID` | number | yes | Legacy numeric ID | +| `name` | string | yes | Category display name (translated if `X-Language`) | +| `parentID` | number | yes | Parent category ID (`0` = top-level) | +| `icon` | string | no | Category icon URL | +| `wideBanner` | string | no | Wide banner image URL | +| `itemCount` | number | no | Number of items in category | +| `priority` | number | no | Sort priority (higher = first) | +| `id` | string | no | BackOffice string ID | +| `visible` | boolean | no | Whether category is shown (`true` default) | +| `img` | string | no | BackOffice image URL (maps to `icon`) | +| `projectId` | string | no | BackOffice project reference | +| `subcategories` | Subcategory[] | no | Nested subcategories | + +**Subcategory object:** + +| Field | Type | Required | Description | +|------------------|---------------|----------|------------------------------------| +| `id` | string | yes | Subcategory ID | +| `name` | string | yes | Display name | +| `visible` | boolean | no | Whether visible | +| `priority` | number | no | Sort priority | +| `img` | string | no | Image URL | +| `categoryId` | string | yes | Parent category ID | +| `parentId` | string | yes | Direct parent ID | +| `itemCount` | number | no | Number of items | +| `hasItems` | boolean | no | Whether has any items | +| `subcategories` | Subcategory[] | no | Nested children | + +--- + +### `GET /category/:categoryID` + +Returns items in a specific category. Respects `X-Region` and `X-Language` headers. + +**Query params:** + +| Param | Type | Default | Description | +|----------|--------|---------|--------------------| +| `count` | number | `50` | Items per page | +| `skip` | number | `0` | Offset for paging | + +**Response `200`:** Array of [Item](#item-object) objects. + +--- + +## 3. Items + +### `GET /item/:itemID` + +Returns a single item. Respects `X-Region` and `X-Language` headers. + +**Response `200`:** A single [Item](#item-object) object. + +--- + +### `GET /searchitems` + +Full-text search across items. Respects `X-Region` and `X-Language` headers. + +**Query params:** + +| Param | Type | Default | Description | +|----------|--------|---------|----------------------| +| `search` | string | — | Search query (required) | +| `count` | number | `50` | Items per page | +| `skip` | number | `0` | Offset for paging | + +**Response `200`:** +```json +{ + "items": [ /* Item objects */ ], + "total": 128, + "count": 50, + "skip": 0 +} +``` + +--- + +### `GET /randomitems` + +Returns random items for carousel/recommendations. Respects `X-Region` and `X-Language` headers. + +**Query params:** + +| Param | Type | Default | Description | +|------------|--------|---------|------------------------------------| +| `count` | number | `5` | Number of items to return | +| `category` | number | — | Optional: limit to this category | + +**Response `200`:** Array of [Item](#item-object) objects. + +--- + +### Item Object + +The backend can return items in **either** legacy format or BackOffice format. The frontend normalizes both. + +```json +{ + "categoryID": 1, + "itemID": 123, + "name": "iPhone 15 Pro", + "photos": [{ "url": "https://..." }], + "description": "Описание товара", + "currency": "RUB", + "price": 89990, + "discount": 10, + "remainings": "high", + "rating": 4.5, + "callbacks": [ + { + "rating": 5, + "content": "Отличный товар!", + "userID": "user_123", + "timestamp": "2026-02-01T12:00:00Z" + } + ], + "questions": [ + { + "question": "Есть ли гарантия?", + "answer": "Да, 12 месяцев", + "upvotes": 5, + "downvotes": 0 + } + ], + + "id": "item_abc123", + "visible": true, + "priority": 10, + "imgs": ["https://img1.jpg", "https://img2.jpg"], + "tags": ["new", "popular"], + "badges": ["bestseller", "sale"], + "simpleDescription": "Краткое описание", + "descriptionFields": [ + { "key": "Процессор", "value": "A17 Pro" }, + { "key": "Память", "value": "256 GB" } + ], + "subcategoryId": "sub_001", + "translations": { + "en": { + "name": "iPhone 15 Pro", + "simpleDescription": "Short description", + "description": [ + { "key": "Processor", "value": "A17 Pro" } + ] + }, + "hy": { + "name": "iPhone 15 Pro", + "simpleDescription": "Կարcheck check check" + } + }, + "comments": [ + { + "id": "cmt_001", + "text": "Отличный товар!", + "author": "user_123", + "stars": 5, + "createdAt": "2026-02-01T12:00:00Z" + } + ], + "quantity": 50 +} +``` + +**Full Item fields:** + +| Field | Type | Required | Description | +|---------------------|-------------------|----------|------------------------------------------------------------| +| `categoryID` | number | yes | Category this item belongs to | +| `itemID` | number | yes | Legacy numeric item ID | +| `name` | string | yes | Item display name | +| `photos` | Photo[] | no | Legacy photo array `[{ url }]` | +| `description` | string | yes | Text description | +| `currency` | string | yes | Currency code (default: `RUB`) | +| `price` | number | yes | Price in the currency's smallest display unit | +| `discount` | number | yes | Discount percentage (`0`–`100`) | +| `remainings` | string | no | Stock level: `high`, `medium`, `low`, `out` | +| `rating` | number | yes | Average rating (`0`–`5`) | +| `callbacks` | Review[] | no | Legacy reviews (alias for reviews) | +| `questions` | Question[] | no | Q&A entries | +| `id` | string | no | BackOffice string ID | +| `visible` | boolean | no | Whether item is visible (`true` default) | +| `priority` | number | no | Sort priority (higher = first) | +| `imgs` | string[] | no | BackOffice image URLs (maps to `photos`) | +| `tags` | string[] | no | Item tags for filtering | +| `badges` | string[] | no | Display badges (`bestseller`, `sale`, etc.) | +| `simpleDescription` | string | no | Short plain-text description | +| `descriptionFields` | DescriptionField[]| no | Structured `[{ key, value }]` descriptions | +| `subcategoryId` | string | no | BackOffice subcategory reference | +| `translations` | Record | no | Translations keyed by lang code (see below) | +| `comments` | Comment[] | no | BackOffice comments format | +| `quantity` | number | no | Numeric stock count (maps to `remainings` on frontend) | + +**Nested types:** + +| Type | Fields | +|--------------------|-----------------------------------------------------------------| +| `Photo` | `url: string`, `photo?: string`, `video?: string`, `type?: string` | +| `DescriptionField` | `key: string`, `value: string` | +| `Comment` | `id?: string`, `text: string`, `author?: string`, `stars?: number`, `createdAt?: string` | +| `Review` | `rating?: number`, `content?: string`, `userID?: string`, `answer?: string`, `timestamp?: string` | +| `Question` | `question: string`, `answer: string`, `upvotes: number`, `downvotes: number` | +| `ItemTranslation` | `name?: string`, `simpleDescription?: string`, `description?: DescriptionField[]` | + +--- + +## 4. Cart + +### `POST /cart` — Add item to cart + +**Request body:** +```json +{ "itemID": 123, "quantity": 1 } +``` + +**Response `200`:** +```json +{ "message": "Added to cart" } +``` + +--- + +### `PATCH /cart` — Update item quantity + +**Request body:** +```json +{ "itemID": 123, "quantity": 3 } +``` + +**Response `200`:** +```json +{ "message": "Updated" } +``` + +--- + +### `DELETE /cart` — Remove items from cart + +**Request body:** Array of item IDs +```json +[123, 456] +``` + +**Response `200`:** +```json +{ "message": "Removed" } +``` + +--- + +### `GET /cart` — Get cart contents + +**Response `200`:** Array of [Item](#item-object) objects (each with `quantity` field). + +--- + +## 5. Payments (SBP / QR) + +### `POST /cart` — Create payment (SBP QR) + +> Note: Same endpoint as add-to-cart but with different body schema. + +**Request body:** +```json +{ + "amount": 89990, + "currency": "RUB", + "siteuserID": "tg_123456789", + "siteorderID": "order_abc123", + "redirectUrl": "", + "telegramUsername": "john_doe", + "items": [ + { "itemID": 123, "price": 89990, "name": "iPhone 15 Pro" } + ] +} +``` + +**Response `200`:** +```json +{ + "qrId": "qr_abc123", + "qrStatus": "CREATED", + "qrExpirationDate": "2026-02-28T13:00:00Z", + "payload": "https://qr.nspk.ru/...", + "qrUrl": "https://qr.nspk.ru/..." +} +``` + +--- + +### `GET /qr/payment/:qrId` — Check payment status + +**Response `200`:** +```json +{ + "additionalInfo": "", + "paymentPurpose": "Order #order_abc123", + "amount": 89990, + "code": "SUCCESS", + "createDate": "2026-02-28T12:00:00Z", + "currency": "RUB", + "order": "order_abc123", + "paymentStatus": "COMPLETED", + "qrId": "qr_abc123", + "transactionDate": "2026-02-28T12:01:00Z", + "transactionId": 999, + "qrExpirationDate": "2026-02-28T13:00:00Z", + "phoneNumber": "+7XXXXXXXXXX" +} +``` + +| `paymentStatus` values | Meaning | +|------------------------|---------------------------| +| `CREATED` | QR generated, not paid | +| `WAITING` | Payment in progress | +| `COMPLETED` | Payment successful | +| `EXPIRED` | QR code expired | +| `CANCELLED` | Payment cancelled | + +--- + +### `POST /purchase-email` — Submit email after payment + +**Request body:** +```json +{ + "email": "user@example.com", + "telegramUserId": "123456789", + "items": [ + { "itemID": 123, "name": "iPhone 15 Pro", "price": 89990, "currency": "RUB" } + ] +} +``` + +**Response `200`:** +```json +{ "message": "Email sent" } +``` + +--- + +## 6. Reviews / Comments + +### `POST /comment` — Submit a review + +**Request body:** +```json +{ + "itemID": 123, + "rating": 5, + "comment": "Great product!", + "username": "john_doe", + "userId": 123456789, + "timestamp": "2026-02-28T12:00:00Z" +} +``` + +**Response `200`:** +```json +{ "message": "Review submitted" } +``` + +--- + +## 7. Regions + +### `GET /regions` — List available regions + +Returns regions where the marketplace operates. + +**Response `200`:** +```json +[ + { + "id": "moscow", + "city": "Москва", + "country": "Россия", + "countryCode": "RU", + "timezone": "Europe/Moscow" + }, + { + "id": "spb", + "city": "Санкт-Петербург", + "country": "Россия", + "countryCode": "RU", + "timezone": "Europe/Moscow" + }, + { + "id": "yerevan", + "city": "Ереван", + "country": "Армения", + "countryCode": "AM", + "timezone": "Asia/Yerevan" + } +] +``` + +**Region object:** + +| Field | Type | Required | Description | +|---------------|--------|----------|--------------------------| +| `id` | string | yes | Unique region identifier | +| `city` | string | yes | City name (display) | +| `country` | string | yes | Country name | +| `countryCode` | string | yes | ISO 3166-1 alpha-2 | +| `timezone` | string | no | IANA timezone | + +> **Fallback:** If this endpoint is down, the frontend uses 6 hardcoded defaults: Moscow, SPB, Yerevan, Minsk, Almaty, Tbilisi. + +--- + +## 8. Authentication (Telegram Login) + +Authentication is **Telegram-based** with **cookie sessions** (HttpOnly, Secure, SameSite=None). + +All auth endpoints must include `withCredentials: true` CORS support. + +### Auth flow + +``` +1. User clicks "Checkout" → not authenticated → login dialog shown +2. User clicks "Log in with Telegram" → opens https://t.me/{bot}?start=auth_{callback} +3. User starts the bot in Telegram +4. Bot sends user data → backend /auth/telegram/callback +5. Backend creates session → sets Set-Cookie +6. Frontend polls GET /auth/session every 3s +7. Session detected → dialog closes → checkout proceeds +``` + +--- + +### `GET /auth/session` — Check current session + +**Request:** Cookies only (session cookie set by backend). + +**Response `200`** (authenticated): +```json +{ + "sessionId": "sess_abc123", + "telegramUserId": 123456789, + "username": "john_doe", + "displayName": "John Doe", + "active": true, + "expiresAt": "2026-03-01T12:00:00Z" +} +``` + +**Response `200`** (expired): +```json +{ + "sessionId": "sess_abc123", + "telegramUserId": 123456789, + "username": "john_doe", + "displayName": "John Doe", + "active": false, + "expiresAt": "2026-02-27T12:00:00Z" +} +``` + +**Response `401`** (no session): +```json +{ "error": "No active session" } +``` + +**AuthSession object:** + +| Field | Type | Required | Description | +|------------------|---------|----------|--------------------------------------------| +| `sessionId` | string | yes | Unique session ID | +| `telegramUserId` | number | yes | Telegram user ID | +| `username` | string? | no | Telegram @username (can be null) | +| `displayName` | string | yes | User display name (first + last) | +| `active` | boolean | yes | Whether session is valid | +| `expiresAt` | string | yes | ISO 8601 expiration datetime | + +--- + +### `GET /auth/telegram/callback` — Telegram bot auth callback + +Called by the Telegram bot after user authenticates. + +**Request body (from bot):** +```json +{ + "id": 123456789, + "first_name": "John", + "last_name": "Doe", + "username": "john_doe", + "photo_url": "https://t.me/i/userpic/...", + "auth_date": 1709100000, + "hash": "abc123def456..." +} +``` + +**Response:** Must set a session cookie and return: +```json +{ + "sessionId": "sess_abc123", + "message": "Authenticated successfully" +} +``` + +**Cookie requirements:** + +| Attribute | Value | Notes | +|------------|----------------|--------------------------------------------| +| `HttpOnly` | `true` | Not accessible via JS | +| `Secure` | `true` | HTTPS only | +| `SameSite` | `None` | Required for cross-origin (API ≠ frontend) | +| `Path` | `/` | | +| `Max-Age` | `86400` (24h) | Or as needed | + +--- + +### `POST /auth/logout` — End session + +**Request:** Cookies only, empty body `{}` + +**Response `200`:** +```json +{ "message": "Logged out" } +``` + +Must clear/invalidate the session cookie. + +--- + +### Session refresh + +The frontend re-checks the session **60 seconds before `expiresAt`**. If the backend supports sliding expiration, it can reset the cookie's `Max-Age` on each `GET /auth/session`. + +--- + +## 9. i18n / Translations + +The frontend supports 3 languages: **Russian (ru)**, **English (en)**, **Armenian (hy)**. + +The active language is sent via the `X-Language` HTTP header on every request. + +### What the backend should do with `X-Language` + +1. **Categories & items**: If `translations` field exists for the requested language, return the translated `name`, `description`, etc. OR the backend can apply translations server-side and return already-translated fields. + +2. **The `translations` field** on items (optional approach): + ```json + { + "translations": { + "en": { + "name": "iPhone 15 Pro", + "simpleDescription": "Short desc in English", + "description": [{ "key": "Processor", "value": "A17 Pro" }] + }, + "hy": { + "name": "iPhone 15 Pro", + "simpleDescription": "Կarcheck check" + } + } + } + ``` + +3. **Recommended approach**: Read `X-Language` header and return the `name`/`description` in that language directly. If no translation exists, return the Russian default. + +--- + +## 10. CORS Configuration + +For auth cookies and custom headers to work, the backend CORS config must include: + +``` +Access-Control-Allow-Origin: https://dexarmarket.ru (NOT wildcard *) +Access-Control-Allow-Credentials: true +Access-Control-Allow-Headers: Content-Type, X-Region, X-Language +Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS +``` + +> **Important:** `Access-Control-Allow-Origin` cannot be `*` when `Allow-Credentials: true`. Must be the exact frontend origin. + +**Allowed origins:** +- `https://dexarmarket.ru` +- `https://novo.market` +- `http://localhost:4200` (dev) +- `http://localhost:4201` (dev, Novo) + +--- + +## 11. Telegram Bot Setup + +Each brand needs its own bot: +- **Dexar:** `@dexarmarket_bot` +- **Novo:** `@novomarket_bot` + +The bot should: +1. Listen for `/start auth_{callbackUrl}` command +2. Extract the callback URL +3. Send the user's Telegram data (`id`, `first_name`, `username`, etc.) to that callback URL +4. The callback URL is `{apiUrl}/auth/telegram/callback` + +--- + +## Complete Endpoint Reference + +### New endpoints + +| Method | Path | Description | Auth | +|--------|---------------------------|----------------------------|----------| +| `GET` | `/regions` | List available regions | No | +| `GET` | `/auth/session` | Check current session | Cookie | +| `GET` | `/auth/telegram/callback` | Telegram bot auth callback | No (bot) | +| `POST` | `/auth/logout` | End session | Cookie | + +### Existing endpoints + +| Method | Path | Description | Auth | Headers | +|----------|-----------------------|-------------------------|------|--------------------| +| `GET` | `/ping` | Health check | No | — | +| `GET` | `/category` | List categories | No | X-Region, X-Language | +| `GET` | `/category/:id` | Items in category | No | X-Region, X-Language | +| `GET` | `/item/:id` | Single item | No | X-Region, X-Language | +| `GET` | `/searchitems` | Search items | No | X-Region, X-Language | +| `GET` | `/randomitems` | Random items | No | X-Region, X-Language | +| `POST` | `/cart` | Add to cart / Payment | No* | — | +| `PATCH` | `/cart` | Update cart quantity | No* | — | +| `DELETE` | `/cart` | Remove from cart | No* | — | +| `GET` | `/cart` | Get cart contents | No* | — | +| `POST` | `/comment` | Submit review | No | — | +| `GET` | `/qr/payment/:qrId` | Check payment status | No | — | +| `POST` | `/purchase-email` | Submit email after pay | No | — | + +> \* Cart/payment endpoints may use the session cookie if available for order association, but don't strictly require auth. The frontend enforces auth before checkout. diff --git a/docs/API_DOCS_RU.md b/docs/API_DOCS_RU.md new file mode 100644 index 0000000..f04007f --- /dev/null +++ b/docs/API_DOCS_RU.md @@ -0,0 +1,726 @@ +# Полная документация Backend API + +> **Последнее обновление:** Февраль 2026 +> **Фронтенд:** Angular 21 · Два бренда (Dexar + Novo) +> **Охватывает:** Каталог, Корзина, Оплата, Отзывы, Регионы, Авторизация, i18n, BackOffice + +--- + +## Базовые URL + +| Бренд | Dev | Production | +|--------|----------------------------------|----------------------------------| +| Dexar | `https://api.dexarmarket.ru:445` | `https://api.dexarmarket.ru:445` | +| Novo | `https://api.novo.market:444` | `https://api.novo.market:444` | + +--- + +## Глобальные HTTP-заголовки + +Фронтенд **автоматически добавляет** два кастомных заголовка к **каждому API-запросу** через interceptor. Бэкенд должен читать эти заголовки и использовать для фильтрации/перевода ответов. + +| Заголовок | Пример значения | Описание | +|---------------|-----------------|-------------------------------------------------------------------| +| `X-Region` | `moscow` | ID региона, выбранного пользователем. **Отсутствует** = все регионы. | +| `X-Language` | `ru` | Активный язык интерфейса: `ru`, `en` или `hy`. | + +### Поведение бэкенда + +- **`X-Region`**: Если присутствует — фильтровать товары/категории только по этому региону. Если отсутствует — возвращать всё (глобальный каталог). +- **`X-Language`**: Если присутствует — возвращать переведённые `name`, `description` и т.д., если переводы существуют. Если отсутствует или `ru` — возвращать на русском (по умолчанию). + +### Требования CORS для этих заголовков + +``` +Access-Control-Allow-Headers: Content-Type, X-Region, X-Language +``` + +--- + +## 1. Проверка состояния + +### `GET /ping` + +Простая проверка работоспособности. + +**Ответ `200`:** +```json +{ "message": "pong" } +``` + +--- + +## 2. Каталог — Категории + +### `GET /category` + +Возвращает все категории верхнего уровня. Учитывает заголовки `X-Region` и `X-Language`. + +**Ответ `200`:** +```json +[ + { + "categoryID": 1, + "name": "Электроника", + "parentID": 0, + "icon": "https://...", + "wideBanner": "https://...", + "itemCount": 42, + "priority": 10, + + "id": "cat_abc123", + "visible": true, + "img": "https://...", + "projectId": "proj_xyz", + "subcategories": [ + { + "id": "sub_001", + "name": "Смартфоны", + "visible": true, + "priority": 5, + "img": "https://...", + "categoryId": "cat_abc123", + "parentId": "cat_abc123", + "itemCount": 20, + "hasItems": true, + "subcategories": [] + } + ] + } +] +``` + +**Объект Category:** + +| Поле | Тип | Обязат. | Описание | +|------------------|---------------|---------|-----------------------------------------------------| +| `categoryID` | number | да | Числовой ID (legacy) | +| `name` | string | да | Название категории (переведённое если `X-Language`) | +| `parentID` | number | да | ID родительской категории (`0` = верхний уровень) | +| `icon` | string | нет | URL иконки категории | +| `wideBanner` | string | нет | URL широкого баннера | +| `itemCount` | number | нет | Количество товаров в категории | +| `priority` | number | нет | Приоритет сортировки (больше = выше) | +| `id` | string | нет | Строковый ID из BackOffice | +| `visible` | boolean | нет | Видима ли категория (по умолч. `true`) | +| `img` | string | нет | URL изображения из BackOffice (маппится на `icon`) | +| `projectId` | string | нет | Ссылка на проект в BackOffice | +| `subcategories` | Subcategory[] | нет | Вложенные подкатегории | + +**Объект Subcategory:** + +| Поле | Тип | Обязат. | Описание | +|------------------|---------------|---------|----------------------------------| +| `id` | string | да | ID подкатегории | +| `name` | string | да | Отображаемое название | +| `visible` | boolean | нет | Видима ли | +| `priority` | number | нет | Приоритет сортировки | +| `img` | string | нет | URL изображения | +| `categoryId` | string | да | ID родительской категории | +| `parentId` | string | да | ID прямого родителя | +| `itemCount` | number | нет | Количество товаров | +| `hasItems` | boolean | нет | Есть ли товары | +| `subcategories` | Subcategory[] | нет | Вложенные дочерние подкатегории | + +--- + +### `GET /category/:categoryID` + +Возвращает товары определённой категории. Учитывает заголовки `X-Region` и `X-Language`. + +**Query-параметры:** + +| Параметр | Тип | По умолч. | Описание | +|----------|--------|-----------|-----------------------| +| `count` | number | `50` | Товаров на страницу | +| `skip` | number | `0` | Смещение для пагинации | + +**Ответ `200`:** Массив объектов [Item](#объект-item). + +--- + +## 3. Товары + +### `GET /item/:itemID` + +Возвращает один товар. Учитывает заголовки `X-Region` и `X-Language`. + +**Ответ `200`:** Один объект [Item](#объект-item). + +--- + +### `GET /searchitems` + +Полнотекстовый поиск по товарам. Учитывает заголовки `X-Region` и `X-Language`. + +**Query-параметры:** + +| Параметр | Тип | По умолч. | Описание | +|----------|--------|-----------|-------------------------------| +| `search` | string | — | Поисковый запрос (обязателен) | +| `count` | number | `50` | Товаров на страницу | +| `skip` | number | `0` | Смещение для пагинации | + +**Ответ `200`:** +```json +{ + "items": [ /* объекты Item */ ], + "total": 128, + "count": 50, + "skip": 0 +} +``` + +--- + +### `GET /randomitems` + +Возвращает случайные товары для карусели/рекомендаций. Учитывает заголовки `X-Region` и `X-Language`. + +**Query-параметры:** + +| Параметр | Тип | По умолч. | Описание | +|------------|--------|-----------|--------------------------------------| +| `count` | number | `5` | Количество товаров | +| `category` | number | — | Ограничить данной категорией (опц.) | + +**Ответ `200`:** Массив объектов [Item](#объект-item). + +--- + +### Объект Item + +Бэкенд может возвращать товары в **любом** из двух форматов — legacy или BackOffice. Фронтенд нормализует оба варианта. + +```json +{ + "categoryID": 1, + "itemID": 123, + "name": "iPhone 15 Pro", + "photos": [{ "url": "https://..." }], + "description": "Описание товара", + "currency": "RUB", + "price": 89990, + "discount": 10, + "remainings": "high", + "rating": 4.5, + "callbacks": [ + { + "rating": 5, + "content": "Отличный товар!", + "userID": "user_123", + "timestamp": "2026-02-01T12:00:00Z" + } + ], + "questions": [ + { + "question": "Есть ли гарантия?", + "answer": "Да, 12 месяцев", + "upvotes": 5, + "downvotes": 0 + } + ], + + "id": "item_abc123", + "visible": true, + "priority": 10, + "imgs": ["https://img1.jpg", "https://img2.jpg"], + "tags": ["new", "popular"], + "badges": ["bestseller", "sale"], + "simpleDescription": "Краткое описание", + "descriptionFields": [ + { "key": "Процессор", "value": "A17 Pro" }, + { "key": "Память", "value": "256 GB" } + ], + "subcategoryId": "sub_001", + "translations": { + "en": { + "name": "iPhone 15 Pro", + "simpleDescription": "Short description", + "description": [ + { "key": "Processor", "value": "A17 Pro" } + ] + }, + "hy": { + "name": "iPhone 15 Pro", + "simpleDescription": "Կարcheck check" + } + }, + "comments": [ + { + "id": "cmt_001", + "text": "Отличный товар!", + "author": "user_123", + "stars": 5, + "createdAt": "2026-02-01T12:00:00Z" + } + ], + "quantity": 50 +} +``` + +**Все поля Item:** + +| Поле | Тип | Обязат. | Описание | +|---------------------|-------------------|---------|-----------------------------------------------------------| +| `categoryID` | number | да | Категория, к которой принадлежит товар | +| `itemID` | number | да | Числовой ID товара (legacy) | +| `name` | string | да | Название товара | +| `photos` | Photo[] | нет | Массив фотографий `[{ url }]` (legacy) | +| `description` | string | да | Текстовое описание | +| `currency` | string | да | Код валюты (по умолч. `RUB`) | +| `price` | number | да | Цена | +| `discount` | number | да | Процент скидки (`0`–`100`) | +| `remainings` | string | нет | Уровень остатка: `high`, `medium`, `low`, `out` | +| `rating` | number | да | Средний рейтинг (`0`–`5`) | +| `callbacks` | Review[] | нет | Отзывы (legacy формат) | +| `questions` | Question[] | нет | Вопросы и ответы | +| `id` | string | нет | Строковый ID из BackOffice | +| `visible` | boolean | нет | Виден ли товар (по умолч. `true`) | +| `priority` | number | нет | Приоритет сортировки (больше = выше) | +| `imgs` | string[] | нет | URL картинок из BackOffice (маппится на `photos`) | +| `tags` | string[] | нет | Теги для фильтрации | +| `badges` | string[] | нет | Бейджи (`bestseller`, `sale` и т.д.) | +| `simpleDescription` | string | нет | Краткое текстовое описание | +| `descriptionFields` | DescriptionField[]| нет | Структурированное описание `[{ key, value }]` | +| `subcategoryId` | string | нет | Ссылка на подкатегорию из BackOffice | +| `translations` | Record | нет | Переводы по ключу языка (см. ниже) | +| `comments` | Comment[] | нет | Комментарии в формате BackOffice | +| `quantity` | number | нет | Числовое кол-во на складе (маппится на `remainings`) | + +**Вложенные типы:** + +| Тип | Поля | +|--------------------|-----------------------------------------------------------------| +| `Photo` | `url: string`, `photo?: string`, `video?: string`, `type?: string` | +| `DescriptionField` | `key: string`, `value: string` | +| `Comment` | `id?: string`, `text: string`, `author?: string`, `stars?: number`, `createdAt?: string` | +| `Review` | `rating?: number`, `content?: string`, `userID?: string`, `answer?: string`, `timestamp?: string` | +| `Question` | `question: string`, `answer: string`, `upvotes: number`, `downvotes: number` | +| `ItemTranslation` | `name?: string`, `simpleDescription?: string`, `description?: DescriptionField[]` | + +--- + +## 4. Корзина + +### `POST /cart` — Добавить товар в корзину + +**Тело запроса:** +```json +{ "itemID": 123, "quantity": 1 } +``` + +**Ответ `200`:** +```json +{ "message": "Added to cart" } +``` + +--- + +### `PATCH /cart` — Обновить количество товара + +**Тело запроса:** +```json +{ "itemID": 123, "quantity": 3 } +``` + +**Ответ `200`:** +```json +{ "message": "Updated" } +``` + +--- + +### `DELETE /cart` — Удалить товары из корзины + +**Тело запроса:** Массив ID товаров +```json +[123, 456] +``` + +**Ответ `200`:** +```json +{ "message": "Removed" } +``` + +--- + +### `GET /cart` — Получить содержимое корзины + +**Ответ `200`:** Массив объектов [Item](#объект-item) (каждый с полем `quantity`). + +--- + +## 5. Оплата (СБП / QR) + +### `POST /cart` — Создать платёж (СБП QR) + +> Примечание: Тот же эндпоинт что и добавление в корзину, но с другой схемой тела запроса. + +**Тело запроса:** +```json +{ + "amount": 89990, + "currency": "RUB", + "siteuserID": "tg_123456789", + "siteorderID": "order_abc123", + "redirectUrl": "", + "telegramUsername": "john_doe", + "items": [ + { "itemID": 123, "price": 89990, "name": "iPhone 15 Pro" } + ] +} +``` + +**Ответ `200`:** +```json +{ + "qrId": "qr_abc123", + "qrStatus": "CREATED", + "qrExpirationDate": "2026-02-28T13:00:00Z", + "payload": "https://qr.nspk.ru/...", + "qrUrl": "https://qr.nspk.ru/..." +} +``` + +--- + +### `GET /qr/payment/:qrId` — Проверить статус оплаты + +**Ответ `200`:** +```json +{ + "additionalInfo": "", + "paymentPurpose": "Order #order_abc123", + "amount": 89990, + "code": "SUCCESS", + "createDate": "2026-02-28T12:00:00Z", + "currency": "RUB", + "order": "order_abc123", + "paymentStatus": "COMPLETED", + "qrId": "qr_abc123", + "transactionDate": "2026-02-28T12:01:00Z", + "transactionId": 999, + "qrExpirationDate": "2026-02-28T13:00:00Z", + "phoneNumber": "+7XXXXXXXXXX" +} +``` + +| Значение `paymentStatus` | Значение | +|--------------------------|------------------------------| +| `CREATED` | QR создан, не оплачен | +| `WAITING` | Оплата в процессе | +| `COMPLETED` | Оплата успешна | +| `EXPIRED` | QR-код истёк | +| `CANCELLED` | Оплата отменена | + +--- + +### `POST /purchase-email` — Отправить email после оплаты + +**Тело запроса:** +```json +{ + "email": "user@example.com", + "telegramUserId": "123456789", + "items": [ + { "itemID": 123, "name": "iPhone 15 Pro", "price": 89990, "currency": "RUB" } + ] +} +``` + +**Ответ `200`:** +```json +{ "message": "Email sent" } +``` + +--- + +## 6. Отзывы / Комментарии + +### `POST /comment` — Оставить отзыв + +**Тело запроса:** +```json +{ + "itemID": 123, + "rating": 5, + "comment": "Отличный товар!", + "username": "john_doe", + "userId": 123456789, + "timestamp": "2026-02-28T12:00:00Z" +} +``` + +**Ответ `200`:** +```json +{ "message": "Review submitted" } +``` + +--- + +## 7. Регионы + +### `GET /regions` — Список доступных регионов + +Возвращает регионы, в которых работает маркетплейс. + +**Ответ `200`:** +```json +[ + { + "id": "moscow", + "city": "Москва", + "country": "Россия", + "countryCode": "RU", + "timezone": "Europe/Moscow" + }, + { + "id": "spb", + "city": "Санкт-Петербург", + "country": "Россия", + "countryCode": "RU", + "timezone": "Europe/Moscow" + }, + { + "id": "yerevan", + "city": "Ереван", + "country": "Армения", + "countryCode": "AM", + "timezone": "Asia/Yerevan" + } +] +``` + +**Объект Region:** + +| Поле | Тип | Обязат. | Описание | +|---------------|--------|---------|----------------------------------| +| `id` | string | да | Уникальный идентификатор региона | +| `city` | string | да | Название города | +| `country` | string | да | Название страны | +| `countryCode` | string | да | Код страны ISO 3166-1 alpha-2 | +| `timezone` | string | нет | Часовой пояс IANA | + +> **Фоллбэк:** Если эндпоинт недоступен, фронтенд использует 6 захардкоженных значений: Москва, СПб, Ереван, Минск, Алматы, Тбилиси. + +--- + +## 8. Авторизация (вход через Telegram) + +Авторизация **через Telegram** с **cookie-сессиями** (HttpOnly, Secure, SameSite=None). + +Все auth-эндпоинты должны поддерживать CORS с `credentials: true`. + +### Процесс авторизации + +``` +1. Пользователь нажимает «Оформить заказ» → не авторизован → показывается диалог входа +2. Нажимает «Войти через Telegram» → открывается https://t.me/{bot}?start=auth_{callback} +3. Пользователь запускает бота в Telegram +4. Бот отправляет данные пользователя → бэкенд /auth/telegram/callback +5. Бэкенд создаёт сессию → устанавливает Set-Cookie +6. Фронтенд опрашивает GET /auth/session каждые 3 секунды +7. Сессия обнаружена → диалог закрывается → оформление заказа продолжается +``` + +--- + +### `GET /auth/session` — Проверить текущую сессию + +**Запрос:** Только cookie (сессионная cookie, установленная бэкендом). + +**Ответ `200`** (авторизован): +```json +{ + "sessionId": "sess_abc123", + "telegramUserId": 123456789, + "username": "john_doe", + "displayName": "John Doe", + "active": true, + "expiresAt": "2026-03-01T12:00:00Z" +} +``` + +**Ответ `200`** (сессия истекла): +```json +{ + "sessionId": "sess_abc123", + "telegramUserId": 123456789, + "username": "john_doe", + "displayName": "John Doe", + "active": false, + "expiresAt": "2026-02-27T12:00:00Z" +} +``` + +**Ответ `401`** (нет сессии): +```json +{ "error": "No active session" } +``` + +**Объект AuthSession:** + +| Поле | Тип | Обязат. | Описание | +|------------------|---------|---------|-------------------------------------------| +| `sessionId` | string | да | Уникальный ID сессии | +| `telegramUserId` | number | да | ID пользователя в Telegram | +| `username` | string? | нет | @username в Telegram (может быть null) | +| `displayName` | string | да | Отображаемое имя (имя + фамилия) | +| `active` | boolean | да | Действительна ли сессия | +| `expiresAt` | string | да | Дата истечения в формате ISO 8601 | + +--- + +### `GET /auth/telegram/callback` — Callback авторизации Telegram-бота + +Вызывается Telegram-ботом после авторизации пользователя. + +**Тело запроса (от бота):** +```json +{ + "id": 123456789, + "first_name": "John", + "last_name": "Doe", + "username": "john_doe", + "photo_url": "https://t.me/i/userpic/...", + "auth_date": 1709100000, + "hash": "abc123def456..." +} +``` + +**Ответ:** Должен установить cookie сессии и вернуть: +```json +{ + "sessionId": "sess_abc123", + "message": "Authenticated successfully" +} +``` + +**Требования к cookie:** + +| Атрибут | Значение | Примечание | +|------------|----------------|-----------------------------------------------------| +| `HttpOnly` | `true` | Недоступна из JavaScript | +| `Secure` | `true` | Только HTTPS | +| `SameSite` | `None` | Обязательно для cross-origin (API ≠ фронтенд) | +| `Path` | `/` | | +| `Max-Age` | `86400` (24ч) | Или по необходимости | + +--- + +### `POST /auth/logout` — Завершить сессию + +**Запрос:** Только cookie, пустое тело `{}` + +**Ответ `200`:** +```json +{ "message": "Logged out" } +``` + +Должен очистить/инвалидировать cookie сессии. + +--- + +### Обновление сессии + +Фронтенд повторно проверяет сессию за **60 секунд до `expiresAt`**. Если бэкенд поддерживает скользящий срок действия (sliding expiration), можно обновлять `Max-Age` cookie при каждом вызове `GET /auth/session`. + +--- + +## 9. i18n / Переводы + +Фронтенд поддерживает 3 языка: **Русский (ru)**, **Английский (en)**, **Армянский (hy)**. + +Активный язык отправляется через HTTP-заголовок `X-Language` с каждым запросом. + +### Что бэкенд должен делать с `X-Language` + +1. **Категории и товары**: Если для запрошенного языка есть поле `translations`, вернуть переведённые `name`, `description` и т.д. ИЛИ бэкенд может применять переводы на стороне сервера и возвращать уже переведённые поля. + +2. **Поле `translations`** на товарах (опциональный подход): + ```json + { + "translations": { + "en": { + "name": "iPhone 15 Pro", + "simpleDescription": "Short desc in English", + "description": [{ "key": "Processor", "value": "A17 Pro" }] + }, + "hy": { + "name": "iPhone 15 Pro", + "simpleDescription": "Կarcheck check" + } + } + } + ``` + +3. **Рекомендуемый подход**: Читать заголовок `X-Language` и возвращать `name`/`description` на этом языке напрямую. Если перевода нет — возвращать русский вариант по умолчанию. + +--- + +## 10. Настройка CORS + +Для работы auth-cookie и кастомных заголовков конфигурация CORS бэкенда должна включать: + +``` +Access-Control-Allow-Origin: https://dexarmarket.ru (НЕ wildcard *) +Access-Control-Allow-Credentials: true +Access-Control-Allow-Headers: Content-Type, X-Region, X-Language +Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS +``` + +> **Важно:** `Access-Control-Allow-Origin` не может быть `*` при `Allow-Credentials: true`. Должен быть точный origin фронтенда. + +**Разрешённые origins:** +- `https://dexarmarket.ru` +- `https://novo.market` +- `http://localhost:4200` (dev) +- `http://localhost:4201` (dev, Novo) + +--- + +## 11. Настройка Telegram-бота + +Каждому бренду нужен свой бот: +- **Dexar:** `@dexarmarket_bot` +- **Novo:** `@novomarket_bot` + +Бот должен: +1. Слушать команду `/start auth_{callbackUrl}` +2. Извлечь callback URL +3. Отправить данные пользователя (`id`, `first_name`, `username` и т.д.) на этот callback URL +4. Callback URL: `{apiUrl}/auth/telegram/callback` + +--- + +## Полный справочник эндпоинтов + +### Новые эндпоинты + +| Метод | Путь | Описание | Авторизация | +|--------|---------------------------|---------------------------------|-------------| +| `GET` | `/regions` | Список доступных регионов | Нет | +| `GET` | `/auth/session` | Проверка текущей сессии | Cookie | +| `GET` | `/auth/telegram/callback` | Callback авторизации через бота | Нет (бот) | +| `POST` | `/auth/logout` | Завершение сессии | Cookie | + +### Существующие эндпоинты + +| Метод | Путь | Описание | Авт. | Заголовки | +|----------|-----------------------|---------------------------|------|--------------------| +| `GET` | `/ping` | Проверка состояния | Нет | — | +| `GET` | `/category` | Список категорий | Нет | X-Region, X-Language | +| `GET` | `/category/:id` | Товары категории | Нет | X-Region, X-Language | +| `GET` | `/item/:id` | Один товар | Нет | X-Region, X-Language | +| `GET` | `/searchitems` | Поиск товаров | Нет | X-Region, X-Language | +| `GET` | `/randomitems` | Случайные товары | Нет | X-Region, X-Language | +| `POST` | `/cart` | Добавить в корзину / Оплата | Нет* | — | +| `PATCH` | `/cart` | Обновить кол-во | Нет* | — | +| `DELETE` | `/cart` | Удалить из корзины | Нет* | — | +| `GET` | `/cart` | Содержимое корзины | Нет* | — | +| `POST` | `/comment` | Оставить отзыв | Нет | — | +| `GET` | `/qr/payment/:qrId` | Статус оплаты | Нет | — | +| `POST` | `/purchase-email` | Отправить email после оплаты | Нет | — | + +> \* Эндпоинты корзины/оплаты могут использовать cookie сессии (если есть) для привязки к заказу, но не требуют авторизации строго. Фронтенд проверяет авторизацию перед оформлением заказа. diff --git a/files/changes.txt b/files/changes.txt index 8854db3..45550e1 100644 --- a/files/changes.txt +++ b/files/changes.txt @@ -1,500 +1,11 @@ -we ae going to redesing dexar. here are css from the figma. i will try to explain all. -pls do responsive and better! thank you - -you are free to do changes better and responsive ofc!! - -Header: -
- -
-
Главная
-
О нас
-
Контакты
-
-
-
-
Искать...
- -
-
-
-
-
RU
-
-
-
-
- - .frame { - width: 1440px; - height: 84px; - display: flex; - background-color: #74787b1a; -} - -.frame .group { - margin-top: 18px; - width: 148px; - height: 48px; - position: relative; - margin-left: 56px; -} - -.frame .div { - display: inline-flex; - margin-top: 18px; - width: 569px; - height: 49px; - position: relative; - margin-left: 57px; - align-items: flex-start; -} - -.frame .div-wrapper { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 10px; - padding: 10px 48px; - position: relative; - flex: 0 0 auto; - background-color: #497671; - border-radius: 13px 0px 0px 13px; - border: 1px solid; - border-color: #d3dad9; - box-shadow: 0px 3px 4px #00000026; -} - -.frame .text-wrapper { - position: relative; - width: fit-content; - margin-top: -1.00px; - font-family: "DM Sans-SemiBold", Helvetica; - font-weight: 600; - color: #ffffff; - font-size: 22px; - text-align: center; - letter-spacing: 0; - line-height: normal; -} - -.frame .div-wrapper-2 { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 10px; - padding: 10px 63px; - position: relative; - flex: 0 0 auto; - background-color: #a1b4b5; - border: 1px solid; - border-color: #d3dad9; - box-shadow: 0px 3px 4px #00000026; -} - -.frame .div-wrapper-3 { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 10px; - padding: 10px 42px; - position: relative; - flex: 0 0 auto; - background-color: #ffffffbd; - border-radius: 0px 13px 13px 0px; - border: 1px solid; - border-color: #d3dad9; - box-shadow: 0px 3px 4px #00000026; -} - -.frame .text-wrapper-2 { - color: #1e3c38; - position: relative; - width: fit-content; - margin-top: -1.00px; - font-family: "DM Sans-SemiBold", Helvetica; - font-weight: 600; - font-size: 22px; - text-align: center; - letter-spacing: 0; - line-height: normal; -} - -.frame .frame-wrapper { - margin-top: 18px; - width: 234px; - height: 49px; - position: relative; - margin-left: 126px; - background-color: #ffffffbd; - border-radius: 22px; - border: 1px solid; - border-color: #d2dad9; - box-shadow: 0px 3px 4px #00000026; -} - -.frame .div-2 { - display: inline-flex; - align-items: center; - gap: 27px; - padding: 0px 20px; - position: relative; - top: 10px; - left: 50px; -} - -.frame .text-wrapper-3 { - color: #828e8d; - position: relative; - width: fit-content; - margin-top: -1.00px; - font-family: "DM Sans-SemiBold", Helvetica; - font-weight: 600; - font-size: 22px; - text-align: center; - letter-spacing: 0; - line-height: normal; -} - -.frame .icn { - position: absolute; - top: 1px; - left: -32px; - width: 28px; - height: 28px; -} - -.frame .korzina-frame { - margin-top: 26px; - width: 48px; - height: 32px; - position: relative; - margin-left: 57px; - background-color: #ffffff4c; - border-radius: 12px; - border: 1px solid; - border-color: #667a77; -} - -.frame .cart { - position: absolute; - top: calc(50.00% - 13px); - left: calc(50.00% - 14px); - width: 27px; - height: 27px; -} - -.frame .RU-frame { - display: flex; - margin-top: 26px; - width: 67px; - height: 32px; - position: relative; - margin-left: 4px; - align-items: center; - gap: 8px; - padding: 6px; - background-color: #ffffff4c; - border-radius: 12px; - border: 1px solid; - border-color: #667a77; -} - -.frame .text-wrapper-4 { - position: relative; - width: fit-content; - margin-top: -6.50px; - margin-bottom: -4.50px; - font-family: "DM Sans-Medium", Helvetica; - font-weight: 500; - color: #1e3c38; - font-size: 24px; - letter-spacing: 0; - line-height: normal; -} - -.frame .group-2 { - position: relative; - width: 9.29px; - height: 14px; - transform: rotate(90.00deg); -} - -.frame .line { - top: -2px; - position: absolute; - left: 1px; - width: 9px; - height: 10px; - transform: rotate(-90.00deg); -} - -.frame .img { - top: 6px; - position: absolute; - left: 1px; - width: 9px; - height: 10px; - transform: rotate(-90.00deg); -} - -.frame .login-frame { - margin-top: 26px; - width: 48px; - height: 32px; - position: relative; - margin-left: 4px; - background-color: #ffffff4c; - border-radius: 12px; - border: 1px solid; - border-color: #667a77; -} - -.frame .icon { - position: absolute; - top: calc(50.00% - 12px); - left: calc(50.00% - 12px); - width: 24px; - height: 24px; -} - - - - -1. background: rgba(117, 121, 124, 0.1); - padding: 14px 0px; - width: 1440px; - height: 84px; -2. logo stays the - - - - - - - - - - - - - - - - - - - - - - -3. after logo 3 btns in same div and without gap - 3.1 "главная" - border: 1px solid #d3dad9; - border-radius: 13px 0 0 13px; - padding: 10px 48px; - width: 187px; - height: 49px; - 3.2 "о нас"border: - 1px solid #d3dad9; - padding: 10px 63px; - width: 188px; - height: 49px; - 3.3 "котакты"border: - 1px solid #d3dad9; - border-radius: 0 13px 13px 0; - padding: 10px 42px; - width: 194px; - height: 49px; - - box-shadow: 0 3px 4px 0 rgba(0, 0, 0, 0.15); - background: rgba(255, 255, 255, 0.74); - hover: background: #a1b4b5; - active : background: #497671; - - -4. next search btn with place holder "искать..." and on the left fixed svg icon " - - -" - border: 1px solid #d3dad9; -border-radius: 22px; -padding: 6px 10px; -width: 234px; -height: 49px; -box-shadow: 0 3px 4px 0 rgba(0, 0, 0, 0.15); -background: rgba(255, 255, 255, 0.74); - - -5. after 3 buttons to the right - 5.1 cart btn - border-radius: 12px; - fill: rgba(255, 255, 255, 0.3); - border: 1px solid #677b78; - - - - - - - - 5.2 lang selector btn style border: 1px solid #677b78; - border-radius: 12px; - padding: 6px; - width: 67px; - height: 32px; - - -HERO - we are goung to have a width wide hero, photos for dekstop and mobile you can see in the same folder - - on it text. here are codes from figma -
-
Здесь ты найдёшь всё
-

Тысячи товаров в одном месте

-
просто и удобно
-
- - .frame { - display: flex; - flex-direction: column; - width: 639px; - align-items: flex-start; - gap: 18px; - position: relative; -} - -.frame .text-wrapper { - position: relative; - width: 659px; - margin-top: -1.00px; - margin-right: -20.00px; - font-size: 57px; - font-family: "DM Sans-Medium", Helvetica; - font-weight: 500; - color: #1e3c38; - letter-spacing: 0; - line-height: normal; -} - -.frame .div { - position: absolute; - top: 87px; - left: 0; - width: 581px; - font-size: 34px; - font-family: "DM Sans-Medium", Helvetica; - font-weight: 500; - color: #1e3c38; - letter-spacing: 0; - line-height: normal; -} - -.frame .text-wrapper-2 { - position: absolute; - top: 133px; - left: 0; - width: 281px; - font-size: 34px; - font-family: "DM Sans-Medium", Helvetica; - font-weight: 500; - color: #1e3c38; - letter-spacing: 0; - line-height: normal; -} - - - -under the text we have btns.. hovers and actives for all web site are the same as from header - -first -
Перейти в каталог
- .pereyti-v-katalog { - width: 337px; - height: 60px; - display: flex; - border-radius: 13px; - border: 1px solid; - border-color: #d3dad9; - background: linear-gradient( - 360deg, - rgba(73, 118, 113, 1) 0%, - rgba(167, 206, 202, 1) 100% - ); -} - -.pereyti-v-katalog .text-wrapper { - margin-top: 12px; - width: 269px; - height: 36px; - margin-left: 34px; - position: relative; - font-family: "DM Sans-Medium", Helvetica; - font-weight: 500; - color: #ffffff; - font-size: 27px; - text-align: center; - letter-spacing: 1.08px; - line-height: normal; -} - - -second btn -
-
Найти товар
-
-
- - .frame { - width: 264px; - height: 60px; - display: flex; - gap: 9.2px; - background-color: #f5f5f5; - border-radius: 13px; - border: 1px solid; - border-color: #d3dad9; -} - -.frame .text-wrapper { - margin-top: 12px; - width: 181px; - height: 36px; - position: relative; - margin-left: 36px; - font-family: "DM Sans-Medium", Helvetica; - font-weight: 500; - color: #1e3c38; - font-size: 27px; - text-align: center; - letter-spacing: 1.08px; - line-height: normal; -} - -.frame .group { - margin-top: 22.0px; - width: 10.62px; - height: 16px; - position: relative; -} - -.frame .line { - top: -1px; - width: 12px; - position: absolute; - left: 1px; - height: 10px; -} - -.frame .img { - top: 7px; - width: 11px; - position: absolute; - left: 1px; - height: 10px; -} \ No newline at end of file +bro we need to do changes, that client required +1. we need to add location logic +1.1 the catalogs will come or for global or for exact region +1.2 need to add a place where the user can choose his region like city if choosed moscow the country is set russian +1.3 can we try to understand what country is user logged or whach city by global ip and set it? +2. we need to add somekind of user login logic +2.1 user can add to cart, look the items and etc without logged in, but when he is going to buy/pay -> + at first he have to login with telegram, i will send you the bots adress. + 2.1.1 if is not logged -> will see the QR or link for logging via telegram + 2.1.2 if logged we need to ping server to check if he is active user. the expiration date (like day or 5 days) we will get from bakcend with session id +2.2 and when user is logged, that time he can do a payment diff --git a/package-lock.json b/package-lock.json index 476b2b7..f02d078 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,15 @@ "name": "dexarmarket", "version": "0.0.0", "dependencies": { + "@angular/animations": "^21.1.5", + "@angular/cdk": "^21.1.5", "@angular/common": "^21.0.6", "@angular/compiler": "^21.0.6", "@angular/core": "^21.0.6", "@angular/forms": "^21.0.6", + "@angular/material": "^21.1.5", "@angular/platform-browser": "^21.0.6", + "@angular/platform-browser-dynamic": "^21.1.5", "@angular/router": "^21.0.6", "@angular/service-worker": "^21.0.6", "primeicons": "^7.0.0", @@ -324,6 +328,21 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@angular/animations": { + "version": "21.1.5", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.1.5.tgz", + "integrity": "sha512-gsqHX8lCYV8cgVtHs0iLwrX8SVlmcjUF44l/xCc/jBC/TeKWRl2e6Jqrn1Wcd0NDlGiNsm+mYNyqMyy5/I7kjw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/core": "21.1.5" + } + }, "node_modules/@angular/build": { "version": "21.1.0", "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.1.0.tgz", @@ -472,6 +491,22 @@ "dev": true, "license": "MIT" }, + "node_modules/@angular/cdk": { + "version": "21.1.5", + "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-21.1.5.tgz", + "integrity": "sha512-AlQPgqe3LLwXCyrDwYSX3m/WKnl2ppCMW7Gb+7bJpIcpMdWYEpSOSQF318jXGYIysKg43YbdJ1tWhJWY/cbn3w==", + "license": "MIT", + "dependencies": { + "parse5": "^8.0.0", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": "^21.0.0 || ^22.0.0", + "@angular/core": "^21.0.0 || ^22.0.0", + "@angular/platform-browser": "^21.0.0 || ^22.0.0", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, "node_modules/@angular/cli": { "version": "21.1.0", "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.1.0.tgz", @@ -613,6 +648,23 @@ "rxjs": "^6.5.3 || ^7.4.0" } }, + "node_modules/@angular/material": { + "version": "21.1.5", + "resolved": "https://registry.npmjs.org/@angular/material/-/material-21.1.5.tgz", + "integrity": "sha512-D6JvFulPvIKhPJ52prMV7DxwYMzcUpHar11ZcMb7r9WQzUfCS3FDPXfMAce5n3h+3kFccfmmGpnyBwqTlLPSig==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/cdk": "21.1.5", + "@angular/common": "^21.0.0 || ^22.0.0", + "@angular/core": "^21.0.0 || ^22.0.0", + "@angular/forms": "^21.0.0 || ^22.0.0", + "@angular/platform-browser": "^21.0.0 || ^22.0.0", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, "node_modules/@angular/platform-browser": { "version": "21.0.6", "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-21.0.6.tgz", @@ -635,6 +687,24 @@ } } }, + "node_modules/@angular/platform-browser-dynamic": { + "version": "21.1.5", + "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-21.1.5.tgz", + "integrity": "sha512-Pd8nPbJSIONnze1WS9wLBAtaFw4TYIH+ZGjKHS9G1E9l09tDWtHWyB7dY82Sc//Nc8iR4V7dcsbUmFjOJHThww==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/common": "21.1.5", + "@angular/compiler": "21.1.5", + "@angular/core": "21.1.5", + "@angular/platform-browser": "21.1.5" + } + }, "node_modules/@angular/router": { "version": "21.0.6", "resolved": "https://registry.npmjs.org/@angular/router/-/router-21.0.6.tgz", @@ -7687,7 +7757,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", - "dev": true, "license": "MIT", "dependencies": { "entities": "^6.0.0" @@ -7741,7 +7810,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" diff --git a/package.json b/package.json index 13980de..fb051d4 100644 --- a/package.json +++ b/package.json @@ -16,11 +16,15 @@ }, "private": true, "dependencies": { + "@angular/animations": "^21.1.5", + "@angular/cdk": "^21.1.5", "@angular/common": "^21.0.6", "@angular/compiler": "^21.0.6", "@angular/core": "^21.0.6", "@angular/forms": "^21.0.6", + "@angular/material": "^21.1.5", "@angular/platform-browser": "^21.0.6", + "@angular/platform-browser-dynamic": "^21.1.5", "@angular/router": "^21.0.6", "@angular/service-worker": "^21.0.6", "primeicons": "^7.0.0", diff --git a/src/app/app.config.ts b/src/app/app.config.ts index 773a15b..334a312 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -4,6 +4,8 @@ import { provideHttpClient, withInterceptors } from '@angular/common/http'; import { routes } from './app.routes'; import { cacheInterceptor } from './interceptors/cache.interceptor'; +import { apiHeadersInterceptor } from './interceptors/api-headers.interceptor'; +import { mockDataInterceptor } from './interceptors/mock-data.interceptor'; import { provideServiceWorker } from '@angular/service-worker'; export const appConfig: ApplicationConfig = { @@ -15,7 +17,7 @@ export const appConfig: ApplicationConfig = { withInMemoryScrolling({ scrollPositionRestoration: 'top' }) ), provideHttpClient( - withInterceptors([cacheInterceptor]) + withInterceptors([mockDataInterceptor, apiHeadersInterceptor, cacheInterceptor]) ), provideServiceWorker('ngsw-worker.js', { enabled: !isDevMode(), diff --git a/src/app/app.html b/src/app/app.html index 882d9e9..0dddb0a 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -19,4 +19,5 @@ + } \ No newline at end of file diff --git a/src/app/app.ts b/src/app/app.ts index ccaab04..44b2818 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -5,6 +5,7 @@ import { Title } from '@angular/platform-browser'; import { HeaderComponent } from './components/header/header.component'; import { FooterComponent } from './components/footer/footer.component'; import { BackButtonComponent } from './components/back-button/back-button.component'; +import { TelegramLoginComponent } from './components/telegram-login/telegram-login.component'; import { ApiService } from './services'; import { interval, concat } from 'rxjs'; import { filter, first } from 'rxjs/operators'; @@ -16,7 +17,7 @@ import { TranslateService } from './i18n/translate.service'; @Component({ selector: 'app-root', - imports: [RouterOutlet, HeaderComponent, FooterComponent, BackButtonComponent, TranslatePipe], + imports: [RouterOutlet, HeaderComponent, FooterComponent, BackButtonComponent, TelegramLoginComponent, TranslatePipe], templateUrl: './app.html', styleUrl: './app.scss' }) diff --git a/src/app/components/header/header.component.html b/src/app/components/header/header.component.html index 13ac5c0..258f074 100644 --- a/src/app/components/header/header.component.html +++ b/src/app/components/header/header.component.html @@ -27,6 +27,7 @@
+ @@ -106,6 +107,11 @@ } + +
+ +
+
@@ -171,6 +177,11 @@ + +
+ +
+
diff --git a/src/app/components/header/header.component.ts b/src/app/components/header/header.component.ts index ca3c36e..929a437 100644 --- a/src/app/components/header/header.component.ts +++ b/src/app/components/header/header.component.ts @@ -4,12 +4,13 @@ import { CartService, LanguageService } from '../../services'; import { environment } from '../../../environments/environment'; import { LogoComponent } from '../logo/logo.component'; import { LanguageSelectorComponent } from '../language-selector/language-selector.component'; +import { RegionSelectorComponent } from '../region-selector/region-selector.component'; import { LangRoutePipe } from '../../pipes/lang-route.pipe'; import { TranslatePipe } from '../../i18n/translate.pipe'; @Component({ selector: 'app-header', - imports: [RouterLink, RouterLinkActive, LogoComponent, LanguageSelectorComponent, LangRoutePipe, TranslatePipe], + imports: [RouterLink, RouterLinkActive, LogoComponent, LanguageSelectorComponent, RegionSelectorComponent, LangRoutePipe, TranslatePipe], templateUrl: './header.component.html', styleUrls: ['./header.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush diff --git a/src/app/components/items-carousel/items-carousel.component.html b/src/app/components/items-carousel/items-carousel.component.html index 72cff90..3f03f84 100644 --- a/src/app/components/items-carousel/items-carousel.component.html +++ b/src/app/components/items-carousel/items-carousel.component.html @@ -18,14 +18,21 @@
- + @if (product.discount > 0) { -{{ product.discount }}% } + @if (product.badges && product.badges.length > 0) { +
+ @for (badge of product.badges; track badge) { + {{ badge }} + } +
+ }
-

{{ product.name }}

+

{{ itemName(product) }}

@if (product.rating) {
diff --git a/src/app/components/items-carousel/items-carousel.component.ts b/src/app/components/items-carousel/items-carousel.component.ts index 83d3feb..dda963c 100644 --- a/src/app/components/items-carousel/items-carousel.component.ts +++ b/src/app/components/items-carousel/items-carousel.component.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, signal, ChangeDetectionStrategy } from '@angular/core'; +import { Component, OnInit, signal, ChangeDetectionStrategy, inject } from '@angular/core'; import { DecimalPipe } from '@angular/common'; import { RouterLink } from '@angular/router'; import { CarouselModule } from 'primeng/carousel'; @@ -7,7 +7,8 @@ import { TagModule } from 'primeng/tag'; import { ApiService, CartService } from '../../services'; import { Item } from '../../models'; import { environment } from '../../../environments/environment'; -import { getDiscountedPrice, getMainImage } from '../../utils/item.utils'; +import { getDiscountedPrice, getMainImage, getBadgeClass, getTranslatedField } from '../../utils/item.utils'; +import { LanguageService } from '../../services/language.service'; import { LangRoutePipe } from '../../pipes/lang-route.pipe'; import { TranslatePipe } from '../../i18n/translate.pipe'; @@ -98,6 +99,10 @@ export class ItemsCarouselComponent implements OnInit { readonly getItemImage = getMainImage; readonly getDiscountedPrice = getDiscountedPrice; + readonly getBadgeClass = getBadgeClass; + + private langService = inject(LanguageService); + itemName(product: Item): string { return getTranslatedField(product, 'name', this.langService.currentLanguage()); } addToCart(event: Event, item: Item): void { event.preventDefault(); diff --git a/src/app/components/language-selector/language-selector.component.html b/src/app/components/language-selector/language-selector.component.html index 66b2aba..f0fa815 100644 --- a/src/app/components/language-selector/language-selector.component.html +++ b/src/app/components/language-selector/language-selector.component.html @@ -24,4 +24,25 @@ }
+ + + +
+ @for (cur of languageService.currencies; track cur.code) { + + } +
diff --git a/src/app/components/language-selector/language-selector.component.scss b/src/app/components/language-selector/language-selector.component.scss index d1a2fe0..b720b27 100644 --- a/src/app/components/language-selector/language-selector.component.scss +++ b/src/app/components/language-selector/language-selector.component.scss @@ -301,3 +301,162 @@ } } } + +// ── Currency selector ── +.language-selector { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.currency-button { + display: flex; + align-items: center; + gap: 4px; + padding: 8px 10px; + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + color: #ffffff; + cursor: pointer; + transition: all 0.3s ease; + font-size: 14px; + font-weight: 500; + + &:hover { + background: rgba(255, 255, 255, 0.05); + border-color: rgba(255, 255, 255, 0.3); + } + + .currency-symbol { + font-size: 15px; + font-weight: 700; + } + + .currency-code { + font-size: 13px; + letter-spacing: 0.5px; + } + + .dropdown-arrow { + transition: transform 0.3s ease; + opacity: 0.7; + &.rotated { transform: rotate(180deg); } + } +} + +.currency-dropdown { + position: absolute; + top: calc(100% + 8px); + right: 0; + min-width: 170px; + background: var(--card-bg, #1a1a1a); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); + opacity: 0; + visibility: hidden; + transform: translateY(-10px); + transition: all 0.3s ease; + z-index: 1000; + overflow: hidden; + + &.open { + opacity: 1; + visibility: visible; + transform: translateY(0); + } +} + +.currency-option { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 11px 16px; + background: transparent; + border: none; + color: #ffffff; + cursor: pointer; + transition: background 0.2s ease; + font-size: 14px; + + &:hover { + background: rgba(255, 255, 255, 0.05); + } + + &.active { + background: rgba(255, 255, 255, 0.1); + font-weight: 600; + } + + .cur-symbol { + font-size: 16px; + font-weight: 700; + width: 20px; + text-align: center; + } + + .cur-name { + flex: 1; + } + + .cur-code { + font-size: 12px; + opacity: 0.6; + } +} + +// Light / Novo / Dexar theme adjustments for currency +:host-context(.novo-header), +:host-context(.header) { + .currency-button { + border-color: rgba(0, 0, 0, 0.2); + color: #333333; + &:hover { + background: rgba(0, 0, 0, 0.05); + border-color: rgba(0, 0, 0, 0.3); + } + } +} + +:host-context(.light-theme) { + .currency-dropdown { + background: #ffffff; + border-color: rgba(0, 0, 0, 0.1); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + } + .currency-option { + color: #333333; + &:hover { background: rgba(0, 0, 0, 0.05); } + &.active { background: rgba(0, 0, 0, 0.1); } + } +} + +:host-context(.dexar-header), +:host-context(.dexar-mobile-menu) { + .currency-button { + padding: 4px 8px; + gap: 3px; + background: rgba(255, 255, 255, 0.3); + border: 1px solid #677b78; + border-radius: 8px; + color: #1e3c38; + &:hover { + background: rgba(255, 255, 255, 0.5); + } + .currency-symbol { font-size: 14px; color: #1e3c38; } + .currency-code { font-size: 13px; color: #1e3c38; } + .dropdown-arrow path { stroke: #1e3c38; } + } + .currency-dropdown { + background: #ffffff; + border-color: #d3dad9; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + } + .currency-option { + color: #1e3c38; + &:hover { background: rgba(161, 180, 181, 0.2); } + &.active { background: rgba(73, 118, 113, 0.1); } + } +} diff --git a/src/app/components/language-selector/language-selector.component.ts b/src/app/components/language-selector/language-selector.component.ts index 5b0e03b..23cfe7f 100644 --- a/src/app/components/language-selector/language-selector.component.ts +++ b/src/app/components/language-selector/language-selector.component.ts @@ -1,5 +1,5 @@ import { Component, HostListener, ElementRef, ChangeDetectionStrategy } from '@angular/core'; -import { LanguageService, Language } from '../../services/language.service'; +import { LanguageService, Language, Currency } from '../../services/language.service'; @Component({ selector: 'app-language-selector', @@ -10,6 +10,7 @@ import { LanguageService, Language } from '../../services/language.service'; }) export class LanguageSelectorComponent { dropdownOpen = false; + currencyOpen = false; constructor( public languageService: LanguageService, @@ -18,6 +19,12 @@ export class LanguageSelectorComponent { toggleDropdown(): void { this.dropdownOpen = !this.dropdownOpen; + this.currencyOpen = false; + } + + toggleCurrency(): void { + this.currencyOpen = !this.currencyOpen; + this.dropdownOpen = false; } selectLanguage(lang: Language): void { @@ -27,8 +34,14 @@ export class LanguageSelectorComponent { } } + selectCurrency(currency: Currency): void { + this.languageService.setCurrency(currency.code); + this.currencyOpen = false; + } + closeDropdown(): void { this.dropdownOpen = false; + this.currencyOpen = false; } onKeyDown(event: KeyboardEvent): void { @@ -44,6 +57,7 @@ export class LanguageSelectorComponent { onClickOutside(event: Event): void { if (!this.elementRef.nativeElement.contains(event.target)) { this.dropdownOpen = false; + this.currencyOpen = false; } } } diff --git a/src/app/components/region-selector/region-selector.component.html b/src/app/components/region-selector/region-selector.component.html new file mode 100644 index 0000000..38bef6f --- /dev/null +++ b/src/app/components/region-selector/region-selector.component.html @@ -0,0 +1,54 @@ +
+ + + @if (dropdownOpen()) { +
+ + +
+ + + @for (r of regions(); track r.id) { + + } +
+
+ } +
diff --git a/src/app/components/region-selector/region-selector.component.scss b/src/app/components/region-selector/region-selector.component.scss new file mode 100644 index 0000000..37ddbd7 --- /dev/null +++ b/src/app/components/region-selector/region-selector.component.scss @@ -0,0 +1,180 @@ +.region-selector { + position: relative; +} + +.region-trigger { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 8px; + background: transparent; + cursor: pointer; + font-size: 13px; + color: var(--text-primary, #333); + transition: all 0.2s ease; + white-space: nowrap; + + &:hover, &.active { + border-color: var(--accent-color, #497671); + background: var(--bg-hover, rgba(73, 118, 113, 0.05)); + } + + .pin-icon { + flex-shrink: 0; + color: var(--accent-color, #497671); + } + + .region-name { + max-width: 120px; + overflow: hidden; + text-overflow: ellipsis; + + .detecting { + animation: pulse 1s ease infinite; + } + } + + .chevron { + flex-shrink: 0; + transition: transform 0.2s ease; + + &.rotated { + transform: rotate(180deg); + } + } +} + +.region-dropdown { + position: absolute; + top: calc(100% + 4px); + left: 0; + min-width: 220px; + background: var(--bg-card, #fff); + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 12px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + z-index: 1000; + overflow: hidden; + animation: slideDown 0.15s ease; +} + +.dropdown-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + border-bottom: 1px solid var(--border-color, #e0e0e0); + font-size: 12px; + font-weight: 600; + color: var(--text-secondary, #666); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.detect-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 6px; + background: var(--bg-hover, rgba(73, 118, 113, 0.08)); + color: var(--accent-color, #497671); + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: var(--accent-color, #497671); + color: #fff; + } +} + +.region-list { + max-height: 280px; + overflow-y: auto; + padding: 4px; +} + +.region-option { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 10px 12px; + border: none; + border-radius: 8px; + background: transparent; + cursor: pointer; + font-size: 14px; + color: var(--text-primary, #333); + text-align: left; + transition: background 0.15s ease; + + &:hover { + background: var(--bg-hover, rgba(73, 118, 113, 0.06)); + } + + &.selected { + background: var(--accent-color, #497671); + color: #fff; + + .region-country { + color: rgba(255, 255, 255, 0.7); + } + } + + .region-city { + flex: 1; + } + + .region-country { + font-size: 12px; + color: var(--text-secondary, #999); + } +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +// Mobile adjustments +@media (max-width: 768px) { + .region-trigger { + padding: 5px 8px; + font-size: 12px; + + .region-name { + max-width: 80px; + } + } + + .region-dropdown { + position: fixed; + top: auto; + bottom: 0; + left: 0; + right: 0; + min-width: 100%; + border-radius: 16px 16px 0 0; + max-height: 60vh; + + .region-list { + max-height: 50vh; + } + } +} diff --git a/src/app/components/region-selector/region-selector.component.ts b/src/app/components/region-selector/region-selector.component.ts new file mode 100644 index 0000000..622e0ed --- /dev/null +++ b/src/app/components/region-selector/region-selector.component.ts @@ -0,0 +1,47 @@ +import { Component, ChangeDetectionStrategy, inject, signal, HostListener } from '@angular/core'; +import { LocationService } from '../../services/location.service'; +import { Region } from '../../models/location.model'; +import { TranslatePipe } from '../../i18n/translate.pipe'; + +@Component({ + selector: 'app-region-selector', + imports: [TranslatePipe], + templateUrl: './region-selector.component.html', + styleUrls: ['./region-selector.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class RegionSelectorComponent { + private locationService = inject(LocationService); + + region = this.locationService.region; + regions = this.locationService.regions; + detecting = this.locationService.detecting; + + dropdownOpen = signal(false); + + toggleDropdown(): void { + this.dropdownOpen.update(v => !v); + } + + selectRegion(region: Region): void { + this.locationService.setRegion(region); + this.dropdownOpen.set(false); + } + + selectGlobal(): void { + this.locationService.clearRegion(); + this.dropdownOpen.set(false); + } + + detectLocation(): void { + this.locationService.detectLocation(); + } + + @HostListener('document:click', ['$event']) + onDocumentClick(event: MouseEvent): void { + const target = event.target as HTMLElement; + if (!target.closest('app-region-selector')) { + this.dropdownOpen.set(false); + } + } +} diff --git a/src/app/components/telegram-login/telegram-login.component.html b/src/app/components/telegram-login/telegram-login.component.html new file mode 100644 index 0000000..42f3207 --- /dev/null +++ b/src/app/components/telegram-login/telegram-login.component.html @@ -0,0 +1,47 @@ +@if (showDialog()) { + +} diff --git a/src/app/components/telegram-login/telegram-login.component.scss b/src/app/components/telegram-login/telegram-login.component.scss new file mode 100644 index 0000000..2b92f22 --- /dev/null +++ b/src/app/components/telegram-login/telegram-login.component.scss @@ -0,0 +1,184 @@ +.login-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + animation: fadeIn 0.2s ease; + padding: 16px; +} + +.login-dialog { + position: relative; + background: var(--bg-card, #fff); + border-radius: 20px; + padding: 32px 28px; + max-width: 400px; + width: 100%; + text-align: center; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2); + animation: scaleIn 0.25s ease; +} + +.close-btn { + position: absolute; + top: 12px; + right: 12px; + width: 32px; + height: 32px; + border: none; + border-radius: 50%; + background: var(--bg-hover, #f0f0f0); + color: var(--text-secondary, #666); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + + &:hover { + background: #e0e0e0; + color: #333; + } +} + +.login-icon { + margin: 0 auto 16px; + width: 72px; + height: 72px; + border-radius: 50%; + background: var(--accent-light, rgba(73, 118, 113, 0.1)); + color: var(--accent-color, #497671); + display: flex; + align-items: center; + justify-content: center; +} + +h2 { + margin: 0 0 8px; + font-size: 20px; + font-weight: 700; + color: var(--text-primary, #1a1a1a); +} + +.login-desc { + margin: 0 0 24px; + font-size: 14px; + color: var(--text-secondary, #666); + line-height: 1.5; +} + +.telegram-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + width: 100%; + padding: 14px 24px; + border: none; + border-radius: 12px; + background: #2AABEE; + color: #fff; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: #229ED9; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(42, 171, 238, 0.3); + } + + &:active { + transform: translateY(0); + } + + .tg-icon { + flex-shrink: 0; + } +} + +.qr-section { + margin-top: 20px; + + .qr-hint { + margin: 0 0 12px; + font-size: 13px; + color: var(--text-secondary, #999); + } + + .qr-container { + display: inline-flex; + padding: 12px; + background: #fff; + border-radius: 12px; + border: 1px solid #e8e8e8; + + img { + display: block; + border-radius: 4px; + } + } +} + +.login-note { + margin: 16px 0 0; + font-size: 12px; + color: var(--text-secondary, #999); + line-height: 1.4; +} + +.login-status { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 16px; + color: var(--text-secondary, #666); + font-size: 14px; + + .spinner { + width: 20px; + height: 20px; + border: 2px solid #e0e0e0; + border-top-color: var(--accent-color, #497671); + border-radius: 50%; + animation: spin 0.8s linear infinite; + } +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes scaleIn { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +@media (max-width: 480px) { + .login-dialog { + padding: 24px 20px; + border-radius: 16px; + } + + .qr-section .qr-container img { + width: 140px; + height: 140px; + } +} diff --git a/src/app/components/telegram-login/telegram-login.component.ts b/src/app/components/telegram-login/telegram-login.component.ts new file mode 100644 index 0000000..d726320 --- /dev/null +++ b/src/app/components/telegram-login/telegram-login.component.ts @@ -0,0 +1,66 @@ +import { Component, ChangeDetectionStrategy, inject, signal, OnInit, OnDestroy } from '@angular/core'; +import { AuthService } from '../../services/auth.service'; +import { TranslatePipe } from '../../i18n/translate.pipe'; + +@Component({ + selector: 'app-telegram-login', + imports: [TranslatePipe], + templateUrl: './telegram-login.component.html', + styleUrls: ['./telegram-login.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class TelegramLoginComponent implements OnInit, OnDestroy { + private authService = inject(AuthService); + + showDialog = this.authService.showLoginDialog; + status = this.authService.status; + loginUrl = signal(''); + + private pollTimer?: ReturnType; + + ngOnInit(): void { + this.loginUrl.set(this.authService.getTelegramLoginUrl()); + } + + ngOnDestroy(): void { + this.stopPolling(); + } + + close(): void { + this.authService.hideLogin(); + this.stopPolling(); + } + + /** Open Telegram login link and start polling for session */ + openTelegramLogin(): void { + window.open(this.loginUrl(), '_blank'); + this.startPolling(); + } + + /** Start polling the backend to detect when user completes Telegram auth */ + private startPolling(): void { + this.stopPolling(); + // Check every 3 seconds for up to 5 minutes + let checks = 0; + this.pollTimer = setInterval(() => { + checks++; + if (checks > 100) { // 100 * 3s = 5 min + this.stopPolling(); + return; + } + this.authService.checkSession(); + // If authenticated, stop polling and close dialog + if (this.authService.isAuthenticated()) { + this.stopPolling(); + this.authService.hideLogin(); + } + }, 3000); + } + + private stopPolling(): void { + if (this.pollTimer) { + clearInterval(this.pollTimer); + this.pollTimer = undefined; + } + } +} diff --git a/src/app/config/constants.ts b/src/app/config/constants.ts index d8d5c29..7ad10bd 100644 --- a/src/app/config/constants.ts +++ b/src/app/config/constants.ts @@ -8,7 +8,7 @@ export const LINK_COPIED_DURATION_MS = 2000; // Infinite scroll export const SCROLL_THRESHOLD_PX = 1200; export const SCROLL_DEBOUNCE_MS = 100; -export const ITEMS_PER_PAGE = 20; +export const ITEMS_PER_PAGE = 50; // Search export const SEARCH_DEBOUNCE_MS = 300; diff --git a/src/app/i18n/en.ts b/src/app/i18n/en.ts index 1a2f08f..512ddc6 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -102,6 +102,10 @@ export const en: Translations = { emailNeedsAt: 'Email must contain @', emailNeedsDomain: 'Email must contain a domain (.com, .ru, etc.)', emailInvalid: 'Invalid email format', + loginRequired: 'Log in to checkout', + loginRequiredDesc: 'Please log in via Telegram to place your order', + loginWithTelegram: 'Log in with Telegram', + orScanQr: 'Or scan the QR code', }, search: { title: 'Product search', @@ -134,6 +138,7 @@ export const en: Translations = { emptyTitle: 'Oops! No subcategories yet', emptyDesc: 'There are no subcategories in this section yet, but they will appear soon', goHome: 'Go home', + itemsInCategory: 'Items in this category', }, itemDetail: { loading: 'Loading...', @@ -148,6 +153,7 @@ export const en: Translations = { mediumStock: 'Running low', addToCart: 'Add to cart', description: 'Description', + specifications: 'Specifications', reviews: 'Reviews', yourReview: 'Your review', leaveReview: 'Leave a review', @@ -169,6 +175,8 @@ export const en: Translations = { yesterday: 'Yesterday', daysAgo: 'd. ago', weeksAgo: 'w. ago', + colour: 'Colour', + size: 'Size', }, app: { connecting: 'Connecting to server...', @@ -185,4 +193,17 @@ export const en: Translations = { retry: 'Try again', loading: 'Loading...', }, + location: { + allRegions: 'All regions', + chooseRegion: 'Choose region', + detectAuto: 'Detect automatically', + }, + auth: { + loginRequired: 'Login required', + loginDescription: 'Please log in via Telegram to proceed with your order', + checking: 'Checking...', + loginWithTelegram: 'Log in with Telegram', + orScanQr: 'Or scan the QR code', + loginNote: 'You will be redirected back after login', + }, }; diff --git a/src/app/i18n/hy.ts b/src/app/i18n/hy.ts index 876bc7e..9f52e59 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -6,7 +6,7 @@ export const hy: Translations = { search: 'Որոնում', about: 'Մեր մասին', contacts: 'Կապ', - searchPlaceholder: 'Որոնել...', + searchPlaceholder: 'Փնտրել...', catalog: 'Կատալոգ', }, footer: { @@ -14,7 +14,7 @@ export const hy: Translations = { company: 'Ընկերություն', aboutUs: 'Մեր մասին', contacts: 'Կապ', - requisites: 'Վավերապայմաններ', + requisites: 'Վճարային տվյալներ', support: 'Աջակցություն', faq: 'ՀՏՀ', delivery: 'Առաքում', @@ -35,133 +35,139 @@ export const hy: Translations = { }, home: { welcomeTo: 'Բարի գալուստ {{brand}}', - subtitle: 'Գտեք ամեն ինչ մեկ վայրում', + subtitle: 'Գտեք այն ամենը, ինչ պետք է՝ մեկ վայրում', startSearch: 'Սկսել որոնումը', - loading: 'Կատեգորիաները բեռնվում են...', - errorTitle: 'Ինչ-որ բան սխալ է գնացել', + loading: 'Բեռնում ենք կատեգորիաները...', + errorTitle: 'Ինչ-որ բան սխալ գնաց', retry: 'Փորձել կրկին', categoriesTitle: 'Ապրանքների կատեգորիաներ', - categoriesSubtitle: 'Ընտրեք հետաքրքրող կատեգորիան', + categoriesSubtitle: 'Ընտրեք ձեզ հետաքրքիր կատեգորիան', categoriesEmpty: 'Կատեգորիաները շուտով կհայտնվեն', - categoriesEmptyDesc: 'Մենք աշխատում ենք կատալոգի համալրման վրա', - dexarHeroTitle: 'Այստեղ դու կգտնես ամեն ինչ', + categoriesEmptyDesc: 'Մենք աշխատում ենք կատալոգի լրացման վրա', + dexarHeroTitle: 'Այստեղ կգտնես ամեն ինչ', dexarHeroSubtitle: 'Հազարավոր ապրանքներ մեկ վայրում', dexarHeroTagline: 'պարզ և հարմար', - goToCatalog: 'Անցնել կատալոգ', + goToCatalog: 'Գնալ կատալոգ', findProduct: 'Գտնել ապրանք', - loadingDexar: 'Կատեգորիաները բեռնվում են...', + loadingDexar: 'Կատեգորիաների բեռնում...', catalogTitle: 'Ապրանքների կատալոգ', emptyCategoriesDexar: 'Կատեգորիաները դեռ չկան', - categoriesSoonDexar: 'Շուտով այստեղ կհայտնվեն ապրանքների կատեգորիաներ', + categoriesSoonDexar: 'Շուտով այստեղ կհայտնվեն կատեգորիաներ', itemsCount: '{{count}} ապրանք', }, cart: { title: 'Զամբյուղ', clear: 'Մաքրել', empty: 'Զամբյուղը դատարկ է', - emptyDesc: 'Ավելացրեք ապրանքներ գնումները սկսելու համար', - goShopping: 'Անցնել գնումների', + emptyDesc: 'Ավելացրեք ապրանքներ՝ գնումները սկսելու համար', + goShopping: 'Գնալ գնումների', total: 'Ընդամենը', items: 'Ապրանքներ', deliveryLabel: 'Առաքում', toPay: 'Վճարման ենթակա', agreeWith: 'Ես համաձայն եմ', - publicOffer: 'հանրային օֆերտային', - returnPolicy: 'վերադարձի քաղաքականությանը', - guaranteeTerms: 'երաշխիքային պայմաններին', - privacyPolicy: 'գաղտնիության քաղաքականությանը', + publicOffer: 'հանրային օֆերտայի', + returnPolicy: 'վերադարձի քաղաքականության', + guaranteeTerms: 'երաշխիքային պայմանների', + privacyPolicy: 'գաղտնիության քաղաքականության', and: 'և', - checkout: 'Ձևակերպել պատվեր', + checkout: 'Ձևակերպել պատվերը', close: 'Փակել', - creatingPayment: 'Վճարումը ստեղծվում է...', - waitFewSeconds: 'Սպասեք մի քանի վայրկյան', - scanQr: 'Սկանավորեք QR կոդը վճարման համար', - amountToPay: 'Վճարման գումարը՝', + creatingPayment: 'Վճարման ստեղծում...', + waitFewSeconds: 'Խնդրում ենք սպասել մի քանի վայրկյան', + scanQr: 'Սքանավորեք QR կոդը վճարման համար', + amountToPay: 'Վճարման գումար՝', waitingPayment: 'Սպասում ենք վճարմանը...', copied: '✓ Պատճենված է', copyLink: 'Պատճենել հղումը', openNewTab: 'Բացել նոր ներդիրում', - paymentSuccess: 'Շնորհավորում ենք։ Վճարումը հաջողությամբ կատարվել է։', - paymentSuccessDesc: 'Մուտքագրեք ձեր կոնտակտային տվյալները, և մենք կուղարկենք գնումը մի քանի րոպեի ընթացքում', + paymentSuccess: 'Շնորհավորում ենք! Վճարումը հաջող է անցել!', + paymentSuccessDesc: 'Մուտքագրեք ձեր տվյալները, և մենք կուղարկենք գնումը մի քանի րոպեի ընթացքում', sending: 'Ուղարկվում է...', send: 'Ուղարկել', - paymentTimeout: 'Սպասման ժամանակը սպառվել է', - paymentTimeoutDesc: 'Մենք չենք ստացել վճարման հաստատում 3 րոպեի ընթացքում։', + paymentTimeout: 'Ժամանակը սպառվեց', + paymentTimeoutDesc: 'Մենք չստացանք վճարման հաստատում 3 րոպեի ընթացքում։', autoClose: 'Պատուհանը կփակվի ավտոմատ...', - confirmClear: 'Համոզվա՞ծ եք, որ ցանկանում եք մաքրել զամբյուղը։', - acceptTerms: 'Խնդրում ենք ընդունել օֆերտայի, վերադարձի և երաշխիքի պայմանները պատվերը հաստատելու համար։', + confirmClear: 'Վստա՞հ եք, որ ցանկանում եք մաքրել զամբյուղը', + acceptTerms: 'Խնդրում ենք ընդունել պայմանները՝ պատվերը հաստատելու համար։', copyError: 'Պատճենման սխալ՝', - emailSuccess: 'Email-ը հաջողությամբ ուղարկվել է։ Ստուգեք ձեր փոստը։', - emailError: 'Email ուղարկելու ժամանակ տեղի ունեցավ սխալ։ Խնդրում ենք փորձել կրկին։', + emailSuccess: 'Email-ը հաջողությամբ ուղարկվեց։ Ստուգեք ձեր փոստը։', + emailError: 'Սխալ email ուղարկելիս։ Խնդրում ենք փորձել կրկին։', phoneRequired: 'Հեռախոսահամարը պարտադիր է', phoneMoreDigits: 'Մուտքագրեք ևս {{count}} թիվ', phoneTooMany: 'Չափազանց շատ թվեր', emailRequired: 'Email-ը պարտադիր է', - emailTooShort: 'Email-ը չափազանց կարճ է (նվազագույնը 5 նիշ)', + emailTooShort: 'Email-ը չափազանց կարճ է (առնվազն 5 նիշ)', emailTooLong: 'Email-ը չափազանց երկար է (առավելագույնը 100 նիշ)', - emailNeedsAt: 'Email-ը պետք է պարունակի @ նշանը', - emailNeedsDomain: 'Email-ը պետք է պարունակի դոմեն (.com, .ru և այլն)', - emailInvalid: 'Email-ի ձևաչափը սխալ է', + emailNeedsAt: 'Email-ը պետք է պարունակի @', + emailNeedsDomain: 'Email-ը պետք է պարունակի դոմեյն (.com, .ru և այլն)', + emailInvalid: 'Սխալ email ձևաչափ', + loginRequired: 'Մուտք գործեք ձևակերպելու համար', + loginRequiredDesc: 'Պատվեր ձևակերպելու համար մուտք գործեք Telegram-ով', + loginWithTelegram: 'Մուտք Telegram-ով', + orScanQr: 'Կամ սքանավորեք QR կոդը', }, search: { title: 'Ապրանքների որոնում', - placeholder: 'Մուտքագրեք ապրանքի անունը...', + placeholder: 'Մուտքագրեք ապրանքի անվանումը...', resultsCount: 'Գտնված ապրանքներ՝', searching: 'Որոնում...', retry: 'Փորձել կրկին', noResults: 'Ոչինչ չի գտնվել', - noResultsFor: '"{{query}}" հարցման համար ապրանքներ չեն գտնվել', + noResultsFor: '"{{query}}" հարցմամբ ապրանքներ չեն գտնվել', noResultsHint: 'Փորձեք փոխել հարցումը կամ օգտագործել այլ բանալի բառեր', addToCart: 'Ավելացնել զամբյուղ', - loadingMore: 'Բեռնվում է...', + loadingMore: 'Բեռնում...', allLoaded: 'Բոլոր արդյունքները բեռնված են', - emptyState: 'Մուտքագրեք հարցում ապրանքներ որոնելու համար', - of: 'ից', + emptyState: 'Մուտքագրեք հարցում որոնման համար', + of: '-ից', }, category: { retry: 'Փորձել կրկին', addToCart: 'Ավելացնել զամբյուղ', - loadingMore: 'Բեռնվում է...', + loadingMore: 'Բեռնում...', allLoaded: 'Բոլոր ապրանքները բեռնված են', - emptyTitle: 'Ուպս։ Այստեղ դեռ դատարկ է', - emptyDesc: 'Այս կատեգորիայում դեռ ապրանքներ չկան, բայց շուտով կհայտնվեն', - goHome: 'Գլխավոր էջ', - loading: 'Ապրանքները բեռնվում են...', + emptyTitle: 'Վա՜յ, այստեղ դեռ դատարկ է', + emptyDesc: 'Այս կատեգորիայում դեռ ապրանքներ չկան', + goHome: 'Գլխավոր', + loading: 'Ապրանքների բեռնում...', }, subcategories: { - loading: 'Ենթակատեգորիաները բեռնվում են...', + loading: 'Ենթակատեգորիաների բեռնում...', retry: 'Փորձել կրկին', - emptyTitle: 'Ուպս։ Ենթակատեգորիաներ դեռ չկան', - emptyDesc: 'Այս բաժնում դեռ ենթակատեգորիաներ չկան, բայց շուտով կհայտնվեն', - goHome: 'Գլխավոր էջ', + emptyTitle: 'Ենթակատեգորիաներ չկան', + emptyDesc: 'Այս բաժնում դեռ ենթակատեգորիաներ չկան', + goHome: 'Գլխավոր', + itemsInCategory: 'Ապրանքներ այս կատեգորիայում', }, itemDetail: { - loading: 'Բեռնվում է...', - loadingDexar: 'Ապրանքը բեռնվում է...', + loading: 'Բեռնում...', + loadingDexar: 'Ապրանքի բեռնում...', back: 'Վերադառնալ', backHome: 'Վերադառնալ գլխավոր էջ', noImage: 'Պատկեր չկա', stock: 'Առկայություն՝', inStock: 'Առկա է', - lowStock: 'Մնացել է քիչ', + lowStock: 'Քիչ է մնացել', lastItems: 'Վերջին հատերը', - mediumStock: 'Վերջանում է', + mediumStock: 'Ավարտվում է', addToCart: 'Ավելացնել զամբյուղ', description: 'Նկարագրություն', + specifications: 'Բնութագրեր', reviews: 'Կարծիքներ', yourReview: 'Ձեր կարծիքը', leaveReview: 'Թողնել կարծիք', rating: 'Գնահատական՝', - reviewPlaceholder: 'Կիսվեք ձեր տպավորություններով ապրանքի մասին...', - reviewPlaceholderDexar: 'Կիսվեք ձեր տպավորություններով...', + reviewPlaceholder: 'Կիսվեք ձեր կարծիքով...', + reviewPlaceholderDexar: 'Կիսվեք տպավորություններով...', anonymous: 'Անանուն', submitting: 'Ուղարկվում է...', submit: 'Ուղարկել', - reviewSuccess: 'Շնորհակալություն ձեր կարծիքի համար։', - reviewError: 'Ուղարկման սխալ։ Փորձեք ավելի ուշ։', + reviewSuccess: 'Շնորհակալություն ձեր կարծիքի համար!', + reviewError: 'Սխալ ուղարկելիս։ Փորձեք ավելի ուշ։', defaultUser: 'Օգտատեր', defaultUserDexar: 'Անանուն', - noReviews: 'Դեռ կարծիքներ չկան։ Դարձեք առաջինը։', + noReviews: 'Կարծիքներ դեռ չկան', qna: 'Հարցեր և պատասխաններ', photo: 'Լուսանկար', reviewsCount: 'կարծիք', @@ -169,20 +175,35 @@ export const hy: Translations = { yesterday: 'Երեկ', daysAgo: 'օր առաջ', weeksAgo: 'շաբաթ առաջ', + colour: 'Գույն', + size: 'Չափ', }, app: { - connecting: 'Միացում սերվերին...', - serverUnavailable: 'Սերվերը հասանելի չէ', - serverError: 'Չհաջողվեց միանալ սերվերին։ Ստուգեք ինտերնետ կապը։', - retryConnection: 'Կրկնել փորձը', + connecting: 'Կապ սերվերի հետ...', + serverUnavailable: 'Սերվերը անհասանելի է', + serverError: 'Չհաջողվեց միանալ սերվերին։ Ստուգեք ինտերնետը։', + retryConnection: 'Փորձել կրկին', pageTitle: 'Ապրանքների և ծառայությունների մարքեթփլեյս', }, carousel: { - loading: 'Ապրանքները բեռնվում են...', + loading: 'Ապրանքների բեռնում...', addToCart: 'Ավելացնել զամբյուղ', }, common: { retry: 'Փորձել կրկին', - loading: 'Բեռնվում է...', + loading: 'Բեռնում...', + }, + location: { + allRegions: 'Բոլոր տարածաշրջանները', + chooseRegion: 'Ընտրեք տարածաշրջանը', + detectAuto: 'Որոշել ավտոմատ', + }, + auth: { + loginRequired: 'Պահանջվում է մուտք', + loginDescription: 'Պատվերի համար մուտք գործեք Telegram-ով', + checking: 'Ստուգում...', + loginWithTelegram: 'Մուտք Telegram-ով', + orScanQr: 'Կամ սքանավորեք QR կոդը', + loginNote: 'Մուտքից հետո դուք կվերաուղղվեք', }, }; diff --git a/src/app/i18n/ru.ts b/src/app/i18n/ru.ts index 40038a2..7e8c97c 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -102,6 +102,10 @@ export const ru: Translations = { emailNeedsAt: 'Email должен содержать @', emailNeedsDomain: 'Email должен содержать домен (.com, .ru и т.д.)', emailInvalid: 'Некорректный формат email', + loginRequired: 'Войдите для оформления', + loginRequiredDesc: 'Для оформления заказа войдите через Telegram', + loginWithTelegram: 'Войти через Telegram', + orScanQr: 'Или отсканируйте QR-код', }, search: { title: 'Поиск товаров', @@ -134,6 +138,7 @@ export const ru: Translations = { emptyTitle: 'Упс! Подкатегорий пока нет', emptyDesc: 'В этом разделе ещё нет подкатегорий, но скоро они появятся', goHome: 'На главную', + itemsInCategory: 'Товары в этой категории', }, itemDetail: { loading: 'Загрузка...', @@ -148,6 +153,7 @@ export const ru: Translations = { mediumStock: 'Заканчивается', addToCart: 'Добавить в корзину', description: 'Описание', + specifications: 'Характеристики', reviews: 'Отзывы', yourReview: 'Ваш отзыв', leaveReview: 'Оставить отзыв', @@ -169,6 +175,8 @@ export const ru: Translations = { yesterday: 'Вчера', daysAgo: 'дн. назад', weeksAgo: 'нед. назад', + colour: 'Цвет', + size: 'Размер', }, app: { connecting: 'Подключение к серверу...', @@ -185,4 +193,17 @@ export const ru: Translations = { retry: 'Попробовать снова', loading: 'Загрузка...', }, + location: { + allRegions: 'Все регионы', + chooseRegion: 'Выберите регион', + detectAuto: 'Определить автоматически', + }, + auth: { + loginRequired: 'Требуется авторизация', + loginDescription: 'Для оформления заказа войдите через Telegram', + checking: 'Проверка...', + loginWithTelegram: 'Войти через Telegram', + orScanQr: 'Или отсканируйте QR-код', + loginNote: 'После входа вы будете перенаправлены обратно', + }, }; diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index 53f7523..b707b98 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -100,6 +100,10 @@ export interface Translations { emailNeedsAt: string; emailNeedsDomain: string; emailInvalid: string; + loginRequired: string; + loginRequiredDesc: string; + loginWithTelegram: string; + orScanQr: string; }; search: { title: string; @@ -132,6 +136,7 @@ export interface Translations { emptyTitle: string; emptyDesc: string; goHome: string; + itemsInCategory: string; }; itemDetail: { loading: string; @@ -146,6 +151,7 @@ export interface Translations { mediumStock: string; addToCart: string; description: string; + specifications: string; reviews: string; yourReview: string; leaveReview: string; @@ -167,6 +173,8 @@ export interface Translations { yesterday: string; daysAgo: string; weeksAgo: string; + colour: string; + size: string; }; app: { connecting: string; @@ -183,4 +191,17 @@ export interface Translations { retry: string; loading: string; }; + location: { + allRegions: string; + chooseRegion: string; + detectAuto: string; + }; + auth: { + loginRequired: string; + loginDescription: string; + checking: string; + loginWithTelegram: string; + orScanQr: string; + loginNote: string; + }; } diff --git a/src/app/interceptors/api-headers.interceptor.ts b/src/app/interceptors/api-headers.interceptor.ts new file mode 100644 index 0000000..3336170 --- /dev/null +++ b/src/app/interceptors/api-headers.interceptor.ts @@ -0,0 +1,50 @@ +import { HttpInterceptorFn } from '@angular/common/http'; +import { inject } from '@angular/core'; +import { LocationService } from '../services/location.service'; +import { LanguageService } from '../services/language.service'; +import { AuthService } from '../services/auth.service'; +import { environment } from '../../environments/environment'; + +/** Map internal language codes to API header values */ +const LANG_HEADER_MAP: Record = { + 'ru': 'RU', + 'en': 'EN', + 'hy': 'AM', +}; + +/** Map region IDs to API header values */ +const REGION_HEADER_MAP: Record = { + 'moscow': 'Moscow', + 'spb': 'ST. Petersburg', + 'yerevan': 'Yerevan', +}; + +export const apiHeadersInterceptor: HttpInterceptorFn = (req, next) => { + if (!req.url.startsWith(environment.apiUrl)) { + return next(req); + } + + const locationService = inject(LocationService); + const languageService = inject(LanguageService); + const authService = inject(AuthService); + + const regionId = locationService.regionId(); + const lang = languageService.currentLanguage(); + const currency = languageService.currentCurrency(); + const session = authService.session(); + + let headers = req.headers; + + if (regionId) { + headers = headers.set('X-Region', REGION_HEADER_MAP[regionId] ?? regionId); + } + if (lang) { + headers = headers.set('X-Language', LANG_HEADER_MAP[lang] ?? lang.toUpperCase()); + } + headers = headers.set('Currency', currency || 'RUB'); + if (session?.sessionId) { + headers = headers.set('WebSessionID', session.sessionId); + } + + return next(req.clone({ headers })); +}; diff --git a/src/app/interceptors/mock-data.interceptor.ts b/src/app/interceptors/mock-data.interceptor.ts new file mode 100644 index 0000000..0457d81 --- /dev/null +++ b/src/app/interceptors/mock-data.interceptor.ts @@ -0,0 +1,795 @@ +import { HttpInterceptorFn, HttpResponse } from '@angular/common/http'; +import { of, delay } from 'rxjs'; +import { environment } from '../../environments/environment'; + +// ─── Mock Categories (backOffice format: string IDs, img, subcategories, visible) ─── + +const MOCK_CATEGORIES = [ + { + id: 'electronics', + categoryID: 1, + name: 'Электроника', + parentID: 0, + visible: true, + priority: 1, + img: 'https://images.unsplash.com/photo-1498049794561-7780e7231661?w=400&h=300&fit=crop', + icon: 'https://images.unsplash.com/photo-1498049794561-7780e7231661?w=400&h=300&fit=crop', + projectId: 'dexar', + itemCount: 15, + subcategories: [ + { + id: 'smartphones', + name: 'Смартфоны', + visible: true, + priority: 1, + img: 'https://images.unsplash.com/photo-1511707171634-5f897ff02aa9?w=400&h=300&fit=crop', + categoryId: 'electronics', + parentId: 'electronics', + itemCount: 8, + hasItems: true, + subcategories: [] + }, + { + id: 'laptops', + name: 'Ноутбуки', + visible: true, + priority: 2, + img: 'https://images.unsplash.com/photo-1496181133206-80ce9b88a853?w=400&h=300&fit=crop', + categoryId: 'electronics', + parentId: 'electronics', + itemCount: 6, + hasItems: true, + subcategories: [] + } + ] + }, + { + id: 'clothing', + categoryID: 2, + name: 'Одежда', + parentID: 0, + visible: true, + priority: 2, + img: 'https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=400&h=300&fit=crop', + icon: 'https://images.unsplash.com/photo-1441986300917-64674bd600d8?w=400&h=300&fit=crop', + projectId: 'dexar', + itemCount: 25, + subcategories: [ + { + id: 'mens', + name: 'Мужская', + visible: true, + priority: 1, + img: 'https://images.unsplash.com/photo-1490578474895-699cd4e2cf59?w=400&h=300&fit=crop', + categoryId: 'clothing', + parentId: 'clothing', + itemCount: 12, + hasItems: true, + subcategories: [] + }, + { + id: 'womens', + name: 'Женская', + visible: true, + priority: 2, + img: 'https://images.unsplash.com/photo-1487222477894-8943e31ef7b2?w=400&h=300&fit=crop', + categoryId: 'clothing', + parentId: 'clothing', + itemCount: 13, + hasItems: true, + subcategories: [] + } + ] + }, + { + id: 'home', + categoryID: 3, + name: 'Дом и сад', + parentID: 0, + visible: true, + priority: 3, + img: 'https://images.unsplash.com/photo-1556909114-f6e7ad7d3136?w=400&h=300&fit=crop', + icon: 'https://images.unsplash.com/photo-1556909114-f6e7ad7d3136?w=400&h=300&fit=crop', + projectId: 'dexar', + itemCount: 8, + subcategories: [] + }, + // Subcategories as flat entries (for the legacy flat category list) + { + id: 'smartphones', + categoryID: 11, + name: 'Смартфоны', + parentID: 1, + visible: true, + priority: 1, + img: 'https://images.unsplash.com/photo-1511707171634-5f897ff02aa9?w=400&h=300&fit=crop', + icon: 'https://images.unsplash.com/photo-1511707171634-5f897ff02aa9?w=400&h=300&fit=crop', + itemCount: 8 + }, + { + id: 'laptops', + categoryID: 12, + name: 'Ноутбуки', + parentID: 1, + visible: true, + priority: 2, + img: 'https://images.unsplash.com/photo-1496181133206-80ce9b88a853?w=400&h=300&fit=crop', + icon: 'https://images.unsplash.com/photo-1496181133206-80ce9b88a853?w=400&h=300&fit=crop', + itemCount: 6 + }, + { + id: 'mens', + categoryID: 21, + name: 'Мужская одежда', + parentID: 2, + visible: true, + priority: 1, + img: 'https://images.unsplash.com/photo-1490578474895-699cd4e2cf59?w=400&h=300&fit=crop', + icon: 'https://images.unsplash.com/photo-1490578474895-699cd4e2cf59?w=400&h=300&fit=crop', + itemCount: 12 + }, + { + id: 'womens', + categoryID: 22, + name: 'Женская одежда', + parentID: 2, + visible: true, + priority: 2, + img: 'https://images.unsplash.com/photo-1487222477894-8943e31ef7b2?w=400&h=300&fit=crop', + icon: 'https://images.unsplash.com/photo-1487222477894-8943e31ef7b2?w=400&h=300&fit=crop', + itemCount: 13 + } +]; + +// ─── Mock Items (backOffice format with ALL fields) ─── + +const MOCK_ITEMS: any[] = [ + { + id: 'iphone15', + itemID: 101, + name: 'iPhone 15 Pro Max', + visible: true, + priority: 1, + quantity: 50, + price: 149990, + discount: 0, + currency: 'RUB', + rating: 4.8, + remainings: 'high', + categoryID: 11, + imgs: [ + 'https://images.unsplash.com/photo-1695048133142-1a20484d2569?w=600&h=400&fit=crop', + 'https://images.unsplash.com/photo-1592750475338-74b7b21085ab?w=600&h=400&fit=crop' + ], + photos: [ + { url: 'https://images.unsplash.com/photo-1695048133142-1a20484d2569?w=600&h=400&fit=crop' }, + { url: 'https://images.unsplash.com/photo-1592750475338-74b7b21085ab?w=600&h=400&fit=crop' } + ], + tags: ['new', 'featured', 'apple'], + badges: ['new', 'bestseller'], + colour: 'Натуральный титан', + size: '', + names: [ + { language: 'ru', value: 'iPhone 15 Pro Max' }, + { language: 'en', value: 'iPhone 15 Pro Max' }, + { language: 'hy', value: 'iPhone 15 Pro Max' } + ], + descriptions: [ + { language: 'ru', value: 'Новейший iPhone с титановым корпусом и чипом A17 Pro' }, + { language: 'en', value: 'Latest iPhone with titanium body and A17 Pro chip' } + ], + attributes: [ + { key: 'Цвет', value: 'Натуральный титан' }, + { key: 'Память', value: '256 ГБ' }, + { key: 'Процессор', value: 'A17 Pro' } + ], + simpleDescription: 'Новейший iPhone с титановым корпусом и чипом A17 Pro', + description: [ + { key: 'Цвет', value: 'Натуральный титан' }, + { key: 'Память', value: '256 ГБ' }, + { key: 'Дисплей', value: '6.7" Super Retina XDR' }, + { key: 'Процессор', value: 'A17 Pro' }, + { key: 'Камера', value: '48 Мп основная' }, + { key: 'Аккумулятор', value: '4441 мАч' } + ], + descriptionFields: [ + { key: 'Цвет', value: 'Натуральный титан' }, + { key: 'Память', value: '256 ГБ' }, + { key: 'Дисплей', value: '6.7" Super Retina XDR' }, + { key: 'Процессор', value: 'A17 Pro' }, + { key: 'Камера', value: '48 Мп основная' }, + { key: 'Аккумулятор', value: '4441 мАч' } + ], + subcategoryId: 'smartphones', + translations: { + en: { + name: 'iPhone 15 Pro Max', + simpleDescription: 'Latest iPhone with titanium body and A17 Pro chip', + description: [ + { key: 'Color', value: 'Natural Titanium' }, + { key: 'Storage', value: '256GB' }, + { key: 'Display', value: '6.7" Super Retina XDR' }, + { key: 'Chip', value: 'A17 Pro' }, + { key: 'Camera', value: '48MP main' }, + { key: 'Battery', value: '4441 mAh' } + ] + } + }, + comments: [ + { id: 'c1', text: 'Отличный телефон! Камера просто огонь 🔥', author: 'Иван Петров', stars: 5, createdAt: '2025-12-15T10:30:00Z' }, + { id: 'c2', text: 'Батарея держит весь день, очень доволен.', author: 'Мария Козлова', stars: 4, createdAt: '2026-01-05T14:20:00Z' } + ], + callbacks: [ + { rating: 5, content: 'Отличный телефон! Камера просто огонь 🔥', userID: 'Иван Петров', timestamp: '2025-12-15T10:30:00Z' }, + { rating: 4, content: 'Батарея держит весь день, очень доволен.', userID: 'Мария Козлова', timestamp: '2026-01-05T14:20:00Z' } + ], + questions: [] + }, + { + id: 'samsung-s24', + itemID: 102, + name: 'Samsung Galaxy S24 Ultra', + visible: true, + priority: 2, + quantity: 35, + price: 129990, + discount: 10, + currency: 'RUB', + rating: 4.6, + remainings: 'high', + categoryID: 11, + imgs: [ + 'https://images.unsplash.com/photo-1610945415295-d9bbf067e59c?w=600&h=400&fit=crop' + ], + photos: [ + { url: 'https://images.unsplash.com/photo-1610945415295-d9bbf067e59c?w=600&h=400&fit=crop' } + ], + tags: ['new', 'android', 'samsung'], + badges: ['new', 'sale'], + colour: 'Титановый серый', + size: '', + names: [ + { language: 'ru', value: 'Samsung Galaxy S24 Ultra' }, + { language: 'en', value: 'Samsung Galaxy S24 Ultra' } + ], + descriptions: [ + { language: 'ru', value: 'Премиальный флагман Samsung с S Pen' }, + { language: 'en', value: 'Premium Samsung flagship with S Pen' } + ], + attributes: [ + { key: 'Память', value: '512 ГБ' }, + { key: 'ОЗУ', value: '12 ГБ' } + ], + simpleDescription: 'Премиальный флагман Samsung с S Pen', + description: [ + { key: 'Цвет', value: 'Титановый серый' }, + { key: 'Память', value: '512 ГБ' }, + { key: 'ОЗУ', value: '12 ГБ' }, + { key: 'Дисплей', value: '6.8" Dynamic AMOLED 2X' } + ], + descriptionFields: [ + { key: 'Цвет', value: 'Титановый серый' }, + { key: 'Память', value: '512 ГБ' }, + { key: 'ОЗУ', value: '12 ГБ' }, + { key: 'Дисплей', value: '6.8" Dynamic AMOLED 2X' } + ], + subcategoryId: 'smartphones', + translations: { + en: { + name: 'Samsung Galaxy S24 Ultra', + simpleDescription: 'Premium Samsung flagship with S Pen', + description: [ + { key: 'Color', value: 'Titanium Gray' }, + { key: 'Storage', value: '512GB' }, + { key: 'RAM', value: '12GB' }, + { key: 'Display', value: '6.8" Dynamic AMOLED 2X' } + ] + } + }, + comments: [ + { id: 'c3', text: 'S Pen — топ, использую каждый день.', author: 'Алексей', stars: 5, createdAt: '2026-01-20T08:10:00Z' } + ], + callbacks: [ + { rating: 5, content: 'S Pen — топ, использую каждый день.', userID: 'Алексей', timestamp: '2026-01-20T08:10:00Z' } + ], + questions: [] + }, + { + id: 'pixel-8', + itemID: 103, + name: 'Google Pixel 8 Pro', + visible: true, + priority: 3, + quantity: 20, + price: 89990, + discount: 15, + currency: 'RUB', + rating: 4.5, + remainings: 'medium', + categoryID: 11, + imgs: [ + 'https://images.unsplash.com/photo-1598327105666-5b89351aff97?w=600&h=400&fit=crop' + ], + photos: [ + { url: 'https://images.unsplash.com/photo-1598327105666-5b89351aff97?w=600&h=400&fit=crop' } + ], + tags: ['sale', 'android', 'ai', 'google'], + badges: ['sale', 'hot'], + simpleDescription: 'Лучший смартфон для ИИ-фотографии', + description: [ + { key: 'Цвет', value: 'Bay Blue' }, + { key: 'Память', value: '256 ГБ' }, + { key: 'Процессор', value: 'Tensor G3' } + ], + descriptionFields: [ + { key: 'Цвет', value: 'Bay Blue' }, + { key: 'Память', value: '256 ГБ' }, + { key: 'Процессор', value: 'Tensor G3' } + ], + subcategoryId: 'smartphones', + translations: {}, + comments: [], + callbacks: [], + questions: [ + { question: 'Поддерживает eSIM?', answer: 'Да, поддерживает dual eSIM.', upvotes: 12, downvotes: 0 } + ] + }, + { + id: 'macbook-pro', + itemID: 104, + name: 'MacBook Pro 16" M3 Max', + visible: true, + priority: 1, + quantity: 15, + price: 299990, + discount: 0, + currency: 'RUB', + rating: 4.9, + remainings: 'low', + categoryID: 12, + imgs: [ + 'https://images.unsplash.com/photo-1517336714731-489689fd1ca8?w=600&h=400&fit=crop', + 'https://images.unsplash.com/photo-1541807084-5c52b6b3adef?w=600&h=400&fit=crop' + ], + photos: [ + { url: 'https://images.unsplash.com/photo-1517336714731-489689fd1ca8?w=600&h=400&fit=crop' }, + { url: 'https://images.unsplash.com/photo-1541807084-5c52b6b3adef?w=600&h=400&fit=crop' } + ], + tags: ['featured', 'professional', 'apple'], + badges: ['exclusive', 'limited'], + simpleDescription: 'Мощный ноутбук для профессионалов', + description: [ + { key: 'Процессор', value: 'Apple M3 Max' }, + { key: 'ОЗУ', value: '36 ГБ' }, + { key: 'Память', value: '1 ТБ SSD' }, + { key: 'Дисплей', value: '16.2" Liquid Retina XDR' }, + { key: 'Батарея', value: 'До 22 ч' } + ], + descriptionFields: [ + { key: 'Процессор', value: 'Apple M3 Max' }, + { key: 'ОЗУ', value: '36 ГБ' }, + { key: 'Память', value: '1 ТБ SSD' }, + { key: 'Дисплей', value: '16.2" Liquid Retina XDR' }, + { key: 'Батарея', value: 'До 22 ч' } + ], + subcategoryId: 'laptops', + translations: { + en: { + name: 'MacBook Pro 16" M3 Max', + simpleDescription: 'Powerful laptop for professionals', + description: [ + { key: 'Chip', value: 'Apple M3 Max' }, + { key: 'RAM', value: '36GB' }, + { key: 'Storage', value: '1TB SSD' }, + { key: 'Display', value: '16.2" Liquid Retina XDR' }, + { key: 'Battery', value: 'Up to 22h' } + ] + } + }, + comments: [ + { id: 'c4', text: 'Невероятная производительность. Рендер в 3 раза быстрее.', author: 'Дизайнер Про', stars: 5, createdAt: '2025-11-15T12:00:00Z' }, + { id: 'c5', text: 'Стоит каждого рубля. Экран — сказка.', author: 'Видеоредактор', stars: 5, createdAt: '2026-02-01T09:00:00Z' } + ], + callbacks: [ + { rating: 5, content: 'Невероятная производительность. Рендер в 3 раза быстрее.', userID: 'Дизайнер Про', timestamp: '2025-11-15T12:00:00Z' }, + { rating: 5, content: 'Стоит каждого рубля. Экран — сказка.', userID: 'Видеоредактор', timestamp: '2026-02-01T09:00:00Z' } + ], + questions: [] + }, + { + id: 'dell-xps', + itemID: 105, + name: 'Dell XPS 15', + visible: true, + priority: 2, + quantity: 3, + price: 179990, + discount: 5, + currency: 'RUB', + rating: 4.3, + remainings: 'low', + categoryID: 12, + imgs: [ + 'https://images.unsplash.com/photo-1593642702749-b7d2a804c22e?w=600&h=400&fit=crop' + ], + photos: [ + { url: 'https://images.unsplash.com/photo-1593642702749-b7d2a804c22e?w=600&h=400&fit=crop' } + ], + tags: ['windows', 'professional'], + badges: ['limited'], + simpleDescription: 'Тонкий и мощный Windows ноутбук', + description: [ + { key: 'Процессор', value: 'Intel Core i9-13900H' }, + { key: 'ОЗУ', value: '32 ГБ' }, + { key: 'Дисплей', value: '15.6" OLED 3.5K' } + ], + descriptionFields: [ + { key: 'Процессор', value: 'Intel Core i9-13900H' }, + { key: 'ОЗУ', value: '32 ГБ' }, + { key: 'Дисплей', value: '15.6" OLED 3.5K' } + ], + subcategoryId: 'laptops', + translations: {}, + comments: [], + callbacks: [], + questions: [] + }, + { + id: 'jacket-leather', + itemID: 201, + name: 'Кожаная куртка Premium', + visible: true, + priority: 1, + quantity: 8, + price: 34990, + discount: 20, + currency: 'RUB', + rating: 4.7, + remainings: 'medium', + categoryID: 21, + imgs: [ + 'https://images.unsplash.com/photo-1551028719-00167b16eac5?w=600&h=400&fit=crop' + ], + photos: [ + { url: 'https://images.unsplash.com/photo-1551028719-00167b16eac5?w=600&h=400&fit=crop' } + ], + tags: ['leather', 'premium', 'winter'], + badges: ['sale', 'bestseller'], + simpleDescription: 'Стильная мужская кожаная куртка из натуральной кожи', + description: [ + { key: 'Материал', value: 'Натуральная кожа' }, + { key: 'Размеры', value: 'S, M, L, XL, XXL' }, + { key: 'Цвет', value: 'Чёрный' }, + { key: 'Подкладка', value: 'Полиэстер 100%' } + ], + descriptionFields: [ + { key: 'Материал', value: 'Натуральная кожа' }, + { key: 'Размеры', value: 'S, M, L, XL, XXL' }, + { key: 'Цвет', value: 'Чёрный' }, + { key: 'Подкладка', value: 'Полиэстер 100%' } + ], + subcategoryId: 'mens', + translations: { + en: { + name: 'Premium Leather Jacket', + simpleDescription: 'Stylish men\'s genuine leather jacket', + description: [ + { key: 'Material', value: 'Genuine Leather' }, + { key: 'Sizes', value: 'S, M, L, XL, XXL' }, + { key: 'Color', value: 'Black' }, + { key: 'Lining', value: '100% Polyester' } + ] + } + }, + comments: [ + { id: 'c6', text: 'Качество кожи отличное, сидит идеально.', author: 'Антон', stars: 5, createdAt: '2026-01-10T16:30:00Z' } + ], + callbacks: [ + { rating: 5, content: 'Качество кожи отличное, сидит идеально.', userID: 'Антон', timestamp: '2026-01-10T16:30:00Z' } + ], + questions: [] + }, + { + id: 'dress-silk', + itemID: 202, + name: 'Шёлковое платье Elegance', + visible: true, + priority: 1, + quantity: 12, + price: 18990, + discount: 0, + currency: 'RUB', + rating: 4.9, + remainings: 'high', + categoryID: 22, + imgs: [ + 'https://images.unsplash.com/photo-1595777457583-95e059d581b8?w=600&h=400&fit=crop' + ], + photos: [ + { url: 'https://images.unsplash.com/photo-1595777457583-95e059d581b8?w=600&h=400&fit=crop' } + ], + tags: ['silk', 'elegant', 'new'], + badges: ['new', 'featured'], + simpleDescription: 'Элегантное шёлковое платье для особых случаев', + description: [ + { key: 'Материал', value: '100% Шёлк' }, + { key: 'Размеры', value: 'XS, S, M, L' }, + { key: 'Цвет', value: 'Бордовый' }, + { key: 'Длина', value: 'Миди' } + ], + descriptionFields: [ + { key: 'Материал', value: '100% Шёлк' }, + { key: 'Размеры', value: 'XS, S, M, L' }, + { key: 'Цвет', value: 'Бордовый' }, + { key: 'Длина', value: 'Миди' } + ], + subcategoryId: 'womens', + translations: {}, + comments: [ + { id: 'c7', text: 'Восхитительное платье! Ткань потрясающая.', author: 'Елена', stars: 5, createdAt: '2026-02-14T20:00:00Z' }, + { id: 'c8', text: 'Идеально на вечер. Рекомендую!', author: 'Наталья', stars: 5, createdAt: '2026-02-10T11:00:00Z' } + ], + callbacks: [ + { rating: 5, content: 'Восхитительное платье! Ткань потрясающая.', userID: 'Елена', timestamp: '2026-02-14T20:00:00Z' }, + { rating: 5, content: 'Идеально на вечер. Рекомендую!', userID: 'Наталья', timestamp: '2026-02-10T11:00:00Z' } + ], + questions: [] + }, + { + id: 'hoodie-basic', + itemID: 203, + name: 'Худи Oversize Basic', + visible: true, + priority: 3, + quantity: 45, + price: 5990, + discount: 0, + currency: 'RUB', + rating: 4.2, + remainings: 'high', + categoryID: 21, + imgs: [ + 'https://images.unsplash.com/photo-1556821840-3a63f95609a7?w=600&h=400&fit=crop' + ], + photos: [ + { url: 'https://images.unsplash.com/photo-1556821840-3a63f95609a7?w=600&h=400&fit=crop' } + ], + tags: ['casual', 'basic'], + badges: [], + simpleDescription: 'Удобное худи свободного кроя на каждый день', + description: [ + { key: 'Материал', value: 'Хлопок 80%, Полиэстер 20%' }, + { key: 'Размеры', value: 'S, M, L, XL' }, + { key: 'Цвет', value: 'Серый меланж' } + ], + descriptionFields: [ + { key: 'Материал', value: 'Хлопок 80%, Полиэстер 20%' }, + { key: 'Размеры', value: 'S, M, L, XL' }, + { key: 'Цвет', value: 'Серый меланж' } + ], + subcategoryId: 'mens', + translations: {}, + comments: [], + callbacks: [], + questions: [] + }, + { + id: 'sneakers-run', + itemID: 204, + name: 'Кроссовки AirPulse Run', + visible: true, + priority: 2, + quantity: 0, + price: 12990, + discount: 30, + currency: 'RUB', + rating: 4.4, + remainings: 'out', + categoryID: 21, + imgs: [ + 'https://images.unsplash.com/photo-1542291026-7eec264c27ff?w=600&h=400&fit=crop' + ], + photos: [ + { url: 'https://images.unsplash.com/photo-1542291026-7eec264c27ff?w=600&h=400&fit=crop' } + ], + tags: ['sport', 'running'], + badges: ['sale', 'hot'], + simpleDescription: 'Лёгкие беговые кроссовки с пенной амортизацией', + description: [ + { key: 'Верх', value: 'Текстильная сетка' }, + { key: 'Подошва', value: 'Пена EVA' }, + { key: 'Вес', value: '260 г' } + ], + descriptionFields: [ + { key: 'Верх', value: 'Текстильная сетка' }, + { key: 'Подошва', value: 'Пена EVA' }, + { key: 'Вес', value: '260 г' } + ], + subcategoryId: 'mens', + translations: {}, + comments: [ + { id: 'c9', text: 'Нет в наличии уже месяц... Верните!', author: 'Бегун42', stars: 3, createdAt: '2026-02-05T07:00:00Z' } + ], + callbacks: [ + { rating: 3, content: 'Нет в наличии уже месяц... Верните!', userID: 'Бегун42', timestamp: '2026-02-05T07:00:00Z' } + ], + questions: [] + }, + { + id: 'lamp-smart', + itemID: 301, + name: 'Умная лампа Homelight Pro', + visible: true, + priority: 1, + quantity: 100, + price: 3990, + discount: 0, + currency: 'RUB', + rating: 4.1, + remainings: 'high', + categoryID: 3, + imgs: [ + 'https://images.unsplash.com/photo-1507473885765-e6ed057ab6fe?w=600&h=400&fit=crop' + ], + photos: [ + { url: 'https://images.unsplash.com/photo-1507473885765-e6ed057ab6fe?w=600&h=400&fit=crop' } + ], + tags: ['smart-home', 'lighting'], + badges: ['featured'], + simpleDescription: 'Wi-Fi лампа с управлением через приложение и голосом', + description: [ + { key: 'Яркость', value: '1100 лм' }, + { key: 'Цветовая t°', value: '2700K–6500K' }, + { key: 'Совместимость', value: 'Алиса, Google Home, Alexa' } + ], + descriptionFields: [ + { key: 'Яркость', value: '1100 лм' }, + { key: 'Цветовая t°', value: '2700K–6500K' }, + { key: 'Совместимость', value: 'Алиса, Google Home, Alexa' } + ], + subcategoryId: 'home', + translations: {}, + comments: [], + callbacks: [], + questions: [] + } +]; + +// ─── Helper ─── + +function getAllVisibleItems(): any[] { + return MOCK_ITEMS.filter(i => i.visible !== false); +} + +function getItemsByCategoryId(categoryID: number): any[] { + return getAllVisibleItems().filter(i => i.categoryID === categoryID); +} + +function respond(body: T, delayMs = 150) { + return of(new HttpResponse({ status: 200, body })).pipe(delay(delayMs)); +} + +// ─── The Interceptor ─── + +export const mockDataInterceptor: HttpInterceptorFn = (req, next) => { + if (!(environment as any).useMockData) { + return next(req); + } + + const url = req.url; + + // ── GET /ping + if (url.endsWith('/ping') && req.method === 'GET') { + return respond({ message: 'pong (mock)' }); + } + + // ── GET /category (all categories flat list) + if (url.endsWith('/category') && req.method === 'GET') { + return respond(MOCK_CATEGORIES); + } + + // ── GET /category/:id (items for a category) + const catItemsMatch = url.match(/\/category\/(\d+)$/); + if (catItemsMatch && req.method === 'GET') { + const catId = parseInt(catItemsMatch[1], 10); + const items = getItemsByCategoryId(catId); + return respond(items); + } + + // ── GET /item/:id + const itemMatch = url.match(/\/item\/(\d+)$/); + if (itemMatch && req.method === 'GET') { + const itemId = parseInt(itemMatch[1], 10); + const item = MOCK_ITEMS.find(i => i.itemID === itemId); + if (item) { + return respond(item); + } + return of(new HttpResponse({ status: 404, body: { error: 'Item not found' } })).pipe(delay(100)); + } + + // ── GET /searchitems?search=... + if (url.includes('/searchitems') && req.method === 'GET') { + const search = req.params.get('search')?.toLowerCase() || ''; + const items = getAllVisibleItems().filter(i => + i.name.toLowerCase().includes(search) || + i.simpleDescription?.toLowerCase().includes(search) || + i.tags?.some((t: string) => t.toLowerCase().includes(search)) + ); + return respond({ + items, + total: items.length, + count: items.length, + skip: 0 + }); + } + + // ── GET /randomitems + if (url.includes('/randomitems') && req.method === 'GET') { + const count = parseInt(req.params.get('count') || '5', 10); + const shuffled = [...getAllVisibleItems()].sort(() => Math.random() - 0.5); + return respond(shuffled.slice(0, count)); + } + + // ── GET /cart (return empty) + if (url.endsWith('/cart') && req.method === 'GET') { + return respond([]); + } + + // ── POST /cart (add to cart / create payment) + if (url.endsWith('/cart') && req.method === 'POST') { + const body = req.body as any; + if (body?.amount) { + // Payment mock + return respond({ + qrId: 'mock-qr-' + Date.now(), + qrStatus: 'CREATED', + qrExpirationDate: new Date(Date.now() + 180000).toISOString(), + payload: 'https://example.com/pay/mock', + qrUrl: 'https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=mock-payment' + }, 300); + } + return respond({ message: 'Added (mock)' }); + } + + // ── PATCH /cart + if (url.endsWith('/cart') && req.method === 'PATCH') { + return respond({ message: 'Updated (mock)' }); + } + + // ── DELETE /cart + if (url.endsWith('/cart') && req.method === 'DELETE') { + return respond({ message: 'Removed (mock)' }); + } + + // ── POST /comment + if (url.endsWith('/comment') && req.method === 'POST') { + return respond({ message: 'Review submitted (mock)' }, 200); + } + + // ── POST /purchase-email + if (url.endsWith('/purchase-email') && req.method === 'POST') { + return respond({ message: 'Email sent (mock)' }, 200); + } + + // ── GET /qr/payment/:id (always return success for testing) + if (url.includes('/qr/payment/') && req.method === 'GET') { + return respond({ + paymentStatus: 'SUCCESS', + code: 'SUCCESS', + amount: 0, + currency: 'RUB', + qrId: 'mock', + transactionId: 999, + transactionDate: new Date().toISOString(), + additionalInfo: '', + paymentPurpose: '', + createDate: new Date().toISOString(), + order: 'mock-order', + qrExpirationDate: new Date().toISOString(), + phoneNumber: '' + }, 500); + } + + // Fallback — pass through + return next(req); +}; diff --git a/src/app/models/auth.model.ts b/src/app/models/auth.model.ts new file mode 100644 index 0000000..3ffde95 --- /dev/null +++ b/src/app/models/auth.model.ts @@ -0,0 +1,20 @@ +export interface AuthSession { + sessionId: string; + telegramUserId: number; + username: string | null; + displayName: string; + active: boolean; + expiresAt: string; +} + +export interface TelegramAuthData { + id: number; + first_name: string; + last_name?: string; + username?: string; + photo_url?: string; + auth_date: number; + hash: string; +} + +export type AuthStatus = 'unknown' | 'checking' | 'authenticated' | 'expired' | 'unauthenticated'; diff --git a/src/app/models/category.model.ts b/src/app/models/category.model.ts index ee3f477..12b5e85 100644 --- a/src/app/models/category.model.ts +++ b/src/app/models/category.model.ts @@ -6,4 +6,28 @@ export interface Category { wideBanner?: string; itemCount?: number; priority?: number; + + // BackOffice API fields + id?: string; + visible?: boolean; + img?: string; + projectId?: string; + subcategories?: Subcategory[]; +} + +export interface Subcategory { + id: string; + name: string; + visible?: boolean; + priority?: number; + img?: string; + categoryId: string; + parentId: string; + itemCount?: number; + hasItems?: boolean; + subcategories?: Subcategory[]; +} + +export interface CategoryTranslation { + name?: string; } diff --git a/src/app/models/index.ts b/src/app/models/index.ts index 491f438..2c0918a 100644 --- a/src/app/models/index.ts +++ b/src/app/models/index.ts @@ -1,2 +1,4 @@ export * from './category.model'; export * from './item.model'; +export * from './location.model'; +export * from './auth.model'; diff --git a/src/app/models/item.model.ts b/src/app/models/item.model.ts index f757e15..dfcf5c7 100644 --- a/src/app/models/item.model.ts +++ b/src/app/models/item.model.ts @@ -5,6 +5,25 @@ export interface Photo { type?: string; } +export interface DescriptionField { + key: string; + value: string; +} + +export interface Comment { + id?: string; + text: string; + author?: string; + stars?: number; + createdAt?: string; +} + +export interface ItemTranslation { + name?: string; + simpleDescription?: string; + description?: DescriptionField[]; +} + export interface Review { rating?: number; content?: string; @@ -23,6 +42,24 @@ export interface Question { downvotes: number; } +/** Localized name entry from backend */ +export interface ItemName { + language: string; + value: string; +} + +/** Localized description entry from backend */ +export interface ItemDescription { + language: string; + value: string; +} + +/** Key-value attribute pair */ +export interface ItemAttribute { + key: string; + value: string; +} + export interface Item { categoryID: number; itemID: number; @@ -36,7 +73,28 @@ export interface Item { rating: number; callbacks: Review[] | null; questions: Question[] | null; - partnerID?: string; + quantity?: number; + + // Backend API fields + colour?: string; + size?: string; + language?: string; + names?: ItemName[]; + descriptions?: ItemDescription[]; + attributes?: ItemAttribute[]; + + // BackOffice API fields + id?: string; + visible?: boolean; + priority?: number; + imgs?: string[]; + tags?: string[]; + badges?: string[]; + simpleDescription?: string; + descriptionFields?: DescriptionField[]; + subcategoryId?: string; + translations?: Record; + comments?: Comment[]; } export interface CartItem extends Item { diff --git a/src/app/models/location.model.ts b/src/app/models/location.model.ts new file mode 100644 index 0000000..0b65b4d --- /dev/null +++ b/src/app/models/location.model.ts @@ -0,0 +1,17 @@ +export interface Region { + id: string; + city: string; + country: string; + countryCode: string; + timezone?: string; +} + +export interface GeoIpResponse { + city: string; + country: string; + countryCode: string; + region?: string; + timezone?: string; + lat?: number; + lon?: number; +} diff --git a/src/app/pages/cart/cart.component.html b/src/app/pages/cart/cart.component.html index d8d0139..90c143d 100644 --- a/src/app/pages/cart/cart.component.html +++ b/src/app/pages/cart/cart.component.html @@ -31,12 +31,12 @@ (touchstart)="onSwipeStart(item.itemID, $event)">
- +
- {{ item.name }} + {{ itemName(item) }}
-

{{ item.description.substring(0, 100) }}...

+

{{ itemDesc(item) || '' }}...

+ + @if (item.colour || item.size) { +
+ @if (item.colour) { + {{ 'itemDetail.colour' | translate }}: {{ item.colour }} + } + @if (item.size) { + {{ 'itemDetail.size' | translate }}: {{ item.size }} + } +
+ } + + @if (item.badges && item.badges.length > 0) { +
+ @for (badge of item.badges; track badge) { + {{ badge }} + } +
+ } } @@ -166,7 +215,7 @@
{{ 'cart.amountToPay' | translate }} - {{ totalPrice() | number:'1.2-2' }} RUB + {{ totalPrice() | number:'1.2-2' }} {{ currentCurrency }}
@@ -256,3 +305,5 @@
} + + diff --git a/src/app/pages/cart/cart.component.scss b/src/app/pages/cart/cart.component.scss index 38040e2..9c75ba6 100644 --- a/src/app/pages/cart/cart.component.scss +++ b/src/app/pages/cart/cart.component.scss @@ -364,6 +364,22 @@ line-height: 1.5; } + .cart-item-variants { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-top: 4px; + + .cart-variant { + font-size: 0.8rem; + color: #497671; + background: rgba(73, 118, 113, 0.08); + padding: 3px 10px; + border-radius: 6px; + font-weight: 500; + } + } + .item-footer { display: flex; justify-content: space-between; @@ -464,6 +480,22 @@ line-height: 1.6; } + .cart-item-variants { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-top: 4px; + + .cart-variant { + font-size: 0.8rem; + color: #6366f1; + background: rgba(99, 102, 241, 0.08); + padding: 3px 10px; + border-radius: 6px; + font-weight: 500; + } + } + .item-footer { display: flex; justify-content: space-between; @@ -689,6 +721,85 @@ cursor: not-allowed; } } + + .cart-login-gate { + margin-top: 16px; + padding: 20px; + border-radius: 14px; + background: rgba(42, 171, 238, 0.05); + border: 1px dashed rgba(42, 171, 238, 0.3); + text-align: center; + + .login-gate-icon { + margin: 0 auto 10px; + width: 56px; + height: 56px; + border-radius: 50%; + background: rgba(42, 171, 238, 0.1); + color: #2AABEE; + display: flex; + align-items: center; + justify-content: center; + } + + .login-gate-title { + margin: 0 0 4px; + font-size: 1rem; + font-weight: 700; + color: #1a1a1a; + } + + .login-gate-desc { + margin: 0 0 16px; + font-size: 0.85rem; + color: #6b7280; + line-height: 1.4; + } + + .telegram-login-btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 12px 24px; + border: none; + border-radius: 10px; + background: #2AABEE; + color: #fff; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + + &:hover { + background: #229ED9; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(42, 171, 238, 0.3); + } + } + + .login-gate-qr { + margin-top: 14px; + + .qr-hint { + margin: 0 0 8px; + font-size: 0.8rem; + color: #999; + } + + .qr-wrapper { + display: inline-flex; + padding: 10px; + background: #fff; + border-radius: 10px; + border: 1px solid #e5e7eb; + + img { + display: block; + border-radius: 4px; + } + } + } + } } // Novo Cart Summary - Green Modern diff --git a/src/app/pages/cart/cart.component.ts b/src/app/pages/cart/cart.component.ts index 779db57..6a71c14 100644 --- a/src/app/pages/cart/cart.component.ts +++ b/src/app/pages/cart/cart.component.ts @@ -2,13 +2,14 @@ import { Component, computed, ChangeDetectionStrategy, signal, OnDestroy, inject import { DecimalPipe } from '@angular/common'; import { Router, RouterLink } from '@angular/router'; import { FormsModule } from '@angular/forms'; -import { CartService, ApiService, LanguageService } from '../../services'; +import { CartService, ApiService, LanguageService, AuthService } from '../../services'; import { Item, CartItem } from '../../models'; import { interval, Subscription } from 'rxjs'; import { switchMap, take } from 'rxjs/operators'; import { EmptyCartIconComponent } from '../../components/empty-cart-icon/empty-cart-icon.component'; +import { TelegramLoginComponent } from '../../components/telegram-login/telegram-login.component'; import { environment } from '../../../environments/environment'; -import { getDiscountedPrice, getMainImage, trackByItemId } from '../../utils/item.utils'; +import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass, getTranslatedField } from '../../utils/item.utils'; import { LangRoutePipe } from '../../pipes/lang-route.pipe'; import { TranslatePipe } from '../../i18n/translate.pipe'; import { TranslateService } from '../../i18n/translate.service'; @@ -16,7 +17,7 @@ import { PAYMENT_POLL_INTERVAL_MS, PAYMENT_MAX_CHECKS, PAYMENT_TIMEOUT_CLOSE_MS, @Component({ selector: 'app-cart', - imports: [DecimalPipe, RouterLink, FormsModule, EmptyCartIconComponent, LangRoutePipe, TranslatePipe], + imports: [DecimalPipe, RouterLink, FormsModule, EmptyCartIconComponent, TelegramLoginComponent, LangRoutePipe, TranslatePipe], templateUrl: './cart.component.html', styleUrls: ['./cart.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush @@ -29,7 +30,11 @@ export class CartComponent implements OnDestroy { isnovo = environment.theme === 'novo'; private i18n = inject(TranslateService); + private authService = inject(AuthService); + isAuthenticated = this.authService.isAuthenticated; + loginUrl = signal(''); + // Swipe state swipedItemId = signal(null); @@ -64,6 +69,11 @@ export class CartComponent implements OnDestroy { this.items = this.cartService.items; this.itemCount = this.cartService.itemCount; this.totalPrice = this.cartService.totalPrice; + this.loginUrl.set(this.authService.getTelegramLoginUrl()); + } + + requestLogin(): void { + this.authService.requestLogin(); } ngOnDestroy(): void { @@ -129,12 +139,22 @@ export class CartComponent implements OnDestroy { readonly getMainImage = getMainImage; readonly trackByItemId = trackByItemId; readonly getDiscountedPrice = getDiscountedPrice; + readonly getBadgeClass = getBadgeClass; + + itemName(item: Item): string { return getTranslatedField(item, 'name', this.langService.currentLanguage()); } + itemDesc(item: Item): string { return getTranslatedField(item, 'simpleDescription', this.langService.currentLanguage()); } + get currentCurrency(): string { return this.langService.currentCurrency(); } checkout(): void { if (!this.termsAccepted) { alert(this.i18n.t('cart.acceptTerms')); return; } + // Auth gate: require Telegram login before payment + if (!this.authService.isAuthenticated()) { + this.authService.requestLogin(); + return; + } this.openPaymentPopup(); } @@ -168,7 +188,7 @@ export class CartComponent implements OnDestroy { const paymentData = { amount: this.totalPrice(), - currency: this.items()[0]?.currency || 'RUB', + currency: this.langService.currentCurrency(), siteuserID: userId, siteorderID: orderId, redirectUrl: '', diff --git a/src/app/pages/category/category.component.html b/src/app/pages/category/category.component.html index b108c8e..2df3e7a 100644 --- a/src/app/pages/category/category.component.html +++ b/src/app/pages/category/category.component.html @@ -12,14 +12,21 @@
- + @if (item.discount > 0) {
-{{ item.discount }}%
} + @if (item.badges && item.badges.length > 0) { +
+ @for (badge of item.badges; track badge) { + {{ badge }} + } +
+ }
-

{{ item.name }}

+

{{ itemName(item) }}

⭐ {{ item.rating }} diff --git a/src/app/pages/category/category.component.ts b/src/app/pages/category/category.component.ts index da8b32d..e3b51ba 100644 --- a/src/app/pages/category/category.component.ts +++ b/src/app/pages/category/category.component.ts @@ -1,11 +1,12 @@ -import { Component, OnInit, OnDestroy, signal, HostListener, ChangeDetectionStrategy } from '@angular/core'; +import { Component, OnInit, OnDestroy, signal, HostListener, ChangeDetectionStrategy, inject } from '@angular/core'; import { DecimalPipe } from '@angular/common'; import { ActivatedRoute, RouterLink } from '@angular/router'; import { ApiService, CartService } from '../../services'; import { PrefetchService } from '../../services/prefetch.service'; import { Item } from '../../models'; import { Subscription } from 'rxjs'; -import { getDiscountedPrice, getMainImage, trackByItemId } from '../../utils/item.utils'; +import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass, getTranslatedField } from '../../utils/item.utils'; +import { LanguageService } from '../../services/language.service'; import { LangRoutePipe } from '../../pipes/lang-route.pipe'; import { TranslatePipe } from '../../i18n/translate.pipe'; import { SCROLL_THRESHOLD_PX, SCROLL_DEBOUNCE_MS, ITEMS_PER_PAGE } from '../../config/constants'; @@ -115,4 +116,8 @@ export class CategoryComponent implements OnInit, OnDestroy { readonly getDiscountedPrice = getDiscountedPrice; readonly getMainImage = getMainImage; readonly trackByItemId = trackByItemId; + readonly getBadgeClass = getBadgeClass; + + private langService = inject(LanguageService); + itemName(item: Item): string { return getTranslatedField(item, 'name', this.langService.currentLanguage()); } } diff --git a/src/app/pages/category/subcategories.component.html b/src/app/pages/category/subcategories.component.html index c117e1f..d43dcd6 100644 --- a/src/app/pages/category/subcategories.component.html +++ b/src/app/pages/category/subcategories.component.html @@ -18,6 +18,30 @@

{{ parentName() }}

+ + @if (nestedSubcategories().length > 0) { +
+ } + + @if (subcategories().length > 0) {
@for (cat of subcategories(); track trackByCategoryId($index, cat)) { @@ -35,7 +59,53 @@ }
- } @else { + } + + + @if (categoryItems().length > 0) { + + } + + @if (!hasSubcategories() && categoryItems().length === 0) {
diff --git a/src/app/pages/category/subcategories.component.scss b/src/app/pages/category/subcategories.component.scss index f33a949..74fc4f0 100644 --- a/src/app/pages/category/subcategories.component.scss +++ b/src/app/pages/category/subcategories.component.scss @@ -235,6 +235,149 @@ min-height: calc(2 * 1.3em); } + .category-count { + font-family: "DM Sans", sans-serif; + font-size: 0.8rem; + color: #697777; + } + + // Items section within subcategories page + .category-items-section { + margin-top: 40px; + + .items-section-title { + font-family: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-size: 1.5rem; + font-weight: 700; + color: #1e3c38; + margin: 0 0 20px 0; + } + } + + .items-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 24px; + } + + .item-card { + display: flex; + flex-direction: column; + text-decoration: none; + border: 1px solid #d3dad9; + border-radius: 13px; + overflow: hidden; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + background: #fff; + + &:hover { + transform: translateY(-4px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + } + } + + .item-image { + position: relative; + aspect-ratio: 1; + overflow: hidden; + background: #f5f5f5; + + img { + width: 100%; + height: 100%; + object-fit: cover; + transition: transform 0.3s ease; + } + + .item-card:hover & img { + transform: scale(1.05); + } + + .item-discount { + position: absolute; + top: 8px; + right: 8px; + background: #dc2626; + color: white; + font-size: 0.75rem; + font-weight: 700; + padding: 2px 8px; + border-radius: 6px; + } + + .item-badges { + position: absolute; + top: 8px; + left: 8px; + display: flex; + gap: 4px; + flex-wrap: wrap; + } + + .item-badge { + font-size: 0.65rem; + font-weight: 600; + padding: 2px 6px; + border-radius: 4px; + text-transform: uppercase; + } + } + + .item-info { + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; + } + + .item-name { + font-family: "DM Sans", sans-serif; + font-size: 0.9rem; + font-weight: 600; + color: #1e3c38; + margin: 0; + line-height: 1.3; + display: -webkit-box; + line-clamp: 2; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + .item-price { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + + .old-price { + font-size: 0.8rem; + color: #a1b4b5; + text-decoration: line-through; + } + + .current-price { + font-size: 1rem; + font-weight: 700; + color: #1e3c38; + } + } + + .item-cart-btn { + align-self: flex-end; + background: #497671; + color: white; + border: none; + border-radius: 8px; + padding: 6px 10px; + cursor: pointer; + transition: background 0.2s ease; + + &:hover { + background: #3a5f5b; + } + } + // Keyframes @keyframes spin { to { transform: rotate(360deg); } @@ -248,6 +391,11 @@ grid-template-columns: repeat(3, 1fr); gap: 24px; } + + .items-grid { + grid-template-columns: repeat(3, 1fr); + gap: 20px; + } } @media (max-width: 992px) { @@ -273,6 +421,11 @@ gap: 16px; } + .items-grid { + grid-template-columns: repeat(2, 1fr); + gap: 16px; + } + .category-info { padding: 10px 12px; } @@ -294,6 +447,11 @@ gap: 12px; } + .items-grid { + grid-template-columns: repeat(2, 1fr); + gap: 12px; + } + .category-info { padding: 8px 10px; } diff --git a/src/app/pages/category/subcategories.component.ts b/src/app/pages/category/subcategories.component.ts index 310ee91..d709501 100644 --- a/src/app/pages/category/subcategories.component.ts +++ b/src/app/pages/category/subcategories.component.ts @@ -1,15 +1,17 @@ import { Component, OnInit, OnDestroy, signal, ChangeDetectionStrategy, inject } from '@angular/core'; +import { DecimalPipe } from '@angular/common'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; -import { ApiService, LanguageService } from '../../services'; -import { Category } from '../../models'; +import { ApiService, CartService, LanguageService } from '../../services'; +import { Category, Item, Subcategory } from '../../models'; import { Subscription } from 'rxjs'; import { LangRoutePipe } from '../../pipes/lang-route.pipe'; import { TranslatePipe } from '../../i18n/translate.pipe'; import { TranslateService } from '../../i18n/translate.service'; +import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass, getTranslatedField } from '../../utils/item.utils'; @Component({ selector: 'app-subcategories', - imports: [RouterLink, LangRoutePipe, TranslatePipe], + imports: [DecimalPipe, RouterLink, LangRoutePipe, TranslatePipe], templateUrl: './subcategories.component.html', styleUrls: ['./subcategories.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush @@ -17,6 +19,10 @@ import { TranslateService } from '../../i18n/translate.service'; export class SubcategoriesComponent implements OnInit, OnDestroy { categories = signal([]); subcategories = signal([]); + /** Nested subcategories from API with hasItems support */ + nestedSubcategories = signal([]); + /** Items belonging directly to this category (when hasItems is true) */ + categoryItems = signal([]); loading = signal(true); error = signal(null); @@ -29,7 +35,8 @@ export class SubcategoriesComponent implements OnInit, OnDestroy { private route: ActivatedRoute, private router: Router, private apiService: ApiService, - private langService: LanguageService + private langService: LanguageService, + private cartService: CartService ) {} ngOnInit(): void { @@ -45,19 +52,40 @@ export class SubcategoriesComponent implements OnInit, OnDestroy { private loadForParent(parentID: number): void { this.loading.set(true); + this.categoryItems.set([]); + this.nestedSubcategories.set([]); + this.apiService.getCategories().subscribe({ next: (cats) => { this.categories.set(cats); - const subs = cats.filter(c => c.parentID === parentID); const parent = cats.find(c => c.categoryID === parentID); this.parentName.set(parent ? parent.name : this.i18n.t('home.categoriesTitle')); - if (!subs || subs.length === 0) { + // Check for nested subcategories from API response (backOffice format) + const nested = parent?.subcategories || []; + const visibleNested = nested.filter(s => s.visible !== false); + + // Also check flat legacy subcategories + const flatSubs = cats.filter(c => c.parentID === parentID); + + if (visibleNested.length > 0) { + // Use nested subcategories from API + this.nestedSubcategories.set(visibleNested); + this.subcategories.set([]); + + // If this category itself has items, load them too + this.loadCategoryItems(parentID); + } else if (flatSubs.length > 0) { + // Legacy flat subcategories + this.subcategories.set(flatSubs); + this.nestedSubcategories.set([]); + + // Also load items for this category in case it has direct items + this.loadCategoryItems(parentID); + } else { // No subcategories: redirect to items list for this category const lang = this.langService.currentLanguage(); this.router.navigate([`/${lang}/category`, parentID, 'items'], { replaceUrl: true }); - } else { - this.subcategories.set(subs); } this.loading.set(false); @@ -70,8 +98,41 @@ export class SubcategoriesComponent implements OnInit, OnDestroy { }); } + /** Load items that belong directly to this category */ + private loadCategoryItems(categoryID: number): void { + this.apiService.getCategoryItems(categoryID, 50, 0).subscribe({ + next: (items) => { + this.categoryItems.set(items); + }, + error: () => { + // Not critical — subcategories still work + } + }); + } + + hasSubcategories(): boolean { + return this.subcategories().length > 0 || this.nestedSubcategories().length > 0; + } + + addToCart(itemID: number, event: Event): void { + event.preventDefault(); + event.stopPropagation(); + this.cartService.addItem(itemID); + } + // TrackBy function for performance optimization trackByCategoryId(index: number, category: Category): number { return category.categoryID; } + + trackBySubId(index: number, sub: Subcategory): string { + return sub.id; + } + + readonly getDiscountedPrice = getDiscountedPrice; + readonly getMainImage = getMainImage; + readonly trackByItemId = trackByItemId; + readonly getBadgeClass = getBadgeClass; + + itemName(item: Item): string { return getTranslatedField(item, 'name', this.langService.currentLanguage()); } } diff --git a/src/app/pages/info/faq/en/faq-en.component.html b/src/app/pages/info/faq/en/faq-en.component.html index 609c895..f733131 100644 --- a/src/app/pages/info/faq/en/faq-en.component.html +++ b/src/app/pages/info/faq/en/faq-en.component.html @@ -1,240 +1,244 @@ -

Frequently Asked Questions (FAQ) 📌

- - - - - - - - - - - - - - - - - - - - +
diff --git a/src/app/pages/info/faq/hy/faq-hy.component.html b/src/app/pages/info/faq/hy/faq-hy.component.html index 84a9258..f2a7986 100644 --- a/src/app/pages/info/faq/hy/faq-hy.component.html +++ b/src/app/pages/info/faq/hy/faq-hy.component.html @@ -1,240 +1,244 @@ -

Հաճախ տրվող հարցեր (FAQ) 📌

- - - - - - - - - - - - - - - - - - - - +
diff --git a/src/app/pages/info/faq/ru/faq-ru.component.html b/src/app/pages/info/faq/ru/faq-ru.component.html index 9972a3b..d4317c3 100644 --- a/src/app/pages/info/faq/ru/faq-ru.component.html +++ b/src/app/pages/info/faq/ru/faq-ru.component.html @@ -1,240 +1,244 @@ -

Часто задаваемые вопросы (FAQ) 📌

- - - - - - - - - - - - - - - - - - - - +
diff --git a/src/app/pages/item-detail/item-detail.component.html b/src/app/pages/item-detail/item-detail.component.html index 1a94c01..513f82a 100644 --- a/src/app/pages/item-detail/item-detail.component.html +++ b/src/app/pages/item-detail/item-detail.component.html @@ -15,22 +15,21 @@
} - @if (item(); as item) { - @if (!loading()) { + @if (item() && !loading()) {