From 7183f6e497ab40e470f87b327e2b68e86afb0aec Mon Sep 17 00:00:00 2001 From: ulitsaRaskolnikova Date: Mon, 9 Feb 2026 17:26:40 +0300 Subject: [PATCH] fix(front): domains --- .../src/main/resources/application-docker.yml | 7 + domains-frontend/src/api/Constants.ts | 1 + domains-frontend/src/api/apiClientOrders.ts | 109 ++++++ domains-frontend/src/pages/app/CartPage.tsx | 318 ++++++++---------- 4 files changed, 253 insertions(+), 182 deletions(-) create mode 100644 domains-frontend/src/api/apiClientOrders.ts diff --git a/api-gateway/src/main/resources/application-docker.yml b/api-gateway/src/main/resources/application-docker.yml index a91df7f..4942f21 100644 --- a/api-gateway/src/main/resources/application-docker.yml +++ b/api-gateway/src/main/resources/application-docker.yml @@ -50,6 +50,13 @@ spring: filters: - StripPrefix=${API_GATEWAY_STRIP_PREFIX:1} + - id: order-service + uri: http://order-service:8084 + predicates: + - Path=/api/orders/** + filters: + - StripPrefix=${API_GATEWAY_STRIP_PREFIX:1} + - id: audit-service uri: http://audit-service:8087 predicates: diff --git a/domains-frontend/src/api/Constants.ts b/domains-frontend/src/api/Constants.ts index c4893c0..9ba7e42 100644 --- a/domains-frontend/src/api/Constants.ts +++ b/domains-frontend/src/api/Constants.ts @@ -1,4 +1,5 @@ export const AUTH_URL = '/api'; export const DOMAIN_ORDER_URL = '/api/domains'; +export const ORDER_URL = '/api/orders'; export const EXDNS_URL = '/exdns'; export const EXDNS_TOKEN = 'changeme'; \ No newline at end of file diff --git a/domains-frontend/src/api/apiClientOrders.ts b/domains-frontend/src/api/apiClientOrders.ts new file mode 100644 index 0000000..1bd1473 --- /dev/null +++ b/domains-frontend/src/api/apiClientOrders.ts @@ -0,0 +1,109 @@ +import Axios, { type AxiosRequestConfig } from 'axios'; +import { getAccessToken, getRefreshToken, setTokens } from '~/utils/authTokens'; +import { ORDER_URL } from './Constants'; + +const AUTH_HEADER = 'Authorization'; + +export const ORDER_AXIOS_INSTANCE = Axios.create({ + baseURL: ORDER_URL, +}); + +const refreshClient = Axios.create({ + baseURL: ORDER_URL, +}); + +const isRefreshRequest = (url?: string) => url?.includes('/auth/refresh'); + +const applyAccessToken = (config: AxiosRequestConfig, accessToken?: string) => { + if (!accessToken) return; + if (isRefreshRequest(config.url)) return; + + const headers = config.headers ?? {}; + if ( + typeof (headers as { set?: (k: string, v: string) => void }).set === + 'function' + ) { + (headers as { set: (k: string, v: string) => void }).set( + AUTH_HEADER, + `Bearer ${accessToken}` + ); + } else { + (headers as Record)[AUTH_HEADER] = `Bearer ${accessToken}`; + } + config.headers = headers; +}; + +const refreshAccessToken = async () => { + const refreshToken = getRefreshToken(); + if (!refreshToken) { + return null; + } + + const { data } = await refreshClient.post('/auth/refresh', { + refreshToken, + }); + + const accessToken = data?.data?.accessToken; + const nextRefreshToken = data?.data?.refreshToken ?? refreshToken; + + if (!accessToken) { + return null; + } + + setTokens({ access: accessToken, refresh: nextRefreshToken }); + + return accessToken; +}; + +ORDER_AXIOS_INSTANCE.interceptors.request.use((config) => { + applyAccessToken(config, getAccessToken()); + return config; +}); + +let refreshPromise: Promise | null = null; + +ORDER_AXIOS_INSTANCE.interceptors.response.use( + (response) => response, + async (error) => { + const status = error?.response?.status; + const originalConfig = error?.config as + | (AxiosRequestConfig & { _retry?: boolean }) + | undefined; + + if (!originalConfig || status !== 403 || originalConfig._retry) { + return Promise.reject(error); + } + + if (isRefreshRequest(originalConfig.url)) { + return Promise.reject(error); + } + + originalConfig._retry = true; + + if (!refreshPromise) { + refreshPromise = refreshAccessToken().finally(() => { + refreshPromise = null; + }); + } + + const accessToken = await refreshPromise; + if (!accessToken) { + return Promise.reject(error); + } + + applyAccessToken(originalConfig, accessToken); + return ORDER_AXIOS_INSTANCE(originalConfig); + } +); + +export const orderCustomInstance = ( + config: AxiosRequestConfig, + options?: AxiosRequestConfig +): Promise => { + const promise = ORDER_AXIOS_INSTANCE({ + ...config, + ...options, + }).then(({ data }) => data); + + return promise; +}; diff --git a/domains-frontend/src/pages/app/CartPage.tsx b/domains-frontend/src/pages/app/CartPage.tsx index c5b192d..04ba717 100644 --- a/domains-frontend/src/pages/app/CartPage.tsx +++ b/domains-frontend/src/pages/app/CartPage.tsx @@ -1,4 +1,4 @@ -import { +import { Box, Button, Field, @@ -9,79 +9,34 @@ Text, } from '@chakra-ui/react'; import { useCallback, useEffect, useMemo, useState, type FormEvent } from 'react'; -import type { DomainQuery } from '~/api/models/DomainQuery'; -import { RecordType, type Zone as ExdnsZone } from '~/api/models/exdns'; -import { - AddCartItemRequestAction, - AddCartItemRequestTerm, -} from '~/api/models/domain-order'; -import { - addItem, - clearCart, - createDomain, - getCart, - getZoneByName, - removeItem, - searchDomains, -} from '~/api/services/domain-order'; -import { postZonesDomain } from '~/api/services/exdns'; -import DomainsTable from '~/components/domainsTable/DomainsTable'; +import { ORDER_AXIOS_INSTANCE } from '~/api/apiClientOrders'; +import { AXIOS_INSTANCE } from '~/api/apiClientDomains'; -type CartDomain = DomainQuery & { itemId?: string }; +interface CartResponse { + totalMonthlyPrice: number; + totalYearlyPrice: number; + l3Domains: string[]; +} -type DomainSearchItem = { - fqdn?: string; - price?: number; - free?: boolean; -}; +interface L2Zone { + id: number; + name: string; + monthlyPrice?: number; +} -type CartItem = { - id?: string; - fqdn?: string; - price?: number; -}; - -const mapCartItems = (items: CartItem[]): CartDomain[] => - items.map((item) => ({ - itemId: item.id, - fqdn: item.fqdn ?? '', - price: item.price ?? 0, - free: false, - })); - -const mapSearchItems = (items: DomainSearchItem[]): DomainQuery[] => - items.map((item) => ({ - fqdn: item.fqdn ?? '', - price: item.price ?? 0, - free: Boolean(item.free), - })); - -const resolveZoneId = async (fqdn: string) => { - const parts = fqdn.split('.').filter(Boolean); - if (parts.length <= 2) return ''; - - const zoneName = `${parts[parts.length - 2]}.${parts[parts.length - 1]}`; - const zoneResponse = await getZoneByName(zoneName); - return zoneResponse?.data?.id ?? ''; -}; - -const buildZonePayload = (fqdn: string): ExdnsZone => ({ - name: fqdn, - version: 1, - records: [ - { - name: '@', - type: RecordType.A, - ttl: 300, - data: '127.0.0.1', - }, - ], -}); +interface DomainSearchResult { + fqdn: string; + zone: string; + free: boolean; + monthlyPrice: number; +} const CartPage = () => { - const [query, setQuery] = useState(''); - const [cartDomains, setCartDomains] = useState([]); - const [domains, setDomains] = useState([]); + const [query, setQuery] = useState(''); + const [cartDomains, setCartDomains] = useState([]); + const [totalMonthly, setTotalMonthly] = useState(0); + const [totalYearly, setTotalYearly] = useState(0); + const [searchResults, setSearchResults] = useState([]); const [isCartLoading, setIsCartLoading] = useState(false); const [isSearchLoading, setIsSearchLoading] = useState(false); const [error, setError] = useState(''); @@ -91,13 +46,13 @@ const CartPage = () => { const loadCart = useCallback(async () => { setIsCartLoading(true); setError(''); - try { - const response = await getCart(); - const items = response?.data?.items ?? []; - setCartDomains(mapCartItems(items)); + const { data } = await ORDER_AXIOS_INSTANCE.get('/cart/me'); + setCartDomains(data.l3Domains ?? []); + setTotalMonthly(data.totalMonthlyPrice ?? 0); + setTotalYearly(data.totalYearlyPrice ?? 0); } catch { - setError('Не удалось загрузить корзину. Попробуйте еще раз.'); + setError('Не удалось загрузить корзину.'); } finally { setIsCartLoading(false); } @@ -117,11 +72,27 @@ const CartPage = () => { setError(''); try { - const response = await searchDomains({ query: search }); - const items = response?.data ?? []; - setDomains(mapSearchItems(items)); + const [freeRes, zonesRes] = await Promise.all([ + AXIOS_INSTANCE.get(`/l3Domains/${encodeURIComponent(search)}/free`), + AXIOS_INSTANCE.get('/l2Domains'), + ]); + + const freeDomains = new Set(freeRes.data ?? []); + const zones = zonesRes.data ?? []; + + const results: DomainSearchResult[] = zones.map((zone) => { + const fqdn = `${search}.${zone.name}`; + return { + fqdn, + zone: zone.name, + free: freeDomains.has(fqdn), + monthlyPrice: zone.monthlyPrice ?? 200, + }; + }); + + setSearchResults(results); } catch { - setError('Не удалось выполнить поиск доменов. Попробуйте еще раз.'); + setError('Не удалось выполнить поиск доменов.'); } finally { setIsSearchLoading(false); } @@ -129,59 +100,15 @@ const CartPage = () => { [query] ); - const handlePurchase = useCallback(async () => { - if (cartDomains.length === 0) return; - - setIsCartLoading(true); - setError(''); - - try { - for (const domain of cartDomains) { - const fqdn = domain.fqdn.trim(); - if (!fqdn) continue; - - const zoneId = await resolveZoneId(fqdn); - if (!zoneId) { - throw new Error('zone'); - } - - const expiresAt = new Date(); - expiresAt.setFullYear(expiresAt.getFullYear() + 1); - - await createDomain({ - fqdn, - zoneId, - expiresAt: expiresAt.toISOString(), - }); - await postZonesDomain(fqdn, buildZonePayload(fqdn)); - } - - await clearCart(); - setCartDomains([]); - } catch { - setError('Не удалось оформить покупку. Попробуйте еще раз.'); - } finally { - setIsCartLoading(false); - } - }, [cartDomains]); - const handleAddToCart = useCallback( - async (domain: DomainQuery) => { - if (!domain.free) return; - + async (fqdn: string) => { setIsCartLoading(true); setError(''); - try { - await addItem({ - action: AddCartItemRequestAction.register, - term: AddCartItemRequestTerm.yearly, - fqdn: domain.fqdn, - price: domain.price, - }); + await ORDER_AXIOS_INSTANCE.post(`/cart/${encodeURIComponent(fqdn)}`); await loadCart(); } catch { - setError('Не удалось добавить домен в корзину. Попробуйте еще раз.'); + setError('Не удалось добавить домен в корзину.'); } finally { setIsCartLoading(false); } @@ -189,21 +116,24 @@ const CartPage = () => { [loadCart] ); - const handleRemoveFromCart = useCallback(async (domain: CartDomain) => { - if (!domain.itemId) return; - - setIsCartLoading(true); - setError(''); - - try { - await removeItem(domain.itemId); - setCartDomains((prev) => prev.filter((item) => item.itemId !== domain.itemId)); - } catch { - setError('Не удалось удалить домен из корзины. Попробуйте еще раз.'); - } finally { - setIsCartLoading(false); - } - }, []); + const handleCheckout = useCallback( + async (period: 'MONTH' | 'YEAR') => { + if (cartDomains.length === 0) return; + setIsCartLoading(true); + setError(''); + try { + await ORDER_AXIOS_INSTANCE.post('/cart/checkout', { period }); + setCartDomains([]); + setTotalMonthly(0); + setTotalYearly(0); + } catch { + setError('Не удалось оформить покупку.'); + } finally { + setIsCartLoading(false); + } + }, + [cartDomains] + ); return ( @@ -211,22 +141,50 @@ const CartPage = () => { Корзина {cartCount} доменов - + + {/* Cart contents */} + {cartDomains.length > 0 && ( + + {cartDomains.map((domain) => ( + + {domain} + + ))} + + + Итого: {totalMonthly}₽/мес · {totalYearly}₽/год + + + + + + + + )} + + {/* Search */}
- Домен + Поиск домена setQuery(e.target.value)} /> @@ -236,43 +194,39 @@ const CartPage = () => {
+ {error && ( {error} )} - ( - - )} - /> - - Доступные домены - - domain.free ? ( - - ) : ( - Недоступен - ) - } - /> + + {/* Search results */} + {searchResults.length > 0 && ( + + Результаты поиска + {searchResults.map((result) => ( + + {result.fqdn} + + {result.monthlyPrice}₽ / месяц + {result.free ? ( + + ) : ( + Занят + )} + + + ))} + + )}
); };