feat(ui): copy logistics header/theme one-to-one
This commit is contained in:
86
app/composables/useCalcSearchDraft.ts
Normal file
86
app/composables/useCalcSearchDraft.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
188
app/composables/useLocaleCurrency.ts
Normal file
188
app/composables/useLocaleCurrency.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
70
app/composables/useLocalizedNavigation.ts
Normal file
70
app/composables/useLocalizedNavigation.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user