feat(ui): copy logistics header/theme one-to-one

This commit is contained in:
Ruslan Bakiev
2026-04-21 10:34:54 +07:00
parent 670e9b7fd1
commit d3183bf6ad
11 changed files with 1052 additions and 236 deletions

View File

@@ -0,0 +1,86 @@
type CalcSearchDraft = {
from: string
to: string
cargo: string
fromLat: number | null
fromLng: number | null
toLat: number | null
toLng: number | null
}
type CalcSearchDraftPatch = Partial<CalcSearchDraft>
const initialCalcSearchDraft = (): CalcSearchDraft => ({
from: '',
to: '',
cargo: '',
fromLat: null,
fromLng: null,
toLat: null,
toLng: null,
})
function stringParam(value: unknown) {
return typeof value === 'string' ? value.trim() : ''
}
function numberParam(value: unknown) {
const num = Number(value)
return Number.isFinite(num) ? num : null
}
export function useCalcSearchDraft() {
const draft = useState<CalcSearchDraft>('calc-search-draft', initialCalcSearchDraft)
function hydrateFromQuery(query: Record<string, unknown>) {
const nextDraft = { ...draft.value }
const from = stringParam(query.from)
const to = stringParam(query.to)
const cargo = stringParam(query.cargo)
const fromLat = numberParam(query.fromLat)
const fromLng = numberParam(query.fromLng)
const toLat = numberParam(query.toLat)
const toLng = numberParam(query.toLng)
if (from) nextDraft.from = from
if (to) nextDraft.to = to
if (cargo) nextDraft.cargo = cargo
if (fromLat !== null) nextDraft.fromLat = fromLat
if (fromLng !== null) nextDraft.fromLng = fromLng
if (toLat !== null) nextDraft.toLat = toLat
if (toLng !== null) nextDraft.toLng = toLng
draft.value = nextDraft
}
function patchDraft(patch: CalcSearchDraftPatch) {
draft.value = {
...draft.value,
...patch,
}
}
function buildQuery(patch: CalcSearchDraftPatch = {}) {
const nextDraft = {
...draft.value,
...patch,
}
return {
...(nextDraft.from.trim() ? { from: nextDraft.from.trim() } : {}),
...(nextDraft.to.trim() ? { to: nextDraft.to.trim() } : {}),
...(nextDraft.cargo.trim() ? { cargo: nextDraft.cargo.trim() } : {}),
...(nextDraft.fromLat !== null ? { fromLat: String(nextDraft.fromLat) } : {}),
...(nextDraft.fromLng !== null ? { fromLng: String(nextDraft.fromLng) } : {}),
...(nextDraft.toLat !== null ? { toLat: String(nextDraft.toLat) } : {}),
...(nextDraft.toLng !== null ? { toLng: String(nextDraft.toLng) } : {}),
}
}
return {
draft,
hydrateFromQuery,
patchDraft,
buildQuery,
}
}

View File

@@ -0,0 +1,188 @@
export type AppLocale = 'ru' | 'en'
export type AppCurrency = 'USD' | 'RUB' | 'CNY' | 'EUR' | 'AED'
export type CurrencyRatesResponse = {
baseCurrency: 'USD'
rates: Record<AppCurrency, number>
updatedAt: string
nextUpdateAt: string
provider: string
documentation: string
termsOfUse: string
attributionUrl: string
}
export type LocaleOption = {
code: AppLocale
label: string
nativeLabel: string
}
export type CurrencyOption = {
code: AppCurrency
label: string
symbol: string
}
type TranslationKey =
| 'settings.language'
| 'settings.currency'
| 'settings.open'
| 'settings.apply'
| 'settings.ratesProvider'
| 'settings.ratesBy'
const LOCALE_MAP: Record<AppLocale, string> = {
ru: 'ru-RU',
en: 'en-US',
}
const CURRENCY_SYMBOLS: Record<AppCurrency, string> = {
USD: '$',
RUB: '₽',
CNY: '¥',
EUR: '€',
AED: 'د.إ',
}
const LOCALES: AppLocale[] = ['ru', 'en']
const CURRENCIES: AppCurrency[] = ['USD', 'RUB', 'CNY', 'EUR', 'AED']
const localeCodes = new Set<AppLocale>(LOCALES)
const currencyCodes = new Set<AppCurrency>(CURRENCIES)
function normalizeLocale(value: unknown): AppLocale {
return localeCodes.has(value as AppLocale) ? value as AppLocale : 'ru'
}
function assertCurrency(value: unknown): AppCurrency {
const normalized = String(value || '').trim().toUpperCase()
if (!currencyCodes.has(normalized as AppCurrency)) {
throw createError({
statusCode: 500,
statusMessage: `Unsupported currency: ${normalized || 'empty'}`,
})
}
return normalized as AppCurrency
}
function normalizeCurrency(value: unknown): AppCurrency {
const normalized = String(value || '').trim().toUpperCase()
return currencyCodes.has(normalized as AppCurrency) ? normalized as AppCurrency : 'USD'
}
export function useLocaleCurrency() {
const { locale: i18nLocale, setLocale: setI18nLocale, t: i18nT } = useI18n()
const currencyCookie = useCookie<AppCurrency>('ex_currency', {
default: () => 'USD',
sameSite: 'lax',
path: '/',
})
const currencyRates = useState<CurrencyRatesResponse | null>('currency-rates', () => null)
const locale = computed<AppLocale>({
get: () => normalizeLocale(i18nLocale.value),
set: (value) => {
i18nLocale.value = normalizeLocale(value)
},
})
const currency = computed<AppCurrency>({
get: () => normalizeCurrency(currencyCookie.value),
set: (value) => {
currencyCookie.value = normalizeCurrency(value)
},
})
const intlLocale = computed(() => LOCALE_MAP[locale.value])
const localeOptions = computed<LocaleOption[]>(() => LOCALES.map(code => ({
code,
label: i18nT(`settings.locales.${code}.label`),
nativeLabel: i18nT(`settings.locales.${code}.nativeLabel`),
})))
const currencyOptions = computed<CurrencyOption[]>(() => CURRENCIES.map(code => ({
code,
label: i18nT(`settings.currencies.${code}`),
symbol: CURRENCY_SYMBOLS[code],
})))
const languageCode = computed(() => locale.value.toUpperCase())
const currencyCode = computed(() => currency.value)
const ratesProviderUrl = computed(() => currencyRates.value?.attributionUrl || 'https://www.exchangerate-api.com')
async function setLocale(value: AppLocale) {
const normalizedLocale = normalizeLocale(value)
locale.value = normalizedLocale
await setI18nLocale(normalizedLocale)
}
function setCurrency(value: AppCurrency) {
currency.value = value
}
function t(key: TranslationKey) {
return i18nT(key)
}
function getRates() {
if (!currencyRates.value) {
throw createError({
statusCode: 500,
statusMessage: 'Currency rates are not loaded',
})
}
return currencyRates.value.rates
}
function convertMoney(value: number, sourceCurrency: AppCurrency = 'USD', targetCurrency: AppCurrency = currency.value) {
const rates = getRates()
const amountInUsd = value / rates[sourceCurrency]
return amountInUsd * rates[targetCurrency]
}
function formatMoney(value: number, sourceCurrency: string = 'USD') {
const normalizedSourceCurrency = assertCurrency(sourceCurrency)
const convertedValue = convertMoney(value, normalizedSourceCurrency, currency.value)
return new Intl.NumberFormat(intlLocale.value, {
style: 'currency',
currency: currency.value,
maximumFractionDigits: currency.value === 'RUB' ? 0 : 0,
}).format(convertedValue)
}
function formatDate(value: Date | string, options: Intl.DateTimeFormatOptions = {}) {
const date = value instanceof Date ? value : new Date(value)
return new Intl.DateTimeFormat(intlLocale.value, options).format(date)
}
function formatDateTime(value: Date | string) {
return formatDate(value, {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit',
})
}
return {
locale,
currency,
intlLocale,
localeOptions,
currencyOptions,
languageCode,
currencyCode,
setLocale,
setCurrency,
convertMoney,
formatMoney,
formatDate,
formatDateTime,
currencyRates,
ratesProviderUrl,
t,
}
}

View File

@@ -0,0 +1,70 @@
type LocalizedRouteTarget = string | {
path?: string
name?: string
params?: Record<string, unknown>
query?: Record<string, unknown>
hash?: string
}
// This composable is used in route middleware, where `useI18n()` is not available.
const LOCALE_CODES = ['ru', 'en']
function normalizePath(path: string) {
return path || '/'
}
export function stripLocalePrefix(path: string, localeCodes: string[]) {
const normalizedPath = normalizePath(path)
const localePattern = localeCodes.map(code => code.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')
if (!localePattern) return normalizedPath
const match = normalizedPath.match(new RegExp(`^/(${localePattern})(?=/|$)`))
if (!match) return normalizedPath
const nextPath = normalizedPath.slice(match[0].length)
return nextPath || '/'
}
export function useLocalizedNavigation() {
const route = useRoute()
const localePath = useLocalePath()
const basePath = computed(() => stripLocalePrefix(route.path, LOCALE_CODES))
function toLocalized(to: LocalizedRouteTarget) {
if (typeof to === 'string') {
return to.startsWith('/') ? localePath(to) : to
}
if (to.path) {
return {
...to,
path: to.path.startsWith('/') ? localePath(to.path) : to.path,
}
}
return to
}
function isBasePathActive(target: string, exact = false) {
const normalizedTarget = normalizePath(target)
if (exact) {
return basePath.value === normalizedTarget
}
return basePath.value === normalizedTarget || basePath.value.startsWith(`${normalizedTarget}/`)
}
function navigateToLocalized(to: LocalizedRouteTarget, options?: Parameters<typeof navigateTo>[1]) {
return navigateTo(toLocalized(to), options)
}
return {
basePath,
isBasePathActive,
localePath,
navigateToLocalized,
toLocalized,
}
}