diff --git a/docs/API_CHANGES_REQUIRED.md b/docs/API_CHANGES_REQUIRED.md index 8f86647..fde7536 100644 --- a/docs/API_CHANGES_REQUIRED.md +++ b/docs/API_CHANGES_REQUIRED.md @@ -1,168 +1,266 @@ -# Backend API Changes Required +# API Changes Required for Backend -## Cart Quantity Support +## Overview -### 1. Add Quantity to Cart Items +Frontend has been updated with two new features: +1. **Region/Location system** — catalog filtering by region +2. **Auth/Login system** — Telegram-based authentication required before payment -**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) +Base URLs: +- Dexar: `https://api.dexarmarket.ru:445` +- Novo: `https://api.novo.market:444` --- -## Summary +## 1. Region / Location Endpoints -**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 +### 1.1 `GET /regions` — List available regions -**Future (After Discussion):** -- Sorting and filtering query parameters for category items endpoint +Returns the list of regions where the marketplace operates. + +**Response** `200 OK` +```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 (display) | +| `countryCode` | string | yes | ISO 3166-1 alpha-2 code | +| `timezone` | string | no | IANA timezone string | + +> If this endpoint is unavailable, the frontend falls back to 6 hardcoded regions (Moscow, SPB, Yerevan, Minsk, Almaty, Tbilisi). + +--- + +### 1.2 Region Query Parameter on Existing Endpoints + +The following **existing** endpoints now accept an optional `?region=` query parameter: + +| Endpoint | Example | +|---------------------------------|----------------------------------------------| +| `GET /category` | `GET /category?region=moscow` | +| `GET /category/:id` | `GET /category/5?count=50&skip=0®ion=spb` | +| `GET /item/:id` | `GET /item/123?region=yerevan` | +| `GET /searchitems` | `GET /searchitems?search=phone®ion=moscow` | +| `GET /randomitems` | `GET /randomitems?count=5®ion=almaty` | + +**Behavior:** +- If `region` param is **present** → return only items/categories available in that region +- If `region` param is **absent** → return all items globally (current behavior, no change) +- The `region` value matches the `id` field from the `/regions` response + +--- + +## 2. Auth / Login Endpoints + +Authentication is **Telegram-based** with **cookie sessions** (HttpOnly, Secure, SameSite=None). + +All auth endpoints must support CORS with `credentials: true`. + +### 2.1 `GET /auth/session` — Check current session + +Called on every page load to check if the user has an active session. + +**Request:** +- Cookies: session cookie (set by backend) +- CORS: `withCredentials: true` + +**Response `200 OK`** (authenticated): +```json +{ + "sessionId": "sess_abc123", + "telegramUserId": 123456789, + "username": "john_doe", + "displayName": "John Doe", + "active": true, + "expiresAt": "2026-03-01T12:00:00Z" +} +``` + +**Response `200 OK`** (expired session): +```json +{ + "sessionId": "sess_abc123", + "telegramUserId": 123456789, + "username": "john_doe", + "displayName": "John Doe", + "active": false, + "expiresAt": "2026-02-27T12:00:00Z" +} +``` + +**Response `401 Unauthorized`** (no session / invalid cookie): +```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_name + last_name) | +| `active` | boolean | yes | Whether session is currently valid | +| `expiresAt` | string | yes | ISO 8601 expiration datetime | + +--- + +### 2.2 `GET /auth/telegram/callback` — Telegram bot auth callback + +This is the URL the Telegram bot redirects to after the user starts the bot. + +**Flow:** +1. Frontend generates link: `https://t.me/{botUsername}?start=auth_{encodedCallbackUrl}` +2. User clicks → opens Telegram → starts the bot +3. Bot sends user data to this callback endpoint +4. Backend creates session, sets `Set-Cookie` header +5. Frontend polls `GET /auth/session` every 3 seconds to detect when session becomes active + +**Request** (from Telegram bot / webhook): +```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:** Should 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 | +| `Domain` | API domain | | + +> **Important:** Since the API domain differs from the frontend domain, `SameSite=None` + `Secure=true` is required for the cookie to be sent cross-origin. + +--- + +### 2.3 `POST /auth/logout` — End session + +**Request:** +- Cookies: session cookie +- CORS: `withCredentials: true` +- Body: `{}` (empty) + +**Response `200 OK`:** +```json +{ + "message": "Logged out" +} +``` + +Should clear/invalidate the session cookie. + +--- + +## 3. CORS Configuration + +For auth cookies to work cross-origin, the backend CORS config must include: + +``` +Access-Control-Allow-Origin: https://dexarmarket.ru (NOT *) +Access-Control-Allow-Credentials: true +Access-Control-Allow-Headers: Content-Type, Authorization +Access-Control-Allow-Methods: GET, POST, PATCH, DELETE, OPTIONS +``` + +> `Access-Control-Allow-Origin` **cannot** be `*` when `Allow-Credentials: true`. Must be the exact frontend origin. + +For Novo, also allow `https://novo.market`. + +--- + +## 4. Session Refresh Behavior + +The frontend automatically re-checks the session **60 seconds before `expiresAt`**. If the backend supports session extension (sliding expiration), it can re-set the cookie with a fresh `Max-Age` on every `GET /auth/session` call. + +--- + +## 5. Auth Gate — Checkout Flow + +The checkout button (`POST /cart` payment) now requires authentication: +- If the user is **not logged in** → frontend shows a Telegram login dialog instead of proceeding +- If the user **is logged in** → checkout proceeds normally +- The session cookie is sent automatically with the payment request + +No backend changes needed for the payment endpoint itself — just ensure it reads the session cookie if needed for order association. + +--- + +## Summary of New Endpoints + +| Method | Path | Purpose | Auth Required | +|--------|----------------------------|-----------------------------|---------------| +| `GET` | `/regions` | List available regions | No | +| `GET` | `/auth/session` | Check current session | Cookie | +| `GET` | `/auth/telegram/callback` | Telegram bot auth callback | No (from bot) | +| `POST` | `/auth/logout` | End session | Cookie | + +## Summary of Modified Endpoints + +| Method | Path | Change | +|--------|-------------------|---------------------------------------| +| `GET` | `/category` | Added optional `?region=` param | +| `GET` | `/category/:id` | Added optional `?region=` param | +| `GET` | `/item/:id` | Added optional `?region=` param | +| `GET` | `/searchitems` | Added optional `?region=` param | +| `GET` | `/randomitems` | Added optional `?region=` param | + +--- + +## 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` 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/src/app/app.html b/src/app/app.html index 775fccf..b1b5873 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 fd68733..7a14b25 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/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/i18n/en.ts b/src/app/i18n/en.ts index d11f8dd..cc90315 100644 --- a/src/app/i18n/en.ts +++ b/src/app/i18n/en.ts @@ -186,4 +186,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 26a6bbd..a1ff5bc 100644 --- a/src/app/i18n/hy.ts +++ b/src/app/i18n/hy.ts @@ -186,4 +186,18 @@ export const hy: Translations = { retry: '╒У╒╕╓А╒▒╒е╒м ╒п╓А╒п╒л╒╢', 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 c70dc6d..c8679da 100644 --- a/src/app/i18n/ru.ts +++ b/src/app/i18n/ru.ts @@ -186,4 +186,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 a3e0491..7ced44d 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -184,4 +184,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/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/index.ts b/src/app/models/index.ts index d034c53..2c0918a 100644 --- a/src/app/models/index.ts +++ b/src/app/models/index.ts @@ -1,3 +1,4 @@ export * from './category.model'; export * from './item.model'; - +export * from './location.model'; +export * from './auth.model'; 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.ts b/src/app/pages/cart/cart.component.ts index 58b4c90..e0e6065 100644 --- a/src/app/pages/cart/cart.component.ts +++ b/src/app/pages/cart/cart.component.ts @@ -2,7 +2,7 @@ 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'; @@ -28,6 +28,7 @@ export class CartComponent implements OnDestroy { isnovo = environment.theme === 'novo'; private i18n = inject(TranslateService); + private authService = inject(AuthService); // Swipe state swipedItemId = signal(null); @@ -135,6 +136,11 @@ export class CartComponent implements OnDestroy { alert(this.i18n.t('cart.acceptTerms')); return; } + // Auth gate: require Telegram login before payment + if (!this.authService.isAuthenticated()) { + this.authService.requestLogin(); + return; + } this.openPaymentPopup(); } diff --git a/src/app/services/auth.service.ts b/src/app/services/auth.service.ts new file mode 100644 index 0000000..2fbd5cd --- /dev/null +++ b/src/app/services/auth.service.ts @@ -0,0 +1,128 @@ +import { Injectable, signal, computed } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable, of, catchError, map, tap } from 'rxjs'; +import { AuthSession, AuthStatus } from '../models/auth.model'; +import { environment } from '../../environments/environment'; + +@Injectable({ + providedIn: 'root' +}) +export class AuthService { + private sessionSignal = signal(null); + private statusSignal = signal('unknown'); + private showLoginSignal = signal(false); + + /** Current auth session */ + readonly session = this.sessionSignal.asReadonly(); + /** Current auth status */ + readonly status = this.statusSignal.asReadonly(); + /** Whether user is fully authenticated */ + readonly isAuthenticated = computed(() => this.statusSignal() === 'authenticated'); + /** Whether to show login dialog */ + readonly showLoginDialog = this.showLoginSignal.asReadonly(); + /** Display name of authenticated user */ + readonly displayName = computed(() => this.sessionSignal()?.displayName ?? null); + + private readonly apiUrl = environment.apiUrl; + private sessionCheckTimer?: ReturnType; + + constructor(private http: HttpClient) { + // On init, check existing session via cookie + this.checkSession(); + } + + /** + * Check current session status with backend. + * The backend reads the session cookie and returns the session info. + */ + checkSession(): void { + this.statusSignal.set('checking'); + + this.http.get(`${this.apiUrl}/auth/session`, { + withCredentials: true + }).pipe( + catchError(() => { + this.statusSignal.set('unauthenticated'); + this.sessionSignal.set(null); + return of(null); + }) + ).subscribe(session => { + if (session && session.active) { + this.sessionSignal.set(session); + this.statusSignal.set('authenticated'); + this.scheduleSessionRefresh(session.expiresAt); + } else if (session && !session.active) { + this.sessionSignal.set(null); + this.statusSignal.set('expired'); + } else { + this.statusSignal.set('unauthenticated'); + } + }); + } + + /** + * Called after user completes Telegram login. + * The callback URL from Telegram will hit our backend which sets the cookie. + * Then we re-check the session. + */ + onTelegramLoginComplete(): void { + this.checkSession(); + this.hideLogin(); + } + + /** Generate the Telegram login URL for bot-based auth */ + getTelegramLoginUrl(): string { + const botUsername = (environment as Record)['telegramBot'] as string || 'dexarmarket_bot'; + const callbackUrl = encodeURIComponent(`${this.apiUrl}/auth/telegram/callback`); + return `https://t.me/${botUsername}?start=auth_${callbackUrl}`; + } + + /** Get QR code data URL for Telegram login */ + getTelegramQrUrl(): string { + return this.getTelegramLoginUrl(); + } + + /** Show login dialog (called when user tries to pay without being logged in) */ + requestLogin(): void { + this.showLoginSignal.set(true); + } + + /** Hide login dialog */ + hideLogin(): void { + this.showLoginSignal.set(false); + } + + /** Logout — clears session on backend and locally */ + logout(): void { + this.http.post(`${this.apiUrl}/auth/logout`, {}, { + withCredentials: true + }).pipe( + catchError(() => of(null)) + ).subscribe(() => { + this.sessionSignal.set(null); + this.statusSignal.set('unauthenticated'); + this.clearSessionRefresh(); + }); + } + + /** Schedule a session re-check before it expires */ + private scheduleSessionRefresh(expiresAt: string): void { + this.clearSessionRefresh(); + + const expiresMs = new Date(expiresAt).getTime(); + const nowMs = Date.now(); + // Re-check 60 seconds before expiry, minimum 30s from now + const refreshIn = Math.max(expiresMs - nowMs - 60_000, 30_000); + + this.sessionCheckTimer = setTimeout(() => { + this.checkSession(); + }, refreshIn); + } + + private clearSessionRefresh(): void { + if (this.sessionCheckTimer) { + clearTimeout(this.sessionCheckTimer); + this.sessionCheckTimer = undefined; + } + } +} diff --git a/src/app/services/index.ts b/src/app/services/index.ts index 4f32ba6..cae9e7a 100644 --- a/src/app/services/index.ts +++ b/src/app/services/index.ts @@ -3,3 +3,5 @@ export * from './cart.service'; export * from './telegram.service'; export * from './language.service'; export * from './seo.service'; +export * from './location.service'; +export * from './auth.service'; diff --git a/src/app/services/location.service.ts b/src/app/services/location.service.ts new file mode 100644 index 0000000..59a8780 --- /dev/null +++ b/src/app/services/location.service.ts @@ -0,0 +1,135 @@ +import { Injectable, signal, computed } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Region, GeoIpResponse } from '../models/location.model'; +import { environment } from '../../environments/environment'; + +const STORAGE_KEY = 'selected_region'; + +@Injectable({ + providedIn: 'root' +}) +export class LocationService { + private regionSignal = signal(null); + private regionsSignal = signal([]); + private loadingSignal = signal(false); + private detectedSignal = signal(false); + + /** Current selected region (null = global / all regions) */ + readonly region = this.regionSignal.asReadonly(); + /** All available regions */ + readonly regions = this.regionsSignal.asReadonly(); + /** Whether geo-detection is in progress */ + readonly detecting = this.loadingSignal.asReadonly(); + /** Whether region was auto-detected */ + readonly autoDetected = this.detectedSignal.asReadonly(); + + /** Computed region id for API calls — empty string means global */ + readonly regionId = computed(() => this.regionSignal()?.id ?? ''); + + private readonly apiUrl = environment.apiUrl; + + constructor(private http: HttpClient) { + this.loadRegions(); + this.restoreFromStorage(); + } + + /** Fetch available regions from backend */ + loadRegions(): void { + this.http.get(`${this.apiUrl}/regions`).subscribe({ + next: (regions) => { + this.regionsSignal.set(regions); + // If we have a stored region, validate it still exists + const stored = this.regionSignal(); + if (stored && !regions.find(r => r.id === stored.id)) { + this.clearRegion(); + } + }, + error: () => { + // Fallback: hardcoded popular regions + this.regionsSignal.set(this.getFallbackRegions()); + } + }); + } + + /** Set region by user choice */ + setRegion(region: Region): void { + this.regionSignal.set(region); + localStorage.setItem(STORAGE_KEY, JSON.stringify(region)); + } + + /** Clear region (go global) */ + clearRegion(): void { + this.regionSignal.set(null); + localStorage.removeItem(STORAGE_KEY); + } + + /** Auto-detect user location via IP geolocation */ + detectLocation(): void { + if (this.detectedSignal()) return; // already tried + this.loadingSignal.set(true); + + // Using free ip-api.com — no key required, 45 req/min + this.http.get('http://ip-api.com/json/?fields=city,country,countryCode,region,timezone,lat,lon') + .subscribe({ + next: (geo) => { + this.detectedSignal.set(true); + this.loadingSignal.set(false); + + // Only auto-set if user hasn't manually chosen a region + if (!this.regionSignal()) { + const matchedRegion = this.findRegionByGeo(geo); + if (matchedRegion) { + this.setRegion(matchedRegion); + } + } + }, + error: () => { + this.detectedSignal.set(true); + this.loadingSignal.set(false); + } + }); + } + + /** Try to match detected geo data to an available region */ + private findRegionByGeo(geo: GeoIpResponse): Region | null { + const regions = this.regionsSignal(); + if (!regions.length) return null; + + // Exact city match + const cityMatch = regions.find(r => + r.city.toLowerCase() === geo.city?.toLowerCase() + ); + if (cityMatch) return cityMatch; + + // Country match — pick the first region for that country + const countryMatch = regions.find(r => + r.countryCode.toLowerCase() === geo.countryCode?.toLowerCase() + ); + return countryMatch || null; + } + + /** Restore previously selected region from storage */ + private restoreFromStorage(): void { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + const region: Region = JSON.parse(stored); + this.regionSignal.set(region); + } + } catch { + localStorage.removeItem(STORAGE_KEY); + } + } + + /** Fallback regions if backend /regions endpoint is unavailable */ + private getFallbackRegions(): Region[] { + return [ + { 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' }, + { id: 'minsk', city: 'Минск', country: 'Беларусь', countryCode: 'BY', timezone: 'Europe/Minsk' }, + { id: 'almaty', city: 'Алматы', country: 'Казахстан', countryCode: 'KZ', timezone: 'Asia/Almaty' }, + { id: 'tbilisi', city: 'Тбилиси', country: 'Грузия', countryCode: 'GE', timezone: 'Asia/Tbilisi' }, + ]; + } +} diff --git a/src/environments/environment.novo.production.ts b/src/environments/environment.novo.production.ts index 2be3ca7..6e9b307 100644 --- a/src/environments/environment.novo.production.ts +++ b/src/environments/environment.novo.production.ts @@ -10,6 +10,7 @@ export const environment = { supportEmail: 'info@novo.market', domain: 'novo.market', telegram: '@novomarket', + telegramBot: 'novomarket_bot', phones: { armenia: '+374 98 731231', support: '+374 98 731231' diff --git a/src/environments/environment.novo.ts b/src/environments/environment.novo.ts index 8a8030d..45e4ad8 100644 --- a/src/environments/environment.novo.ts +++ b/src/environments/environment.novo.ts @@ -10,6 +10,7 @@ export const environment = { supportEmail: 'info@novo.market', domain: 'novo.market', telegram: '@novomarket', + telegramBot: 'novomarket_bot', phones: { armenia: '+374 98 731231', support: '+374 98 731231' diff --git a/src/environments/environment.production.ts b/src/environments/environment.production.ts index 63732eb..959264d 100644 --- a/src/environments/environment.production.ts +++ b/src/environments/environment.production.ts @@ -10,6 +10,7 @@ export const environment = { supportEmail: 'info@dexarmarket.ru', domain: 'dexarmarket.ru', telegram: '@dexarmarket', + telegramBot: 'dexarmarket_bot', phones: { russia: '+7 (926) 459-31-57', armenia: '+374 94 86 18 16' diff --git a/src/environments/environment.ts b/src/environments/environment.ts index 6a6d9b0..f256912 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -11,6 +11,7 @@ export const environment = { supportEmail: 'info@dexarmarket.ru', domain: 'dexarmarket.ru', telegram: '@dexarmarket', + telegramBot: 'dexarmarket_bot', phones: { russia: '+7 (926) 459-31-57', armenia: '+374 94 86 18 16'