Compare commits
45 Commits
back-offic
...
6e5fb3b86a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e5fb3b86a | ||
|
|
a15f2bca6a | ||
|
|
1897cbe7a6 | ||
|
|
ab1732d74b | ||
|
|
7df15a4243 | ||
|
|
abb74390e8 | ||
|
|
06a7568386 | ||
|
|
77737f0ba9 | ||
|
|
6de461473e | ||
|
|
db781fd871 | ||
|
|
ce301e9c70 | ||
|
|
64288b5ce1 | ||
|
|
a8bb725f78 | ||
|
|
df2208ab53 | ||
|
|
72deb8d5e3 | ||
|
|
5566e011b7 | ||
|
|
ee23fd2d3c | ||
|
|
2a41062769 | ||
|
|
6624de7a32 | ||
|
|
44553f5bd4 | ||
|
|
5ed255dddb | ||
|
|
650bf137f2 | ||
|
|
3a8bc2f893 | ||
|
|
d29de100c6 | ||
|
|
97214c3a90 | ||
|
|
0b3b2ee463 | ||
|
|
c3e4e695eb | ||
|
|
c112aded47 | ||
|
|
75f029b872 | ||
|
|
f823df7e15 | ||
|
|
af78c053ba | ||
|
|
4ef4223367 | ||
|
|
7b18376d28 | ||
|
|
c64b9cfee8 | ||
|
|
712281d2e8 | ||
|
|
0626dcbe46 | ||
|
|
d288a5fb3c | ||
|
|
75b45abe4f | ||
|
|
2bd98b29eb | ||
|
|
82cbf07120 | ||
|
|
e07356a700 | ||
|
|
5068a3a114 | ||
|
|
333ea45c38 | ||
|
|
b22390f3eb | ||
|
|
3f285ca15f |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -38,7 +38,7 @@ yarn-error.log
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
|
||||
/public/images/
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
26
angular.json
26
angular.json
@@ -154,7 +154,7 @@
|
||||
},
|
||||
"serve": {
|
||||
"options": {
|
||||
"allowedHosts": ["novo.market", "dexarmarket.ru", "localhost"],
|
||||
"allowedHosts": ["novo.market", "dexarmarket.ru", "dexar.market","localhost"],
|
||||
"proxyConfig": "proxy.conf.json"
|
||||
},
|
||||
"builder": "@angular/build:dev-server",
|
||||
@@ -166,7 +166,8 @@
|
||||
"buildTarget": "Dexarmarket:build:development"
|
||||
},
|
||||
"novo": {
|
||||
"buildTarget": "Dexarmarket:build:novo"
|
||||
"buildTarget": "Dexarmarket:build:novo",
|
||||
"proxyConfig": "proxy.conf.novo.json"
|
||||
},
|
||||
"novo-production": {
|
||||
"buildTarget": "Dexarmarket:build:novo-production"
|
||||
@@ -176,28 +177,9 @@
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular/build:extract-i18n"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular/build:karma",
|
||||
"options": {
|
||||
"polyfills": [
|
||||
"zone.js",
|
||||
"zone.js/testing"
|
||||
],
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"src/styles.scss"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
824
docs/BACKEND_AUTH_INTEGRATION.md
Normal file
824
docs/BACKEND_AUTH_INTEGRATION.md
Normal file
@@ -0,0 +1,824 @@
|
||||
# Авторизация через Telegram — Backend & Bot
|
||||
|
||||
> Всё что нужно Go-разработчику для реализации авторизации.
|
||||
> Фронтенд **полностью готов** и ждёт эти эндпоинты.
|
||||
|
||||
---
|
||||
|
||||
## Статус
|
||||
|
||||
| Компонент | Готов? |
|
||||
|-----------|--------|
|
||||
| Frontend (Angular) — диалог, QR, поллинг, корзина | ✅ Готов |
|
||||
| Telegram бот (обработка `/start`) | ❌ Нужно |
|
||||
| Backend — 6 HTTP-эндпоинтов | ❌ Нужно |
|
||||
| Хранилище сессий + QR-токенов | ❌ Нужно |
|
||||
| CORS для cookie-based запросов | ❌ Нужно |
|
||||
|
||||
---
|
||||
|
||||
## Архитектура
|
||||
|
||||
Два сценария авторизации:
|
||||
|
||||
### Сценарий 1: Прямой вход (кнопка "Войти через Telegram")
|
||||
|
||||
Пользователь нажимает кнопку → открывается Telegram → бот выдаёт кнопку "Войти на сайт" → callback ставит cookie → фронтенд поллит `/auth/session`.
|
||||
|
||||
### Сценарий 2: QR-логин с десктопа (основной)
|
||||
|
||||
```
|
||||
ДЕСКТОП БРАУЗЕР СЕРВЕР (Go) TELEGRAM
|
||||
│ │ │
|
||||
│ 1. POST /auth/qr/create │ │
|
||||
│ ─────────────────────────────> │ │
|
||||
│ { token: "abc", url: "..." } │ │
|
||||
│ <───────────────────────────── │ │
|
||||
│ │ │
|
||||
│ 2. Показать QR: │ │
|
||||
│ t.me/Bot?start=login_abc │ │
|
||||
│ │ │
|
||||
│ ПОЛЬЗОВАТЕЛЬ СКАНИРУЕТ ТЕЛЕФОНОМ │
|
||||
│ │ │
|
||||
│ │ 3. /start login_abc │
|
||||
│ │ <────────────────────────│
|
||||
│ │ │
|
||||
│ │ Бот → POST /auth/qr/confirm
|
||||
│ │ Бот → "✅ Вы вошли!" │
|
||||
│ │ ────────────────────────>│
|
||||
│ │ │
|
||||
│ 4. GET /auth/qr/poll?token=abc │ │
|
||||
│ (каждые 3 сек) │ │
|
||||
│ ─────────────────────────────> │ │
|
||||
│ { status: "confirmed", │ │
|
||||
│ session: {...} } │ │
|
||||
│ + Set-Cookie: dx_session=... │ │
|
||||
│ <───────────────────────────── │ │
|
||||
│ │ │
|
||||
│ 5. POST /websession/{sessionId} │ │
|
||||
│ [{ itemID, quantity, ... }] │ ← корзина │
|
||||
│ ─────────────────────────────> │ │
|
||||
│ │ │
|
||||
│ 6. Готово! Авторизован + корзина│ │
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Бренды и боты
|
||||
|
||||
| Бренд | Username бота | Домен фронтенда | API сервер | Cookie Domain |
|
||||
|-------|---------------|------------------|------------|---------------|
|
||||
| Dexar | `DexarSupport_bot` | `dexarmarket.ru` | `api.dexarmarket.ru:445` | `.dexarmarket.ru` |
|
||||
| Novo | `novomarket_bot` | `novo.market` | `api.novo.market:444` | `.novo.market` |
|
||||
|
||||
Бот создаётся через https://t.me/BotFather → `/newbot`. Сохранить `BOT_TOKEN`.
|
||||
|
||||
---
|
||||
|
||||
## Хранилище
|
||||
|
||||
### Структура: Сессия
|
||||
|
||||
```go
|
||||
type Session struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
TelegramUserID int64 `json:"telegramUserId"`
|
||||
Username *string `json:"username"` // может быть null
|
||||
DisplayName string `json:"displayName"`
|
||||
Active bool `json:"active"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
}
|
||||
```
|
||||
|
||||
**TTL:** 24 часа.
|
||||
|
||||
### Структура: QR-токен (одноразовый)
|
||||
|
||||
```go
|
||||
type AuthToken struct {
|
||||
Token string `json:"token"`
|
||||
Status string `json:"status"` // "pending" | "confirmed" | "expired"
|
||||
SessionID string `json:"sessionId"` // заполняется после подтверждения ботом
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
}
|
||||
```
|
||||
|
||||
**TTL:** 5 минут.
|
||||
|
||||
### Варианты хранения
|
||||
|
||||
**Redis (рекомендуется):**
|
||||
```go
|
||||
// Сессия
|
||||
redisClient.Set(ctx, "session:"+s.SessionID, json, 24*time.Hour)
|
||||
|
||||
// QR-токен
|
||||
redisClient.Set(ctx, "auth_token:"+t.Token, json, 5*time.Minute)
|
||||
```
|
||||
|
||||
**sync.Map (для MVP):**
|
||||
```go
|
||||
var sessions sync.Map
|
||||
var authTokens sync.Map
|
||||
|
||||
// Очистка устаревших токенов — запустить горутину при старте
|
||||
func cleanupExpiredTokens() {
|
||||
ticker := time.NewTicker(1 * time.Minute)
|
||||
for range ticker.C {
|
||||
authTokens.Range(func(key, value any) bool {
|
||||
t := value.(AuthToken)
|
||||
if time.Now().After(t.ExpiresAt) {
|
||||
authTokens.Delete(key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## HTTP-эндпоинты
|
||||
|
||||
### 1. `POST /auth/qr/create`
|
||||
|
||||
Фронтенд вызывает при открытии диалога логина. Создаёт одноразовый QR-токен.
|
||||
|
||||
```go
|
||||
func handleQrCreate(w http.ResponseWriter, r *http.Request) {
|
||||
// 1. Сгенерировать криптографически безопасный токен
|
||||
tokenBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
http.Error(w, "internal error", 500)
|
||||
return
|
||||
}
|
||||
token := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(tokenBytes)
|
||||
|
||||
// 2. Определить бота по origin
|
||||
botUsername := getBotForOrigin(r.Header.Get("Origin"))
|
||||
|
||||
// 3. Сохранить токен
|
||||
authToken := AuthToken{
|
||||
Token: token,
|
||||
Status: "pending",
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(5 * time.Minute),
|
||||
}
|
||||
saveAuthToken(authToken)
|
||||
|
||||
// 4. Ответить
|
||||
qrURL := fmt.Sprintf("https://t.me/%s?start=login_%s", botUsername, token)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"token": token,
|
||||
"url": qrURL,
|
||||
})
|
||||
}
|
||||
|
||||
func getBotForOrigin(origin string) string {
|
||||
if strings.Contains(origin, "novo.market") {
|
||||
return "novomarket_bot"
|
||||
}
|
||||
return "DexarSupport_bot"
|
||||
}
|
||||
```
|
||||
|
||||
**Ответ:**
|
||||
```json
|
||||
{ "token": "dG9rZW4tYWJj....", "url": "https://t.me/DexarSupport_bot?start=login_dG9rZW4tYWJj...." }
|
||||
```
|
||||
|
||||
> Telegram ограничивает `start` до 64 символов. `login_` (6) + base64url из 32 байт (43) = 49 ✅
|
||||
|
||||
---
|
||||
|
||||
### 2. `GET /auth/qr/poll?token={token}`
|
||||
|
||||
Фронтенд вызывает каждые 3 секунды. Когда бот подтвердил — возвращает сессию и ставит cookie.
|
||||
|
||||
```go
|
||||
func handleQrPoll(w http.ResponseWriter, r *http.Request) {
|
||||
tokenStr := r.URL.Query().Get("token")
|
||||
if tokenStr == "" {
|
||||
http.Error(w, "missing token", 400)
|
||||
return
|
||||
}
|
||||
|
||||
authToken, ok := getAuthToken(tokenStr)
|
||||
if !ok {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "expired"})
|
||||
return
|
||||
}
|
||||
|
||||
switch authToken.Status {
|
||||
case "pending":
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "pending"})
|
||||
|
||||
case "confirmed":
|
||||
session, err := getSession(authToken.SessionID)
|
||||
if err != nil {
|
||||
http.Error(w, "session not found", 500)
|
||||
return
|
||||
}
|
||||
|
||||
// Cookie в ДЕСКТОПНЫЙ браузер
|
||||
domain := getDomainForOrigin(r.Header.Get("Origin"))
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "dx_session",
|
||||
Value: session.SessionID,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteNoneMode,
|
||||
MaxAge: 86400,
|
||||
Domain: domain,
|
||||
})
|
||||
|
||||
// Удалить использованный токен
|
||||
deleteAuthToken(tokenStr)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "confirmed",
|
||||
"session": session,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func getDomainForOrigin(origin string) string {
|
||||
if strings.Contains(origin, "novo.market") {
|
||||
return ".novo.market"
|
||||
}
|
||||
return ".dexarmarket.ru"
|
||||
}
|
||||
```
|
||||
|
||||
**Ответы:**
|
||||
|
||||
| Статус | JSON |
|
||||
|--------|------|
|
||||
| Ждём | `{ "status": "pending" }` |
|
||||
| Подтверждено | `{ "status": "confirmed", "session": { sessionId, telegramUserId, username, displayName, active, expiresAt } }` + `Set-Cookie` |
|
||||
| Истекло | `{ "status": "expired" }` |
|
||||
|
||||
---
|
||||
|
||||
### 3. `POST /auth/qr/confirm` (внутренний, для бота)
|
||||
|
||||
Бот вызывает когда пользователь отсканировал QR. Привязывает сессию к токену.
|
||||
|
||||
```go
|
||||
func handleQrConfirm(w http.ResponseWriter, r *http.Request) {
|
||||
// Проверить секрет бота
|
||||
if r.Header.Get("X-Bot-Secret") != os.Getenv("BOT_INTERNAL_SECRET") {
|
||||
http.Error(w, "forbidden", 403)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Token string `json:"token"`
|
||||
User struct {
|
||||
ID int64 `json:"id"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Username string `json:"username"`
|
||||
} `json:"telegram_user"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "bad request", 400)
|
||||
return
|
||||
}
|
||||
|
||||
authToken, ok := getAuthToken(req.Token)
|
||||
if !ok || authToken.Status != "pending" {
|
||||
http.Error(w, "token not found or already used", 404)
|
||||
return
|
||||
}
|
||||
|
||||
// Создать сессию
|
||||
displayName := req.User.FirstName
|
||||
if req.User.LastName != "" {
|
||||
displayName += " " + req.User.LastName
|
||||
}
|
||||
var username *string
|
||||
if req.User.Username != "" {
|
||||
username = &req.User.Username
|
||||
}
|
||||
|
||||
session := Session{
|
||||
SessionID: uuid.New().String(),
|
||||
TelegramUserID: req.User.ID,
|
||||
Username: username,
|
||||
DisplayName: displayName,
|
||||
Active: true,
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
saveSession(session)
|
||||
|
||||
// Привязать сессию к токену
|
||||
authToken.Status = "confirmed"
|
||||
authToken.SessionID = session.SessionID
|
||||
saveAuthToken(*authToken)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
```
|
||||
|
||||
**Запрос от бота:**
|
||||
```json
|
||||
{
|
||||
"token": "dG9rZW4tYWJj...",
|
||||
"telegram_user": {
|
||||
"id": 123456789,
|
||||
"first_name": "Иван",
|
||||
"last_name": "Петров",
|
||||
"username": "ivan_petrov"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. `GET /auth/session`
|
||||
|
||||
Фронтенд вызывает для проверки текущей сессии. Читает cookie.
|
||||
|
||||
```go
|
||||
func handleGetSession(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie("dx_session")
|
||||
if err != nil {
|
||||
http.Error(w, "unauthorized", 401)
|
||||
return
|
||||
}
|
||||
|
||||
session, err := getSession(cookie.Value)
|
||||
if err != nil {
|
||||
http.Error(w, "unauthorized", 401)
|
||||
return
|
||||
}
|
||||
|
||||
if time.Now().After(session.ExpiresAt) {
|
||||
session.Active = false
|
||||
saveSession(session)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(session)
|
||||
}
|
||||
```
|
||||
|
||||
**Формат ответа (200)** — фронтенд ожидает **точно эти поля**:
|
||||
|
||||
```json
|
||||
{
|
||||
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"telegramUserId": 123456789,
|
||||
"username": "ivan_petrov",
|
||||
"displayName": "Иван Петров",
|
||||
"active": true,
|
||||
"expiresAt": "2026-03-25T14:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
| Поле | Тип | Обязательно | Примечание |
|
||||
|------|-----|-------------|------------|
|
||||
| `sessionId` | string (UUID) | да | Используется для `/websession/{sessionId}` |
|
||||
| `telegramUserId` | number | да | Telegram user ID |
|
||||
| `username` | string / null | нет | Telegram @username |
|
||||
| `displayName` | string | да | "Имя Фамилия" — показывается в UI |
|
||||
| `active` | boolean | да | `false` = истекла |
|
||||
| `expiresAt` | string (ISO 8601) | да | Фронтенд перепроверяет за 60 сек до |
|
||||
|
||||
**Ошибка:** любой HTTP не-200 → фронтенд считает "не авторизован".
|
||||
|
||||
---
|
||||
|
||||
### 5. `GET /auth/telegram/callback`
|
||||
|
||||
Для прямого входа (по кнопке в Telegram, не через QR). Открывается в браузере.
|
||||
|
||||
```go
|
||||
func handleTelegramCallback(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.URL.Query().Get("token")
|
||||
if token == "" {
|
||||
http.Error(w, "missing token", 400)
|
||||
return
|
||||
}
|
||||
|
||||
session, err := getSession(token)
|
||||
if err != nil || !session.Active {
|
||||
http.Error(w, "invalid or expired token", 401)
|
||||
return
|
||||
}
|
||||
|
||||
domain := getDomainForOrigin(r.Header.Get("Origin"))
|
||||
if domain == "" {
|
||||
domain = ".dexarmarket.ru" // fallback для прямого перехода
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "dx_session",
|
||||
Value: session.SessionID,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteNoneMode,
|
||||
MaxAge: 86400,
|
||||
Domain: domain,
|
||||
})
|
||||
|
||||
// Редирект на сайт
|
||||
http.Redirect(w, r, "https://dexarmarket.ru", http.StatusFound)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. `POST /auth/logout`
|
||||
|
||||
```go
|
||||
func handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie("dx_session")
|
||||
if err == nil {
|
||||
deleteSession(cookie.Value)
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "dx_session",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteNoneMode,
|
||||
MaxAge: -1,
|
||||
Domain: ".dexarmarket.ru",
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"message":"ok"}`))
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cookie-параметры
|
||||
|
||||
| Параметр | Значение | Почему |
|
||||
|----------|----------|--------|
|
||||
| `Name` | `dx_session` | |
|
||||
| `SameSite` | `None` | Фронтенд на `dexarmarket.ru`, API на `api.dexarmarket.ru:445` — разные origins |
|
||||
| `Secure` | `true` | Обязательно при `SameSite=None` |
|
||||
| `Domain` | `.dexarmarket.ru` | Доступна и на `dexarmarket.ru` и на `api.dexarmarket.ru` |
|
||||
| `HttpOnly` | `true` | Недоступна из JS — защита от XSS |
|
||||
| `MaxAge` | `86400` | 24 часа |
|
||||
|
||||
---
|
||||
|
||||
## CORS
|
||||
|
||||
Фронтенд шлёт `withCredentials: true`. Бэкенд обязан вернуть правильные заголовки.
|
||||
|
||||
```go
|
||||
func corsMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
|
||||
allowed := map[string]bool{
|
||||
"https://dexarmarket.ru": true,
|
||||
"https://www.dexarmarket.ru": true,
|
||||
"https://novo.market": true,
|
||||
"https://www.novo.market": true,
|
||||
"http://localhost:4200": true,
|
||||
}
|
||||
|
||||
if allowed[origin] {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin) // НЕ "*"
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
}
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(200)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
> **Критично:** `Access-Control-Allow-Origin` не может быть `"*"` при `withCredentials`. Вернуть конкретный origin.
|
||||
|
||||
---
|
||||
|
||||
## Роутинг
|
||||
|
||||
```go
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Существующие
|
||||
mux.HandleFunc("GET /items/{id}", handleGetItem)
|
||||
mux.HandleFunc("GET /category", handleGetCategories)
|
||||
mux.HandleFunc("POST /websession/{id}", handleWebSession)
|
||||
mux.HandleFunc("POST /websession/{id}/qr", handleCreateQR)
|
||||
mux.HandleFunc("GET /websession/{id}/{qrId}", handleCheckPayment)
|
||||
|
||||
// Auth — прямой вход
|
||||
mux.HandleFunc("GET /auth/session", handleGetSession)
|
||||
mux.HandleFunc("GET /auth/telegram/callback", handleTelegramCallback)
|
||||
mux.HandleFunc("POST /auth/logout", handleLogout)
|
||||
|
||||
// Auth — QR-логин
|
||||
mux.HandleFunc("POST /auth/qr/create", handleQrCreate)
|
||||
mux.HandleFunc("GET /auth/qr/poll", handleQrPoll)
|
||||
mux.HandleFunc("POST /auth/qr/confirm", handleQrConfirm)
|
||||
|
||||
handler := corsMiddleware(mux)
|
||||
http.ListenAndServeTLS(":445", "cert.pem", "key.pem", handler)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Telegram-бот
|
||||
|
||||
### Обработчик `/start`
|
||||
|
||||
```go
|
||||
const (
|
||||
confirmURL = "http://localhost:8080/auth/qr/confirm"
|
||||
botInternalSecret = os.Getenv("BOT_INTERNAL_SECRET")
|
||||
)
|
||||
|
||||
func handleStart(update tgbotapi.Update) {
|
||||
text := update.Message.Text
|
||||
user := update.Message.From
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(text, "/start login_"):
|
||||
handleQrLogin(update, user, strings.TrimPrefix(text, "/start login_"))
|
||||
|
||||
case strings.HasPrefix(text, "/start auth"):
|
||||
handleDirectAuth(update, user)
|
||||
|
||||
default:
|
||||
sendWelcome(update)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### QR-логин (основной)
|
||||
|
||||
```go
|
||||
func handleQrLogin(update tgbotapi.Update, user *tgbotapi.User, token string) {
|
||||
reqBody := map[string]interface{}{
|
||||
"token": token,
|
||||
"telegram_user": map[string]interface{}{
|
||||
"id": user.ID,
|
||||
"first_name": user.FirstName,
|
||||
"last_name": user.LastName,
|
||||
"username": user.UserName,
|
||||
},
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(reqBody)
|
||||
|
||||
req, _ := http.NewRequest("POST", confirmURL, bytes.NewReader(bodyBytes))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Bot-Secret", botInternalSecret)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil || resp.StatusCode != 200 {
|
||||
msg := tgbotapi.NewMessage(update.Message.Chat.ID,
|
||||
"❌ Не удалось войти. QR-код мог устареть. Попробуйте обновить страницу и отсканировать новый QR.")
|
||||
bot.Send(msg)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
displayName := buildDisplayName(user)
|
||||
msg := tgbotapi.NewMessage(update.Message.Chat.ID,
|
||||
fmt.Sprintf("✅ Вы вошли на сайт как %s!\n\nМожете вернуться в браузер — страница обновится автоматически.", displayName))
|
||||
bot.Send(msg)
|
||||
}
|
||||
|
||||
func buildDisplayName(user *tgbotapi.User) string {
|
||||
name := user.FirstName
|
||||
if user.LastName != "" {
|
||||
name += " " + user.LastName
|
||||
}
|
||||
return name
|
||||
}
|
||||
```
|
||||
|
||||
### Прямой вход (кнопка, для обратной совместимости)
|
||||
|
||||
```go
|
||||
func handleDirectAuth(update tgbotapi.Update, user *tgbotapi.User) {
|
||||
session := Session{
|
||||
SessionID: uuid.New().String(),
|
||||
TelegramUserID: user.ID,
|
||||
Username: stringPtr(user.UserName),
|
||||
DisplayName: buildDisplayName(user),
|
||||
Active: true,
|
||||
ExpiresAt: time.Now().Add(24 * time.Hour),
|
||||
}
|
||||
saveSession(session)
|
||||
|
||||
callbackURL := "https://api.dexarmarket.ru:445/auth/telegram/callback"
|
||||
loginURL := callbackURL + "?token=" + session.SessionID
|
||||
|
||||
msg := tgbotapi.NewMessage(update.Message.Chat.ID, "Нажмите кнопку чтобы войти:")
|
||||
msg.ReplyMarkup = tgbotapi.NewInlineKeyboardMarkup(
|
||||
tgbotapi.NewInlineKeyboardRow(
|
||||
tgbotapi.NewInlineKeyboardButtonURL("🔐 Войти на сайт", loginURL),
|
||||
),
|
||||
)
|
||||
bot.Send(msg)
|
||||
}
|
||||
```
|
||||
|
||||
### Запуск бота (long polling)
|
||||
|
||||
```go
|
||||
func main() {
|
||||
bot, _ := tgbotapi.NewBotAPI(os.Getenv("BOT_TOKEN"))
|
||||
|
||||
u := tgbotapi.NewUpdate(0)
|
||||
u.Timeout = 60
|
||||
updates := bot.GetUpdatesChan(u)
|
||||
|
||||
for update := range updates {
|
||||
if update.Message != nil && strings.HasPrefix(update.Message.Text, "/start") {
|
||||
handleStart(update)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Синхронизация корзины
|
||||
|
||||
Сразу после QR-логина фронтенд автоматически отправляет корзину:
|
||||
|
||||
```
|
||||
POST /websession/{sessionId}
|
||||
```
|
||||
|
||||
Тело — массив:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"itemID": 123,
|
||||
"quantity": 2,
|
||||
"colour": "#ff0000",
|
||||
"size": "XL",
|
||||
"price": 1500
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
| Поле | Тип | Примечание |
|
||||
|------|-----|------------|
|
||||
| `itemID` | number | ID товара |
|
||||
| `quantity` | number | Количество |
|
||||
| `colour` | string | CSS hex (`#ff0000`). Бэкенд отдаёт `0xff0000`, фронтенд конвертирует |
|
||||
| `size` | string | `"default"` если размер один |
|
||||
| `price` | number | Финальная цена **с учётом скидки** |
|
||||
|
||||
> Этот эндпоинт (`POST /websession/{id}`) уже существует. Ничего менять не нужно, просто учитывать что он вызывается сразу после успешного логина.
|
||||
|
||||
---
|
||||
|
||||
## Безопасность
|
||||
|
||||
### Криптографический токен
|
||||
```go
|
||||
tokenBytes := make([]byte, 32) // 256 бит
|
||||
crypto/rand.Read(tokenBytes)
|
||||
token := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(tokenBytes)
|
||||
```
|
||||
**НЕ использовать:** `math/rand`, UUID, timestamp.
|
||||
|
||||
### Токен одноразовый
|
||||
- После `confirmed` → удалить при первом успешном `poll`
|
||||
- После 5 минут → автоудаление (TTL)
|
||||
- Повторный `poll` → `"expired"`
|
||||
|
||||
### Защита `/auth/qr/confirm`
|
||||
```go
|
||||
if r.Header.Get("X-Bot-Secret") != os.Getenv("BOT_INTERNAL_SECRET") {
|
||||
http.Error(w, "forbidden", 403)
|
||||
return
|
||||
}
|
||||
```
|
||||
Дополнительно: можно ограничить по IP (`127.0.0.1`) если бот на том же сервере.
|
||||
|
||||
### Rate limiting для `/auth/qr/create`
|
||||
Не более **5 токенов в минуту** с одного IP:
|
||||
```go
|
||||
var ipCounts sync.Map
|
||||
|
||||
func rateLimitQrCreate(ip string) bool {
|
||||
key := ip + ":" + time.Now().Format("2006-01-02T15:04")
|
||||
val, _ := ipCounts.LoadOrStore(key, new(int32))
|
||||
count := atomic.AddInt32(val.(*int32), 1)
|
||||
return count <= 5
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
```env
|
||||
BOT_TOKEN=123456:ABC-DEF...
|
||||
BOT_INTERNAL_SECRET=случайная-строка-минимум-32-символа
|
||||
FRONTEND_URL=https://dexarmarket.ru
|
||||
SESSION_TTL=24h
|
||||
REDIS_URL=localhost:6379
|
||||
```
|
||||
|
||||
`BOT_INTERNAL_SECRET` должен совпадать в env сервера и env бота.
|
||||
|
||||
---
|
||||
|
||||
## Тестирование
|
||||
|
||||
### curl-тесты
|
||||
|
||||
**1. Создание токена:**
|
||||
```bash
|
||||
curl -X POST https://api.dexarmarket.ru:445/auth/qr/create \
|
||||
-H "Origin: https://dexarmarket.ru"
|
||||
# → { "token": "dG9r...", "url": "https://t.me/DexarSupport_bot?start=login_dG9r..." }
|
||||
```
|
||||
|
||||
**2. Поллинг (до подтверждения):**
|
||||
```bash
|
||||
curl "https://api.dexarmarket.ru:445/auth/qr/poll?token=dG9r..."
|
||||
# → { "status": "pending" }
|
||||
```
|
||||
|
||||
**3. Подтверждение (имитация бота):**
|
||||
```bash
|
||||
curl -X POST https://api.dexarmarket.ru:445/auth/qr/confirm \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Bot-Secret: ваш-секрет" \
|
||||
-d '{"token":"dG9r...","telegram_user":{"id":123,"first_name":"Тест","last_name":"","username":"testuser"}}'
|
||||
# → { "status": "ok" }
|
||||
```
|
||||
|
||||
**4. Поллинг (после подтверждения):**
|
||||
```bash
|
||||
curl -v "https://api.dexarmarket.ru:445/auth/qr/poll?token=dG9r..."
|
||||
# → { "status": "confirmed", "session": {...} } + Set-Cookie: dx_session=...
|
||||
```
|
||||
|
||||
**5. E2E:**
|
||||
1. Открыть маркетплейс → добавить товар в корзину
|
||||
2. Нажать "Оформить заказ" → появляется диалог с QR
|
||||
3. Отсканировать QR телефоном → Telegram → бот: "✅ Вы вошли!"
|
||||
4. Через 3 сек диалог закрывается → авторизован
|
||||
5. Корзина синхронизирована (`POST /websession/{sessionId}`)
|
||||
|
||||
### Отладка
|
||||
|
||||
| Проблема | Где смотреть |
|
||||
|----------|-------------|
|
||||
| QR не показывается | `POST /auth/qr/create` — ошибка? CORS? |
|
||||
| QR отсканирован, ничего не происходит | Бот получил `/start login_...`? Бот вызвал `confirm`? |
|
||||
| Бот пишет "❌ QR устарел" | Токен expired? 5 минут прошло? |
|
||||
| Поллинг "pending" бесконечно | Бот не вызвал `confirm`. Логи бота |
|
||||
| Поллинг "confirmed" но cookie нет | `SameSite`, `Secure`, `Domain`, CORS |
|
||||
|
||||
---
|
||||
|
||||
## Чеклист
|
||||
|
||||
### Бэкенд (Go)
|
||||
|
||||
- [ ] Структура `Session` + `AuthToken`, функции save/get/delete
|
||||
- [ ] `POST /auth/qr/create` — генерация токена
|
||||
- [ ] `GET /auth/qr/poll?token=...` — статус + cookie при confirmed
|
||||
- [ ] `POST /auth/qr/confirm` — приём от бота с `X-Bot-Secret`
|
||||
- [ ] `GET /auth/session` — чтение cookie, JSON сессии
|
||||
- [ ] `GET /auth/telegram/callback?token=...` — cookie + редирект
|
||||
- [ ] `POST /auth/logout` — удаление сессии и cookie
|
||||
- [ ] TTL 5 мин для токенов, 24ч для сессий
|
||||
- [ ] Rate limiting `/auth/qr/create` (5/мин/IP)
|
||||
- [ ] Очистка устаревших токенов
|
||||
- [ ] CORS middleware
|
||||
- [ ] `BOT_INTERNAL_SECRET` в env
|
||||
|
||||
### Telegram бот
|
||||
|
||||
- [ ] Обработка `/start login_{token}` → `POST /auth/qr/confirm`
|
||||
- [ ] Обработка `/start auth` → создание сессии + кнопка "Войти"
|
||||
- [ ] Сообщения: "✅ Вы вошли" / "❌ QR устарел"
|
||||
- [ ] `BOT_INTERNAL_SECRET` в env (совпадает с сервером)
|
||||
- [ ] `BOT_TOKEN` в env
|
||||
@@ -1,11 +1,748 @@
|
||||
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
|
||||
General Information
|
||||
Information exchange with the SBP server is realized via RESTful API. All requests to the server must be executed via HTTPS using GET||POST||PUT||DELETE requests to the given ROOT address. Body of requests must be in JSON format. All not public requests must be signed by the client and the public key must be sent to the server for client identification and sign checking.
|
||||
Header:
|
||||
“Authorization”: {JSON WITH KEY AND PARTNERID}
|
||||
“X-Region” : Moscow | Yerevan | ST. Petersburg
|
||||
“X-Language” : RU | AM | EN
|
||||
“WebSessionID” : f02fe5d6-c6ae-4b2e-9b4d-687534e11b01
|
||||
“Currency” :RUB | AMD | USD
|
||||
Root:
|
||||
API.dexarmarket.ru
|
||||
|
||||
|
||||
General Information
|
||||
Check if server is available
|
||||
Get Marketplaces
|
||||
Set Marketplaces
|
||||
Get Item
|
||||
Delete Item
|
||||
New Item
|
||||
New Callback
|
||||
New Question
|
||||
Get random Items
|
||||
Get items in category
|
||||
Get searched items
|
||||
Get Categories
|
||||
Delete Category
|
||||
New Category
|
||||
Create new websession
|
||||
Check websession status
|
||||
Delete websession status
|
||||
Add to cart
|
||||
Create New QR code for cart checkout
|
||||
Check QR code
|
||||
item structure
|
||||
category structure
|
||||
Check if server is available
|
||||
Client needs to periodically check if the server is available by sending “ping” to the client. On error corresponding message must be shown.
|
||||
Protocol: https
|
||||
Type: GET
|
||||
Path: /ping
|
||||
Request Parameters:
|
||||
{
|
||||
|
||||
|
||||
}
|
||||
Response (Error):
|
||||
{
|
||||
"message": "pong",
|
||||
"status": "Wrong Header"
|
||||
}
|
||||
Response (OK):
|
||||
{
|
||||
"message": "pong",
|
||||
"status": "Correct Header"
|
||||
}
|
||||
________________
|
||||
|
||||
|
||||
Get Marketplaces
|
||||
Get Available Marketplaces
|
||||
Protocol: https
|
||||
Type: GET
|
||||
Path: /marketplaces
|
||||
Request Parameters:
|
||||
{
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
[{“brand” : “dexar”,
|
||||
“api”:”dexar.market”,
|
||||
“bot”:”dexarmarket_bot”,
|
||||
“languagies”:[“”am,”ru”,”en”],
|
||||
“regions”:[“Mosocw - Russia”, ”St Petersburg - Russia”, ”Yerevan - Armenia”]
|
||||
“currency”:[“RUB, ”AMD”, ”USD”]
|
||||
“icon”:”./dexar.market.png”},
|
||||
{“brand” : “store”,
|
||||
“api”:”dexarmarket.store”,
|
||||
“bot”:”dexarstore_bot”,
|
||||
“languagies”:[“”am,”ru”,”en”],
|
||||
“regions”:[“Mosocw - Russia”,”St Petersburg - Russia”,”Yerevan - Armenia”]
|
||||
“currency”:[“”RUB,”AMD”,”USD”]
|
||||
“icon”:”./dexarmarket.store.png”},
|
||||
{“brand” : “Novo”,
|
||||
“api”:”novo.market”,
|
||||
“bot”:”novomarket_bot”,
|
||||
“languagies”:[“”am,”ru”,”en”],
|
||||
“regions”:[“Mosocw - Russia”, ”St Petersburg - Russia”,”Yerevan - Armenia”]
|
||||
“currency”:[“”RUB,”AMD”,”USD”]
|
||||
“icon”:”./novo.market.png”}]
|
||||
|
||||
|
||||
}
|
||||
________________
|
||||
|
||||
|
||||
Set Marketplaces
|
||||
Get Available Marketplaces
|
||||
Protocol: https
|
||||
Type: PUT
|
||||
Path: /marketplaces
|
||||
Request Parameters:
|
||||
{
|
||||
[{“brand” : “dexar”,
|
||||
“api”:”dexar.market”,
|
||||
“languagies”:[“”am,”ru”,”en”],
|
||||
“regions”:[“Mosocw - Russia”,”St Petersburg - Russia”,”Yerevan - Armenia”]
|
||||
“currency”:[“”RUB,”AMD”,”USD”]
|
||||
“icon”:”./dexar.market.png”},
|
||||
{“brand” : “store”,
|
||||
“api”:”dexarmarket.store”,
|
||||
“languagies”:[“”am,”ru”,”en”],
|
||||
“regions”:[“Mosocw - Russia”,”St Petersburg - Russia”,”Yerevan - Armenia”]
|
||||
“currency”:[“”RUB,”AMD”,”USD”]
|
||||
“icon”:”./dexarmarket.store.png”},
|
||||
{“brand” : “Novo”,
|
||||
“api”:”novo.market”,
|
||||
“languagies”:[“”am,”ru”,”en”],
|
||||
“regions”:[“Mosocw - Russia”, ”St Petersburg - Russia”,”Yerevan - Armenia”]
|
||||
“currency”:[“”RUB,”AMD”,”USD”]
|
||||
“icon”:”./novo.market.png”}]
|
||||
|
||||
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
“status”:”Marketplace updated”
|
||||
}
|
||||
________________
|
||||
|
||||
|
||||
Get Item
|
||||
Get Item by ID
|
||||
Protocol: https
|
||||
Type: GET
|
||||
Path: /items/:itemID
|
||||
Request Parameters:
|
||||
{
|
||||
|
||||
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
“itemID”:...
|
||||
}
|
||||
________________
|
||||
Delete Item
|
||||
Delete the item
|
||||
Protocol: https
|
||||
Type: Delete
|
||||
Path: /items/:itemID
|
||||
Request Parameters:
|
||||
{
|
||||
|
||||
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
“status”:”Item was deleted”
|
||||
}
|
||||
________________
|
||||
New Item
|
||||
Create new Item
|
||||
Protocol: https
|
||||
Type: POST
|
||||
Path: /items/:itemID
|
||||
Request Parameters:
|
||||
{
|
||||
“itemID”:...
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
“itemID”:...
|
||||
}
|
||||
________________
|
||||
Update Item
|
||||
Update the item
|
||||
Protocol: https
|
||||
Type: PUT
|
||||
Path: /items/:itemID
|
||||
Request Parameters:
|
||||
{
|
||||
“itemID”:...
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
“status”:”Item updated”
|
||||
}
|
||||
________________
|
||||
|
||||
|
||||
New Callback
|
||||
Update the item
|
||||
Protocol: https
|
||||
Type: POST
|
||||
Path: /items/:itemID/callback
|
||||
Request Parameters:
|
||||
{
|
||||
"rating": 5,
|
||||
"comment": "Отличный товар!",
|
||||
"sessionID": “ f02fe5d6-c6ae-4b2e-9b4d-687534e11b01”
|
||||
"timestamp": "2026-02-28T12:00:00Z"
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong item"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
“status”:”Callback added”
|
||||
}
|
||||
________________
|
||||
New Question
|
||||
Update the item
|
||||
Protocol: https
|
||||
Type: POST
|
||||
Path: /items/:itemID/questiion
|
||||
Request Parameters:
|
||||
{
|
||||
"question": "some question!",
|
||||
"sessionID": “ f02fe5d6-c6ae-4b2e-9b4d-687534e11b01”
|
||||
"timestamp": "2026-02-28T12:00:00Z"
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong item"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
“status”:”Questiion added”
|
||||
}
|
||||
________________
|
||||
|
||||
|
||||
Get random Items
|
||||
Get given number of items from random categorues
|
||||
Protocol: https
|
||||
Type: GET
|
||||
Path: /items/randomitems?count=15 // 20 is the default
|
||||
Request Parameters:
|
||||
{
|
||||
|
||||
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
[“itemID”:...]
|
||||
}
|
||||
________________
|
||||
Get items in category
|
||||
Get all items in category and in all subcategories inside the category
|
||||
Protocol: https
|
||||
Type: GET
|
||||
Path: /category/:categoryID?count=30, skip=60 // default skip=0, default count=20
|
||||
|
||||
|
||||
Request Parameters:
|
||||
{
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
[“itemID”:...]
|
||||
}
|
||||
________________
|
||||
|
||||
|
||||
Get searched items
|
||||
Get all items in category and in all subcategories inside the category
|
||||
Protocol: https
|
||||
Type: GET
|
||||
Path: /searchitems
|
||||
Parameters:
|
||||
{
|
||||
search (string) — query text
|
||||
categoryIDs (string) — e.g., 1,2,5 (includes all subcategories)
|
||||
minPrice / maxPrice (float) — price range
|
||||
tag (string) — e.g., sale
|
||||
sort (string) — relevance (default), price_asc, price_desc, popular, rating
|
||||
skip / count — default 0 / 20
|
||||
}
|
||||
Examples:
|
||||
* ?search=iphone&sort=popular
|
||||
* ?categoryIDs=1,5&minPrice=100&maxPrice=500
|
||||
* ?tag=new&sort=price_asc&count=10
|
||||
|
||||
|
||||
Request Parameters:
|
||||
{
|
||||
|
||||
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
"total": 12,
|
||||
"skip": 0,
|
||||
"count": 12,
|
||||
"isGlobal": false,
|
||||
"items": [
|
||||
{ "itemID": 101, "name": "..." }
|
||||
]
|
||||
}
|
||||
________________
|
||||
|
||||
|
||||
Get Categories
|
||||
Get all available categories
|
||||
Protocol: https
|
||||
Type: GET
|
||||
Path: /category
|
||||
Request Parameters:
|
||||
{
|
||||
|
||||
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
“categoryID”:...
|
||||
}
|
||||
________________
|
||||
Delete Category
|
||||
Delete EMPTY category, no items and no subcategories must present
|
||||
Protocol: https
|
||||
Type: Delete
|
||||
Path: /category/:categoryID
|
||||
Request Parameters:
|
||||
{
|
||||
|
||||
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
“status”:”Category was deleted”
|
||||
}
|
||||
________________
|
||||
New Category
|
||||
Create new category
|
||||
Protocol: https
|
||||
Type: POST
|
||||
Path: /category/
|
||||
Request Parameters:
|
||||
{
|
||||
“CategoryID”:...
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
“CategoryID”:...
|
||||
}
|
||||
________________
|
||||
Update Category
|
||||
Update existing category
|
||||
Protocol: https
|
||||
Type: PUT
|
||||
Path: /category/:categoryID
|
||||
Request Parameters:
|
||||
{
|
||||
“itemID”:...
|
||||
}
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong header"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
“status”:”Category was updated”
|
||||
}
|
||||
________________
|
||||
Create new websession
|
||||
Creates a new websession for qr generation. By timeout a new websession must be requested, after the user shows some activity (click on qr).
|
||||
Protocol: https
|
||||
Type POST
|
||||
Path /websession
|
||||
Request Parameters:
|
||||
{
|
||||
|
||||
|
||||
}
|
||||
Response (OK):
|
||||
{
|
||||
"sessionId": “1AF3781BF6B94604B771AEA1D44FA63A”,
|
||||
"userId" : "",
|
||||
"expires" : "sessionId",
|
||||
"userSessionId": "",
|
||||
"status": false
|
||||
}
|
||||
________________
|
||||
Check websession status
|
||||
Check if the user is already logged in. a new websession for qr generation. By timeout a new websession must be requested, after the user shows some activity (click on qr).
|
||||
Protocol: https
|
||||
Type GET
|
||||
Path /websession/:webSessionID
|
||||
Request Parameters:
|
||||
{
|
||||
|
||||
|
||||
}
|
||||
Response (OK):
|
||||
{
|
||||
"sessionId": “1AF3781BF6B94604B771AEA1D44FA63A”,
|
||||
"userId" : "kHaAe9roaC2uq63AKGE/8+Ti/t/iFro68QhEZ1dRGLo",
|
||||
"expires" : "sessionId",
|
||||
"userSessionId": "8A94EFEFD003426A9B456C48CAC99BE6",
|
||||
"x-Region" : "Moscow",
|
||||
"x-Language" : "RU",
|
||||
"currency" : "RUB",
|
||||
"Status": true,
|
||||
"cart": [
|
||||
{ "itemID": 12, "quantity": 1, “colour”:”black”, “size”:”42”,"priice":230.50 },
|
||||
{ "itemID": 13, "quantity": 2, “colour”:”dark”, “size”:”L”,"priice":250.50 },
|
||||
{ "itemID": 14, "quantity": 3, “colour”:”blue”, “size”:”50”,"priice":290.50 },
|
||||
]
|
||||
}
|
||||
________________
|
||||
Delete websession status
|
||||
Delete the session to log out from the system.
|
||||
Protocol: https
|
||||
Type DELETE
|
||||
Path /websession/:webSessionID
|
||||
Request Parameters:
|
||||
{
|
||||
}
|
||||
Response (OK):
|
||||
{
|
||||
“status”:”User logged out”
|
||||
}
|
||||
|
||||
________________
|
||||
Add to cart
|
||||
Add a all item to users (session) cart
|
||||
Protocol: https
|
||||
Type Post
|
||||
Path /websession/:webSessionID
|
||||
Request Parameters:
|
||||
{
|
||||
[
|
||||
{ "itemID": 12, "quantity": 1, “colour”:”black”, “size”:”42”,"priice":230.50 },
|
||||
{ "itemID": 13, "quantity": 2, “colour”:”dark”, “size”:”L”,"priice":250.50 },
|
||||
{ "itemID": 14, "quantity": 3, “colour”:”blue”, “size”:”50”,"priice":290.50 },
|
||||
]
|
||||
}
|
||||
Response (OK):
|
||||
{
|
||||
"sessionId": “1AF3781BF6B94604B771AEA1D44FA63A”,
|
||||
"userId" : "kHaAe9roaC2uq63AKGE/8+Ti/t/iFro68QhEZ1dRGLo",
|
||||
"expires" : "sessionId",
|
||||
"userSessionId": "8A94EFEFD003426A9B456C48CAC99BE6",
|
||||
"Status": true,
|
||||
"cart": [
|
||||
{ "itemID": 12, "quantity": 1, “colour”:”black”, “size”:”42”,"priice":230.50 },
|
||||
{ "itemID": 13, "quantity": 2, “colour”:”dark”, “size”:”L”,"priice":250.50 },
|
||||
{ "itemID": 14, "quantity": 3, “colour”:”blue”, “size”:”50”,"priice":290.50 },
|
||||
]
|
||||
}
|
||||
________________
|
||||
Create New QR code for cart checkout
|
||||
Create New QR for payment via SBP
|
||||
Protocol: https
|
||||
Type POST
|
||||
Path /websession/:webSessionID/qr
|
||||
Request Parameters:
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "wrong key"
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
"qrId": "BD10002CI1V3JP1T8QR8TIQ8K35RBVQB",
|
||||
"qrStatus": "NEW",
|
||||
"qrExpirationDate": "2025-11-20T10:10:44Z",
|
||||
"Payload": "https://qr.nspk.ru/BD10002CI1V3JP1T8QR8TIQ8K35RBVQB?type=02&bank=100000000007&sum=1000&cur=RUB&crc=8ACC",
|
||||
"qrUrl": "https://e-commerce.raiffeisen.ru/api/sbp/v1/qr/BD10002CI1V3JP1T8QR8TIQ8K35RBVQB/image"
|
||||
}
|
||||
________________
|
||||
|
||||
|
||||
Check QR code
|
||||
Check QR status
|
||||
Protocol: https
|
||||
Type GET
|
||||
Path /websession/:webSessionID/:qrID
|
||||
Request Parameters:
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
Response !=200(Error):
|
||||
{
|
||||
"error": "Error from the bank "
|
||||
}
|
||||
Response =200(OK):
|
||||
{
|
||||
"additionalInfo": "",
|
||||
"paymentPurpose": "",
|
||||
"amount": 10,
|
||||
"code": "SUCCESS",
|
||||
"createDate": "2025-11-20T13:17:20.453884+03:00",
|
||||
"currency": "RUB",
|
||||
"order": "102_540",
|
||||
"paymentStatus": "NO_INFO", //check for SUCCESS
|
||||
"qrId": "BD1000263VS7G81D8JCP5FHFTFEH38MT",
|
||||
"transactionDate": "",
|
||||
"transactionId": 0,
|
||||
"qrExpirationDate": "2025-11-20T13:32:20+03:00"
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
## 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..."
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
|
||||
Бот должен:
|
||||
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 |
|
||||
|
||||
|
||||
________________
|
||||
|
||||
|
||||
item structure
|
||||
CategoryID uint64 `json:"categoryID" binding:"required"`
|
||||
ItemID uint64 `json:"itemID" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Discount float32 `json:"discount" `
|
||||
Rating float32 `json:"rating" binding:"required"`
|
||||
Visible bool `json:"rating"`
|
||||
Priority uint64 `json:"priority"`
|
||||
Tags []string `json:"tags"`
|
||||
Badges []string `json:"badges"`
|
||||
Details []itemdetail `json:"itemdetails"`
|
||||
Colour string `json:"colour" binding:"required"`
|
||||
Size string `json:"size" binding:"required"`
|
||||
Price float32 `json:"price" binding:"required"`
|
||||
Currency string `json:"currency" binding:"required"`
|
||||
Remaining uint64 `json:"remaining" binding:"required"`
|
||||
Names []itemname `json:"names"`
|
||||
Language string `json:"language"`
|
||||
Value string `json:"value"`
|
||||
Descriptions []itemdescription `json:"descriptions" `
|
||||
Language string `json:"language"`
|
||||
Value string `json:"value"`
|
||||
Attributes []attribute `json:"attributes" binding:"required"`
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
Photos []photo `json:"photos"`
|
||||
Type string `json:"type" binding:"required"` //video || photo
|
||||
URL string `json:"url" binding:"required"`
|
||||
Questions []question `json:"questions"`
|
||||
Question string `json:"question" `
|
||||
Answer string `json:"answer" `
|
||||
Like uint64 `json:"like" `
|
||||
Dislike uint64 `json:"dislike" `
|
||||
Visits uint64 `json:"visits"`
|
||||
Callbacks []callback `json:"callbacks" binding:"required"`
|
||||
Rating float32 `json:"rating,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Userid string `json:"userID"`
|
||||
Answer string `json:"answer"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
PartnerID []string `json:"partnerID" binding:"required"`
|
||||
|
||||
|
||||
category structure
|
||||
CategoryID uint64 `json:"categoryID" binding:"required"`
|
||||
ParentID uint64 `json:"parentID" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Visible bool `json:"visible" `
|
||||
Priority uint64 `json:"priority" `
|
||||
Icon string `json:"icon"`
|
||||
WideIcon string `json:"wideicon"`
|
||||
ItemsCount uint64
|
||||
CategoriesCount uint64
|
||||
Names []itemname `json:"names"`
|
||||
Language string `json:"language"`
|
||||
Value string `json:"value"`
|
||||
@@ -36,6 +36,9 @@ server {
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://telegram.org; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https:; frame-src https://telegram.org;" always;
|
||||
|
||||
# Brotli compression (if available)
|
||||
# brotli on;
|
||||
|
||||
@@ -30,7 +30,9 @@
|
||||
{
|
||||
"name": "api-cache",
|
||||
"urls": [
|
||||
"/api/**"
|
||||
"/api/**",
|
||||
"https://api.dexarmarket.ru:445/**",
|
||||
"https://api.novo.market:444/**"
|
||||
],
|
||||
"cacheConfig": {
|
||||
"maxSize": 100,
|
||||
|
||||
1
package-lock.json
generated
1
package-lock.json
generated
@@ -9580,3 +9580,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"dexar": "ng serve --configuration=development --port 4200",
|
||||
"novo": "ng serve --configuration=novo --port 4201",
|
||||
"novo": "ng serve --configuration=novo --port 4201 --proxy-config proxy.conf.novo.json",
|
||||
"start:dexar": "ng serve --configuration=development --port 4200",
|
||||
"start:novo": "ng serve --configuration=novo --port 4201",
|
||||
"build": "ng build",
|
||||
@@ -47,3 +47,4 @@
|
||||
"typescript": "~5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
11
proxy.conf.novo.json
Normal file
11
proxy.conf.novo.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "https://api.novo.market:444",
|
||||
"secure": false,
|
||||
"changeOrigin": true,
|
||||
"pathRewrite": {
|
||||
"^/api": ""
|
||||
},
|
||||
"logLevel": "debug"
|
||||
}
|
||||
}
|
||||
BIN
public/icons/icon-192x192.png
Normal file
BIN
public/icons/icon-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 547 B |
BIN
public/icons/icon-512x512.png
Normal file
BIN
public/icons/icon-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
|
||||
"name": "Novo Market - Интернет-магазин",
|
||||
"short_name": "Novo",
|
||||
"description": "Novo Market - ваш онлайн магазин качественных товаров с доставкой",
|
||||
@@ -12,34 +11,10 @@
|
||||
"categories": ["shopping", "lifestyle"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-128x128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-152x152.png",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
"src": "assets/images/novo-favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-192x192.png",
|
||||
@@ -47,12 +22,6 @@
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
|
||||
@@ -11,34 +11,10 @@
|
||||
"categories": ["shopping", "marketplace"],
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-128x128.png",
|
||||
"sizes": "128x128",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-152x152.png",
|
||||
"sizes": "152x152",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
"src": "assets/images/dexar-favicon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-192x192.png",
|
||||
@@ -46,12 +22,6 @@
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable any"
|
||||
},
|
||||
{
|
||||
"src": "icons/icon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
|
||||
@@ -12,10 +12,10 @@
|
||||
</div>
|
||||
} @else {
|
||||
<app-header></app-header>
|
||||
@if (!isHomePage()) {
|
||||
<app-back-button />
|
||||
}
|
||||
<main class="main-content">
|
||||
@if (!isHomePage()) {
|
||||
<app-back-button />
|
||||
}
|
||||
<router-outlet></router-outlet>
|
||||
</main>
|
||||
<app-footer></app-footer>
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { App } from './app';
|
||||
import { provideRouter } from '@angular/router';
|
||||
|
||||
describe('App', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [App],
|
||||
providers: [provideRouter([])]
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
it('should create the app', () => {
|
||||
const fixture = TestBed.createComponent(App);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -91,7 +91,7 @@
|
||||
<div class="card-icon">📞</div>
|
||||
<h3>Contact Us</h3>
|
||||
<a href="mailto:info@novo.market" class="contact-email">info@novo.market</a>
|
||||
<p><a href="tel:+37498731231">+374 98 731231</a></p>
|
||||
<p><a [href]="env.phoneTel">{{ env.phones.support }}</a></p>
|
||||
<p class="support-note">We are always in touch</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-about-novo-en',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/info/about/about.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class AboutNovoEnComponent {}
|
||||
export class AboutNovoEnComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
<div class="card-icon">📞</div>
|
||||
<h3>Կապվել մեզ հետ</h3>
|
||||
<a href="mailto:info@novo.market" class="contact-email">info@novo.market</a>
|
||||
<p><a href="tel:+37498731231">+374 98 731231</a></p>
|
||||
<p><a [href]="env.phoneTel">{{ env.phones.support }}</a></p>
|
||||
<p class="support-note">Մենք միշտ կապի մեջ ենք</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-about-novo-hy',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/info/about/about.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class AboutNovoHyComponent {}
|
||||
export class AboutNovoHyComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
<div class="card-icon">📞</div>
|
||||
<h3>Связаться с нами</h3>
|
||||
<a href="mailto:info@novo.market" class="contact-email">info@novo.market</a>
|
||||
<p><a href="tel:+37498731231">+374 98 731231</a></p>
|
||||
<p><a [href]="env.phoneTel">{{ env.phones.support }}</a></p>
|
||||
<p class="support-note">Мы всегда на связи</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-about-novo-ru',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/info/about/about.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class AboutNovoRuComponent {}
|
||||
export class AboutNovoRuComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<div class="info-card">
|
||||
<div class="card-icon">📞</div>
|
||||
<h3>Phone</h3>
|
||||
<p><a href="tel:+37498731231">+374 98 731231</a></p>
|
||||
<p><a [href]="env.phoneTel">{{ env.phones.support }}</a></p>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-contacts-novo-en',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/info/contacts/contacts.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ContactsNovoEnComponent {}
|
||||
export class ContactsNovoEnComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<div class="info-card">
|
||||
<div class="card-icon">📞</div>
|
||||
<h3>Հեռախոս</h3>
|
||||
<p><a href="tel:+37498731231">+374 98 731231</a></p>
|
||||
<p><a [href]="env.phoneTel">{{ env.phones.support }}</a></p>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-contacts-novo-hy',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/info/contacts/contacts.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ContactsNovoHyComponent {}
|
||||
export class ContactsNovoHyComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<div class="info-card">
|
||||
<div class="card-icon">📞</div>
|
||||
<h3>Телефон</h3>
|
||||
<p><a href="tel:+37498731231">+374 98 731231</a></p>
|
||||
<p><a [href]="env.phoneTel">{{ env.phones.support }}</a></p>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-contacts-novo-ru',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/info/contacts/contacts.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ContactsNovoRuComponent {}
|
||||
export class ContactsNovoRuComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
<h3>Questions about delivery?</h3>
|
||||
<p>Contact the seller or us:</p>
|
||||
<a href="mailto:info@novo.market" class="contact-email">info@novo.market</a>
|
||||
<p><a href="tel:+37498731231">+374 98 731231</a></p>
|
||||
<p><a [href]="env.phoneTel">{{ env.phones.support }}</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-delivery-novo-en',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/info/delivery/delivery.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class DeliveryNovoEnComponent {}
|
||||
export class DeliveryNovoEnComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
<h3>Առաքման հարցեր՞</h3>
|
||||
<p>Կապվեք վաճառողի կամ մեզ հետ՝</p>
|
||||
<a href="mailto:info@novo.market" class="contact-email">info@novo.market</a>
|
||||
<p><a href="tel:+37498731231">+374 98 731231</a></p>
|
||||
<p><a [href]="env.phoneTel">{{ env.phones.support }}</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-delivery-novo-hy',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/info/delivery/delivery.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class DeliveryNovoHyComponent {}
|
||||
export class DeliveryNovoHyComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
<h3>Вопросы по доставке?</h3>
|
||||
<p>Свяжитесь с продавцом или нами:</p>
|
||||
<a href="mailto:info@novo.market" class="contact-email">info@novo.market</a>
|
||||
<p><a href="tel:+37498731231">+374 98 731231</a></p>
|
||||
<p><a [href]="env.phoneTel">{{ env.phones.support }}</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-delivery-novo-ru',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/info/delivery/delivery.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class DeliveryNovoRuComponent {}
|
||||
export class DeliveryNovoRuComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
</div>
|
||||
<div class="contact-item">
|
||||
<strong>Phone</strong>
|
||||
<a href="tel:+37498731231">+374 98 731231</a>
|
||||
<a [href]="env.phoneTel">{{ env.phones.support }}</a>
|
||||
</div>
|
||||
<div class="contact-item">
|
||||
<strong>Working Hours</strong>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-faq-novo-en',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/info/faq/faq.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class FaqNovoEnComponent {}
|
||||
export class FaqNovoEnComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
</div>
|
||||
<div class="contact-item">
|
||||
<strong>Հեռախոս</strong>
|
||||
<a href="tel:+37498731231">+374 98 731231</a>
|
||||
<a [href]="env.phoneTel">{{ env.phones.support }}</a>
|
||||
</div>
|
||||
<div class="contact-item">
|
||||
<strong>Աշխատանքային ժամեր</strong>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-faq-novo-hy',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/info/faq/faq.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class FaqNovoHyComponent {}
|
||||
export class FaqNovoHyComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
</div>
|
||||
<div class="contact-item">
|
||||
<strong>Телефон</strong>
|
||||
<a href="tel:+37498731231">+374 98 731231</a>
|
||||
<a [href]="env.phoneTel">{{ env.phones.support }}</a>
|
||||
</div>
|
||||
<div class="contact-item">
|
||||
<strong>Время работы</strong>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-faq-novo-ru',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/info/faq/faq.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class FaqNovoRuComponent {}
|
||||
export class FaqNovoRuComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
<h3>Need Help?</h3>
|
||||
<p>In case of disputes:</p>
|
||||
<a href="mailto:info@novo.market" class="contact-email">info@novo.market</a>
|
||||
<p><a href="tel:+37498731231">+374 98 731231</a></p>
|
||||
<p><a [href]="env.phoneTel">{{ env.phones.support }}</a></p>
|
||||
<p class="note">Subject: "Warranty Issue - Order #..."</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-guarantee-novo-en',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/info/guarantee/guarantee.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class GuaranteeNovoEnComponent {}
|
||||
export class GuaranteeNovoEnComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
<h3>Օգնությու՞ն պետք է՞</h3>
|
||||
<p>Վեճերի դեպքում՝</p>
|
||||
<a href="mailto:info@novo.market" class="contact-email">info@novo.market</a>
|
||||
<p><a href="tel:+37498731231">+374 98 731231</a></p>
|
||||
<p><a [href]="env.phoneTel">{{ env.phones.support }}</a></p>
|
||||
<p class="note">Թեմա՝ “Երաշխիքային հարց - Պատվեր №...”</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-guarantee-novo-hy',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/info/guarantee/guarantee.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class GuaranteeNovoHyComponent {}
|
||||
export class GuaranteeNovoHyComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
<h3>Нужна помощь?</h3>
|
||||
<p>При возникновении споров:</p>
|
||||
<a href="mailto:info@novo.market" class="contact-email">info@novo.market</a>
|
||||
<p><a href="tel:+37498731231">+374 98 731231</a></p>
|
||||
<p><a [href]="env.phoneTel">{{ env.phones.support }}</a></p>
|
||||
<p class="note">Тема: "Гарантийный вопрос - Заказ №..."</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-guarantee-novo-ru',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/info/guarantee/guarantee.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class GuaranteeNovoRuComponent {}
|
||||
export class GuaranteeNovoRuComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -73,11 +73,11 @@
|
||||
<div class="card-icon">📞</div>
|
||||
<h3>Contact Us</h3>
|
||||
<div class="contacts-grid">
|
||||
<a href="tel:+37498731231" class="contact-link">
|
||||
<a [href]="env.phoneTel" class="contact-link">
|
||||
<span class="contact-icon">📱</span>
|
||||
<div>
|
||||
<div class="contact-label">Phone</div>
|
||||
<div class="contact-value">+374 98 731231</div>
|
||||
<div class="contact-value">{{ env.phones.support }}</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="mailto:info@novo.market" class="contact-link">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-company-details-novo-en',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/legal/company-details/company-details.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class CompanyDetailsNovoEnComponent {}
|
||||
export class CompanyDetailsNovoEnComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -73,11 +73,11 @@
|
||||
<div class="card-icon">📞</div>
|
||||
<h3>Կապվեք մեզ հետ</h3>
|
||||
<div class="contacts-grid">
|
||||
<a href="tel:+37498731231" class="contact-link">
|
||||
<a [href]="env.phoneTel" class="contact-link">
|
||||
<span class="contact-icon">📱</span>
|
||||
<div>
|
||||
<div class="contact-label">Հեռախոս</div>
|
||||
<div class="contact-value">+374 98 731231</div>
|
||||
<div class="contact-value">{{ env.phones.support }}</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="mailto:info@novo.market" class="contact-link">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-company-details-novo-hy',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/legal/company-details/company-details.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class CompanyDetailsNovoHyComponent {}
|
||||
export class CompanyDetailsNovoHyComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -73,11 +73,11 @@
|
||||
<div class="card-icon">📞</div>
|
||||
<h3>Связаться с нами</h3>
|
||||
<div class="contacts-grid">
|
||||
<a href="tel:+37498731231" class="contact-link">
|
||||
<a [href]="env.phoneTel" class="contact-link">
|
||||
<span class="contact-icon">📱</span>
|
||||
<div>
|
||||
<div class="contact-label">Телефон</div>
|
||||
<div class="contact-value">+374 98 731231</div>
|
||||
<div class="contact-value">{{ env.phones.support }}</div>
|
||||
</div>
|
||||
</a>
|
||||
<a href="mailto:info@novo.market" class="contact-link">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-company-details-novo-ru',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/legal/company-details/company-details.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class CompanyDetailsNovoRuComponent {}
|
||||
export class CompanyDetailsNovoRuComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@
|
||||
<p>For questions related to order payments, you can contact us:</p>
|
||||
<ul class="compact-list">
|
||||
<li><strong>Email:</strong> <a href="mailto:info@novo.market" class="contact-email">info@novo.market</a></li>
|
||||
<li><strong>Phone:</strong> <a href="tel:+37498731231">+374 98 731231</a></li>
|
||||
<li><strong>Phone:</strong> <a [href]="env.phoneTel">{{ env.phones.support }}</a></li>
|
||||
<li><strong>Working hours:</strong> 24/7 (technical support)</li>
|
||||
<li><strong>Average response time:</strong> Up to 24 hours on business days</li>
|
||||
</ul>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { LangRoutePipe } from '../../../../../../pipes/lang-route.pipe';
|
||||
|
||||
@@ -9,4 +10,6 @@ import { LangRoutePipe } from '../../../../../../pipes/lang-route.pipe';
|
||||
styleUrls: ['../../../../../../pages/legal/payment-terms/payment-terms.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class PaymentTermsNovoEnComponent {}
|
||||
export class PaymentTermsNovoEnComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@
|
||||
<p>Պատվերների վճարման հետ կապված հարցերի համար կարող եք դիմել՝</p>
|
||||
<ul class="compact-list">
|
||||
<li><strong>Email:</strong> <a href="mailto:info@novo.market" class="contact-email">info@novo.market</a></li>
|
||||
<li><strong>Հեռախոս՝</strong> <a href="tel:+37498731231">+374 98 731231</a></li>
|
||||
<li><strong>Հեռախոս՝</strong> <a [href]="env.phoneTel">{{ env.phones.support }}</a></li>
|
||||
<li><strong>Աշխատանքի ժամերը՝</strong> Հստակետ (տեխնիկական աջակցություն)</li>
|
||||
<li><strong>Պատասխանի միջին ժամանակը՝</strong> Մինչև 24 ժամ աշխատանքային օրերին</li>
|
||||
</ul>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { LangRoutePipe } from '../../../../../../pipes/lang-route.pipe';
|
||||
|
||||
@@ -9,4 +10,6 @@ import { LangRoutePipe } from '../../../../../../pipes/lang-route.pipe';
|
||||
styleUrls: ['../../../../../../pages/legal/payment-terms/payment-terms.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class PaymentTermsNovoHyComponent {}
|
||||
export class PaymentTermsNovoHyComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@
|
||||
<p>По вопросам, связанным с оплатой заказов, вы можете обратиться:</p>
|
||||
<ul class="compact-list">
|
||||
<li><strong>Email:</strong> <a href="mailto:info@novo.market" class="contact-email">info@novo.market</a></li>
|
||||
<li><strong>Телефон:</strong> <a href="tel:+37498731231">+374 98 731231</a></li>
|
||||
<li><strong>Телефон:</strong> <a [href]="env.phoneTel">{{ env.phones.support }}</a></li>
|
||||
<li><strong>Время работы:</strong> Круглосуточно (техническая поддержка)</li>
|
||||
<li><strong>Среднее время ответа:</strong> До 24 часов в рабочие дни</li>
|
||||
</ul>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { LangRoutePipe } from '../../../../../../pipes/lang-route.pipe';
|
||||
|
||||
@@ -9,4 +10,6 @@ import { LangRoutePipe } from '../../../../../../pipes/lang-route.pipe';
|
||||
styleUrls: ['../../../../../../pages/legal/payment-terms/payment-terms.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class PaymentTermsNovoRuComponent {}
|
||||
export class PaymentTermsNovoRuComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@
|
||||
<h3>Contact Information</h3>
|
||||
<p>For all questions regarding personal data processing, please contact:</p>
|
||||
<a href="mailto:info@novo.market" class="contact-email">info@novo.market</a>
|
||||
<p><a href="tel:+37498731231">+374 98 731231</a></p>
|
||||
<p><a [href]="env.phoneTel">{{ env.phones.support }}</a></p>
|
||||
<p class="note">We will respond within 30 days in accordance with the legislation of the Russian Federation</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-privacy-policy-novo-en',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/legal/privacy-policy/privacy-policy.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class PrivacyPolicyNovoEnComponent {}
|
||||
export class PrivacyPolicyNovoEnComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-privacy-policy-novo-hy',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/legal/privacy-policy/privacy-policy.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class PrivacyPolicyNovoHyComponent {}
|
||||
export class PrivacyPolicyNovoHyComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@
|
||||
<h3>Контакты для связи</h3>
|
||||
<p>По всем вопросам обработки персональных данных обращайтесь:</p>
|
||||
<a href="mailto:info@novo.market" class="contact-email">info@novo.market</a>
|
||||
<p><a href="tel:+37498731231">+374 98 731231</a></p>
|
||||
<p><a [href]="env.phoneTel">{{ env.phones.support }}</a></p>
|
||||
<p class="note">Мы ответим в течение 30 дней согласно законодательству РФ</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-privacy-policy-novo-ru',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/legal/privacy-policy/privacy-policy.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class PrivacyPolicyNovoRuComponent {}
|
||||
export class PrivacyPolicyNovoRuComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -257,7 +257,7 @@
|
||||
<h2>Contact Us</h2>
|
||||
<p>Questions about the agreement:</p>
|
||||
<a href="mailto:info@novo.market" class="contact-email">info@novo.market</a>
|
||||
<p><a href="tel:+37498731231">+374 98 731231</a></p>
|
||||
<p><a [href]="env.phoneTel">{{ env.phones.support }}</a></p>
|
||||
<p class="support-note">We are always happy to help</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { LangRoutePipe } from '../../../../../../pipes/lang-route.pipe';
|
||||
|
||||
@@ -9,4 +10,6 @@ import { LangRoutePipe } from '../../../../../../pipes/lang-route.pipe';
|
||||
styleUrls: ['../../../../../../pages/legal/public-offer/public-offer.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class PublicOfferNovoEnComponent {}
|
||||
export class PublicOfferNovoEnComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -257,7 +257,7 @@
|
||||
<h2>Կապ</h2>
|
||||
<p>Համաձայնագրի վերաբերյալ հարցեր՝</p>
|
||||
<a href="mailto:info@novo.market" class="contact-email">info@novo.market</a>
|
||||
<p><a href="tel:+37498731231">+374 98 731231</a></p>
|
||||
<p><a [href]="env.phoneTel">{{ env.phones.support }}</a></p>
|
||||
<p class="support-note">Մենք միշտ պատրաստ ենք օգնելու</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { LangRoutePipe } from '../../../../../../pipes/lang-route.pipe';
|
||||
|
||||
@@ -9,4 +10,6 @@ import { LangRoutePipe } from '../../../../../../pipes/lang-route.pipe';
|
||||
styleUrls: ['../../../../../../pages/legal/public-offer/public-offer.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class PublicOfferNovoHyComponent {}
|
||||
export class PublicOfferNovoHyComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -257,7 +257,7 @@
|
||||
<h2>Контакты</h2>
|
||||
<p>Вопросы по соглашению:</p>
|
||||
<a href="mailto:info@novo.market" class="contact-email">info@novo.market</a>
|
||||
<p><a href="tel:+37498731231">+374 98 731231</a></p>
|
||||
<p><a [href]="env.phoneTel">{{ env.phones.support }}</a></p>
|
||||
<p class="support-note">Мы всегда готовы помочь</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { LangRoutePipe } from '../../../../../../pipes/lang-route.pipe';
|
||||
|
||||
@@ -9,4 +10,6 @@ import { LangRoutePipe } from '../../../../../../pipes/lang-route.pipe';
|
||||
styleUrls: ['../../../../../../pages/legal/public-offer/public-offer.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class PublicOfferNovoRuComponent {}
|
||||
export class PublicOfferNovoRuComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
<p>To resolve disputes through the platform administration:</p>
|
||||
<ul class="compact-list">
|
||||
<li><strong>E-mail:</strong> <a href="mailto:info@novo.market" class="contact-email">info@novo.market</a></li>
|
||||
<li><strong>Phone:</strong> <a href="tel:+37498731231">+374 98 731231</a></li>
|
||||
<li><strong>Phone:</strong> <a [href]="env.phoneTel">{{ env.phones.support }}</a></li>
|
||||
<li><strong>Subject:</strong> "Dispute with seller — Order #"</li>
|
||||
<li><strong>Attach:</strong> correspondence, photos, payment receipt</li>
|
||||
</ul>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-return-policy-novo-en',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/legal/return-policy/return-policy.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ReturnPolicyNovoEnComponent {}
|
||||
export class ReturnPolicyNovoEnComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
<p>Վեճերը հարթակի ադմինիստրացիայի միջոցով լուծելու համար՝</p>
|
||||
<ul class="compact-list">
|
||||
<li><strong>E-mail:</strong> <a href="mailto:info@novo.market" class="contact-email">info@novo.market</a></li>
|
||||
<li><strong>Հեռախոս՝</strong> <a href="tel:+37498731231">+374 98 731231</a></li>
|
||||
<li><strong>Հեռախոս՝</strong> <a [href]="env.phoneTel">{{ env.phones.support }}</a></li>
|
||||
<li><strong>Թեմա՝</strong> «Վեճ վաճառողի հետ — Պատվերի №»</li>
|
||||
<li><strong>Կցեք՝</strong> նամակագրություն, լուսանկարներ, վճարման կտրոն</li>
|
||||
</ul>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-return-policy-novo-hy',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/legal/return-policy/return-policy.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ReturnPolicyNovoHyComponent {}
|
||||
export class ReturnPolicyNovoHyComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
<p>Для разрешения споров через администрацию платформы:</p>
|
||||
<ul class="compact-list">
|
||||
<li><strong>E-mail:</strong> <a href="mailto:info@novo.market" class="contact-email">info@novo.market</a></li>
|
||||
<li><strong>Телефон:</strong> <a href="tel:+37498731231">+374 98 731231</a></li>
|
||||
<li><strong>Телефон:</strong> <a [href]="env.phoneTel">{{ env.phones.support }}</a></li>
|
||||
<li><strong>Тема:</strong> «Разногласия с продавцом — №Заказа»</li>
|
||||
<li><strong>Приложите:</strong> переписку, снимки, чек оплаты</li>
|
||||
</ul>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { environment } from '../../../../../../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-return-policy-novo-ru',
|
||||
@@ -6,4 +7,6 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
styleUrls: ['../../../../../../pages/legal/return-policy/return-policy.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ReturnPolicyNovoRuComponent {}
|
||||
export class ReturnPolicyNovoRuComponent {
|
||||
protected readonly env = environment;
|
||||
}
|
||||
|
||||
@@ -17,14 +17,16 @@ import { TranslateService } from '../../i18n/translate.service';
|
||||
`,
|
||||
styles: [`
|
||||
.dexar-back-btn {
|
||||
position: fixed;
|
||||
top: 76px;
|
||||
position: sticky;
|
||||
top: 72px;
|
||||
left: 20px;
|
||||
z-index: 100;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
padding: 8px 4px;
|
||||
margin-bottom: -40px;
|
||||
width: fit-content;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
svg path {
|
||||
@@ -47,7 +49,7 @@ import { TranslateService } from '../../i18n/translate.service';
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dexar-back-btn {
|
||||
top: 68px;
|
||||
top: 64px;
|
||||
left: 12px;
|
||||
|
||||
svg {
|
||||
|
||||
@@ -30,8 +30,8 @@
|
||||
<app-region-selector />
|
||||
<app-language-selector />
|
||||
|
||||
<a [routerLink]="'/cart' | langRoute" routerLinkActive="novo-cart-active" class="novo-cart" (click)="closeMenu()">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<a [routerLink]="'/cart' | langRoute" routerLinkActive="novo-cart-active" class="novo-cart" (click)="closeMenu()" [attr.aria-label]="'header.cart' | translate">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<circle cx="9" cy="21" r="1"></circle>
|
||||
<circle cx="20" cy="21" r="1"></circle>
|
||||
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"></path>
|
||||
@@ -41,7 +41,7 @@
|
||||
}
|
||||
</a>
|
||||
|
||||
<button class="menu-toggle" (click)="toggleMenu()" [class.active]="menuOpen">
|
||||
<button class="menu-toggle" (click)="toggleMenu()" [class.active]="menuOpen" [attr.aria-label]="menuOpen ? 'Close menu' : 'Open menu'" [attr.aria-expanded]="menuOpen">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
@@ -118,7 +118,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu Toggle -->
|
||||
<button class="dexar-menu-toggle" (click)="toggleMenu()" [class.active]="menuOpen">
|
||||
<button class="dexar-menu-toggle" (click)="toggleMenu()" [class.active]="menuOpen" [attr.aria-label]="menuOpen ? 'Close menu' : 'Open menu'" [attr.aria-expanded]="menuOpen">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="language-selector">
|
||||
<button class="language-button" (click)="toggleDropdown()">
|
||||
<div class="language-selector" role="listbox">
|
||||
<button class="language-button" (click)="toggleDropdown()" (keydown)="onKeyDown($event)" aria-haspopup="listbox" [attr.aria-expanded]="dropdownOpen">
|
||||
<img [src]="languageService.getCurrentLanguage()?.flagSvg"
|
||||
[alt]="languageService.getCurrentLanguage()?.name"
|
||||
class="language-flag">
|
||||
@@ -13,6 +13,8 @@
|
||||
@for (lang of languageService.languages; track lang.code) {
|
||||
<button
|
||||
class="language-option"
|
||||
role="option"
|
||||
[attr.aria-selected]="languageService.currentLanguage() === lang.code"
|
||||
[class.active]="languageService.currentLanguage() === lang.code"
|
||||
[class.disabled]="!lang.enabled"
|
||||
[disabled]="!lang.enabled"
|
||||
|
||||
@@ -44,6 +44,15 @@ export class LanguageSelectorComponent {
|
||||
this.currencyOpen = false;
|
||||
}
|
||||
|
||||
onKeyDown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') {
|
||||
this.dropdownOpen = false;
|
||||
} else if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
this.toggleDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener('document:click', ['$event'])
|
||||
onClickOutside(event: Event): void {
|
||||
if (!this.elementRef.nativeElement.contains(event.target)) {
|
||||
|
||||
@@ -31,13 +31,41 @@
|
||||
|
||||
<div class="qr-section">
|
||||
<p class="qr-hint">{{ 'auth.orScanQr' | translate }}</p>
|
||||
<div class="qr-container">
|
||||
<img [src]="'https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=' + loginUrl()"
|
||||
alt="QR Code"
|
||||
width="180"
|
||||
height="180"
|
||||
loading="lazy" />
|
||||
</div>
|
||||
|
||||
@switch (qrStatus()) {
|
||||
@case ('loading') {
|
||||
<div class="qr-container qr-loading">
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
}
|
||||
@case ('ready') {
|
||||
<div class="qr-container">
|
||||
<img [src]="'https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=' + encodedQrUrl()"
|
||||
alt="QR Code"
|
||||
width="180"
|
||||
height="180"
|
||||
loading="eager" />
|
||||
</div>
|
||||
}
|
||||
@case ('expired') {
|
||||
<div class="qr-container qr-expired" (click)="refreshQr()">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M1 4v6h6M23 20v-6h-6"/>
|
||||
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"/>
|
||||
</svg>
|
||||
<span>{{ 'auth.qrExpired' | translate }}</span>
|
||||
</div>
|
||||
}
|
||||
@case ('error') {
|
||||
<div class="qr-container">
|
||||
<img [src]="'https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=' + encodedQrUrl()"
|
||||
alt="QR Code"
|
||||
width="180"
|
||||
height="180"
|
||||
loading="lazy" />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<p class="login-note">{{ 'auth.loginNote' | translate }}</p>
|
||||
|
||||
@@ -122,6 +122,42 @@ h2 {
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&.qr-loading {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 204px;
|
||||
height: 204px;
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid #e0e0e0;
|
||||
border-top-color: var(--accent-color, #497671);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
&.qr-expired {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 204px;
|
||||
height: 204px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary, #999);
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--accent-color, #497671);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Component, ChangeDetectionStrategy, inject, signal, OnInit, OnDestroy } from '@angular/core';
|
||||
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, OnDestroy } from '@angular/core';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { CartService } from '../../services/cart.service';
|
||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||
import { getDiscountedPrice } from '../../utils/item.utils';
|
||||
|
||||
@Component({
|
||||
selector: 'app-telegram-login',
|
||||
@@ -9,17 +11,28 @@ import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||
styleUrls: ['./telegram-login.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class TelegramLoginComponent implements OnInit, OnDestroy {
|
||||
export class TelegramLoginComponent implements OnDestroy {
|
||||
private authService = inject(AuthService);
|
||||
private cartService = inject(CartService);
|
||||
|
||||
showDialog = this.authService.showLoginDialog;
|
||||
status = this.authService.status;
|
||||
|
||||
loginUrl = signal('');
|
||||
qrToken = signal('');
|
||||
qrStatus = signal<'loading' | 'ready' | 'expired' | 'error'>('loading');
|
||||
encodedQrUrl = computed(() => encodeURIComponent(this.loginUrl()));
|
||||
|
||||
private pollTimer?: ReturnType<typeof setInterval>;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loginUrl.set(this.authService.getTelegramLoginUrl());
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (this.showDialog()) {
|
||||
this.initQrLogin();
|
||||
} else {
|
||||
this.stopPolling();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
@@ -31,32 +44,111 @@ export class TelegramLoginComponent implements OnInit, OnDestroy {
|
||||
this.stopPolling();
|
||||
}
|
||||
|
||||
/** Open Telegram login link and start polling for session */
|
||||
openTelegramLogin(): void {
|
||||
window.open(this.loginUrl(), '_blank');
|
||||
this.startPolling();
|
||||
if (!this.pollTimer) {
|
||||
if (this.qrToken()) {
|
||||
this.startPolling(this.qrToken());
|
||||
} else {
|
||||
this.startSessionPolling();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Start polling the backend to detect when user completes Telegram auth */
|
||||
private startPolling(): void {
|
||||
refreshQr(): void {
|
||||
this.stopPolling();
|
||||
this.initQrLogin();
|
||||
}
|
||||
|
||||
private initQrLogin(): void {
|
||||
this.qrStatus.set('loading');
|
||||
this.authService.createQrToken().subscribe({
|
||||
next: (res) => {
|
||||
this.loginUrl.set(res.url);
|
||||
this.qrToken.set(res.token);
|
||||
this.qrStatus.set('ready');
|
||||
this.startPolling(res.token);
|
||||
},
|
||||
error: () => {
|
||||
this.loginUrl.set(this.authService.getTelegramLoginUrl());
|
||||
this.qrStatus.set('error');
|
||||
this.startSessionPolling();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private startSessionPolling(): 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
|
||||
if (checks > 100) {
|
||||
this.stopPolling();
|
||||
this.qrStatus.set('expired');
|
||||
return;
|
||||
}
|
||||
this.authService.checkSession();
|
||||
// If authenticated, stop polling and close dialog
|
||||
if (this.authService.isAuthenticated()) {
|
||||
this.stopPolling();
|
||||
this.authService.hideLogin();
|
||||
}
|
||||
this.authService.checkSessionOnce().subscribe(session => {
|
||||
if (session && session.active) {
|
||||
this.stopPolling();
|
||||
this.syncCartAndComplete(session.sessionId);
|
||||
}
|
||||
});
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
private startPolling(token: string): void {
|
||||
this.stopPolling();
|
||||
if (!token) return;
|
||||
|
||||
let checks = 0;
|
||||
this.pollTimer = setInterval(() => {
|
||||
checks++;
|
||||
if (checks > 100) {
|
||||
this.stopPolling();
|
||||
this.qrStatus.set('expired');
|
||||
return;
|
||||
}
|
||||
|
||||
this.authService.pollQrToken(token).subscribe({
|
||||
next: (res) => {
|
||||
switch (res.status) {
|
||||
case 'confirmed':
|
||||
this.stopPolling();
|
||||
if (res.session) {
|
||||
this.syncCartAndComplete(res.session.sessionId);
|
||||
} else {
|
||||
this.authService.onTelegramLoginComplete();
|
||||
}
|
||||
break;
|
||||
case 'expired':
|
||||
this.stopPolling();
|
||||
this.qrStatus.set('expired');
|
||||
break;
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
// Network error — keep polling
|
||||
}
|
||||
});
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
private syncCartAndComplete(sessionId: string): void {
|
||||
const cartItems = this.cartService.items().map(item => ({
|
||||
itemID: item.itemID,
|
||||
quantity: item.quantity,
|
||||
colour: item.colour || '',
|
||||
size: item.size || '',
|
||||
price: item.discount > 0
|
||||
? item.price * (1 - item.discount / 100)
|
||||
: item.price,
|
||||
}));
|
||||
|
||||
this.authService.syncCart(sessionId, cartItems).subscribe(() => {
|
||||
this.authService.onTelegramLoginComplete();
|
||||
});
|
||||
}
|
||||
|
||||
private stopPolling(): void {
|
||||
if (this.pollTimer) {
|
||||
clearInterval(this.pollTimer);
|
||||
|
||||
19
src/app/config/constants.ts
Normal file
19
src/app/config/constants.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// Payment polling
|
||||
export const PAYMENT_POLL_INTERVAL_MS = 5000;
|
||||
export const PAYMENT_MAX_CHECKS = 36;
|
||||
export const PAYMENT_TIMEOUT_CLOSE_MS = 3000;
|
||||
export const PAYMENT_ERROR_CLOSE_MS = 4000;
|
||||
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 = 50;
|
||||
|
||||
// Search
|
||||
export const SEARCH_DEBOUNCE_MS = 300;
|
||||
export const SEARCH_MIN_LENGTH = 3;
|
||||
|
||||
// Cache
|
||||
export const CACHE_DURATION_MS = 5 * 60 * 1000;
|
||||
export const CATEGORY_CACHE_DURATION_MS = 2 * 60 * 1000;
|
||||
@@ -205,5 +205,6 @@ export const en: Translations = {
|
||||
loginWithTelegram: 'Log in with Telegram',
|
||||
orScanQr: 'Or scan the QR code',
|
||||
loginNote: 'You will be redirected back after login',
|
||||
qrExpired: 'QR code expired. Click to refresh',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Translations } from './translations';
|
||||
import { Translations } from './translations';
|
||||
|
||||
export const hy: Translations = {
|
||||
header: {
|
||||
@@ -205,5 +205,6 @@ export const hy: Translations = {
|
||||
loginWithTelegram: 'Մուտք Telegram-ով',
|
||||
orScanQr: 'Կամ սքանավորեք QR կոդը',
|
||||
loginNote: 'Մուտքից հետո դուք կվերաուղղվեք',
|
||||
qrExpired: 'QR կոդը հնացել է։ Սեղմեք՝ թարմացնելու համար',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -205,5 +205,6 @@ export const ru: Translations = {
|
||||
loginWithTelegram: 'Войти через Telegram',
|
||||
orScanQr: 'Или отсканируйте QR-код',
|
||||
loginNote: 'После входа вы будете перенаправлены обратно',
|
||||
qrExpired: 'QR-код устарел. Нажмите, чтобы обновить',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -203,5 +203,6 @@ export interface Translations {
|
||||
loginWithTelegram: string;
|
||||
orScanQr: string;
|
||||
loginNote: string;
|
||||
qrExpired: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,6 +19,25 @@ const REGION_HEADER_MAP: Record<string, string> = {
|
||||
'yerevan': 'Yerevan',
|
||||
};
|
||||
|
||||
const SESSION_STORAGE_KEY = 'web_session_id';
|
||||
|
||||
/** Generate a 32-char hex string (GUID without dashes) */
|
||||
function generateSessionId(): string {
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/** Get or create a persistent anonymous session ID */
|
||||
function getAnonymousSessionId(): string {
|
||||
let id = localStorage.getItem(SESSION_STORAGE_KEY);
|
||||
if (!id || id.length !== 32) {
|
||||
id = generateSessionId();
|
||||
localStorage.setItem(SESSION_STORAGE_KEY, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export const apiHeadersInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
if (!req.url.startsWith(environment.apiUrl)) {
|
||||
return next(req);
|
||||
@@ -42,9 +61,7 @@ export const apiHeadersInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
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);
|
||||
}
|
||||
headers = headers.set('WebSessionID', session?.sessionId || getAnonymousSessionId());
|
||||
|
||||
return next(req.clone({ headers }));
|
||||
};
|
||||
|
||||
@@ -2,8 +2,9 @@ import { HttpInterceptorFn, HttpResponse } from '@angular/common/http';
|
||||
import { of } from 'rxjs';
|
||||
import { tap } from 'rxjs/operators';
|
||||
|
||||
import { CACHE_DURATION_MS, CATEGORY_CACHE_DURATION_MS } from '../config/constants';
|
||||
|
||||
const cache = new Map<string, { response: HttpResponse<unknown>, timestamp: number }>();
|
||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 минут
|
||||
|
||||
export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
// Кэшируем только GET запросы
|
||||
@@ -11,12 +12,16 @@ export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
return next(req);
|
||||
}
|
||||
|
||||
// Кэшируем только запросы списка категорий (не товары категорий)
|
||||
const shouldCache = req.url.match(/\/category$/) !== null;
|
||||
if (!shouldCache) {
|
||||
// Кэшируем списки категорий, товары категорий и отдельные товары
|
||||
const isCategoryList = /\/category$/.test(req.url);
|
||||
const isCategoryItems = /\/category\/[^/?]+/.test(req.url);
|
||||
const isItem = /\/items\/[^/?]+/.test(req.url);
|
||||
if (!isCategoryList && !isCategoryItems && !isItem) {
|
||||
return next(req);
|
||||
}
|
||||
|
||||
const ttl = isCategoryList ? CACHE_DURATION_MS : CATEGORY_CACHE_DURATION_MS;
|
||||
|
||||
// Cleanup expired entries before checking
|
||||
cleanupExpiredCache();
|
||||
|
||||
@@ -25,7 +30,7 @@ export const cacheInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
// Проверяем наличие и актуальность кэша
|
||||
if (cachedResponse) {
|
||||
const age = Date.now() - cachedResponse.timestamp;
|
||||
if (age < CACHE_DURATION) {
|
||||
if (age < ttl) {
|
||||
return of(cachedResponse.response.clone());
|
||||
} else {
|
||||
cache.delete(req.url);
|
||||
@@ -53,7 +58,7 @@ export function clearCache(): void {
|
||||
function cleanupExpiredCache(): void {
|
||||
const now = Date.now();
|
||||
for (const [url, data] of cache.entries()) {
|
||||
if (now - data.timestamp >= CACHE_DURATION) {
|
||||
if (now - data.timestamp >= CACHE_DURATION_MS) {
|
||||
cache.delete(url);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -661,14 +661,19 @@ function getAllVisibleItems(): any[] {
|
||||
return MOCK_ITEMS.filter(i => i.visible !== false);
|
||||
}
|
||||
|
||||
function getItemsByCategoryId(categoryID: number): any[] {
|
||||
return getAllVisibleItems().filter(i => i.categoryID === categoryID);
|
||||
function getItemsByCategoryId(categoryID: number | string): any[] {
|
||||
return getAllVisibleItems().filter(i =>
|
||||
i.categoryID === categoryID || i.subcategoryId === categoryID
|
||||
);
|
||||
}
|
||||
|
||||
function respond<T>(body: T, delayMs = 150) {
|
||||
return of(new HttpResponse({ status: 200, body })).pipe(delay(delayMs));
|
||||
}
|
||||
|
||||
// ─── Mock Auth State ───
|
||||
let mockQrPollCount = 0;
|
||||
|
||||
// ─── The Interceptor ───
|
||||
|
||||
export const mockDataInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
@@ -683,24 +688,63 @@ export const mockDataInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
return respond({ message: 'pong (mock)' });
|
||||
}
|
||||
|
||||
// ── GET /auth/session
|
||||
if (url.includes('/auth/session') && req.method === 'GET') {
|
||||
return respond({ active: false }, 100);
|
||||
}
|
||||
|
||||
// ── POST /auth/qr/create
|
||||
if (url.includes('/auth/qr/create') && req.method === 'POST') {
|
||||
const token = 'mock-qr-token-' + Date.now();
|
||||
const botUsername = (environment as Record<string, unknown>)['telegramBot'] as string || 'DexarSupport_bot';
|
||||
mockQrPollCount = 0;
|
||||
return respond({
|
||||
token,
|
||||
url: `https://t.me/${botUsername}?start=qr_${token}`
|
||||
}, 200);
|
||||
}
|
||||
|
||||
// ── GET /auth/qr/poll
|
||||
if (url.includes('/auth/qr/poll') && req.method === 'GET') {
|
||||
mockQrPollCount++;
|
||||
// Simulate confirmed after 3 polls (~9 seconds)
|
||||
if (mockQrPollCount >= 3) {
|
||||
return respond({
|
||||
status: 'confirmed',
|
||||
session: {
|
||||
sessionId: 'mock-session-' + Date.now(),
|
||||
active: true,
|
||||
displayName: 'Telegram User',
|
||||
expiresAt: new Date(Date.now() + 3600000).toISOString()
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
return respond({ status: 'pending' }, 200);
|
||||
}
|
||||
|
||||
// ── 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+)$/);
|
||||
const catItemsMatch = url.match(/\/category\/([^/?]+)$/);
|
||||
if (catItemsMatch && req.method === 'GET') {
|
||||
const catId = parseInt(catItemsMatch[1], 10);
|
||||
const raw = catItemsMatch[1];
|
||||
const num = Number(raw);
|
||||
const catId = Number.isFinite(num) ? num : raw;
|
||||
const items = getItemsByCategoryId(catId);
|
||||
return respond(items);
|
||||
}
|
||||
|
||||
// ── GET /item/:id
|
||||
const itemMatch = url.match(/\/item\/(\d+)$/);
|
||||
const itemMatch = url.match(/\/item\/([^/?]+)$/);
|
||||
if (itemMatch && req.method === 'GET') {
|
||||
const itemId = parseInt(itemMatch[1], 10);
|
||||
const item = MOCK_ITEMS.find(i => i.itemID === itemId);
|
||||
const raw = itemMatch[1];
|
||||
const num = Number(raw);
|
||||
const item = MOCK_ITEMS.find(i =>
|
||||
Number.isFinite(num) ? i.itemID === num : i.id === raw
|
||||
);
|
||||
if (item) {
|
||||
return respond(item);
|
||||
}
|
||||
@@ -735,34 +779,28 @@ export const mockDataInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
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)' });
|
||||
// ── POST /websession/:id (add to cart)
|
||||
if (url.match(/\/websession\/[^/]+$/) && req.method === 'POST') {
|
||||
return respond({
|
||||
sessionId: 'mock-session',
|
||||
Status: true,
|
||||
cart: req.body
|
||||
});
|
||||
}
|
||||
|
||||
// ── PATCH /cart
|
||||
if (url.endsWith('/cart') && req.method === 'PATCH') {
|
||||
return respond({ message: 'Updated (mock)' });
|
||||
// ── POST /websession/:id/qr (create payment QR)
|
||||
if (url.match(/\/websession\/[^/]+\/qr$/) && req.method === 'POST') {
|
||||
return respond({
|
||||
qrId: 'mock-qr-' + Date.now(),
|
||||
qrStatus: 'NEW',
|
||||
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);
|
||||
}
|
||||
|
||||
// ── DELETE /cart
|
||||
if (url.endsWith('/cart') && req.method === 'DELETE') {
|
||||
return respond({ message: 'Removed (mock)' });
|
||||
}
|
||||
|
||||
// ── POST /comment
|
||||
if (url.endsWith('/comment') && req.method === 'POST') {
|
||||
// ── POST /items/:id/callback (review)
|
||||
if (url.match(/\/items\/\d+\/callback$/) && req.method === 'POST') {
|
||||
return respond({ message: 'Review submitted (mock)' }, 200);
|
||||
}
|
||||
|
||||
@@ -771,8 +809,8 @@ export const mockDataInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
return respond({ message: 'Email sent (mock)' }, 200);
|
||||
}
|
||||
|
||||
// ── GET /qr/payment/:id (always return success for testing)
|
||||
if (url.includes('/qr/payment/') && req.method === 'GET') {
|
||||
// ── GET /websession/:id/:qrId (check QR payment status)
|
||||
if (url.match(/\/websession\/[^/]+\/[^/]+$/) && !url.match(/\/websession\/[^/]+\/qr$/) && req.method === 'GET') {
|
||||
return respond({
|
||||
paymentStatus: 'SUCCESS',
|
||||
code: 'SUCCESS',
|
||||
@@ -785,8 +823,7 @@ export const mockDataInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
paymentPurpose: '',
|
||||
createDate: new Date().toISOString(),
|
||||
order: 'mock-order',
|
||||
qrExpirationDate: new Date().toISOString(),
|
||||
phoneNumber: ''
|
||||
qrExpirationDate: new Date().toISOString()
|
||||
}, 500);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,4 +17,9 @@ export interface TelegramAuthData {
|
||||
hash: string;
|
||||
}
|
||||
|
||||
export interface QrPollResponse {
|
||||
status: 'pending' | 'confirmed' | 'expired';
|
||||
session?: AuthSession;
|
||||
}
|
||||
|
||||
export type AuthStatus = 'unknown' | 'checking' | 'authenticated' | 'expired' | 'unauthenticated';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ItemName } from './item.model';
|
||||
|
||||
export interface Category {
|
||||
categoryID: number;
|
||||
name: string;
|
||||
@@ -5,7 +7,10 @@ export interface Category {
|
||||
icon?: string;
|
||||
wideBanner?: string;
|
||||
itemCount?: number;
|
||||
categoriesCount?: number;
|
||||
priority?: number;
|
||||
names?: ItemName[];
|
||||
translations?: Record<string, CategoryTranslation>;
|
||||
|
||||
// BackOffice API fields
|
||||
id?: string;
|
||||
|
||||
@@ -40,6 +40,8 @@ export interface Question {
|
||||
answer: string;
|
||||
upvotes: number;
|
||||
downvotes: number;
|
||||
like?: number;
|
||||
dislike?: number;
|
||||
}
|
||||
|
||||
/** Localized name entry from backend */
|
||||
@@ -60,6 +62,16 @@ export interface ItemAttribute {
|
||||
value: string;
|
||||
}
|
||||
|
||||
/** Item variant detail (price, size, colour per variant) */
|
||||
export interface ItemDetail {
|
||||
color?: string;
|
||||
colour?: string;
|
||||
size?: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
export interface Item {
|
||||
categoryID: number;
|
||||
itemID: number;
|
||||
@@ -95,6 +107,8 @@ export interface Item {
|
||||
subcategoryId?: string;
|
||||
translations?: Record<string, ItemTranslation>;
|
||||
comments?: Comment[];
|
||||
visits?: number;
|
||||
itemDetails?: ItemDetail[];
|
||||
}
|
||||
|
||||
export interface CartItem extends Item {
|
||||
|
||||
@@ -46,12 +46,15 @@
|
||||
|
||||
<p class="item-description">{{ itemDesc(item) || '' }}...</p>
|
||||
|
||||
@if (item.colour || item.size) {
|
||||
@if (item.colour || (item.size && item.size.toLowerCase() !== 'default')) {
|
||||
<div class="cart-item-variants">
|
||||
@if (item.colour) {
|
||||
<span class="cart-variant">{{ 'itemDetail.colour' | translate }}: {{ item.colour }}</span>
|
||||
<span class="cart-variant cart-variant-colour">
|
||||
{{ 'itemDetail.colour' | translate }}:
|
||||
<span class="cart-colour-swatch" [style.background-color]="item.colour" [title]="item.colour"></span>
|
||||
</span>
|
||||
}
|
||||
@if (item.size) {
|
||||
@if (item.size && item.size.toLowerCase() !== 'default') {
|
||||
<span class="cart-variant">{{ 'itemDetail.size' | translate }}: {{ item.size }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -368,6 +368,7 @@
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
|
||||
.cart-variant {
|
||||
@@ -377,6 +378,18 @@
|
||||
padding: 3px 10px;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cart-colour-swatch {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,6 +497,7 @@
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
|
||||
.cart-variant {
|
||||
@@ -493,6 +507,18 @@
|
||||
padding: 3px 10px;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cart-colour-swatch {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -886,6 +912,85 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.cart-login-gate {
|
||||
margin-top: 20px;
|
||||
padding: 24px;
|
||||
border-radius: 16px;
|
||||
background: rgba(16, 185, 129, 0.04);
|
||||
border: 1px dashed rgba(16, 185, 129, 0.3);
|
||||
text-align: center;
|
||||
|
||||
.login-gate-icon {
|
||||
margin: 0 auto 12px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
color: #10b981;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login-gate-title {
|
||||
margin: 0 0 4px;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.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: 12px;
|
||||
background: #2AABEE;
|
||||
color: #fff;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&: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: #9ca3af;
|
||||
}
|
||||
|
||||
.qr-wrapper {
|
||||
display: inline-flex;
|
||||
padding: 10px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
|
||||
img {
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Terms agreement - shared base
|
||||
|
||||
@@ -13,6 +13,7 @@ import { getDiscountedPrice, getMainImage, trackByItemId, getBadgeClass, getTran
|
||||
import { LangRoutePipe } from '../../pipes/lang-route.pipe';
|
||||
import { TranslatePipe } from '../../i18n/translate.pipe';
|
||||
import { TranslateService } from '../../i18n/translate.service';
|
||||
import { PAYMENT_POLL_INTERVAL_MS, PAYMENT_MAX_CHECKS, PAYMENT_TIMEOUT_CLOSE_MS, PAYMENT_ERROR_CLOSE_MS, LINK_COPIED_DURATION_MS } from '../../config/constants';
|
||||
|
||||
@Component({
|
||||
selector: 'app-cart',
|
||||
@@ -55,7 +56,7 @@ export class CartComponent implements OnDestroy {
|
||||
emailSubmitting = signal<boolean>(false);
|
||||
paidItems: CartItem[] = [];
|
||||
|
||||
maxChecks = 36; // 36 checks * 5 seconds = 180 seconds (3 minutes)
|
||||
maxChecks = PAYMENT_MAX_CHECKS;
|
||||
private pollingSubscription?: Subscription;
|
||||
private closeTimeout?: ReturnType<typeof setTimeout>;
|
||||
|
||||
@@ -181,51 +182,62 @@ export class CartComponent implements OnDestroy {
|
||||
}
|
||||
|
||||
createPayment(): void {
|
||||
const telegramUsername = this.getTelegramUsername();
|
||||
const userId = this.getUserId();
|
||||
const orderId = this.generateOrderId();
|
||||
const sessionId = this.authService.session()?.sessionId || '';
|
||||
if (!sessionId) {
|
||||
this.paymentStatus.set('timeout');
|
||||
return;
|
||||
}
|
||||
|
||||
const paymentData = {
|
||||
amount: this.totalPrice(),
|
||||
currency: this.langService.currentCurrency(),
|
||||
siteuserID: userId,
|
||||
siteorderID: orderId,
|
||||
redirectUrl: '',
|
||||
telegramUsername: telegramUsername,
|
||||
items: this.items().map((item: CartItem) => ({
|
||||
itemID: item.itemID,
|
||||
price: item.discount > 0
|
||||
? item.price * (1 - item.discount / 100)
|
||||
: item.price,
|
||||
name: item.name,
|
||||
quantity: item.quantity
|
||||
}))
|
||||
};
|
||||
// First sync cart items to server via websession, then create QR
|
||||
const cartItems = this.items().map((item: CartItem) => ({
|
||||
itemID: item.itemID,
|
||||
quantity: item.quantity,
|
||||
colour: item.colour || '',
|
||||
size: item.size || '',
|
||||
price: item.discount > 0
|
||||
? item.price * (1 - item.discount / 100)
|
||||
: item.price,
|
||||
}));
|
||||
|
||||
this.apiService.createPayment(paymentData).subscribe({
|
||||
next: (response) => {
|
||||
this.paymentId.set(response.qrId);
|
||||
this.qrCodeUrl.set(response.qrUrl);
|
||||
this.paymentUrl.set(response.payload);
|
||||
this.paymentStatus.set('waiting');
|
||||
this.startPolling();
|
||||
this.apiService.addToCart(sessionId, cartItems).subscribe({
|
||||
next: () => {
|
||||
this.apiService.createPayment(sessionId).subscribe({
|
||||
next: (response) => {
|
||||
this.paymentId.set(response.qrId);
|
||||
this.qrCodeUrl.set(response.qrUrl);
|
||||
this.paymentUrl.set(response.Payload);
|
||||
this.paymentStatus.set('waiting');
|
||||
this.startPolling();
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error creating payment:', err);
|
||||
this.paymentStatus.set('timeout');
|
||||
if (this.closeTimeout) clearTimeout(this.closeTimeout);
|
||||
this.closeTimeout = setTimeout(() => {
|
||||
this.closePaymentPopup();
|
||||
}, PAYMENT_ERROR_CLOSE_MS);
|
||||
}
|
||||
});
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error creating payment:', err);
|
||||
console.error('Error syncing cart:', err);
|
||||
this.paymentStatus.set('timeout');
|
||||
if (this.closeTimeout) clearTimeout(this.closeTimeout);
|
||||
this.closeTimeout = setTimeout(() => {
|
||||
this.closePaymentPopup();
|
||||
}, 4000);
|
||||
}, PAYMENT_ERROR_CLOSE_MS);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
startPolling(): void {
|
||||
this.pollingSubscription = interval(5000) // every 5 seconds
|
||||
this.stopPolling();
|
||||
this.pollingSubscription = interval(PAYMENT_POLL_INTERVAL_MS)
|
||||
.pipe(
|
||||
take(this.maxChecks), // maximum 36 checks (3 minutes)
|
||||
switchMap(() => {
|
||||
return this.apiService.checkPaymentStatus(this.paymentId());
|
||||
const sessionId = this.authService.session()?.sessionId || '';
|
||||
return this.apiService.checkPaymentStatus(sessionId, this.paymentId());
|
||||
})
|
||||
)
|
||||
.subscribe({
|
||||
@@ -245,17 +257,19 @@ export class CartComponent implements OnDestroy {
|
||||
if (this.paymentStatus() === 'waiting') {
|
||||
this.paymentStatus.set('timeout');
|
||||
// Close popup after showing timeout message
|
||||
if (this.closeTimeout) clearTimeout(this.closeTimeout);
|
||||
this.closeTimeout = setTimeout(() => {
|
||||
this.closePaymentPopup();
|
||||
}, 3000);
|
||||
}, PAYMENT_TIMEOUT_CLOSE_MS);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error checking payment status:', err);
|
||||
// Continue checking even on error until time runs out
|
||||
if (this.closeTimeout) clearTimeout(this.closeTimeout);
|
||||
this.closeTimeout = setTimeout(() => {
|
||||
this.closePaymentPopup();
|
||||
}, 3000);
|
||||
}, PAYMENT_TIMEOUT_CLOSE_MS);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -271,34 +285,13 @@ export class CartComponent implements OnDestroy {
|
||||
if (url) {
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
this.linkCopied.set(true);
|
||||
setTimeout(() => this.linkCopied.set(false), 2000);
|
||||
setTimeout(() => this.linkCopied.set(false), LINK_COPIED_DURATION_MS);
|
||||
}).catch(err => {
|
||||
console.error(this.i18n.t('cart.copyError'), err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private getTelegramUsername(): string {
|
||||
if (typeof window !== 'undefined' && window.Telegram?.WebApp?.initDataUnsafe?.user) {
|
||||
const user = window.Telegram.WebApp.initDataUnsafe.user;
|
||||
return user.username || 'nontelegram';
|
||||
}
|
||||
return 'nontelegram';
|
||||
}
|
||||
|
||||
private getUserId(): string {
|
||||
if (typeof window !== 'undefined' && window.Telegram?.WebApp?.initDataUnsafe?.user) {
|
||||
return window.Telegram.WebApp.initDataUnsafe.user.id.toString();
|
||||
}
|
||||
return `web_${Date.now()}`;
|
||||
}
|
||||
|
||||
private generateOrderId(): string {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 8);
|
||||
return `order_${timestamp}_${random}`;
|
||||
}
|
||||
|
||||
submitEmail(): void {
|
||||
// Mark both fields as touched
|
||||
this.emailTouched.set(true);
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
@if (!error()) {
|
||||
<div class="items-grid">
|
||||
@for (item of items(); track trackByItemId($index, item)) {
|
||||
<div class="item-card">
|
||||
<div class="item-card" (mouseenter)="onItemHover(item.itemID)">
|
||||
<a [routerLink]="['/item', item.itemID] | langRoute" class="item-link">
|
||||
<div class="item-image">
|
||||
<img [src]="getMainImage(item)" [alt]="itemName(item)" loading="lazy" decoding="async" width="300" height="300" />
|
||||
@@ -52,19 +52,29 @@
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<button class="add-to-cart-btn" (click)="addToCart(item.itemID, $event)">
|
||||
<button class="add-to-cart-btn" (click)="addToCart(item.itemID, $event)" [attr.aria-label]="('category.addToCart' | translate) + ': ' + item.name">
|
||||
{{ 'category.addToCart' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (loading() && items().length > 0) {
|
||||
<div class="loading-more">
|
||||
<div class="spinner"></div>
|
||||
<p>{{ 'category.loadingMore' | translate }}</p>
|
||||
</div>
|
||||
}
|
||||
@if (loading() && items().length > 0) {
|
||||
@for (i of skeletonSlots; track i) {
|
||||
<div class="item-card skeleton-card">
|
||||
<div class="item-link">
|
||||
<div class="item-image skeleton-image"></div>
|
||||
<div class="item-details">
|
||||
<div class="skeleton-line skeleton-title"></div>
|
||||
<div class="skeleton-line skeleton-rating"></div>
|
||||
<div class="skeleton-line skeleton-price"></div>
|
||||
<div class="skeleton-line skeleton-stock"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="skeleton-btn"></div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!hasMore() && items().length > 0) {
|
||||
<div class="no-more">
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
|
||||
.items-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 30px;
|
||||
margin-bottom: 40px;
|
||||
width: 100%;
|
||||
@@ -103,8 +103,10 @@
|
||||
|
||||
.item-card {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&:hover {
|
||||
@@ -139,7 +141,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f5f5;
|
||||
background: #f0f0f0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
@@ -147,7 +149,7 @@
|
||||
object-fit: contain;
|
||||
background: white;
|
||||
padding: 12px;
|
||||
transition: transform 0.3s ease;
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover img {
|
||||
@@ -192,6 +194,7 @@
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
display: -webkit-box;
|
||||
line-clamp: 2;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
@@ -267,7 +270,7 @@
|
||||
.add-to-cart-btn {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: #497671;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0 0 13px 13px;
|
||||
@@ -279,7 +282,7 @@
|
||||
margin-top: -1px;
|
||||
|
||||
&:hover {
|
||||
background: #3a5f5b;
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
&:active {
|
||||
@@ -287,16 +290,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #d3dad9;
|
||||
border-top: 4px solid #497671;
|
||||
border-top: 4px solid var(--primary-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 12px;
|
||||
@@ -312,24 +310,77 @@
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
// Skeleton loading cards
|
||||
.skeleton-card {
|
||||
pointer-events: none;
|
||||
|
||||
.skeleton-image {
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(90deg, #e8e8e8 25%, #d8d8d8 50%, #e8e8e8 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
.skeleton-title {
|
||||
height: 16px;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.skeleton-rating {
|
||||
height: 12px;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.skeleton-price {
|
||||
height: 18px;
|
||||
width: 40%;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.skeleton-stock {
|
||||
height: 6px;
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.skeleton-btn {
|
||||
height: 42px;
|
||||
background: linear-gradient(90deg, #5a8a85 25%, #497671 50%, #5a8a85 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: 0 0 13px 13px;
|
||||
margin-top: -1px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
// Responsive
|
||||
@media (max-width: 1200px) {
|
||||
.items-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.items-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.items-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@@ -353,7 +404,7 @@
|
||||
}
|
||||
|
||||
.items-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,14 @@ import { Component, OnInit, OnDestroy, signal, HostListener, ChangeDetectionStra
|
||||
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, 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';
|
||||
|
||||
@Component({
|
||||
selector: 'app-category',
|
||||
@@ -24,7 +26,7 @@ export class CategoryComponent implements OnInit, OnDestroy {
|
||||
hasMore = signal(true);
|
||||
|
||||
private skip = 0;
|
||||
private readonly count = 50;
|
||||
private readonly count = ITEMS_PER_PAGE;
|
||||
private isLoadingMore = false;
|
||||
private routeSubscription?: Subscription;
|
||||
private scrollTimeout?: ReturnType<typeof setTimeout>;
|
||||
@@ -32,7 +34,8 @@ export class CategoryComponent implements OnInit, OnDestroy {
|
||||
constructor(
|
||||
private route: ActivatedRoute,
|
||||
private apiService: ApiService,
|
||||
private cartService: CartService
|
||||
private cartService: CartService,
|
||||
private prefetchService: PrefetchService
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
@@ -91,12 +94,12 @@ export class CategoryComponent implements OnInit, OnDestroy {
|
||||
|
||||
this.scrollTimeout = setTimeout(() => {
|
||||
const scrollPosition = window.innerHeight + window.scrollY;
|
||||
const bottomPosition = document.documentElement.scrollHeight - 500;
|
||||
const bottomPosition = document.documentElement.scrollHeight - SCROLL_THRESHOLD_PX;
|
||||
|
||||
if (scrollPosition >= bottomPosition && !this.loading() && this.hasMore() && !this.isLoadingMore) {
|
||||
this.loadItems();
|
||||
}
|
||||
}, 100);
|
||||
}, SCROLL_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
addToCart(itemID: number, event: Event): void {
|
||||
@@ -105,6 +108,11 @@ export class CategoryComponent implements OnInit, OnDestroy {
|
||||
this.cartService.addItem(itemID);
|
||||
}
|
||||
|
||||
onItemHover(itemID: number): void {
|
||||
this.prefetchService.prefetchItem(itemID);
|
||||
}
|
||||
|
||||
readonly skeletonSlots = Array.from({ length: 8 });
|
||||
readonly getDiscountedPrice = getDiscountedPrice;
|
||||
readonly getMainImage = getMainImage;
|
||||
readonly trackByItemId = trackByItemId;
|
||||
|
||||
@@ -48,13 +48,13 @@
|
||||
<a [routerLink]="['/category', cat.categoryID] | langRoute" class="category-card">
|
||||
<div class="category-image">
|
||||
@if (cat.icon) {
|
||||
<img [src]="cat.icon" [alt]="cat.name" loading="lazy" decoding="async" />
|
||||
<img [src]="cat.icon" [alt]="categoryName(cat)" loading="lazy" decoding="async" />
|
||||
} @else {
|
||||
<div class="category-fallback">{{ cat.name.charAt(0) }}</div>
|
||||
<div class="category-fallback">{{ categoryName(cat).charAt(0) }}</div>
|
||||
}
|
||||
</div>
|
||||
<div class="category-info">
|
||||
<h3 class="category-name">{{ cat.name }}</h3>
|
||||
<h3 class="category-name">{{ categoryName(cat) }}</h3>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
|
||||
@@ -365,7 +365,7 @@
|
||||
|
||||
.item-cart-btn {
|
||||
align-self: flex-end;
|
||||
background: #497671;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
@@ -374,7 +374,7 @@
|
||||
transition: background 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: #3a5f5b;
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user