hallo-kit 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +168 -0
- package/bin/cli.mjs +136 -0
- package/fonts/Manrope/Manrope-VariableFont_wght.woff2 +0 -0
- package/fonts/Manrope/OFL.txt +93 -0
- package/fonts/index.ts +30 -0
- package/package.json +77 -0
- package/src/config.ts +11 -0
- package/src/hooks/index.ts +14 -0
- package/src/hooks/useAutoHideOnScroll.ts +26 -0
- package/src/hooks/useCountdown.ts +44 -0
- package/src/hooks/useDevice.ts +63 -0
- package/src/hooks/useIsTelegramMiniApp.ts +17 -0
- package/src/hooks/useIsTouch.ts +23 -0
- package/src/hooks/useKeyboardOpen.ts +35 -0
- package/src/hooks/useMediaQuery.ts +24 -0
- package/src/hooks/usePlatform.ts +102 -0
- package/src/hooks/useQueryParams.tsx +70 -0
- package/src/hooks/useRevealOnce.ts +21 -0
- package/src/hooks/useScrolledPast.ts +19 -0
- package/src/hooks/useSettleOnce.ts +26 -0
- package/src/hooks/useTelegram.tsx +57 -0
- package/src/i18n/index.tsx +92 -0
- package/src/index.ts +35 -0
- package/src/lib/device.ts +28 -0
- package/src/lib/platform.ts +19 -0
- package/src/lib/utils.ts +36 -0
- package/src/logo/LogoFull.tsx +59 -0
- package/src/logo/LogoMark.tsx +31 -0
- package/src/logo/index.ts +2 -0
- package/src/next.ts +13 -0
- package/src/server.ts +4 -0
- package/src/theme-color-sync.tsx +43 -0
- package/src/theme-provider.tsx +31 -0
- package/src/types/css.d.ts +6 -0
- package/src/types/globals.d.ts +47 -0
- package/src/ui/Icon/Icon.module.css +25 -0
- package/src/ui/Icon/Icon.tsx +45 -0
- package/src/ui/Icon/Icon.types.ts +60 -0
- package/src/ui/Icon/data/brand.data.tsx +75 -0
- package/src/ui/Icon/data/index.ts +2 -0
- package/src/ui/Icon/data/ui.data.tsx +780 -0
- package/src/ui/Icon/index.ts +3 -0
- package/src/ui/badge/badge.tsx +185 -0
- package/src/ui/badge/index.ts +1 -0
- package/src/ui/breadcrumbs/breadcrumbs.tsx +63 -0
- package/src/ui/breadcrumbs/index.ts +1 -0
- package/src/ui/burger-icon/burger-icon.tsx +22 -0
- package/src/ui/burger-icon/index.ts +1 -0
- package/src/ui/button/button.tsx +179 -0
- package/src/ui/button/index.ts +1 -0
- package/src/ui/card/card.tsx +100 -0
- package/src/ui/card/index.ts +1 -0
- package/src/ui/confirm/confirm-dialog-host.tsx +89 -0
- package/src/ui/confirm/index.ts +1 -0
- package/src/ui/count-up/count-up.tsx +96 -0
- package/src/ui/count-up/index.ts +1 -0
- package/src/ui/data-list/data-list.tsx +331 -0
- package/src/ui/data-list/index.ts +1 -0
- package/src/ui/drawer/drawer.tsx +166 -0
- package/src/ui/drawer/index.ts +1 -0
- package/src/ui/flag/flag.data.ts +240 -0
- package/src/ui/flag/flag.tsx +57 -0
- package/src/ui/flag/index.ts +1 -0
- package/src/ui/image/image.tsx +67 -0
- package/src/ui/image/index.ts +1 -0
- package/src/ui/input/amount-input.tsx +55 -0
- package/src/ui/input/index.ts +2 -0
- package/src/ui/input/input.tsx +391 -0
- package/src/ui/label/index.ts +1 -0
- package/src/ui/label/label.tsx +34 -0
- package/src/ui/method-card/index.ts +1 -0
- package/src/ui/method-card/method-card.tsx +101 -0
- package/src/ui/modal/index.ts +1 -0
- package/src/ui/modal/modal.tsx +128 -0
- package/src/ui/otp-input/index.ts +1 -0
- package/src/ui/otp-input/otp-input.tsx +78 -0
- package/src/ui/pagination/index.ts +1 -0
- package/src/ui/pagination/pagination.tsx +138 -0
- package/src/ui/radio-group/index.ts +1 -0
- package/src/ui/radio-group/radio-group.tsx +81 -0
- package/src/ui/select/index.ts +1 -0
- package/src/ui/select/select.tsx +215 -0
- package/src/ui/separator/index.ts +1 -0
- package/src/ui/separator/separator.tsx +54 -0
- package/src/ui/skeleton/index.ts +1 -0
- package/src/ui/skeleton/skeleton.tsx +44 -0
- package/src/ui/spinner/index.ts +1 -0
- package/src/ui/spinner/spinner.tsx +33 -0
- package/src/ui/switch/index.ts +1 -0
- package/src/ui/switch/switch.tsx +48 -0
- package/src/ui/tabs/index.ts +1 -0
- package/src/ui/tabs/tabs.tsx +190 -0
- package/src/ui/textarea/index.ts +1 -0
- package/src/ui/textarea/textarea.tsx +151 -0
- package/src/ui/theme-switch/index.ts +1 -0
- package/src/ui/theme-switch/theme-switch.tsx +161 -0
- package/src/ui/toast/index.ts +1 -0
- package/src/ui/toast/toast.tsx +159 -0
- package/src/utils/api/stringFormat.ts +5 -0
- package/src/utils/clearAllCookies.ts +7 -0
- package/src/utils/clsx.ts +18 -0
- package/src/utils/confirm.ts +48 -0
- package/src/utils/copy-to-clipboard.ts +12 -0
- package/src/utils/date/convertTimestamp.ts +44 -0
- package/src/utils/date/formatCountdown.ts +9 -0
- package/src/utils/date/formatDayLabel.ts +50 -0
- package/src/utils/date/formatRelativeDateTime.ts +34 -0
- package/src/utils/date/locales.ts +9 -0
- package/src/utils/form/validationSchema.ts +46 -0
- package/src/utils/getByKeyString.ts +15 -0
- package/src/utils/getCssVar.ts +2 -0
- package/src/utils/getDeepType.ts +3 -0
- package/src/utils/getQueryParams.ts +21 -0
- package/src/utils/inAppBrowser.ts +151 -0
- package/src/utils/index.ts +28 -0
- package/src/utils/isRTL.ts +29 -0
- package/src/utils/notify.ts +46 -0
- package/src/utils/openExternal.ts +27 -0
- package/src/utils/system/getMillisecondsPeriod.ts +31 -0
- package/src/utils/validation/authValidators.ts +23 -0
- package/src/utils/validation/refCode.ts +30 -0
- package/styles/base.css +97 -0
- package/styles/brand.css +36 -0
- package/styles/index.css +19 -0
- package/styles/motion.css +207 -0
- package/styles/semantic.css +151 -0
- package/styles/theme.css +143 -0
- package/styles/utilities.css +289 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
|
|
5
|
+
import * as ToastPrimitive from '@radix-ui/react-toast'
|
|
6
|
+
import { create } from 'zustand'
|
|
7
|
+
|
|
8
|
+
import { Icon } from '../Icon'
|
|
9
|
+
import { cn } from '../../lib/utils'
|
|
10
|
+
import {
|
|
11
|
+
type NotifyOptions,
|
|
12
|
+
type NotifyVariant,
|
|
13
|
+
registerNotifyHandler,
|
|
14
|
+
} from '../../utils/notify'
|
|
15
|
+
|
|
16
|
+
type IconType = React.ComponentProps<typeof Icon>['type']
|
|
17
|
+
|
|
18
|
+
// Вид по варианту: soft-фон карточки + рамка в полном цвете статуса + иконка из
|
|
19
|
+
// нашего набора и её цвет. Ширину рамки задаёт общий `border` на Root.
|
|
20
|
+
const TOAST_VARIANT: Record<
|
|
21
|
+
NotifyVariant,
|
|
22
|
+
{ wrap: string; icon: IconType; tone: string }
|
|
23
|
+
> = {
|
|
24
|
+
error: {
|
|
25
|
+
wrap: 'bg-error-soft border-error',
|
|
26
|
+
icon: 'alert',
|
|
27
|
+
tone: 'text-error',
|
|
28
|
+
},
|
|
29
|
+
success: {
|
|
30
|
+
wrap: 'bg-success-soft border-success',
|
|
31
|
+
icon: 'check_circle',
|
|
32
|
+
tone: 'text-success',
|
|
33
|
+
},
|
|
34
|
+
warning: {
|
|
35
|
+
wrap: 'bg-warning-soft border-warning',
|
|
36
|
+
icon: 'warning',
|
|
37
|
+
tone: 'text-warning',
|
|
38
|
+
},
|
|
39
|
+
info: { wrap: 'bg-secondary border-primary', icon: 'info', tone: 'text-primary' },
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const DEFAULT_DURATION = 5000
|
|
43
|
+
// Сколько держать запись после закрытия, чтобы проиграть exit-анимацию, перед тем
|
|
44
|
+
// как выкинуть её из очереди.
|
|
45
|
+
const REMOVE_DELAY = 200
|
|
46
|
+
|
|
47
|
+
interface ToastItem {
|
|
48
|
+
id: string
|
|
49
|
+
open: boolean
|
|
50
|
+
title: string
|
|
51
|
+
description?: string
|
|
52
|
+
variant: NotifyVariant
|
|
53
|
+
duration: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let idCounter = 0
|
|
57
|
+
const nextId = () => `toast-${++idCounter}`
|
|
58
|
+
|
|
59
|
+
// Очередь тостов на zustand. Radix управляет таймером/свайпом каждой карточки и
|
|
60
|
+
// дёргает onOpenChange(false); по нему гасим open (запускает exit-анимацию) и через
|
|
61
|
+
// REMOVE_DELAY убираем из очереди.
|
|
62
|
+
interface ToastStore {
|
|
63
|
+
toasts: ToastItem[]
|
|
64
|
+
push: (t: Omit<ToastItem, 'id' | 'open'>) => void
|
|
65
|
+
setOpen: (id: string, open: boolean) => void
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const useToastStore = create<ToastStore>(set => ({
|
|
69
|
+
toasts: [],
|
|
70
|
+
push: t =>
|
|
71
|
+
set(s => ({ toasts: [...s.toasts, { ...t, id: nextId(), open: true }] })),
|
|
72
|
+
setOpen: (id, open) => {
|
|
73
|
+
set(s => ({
|
|
74
|
+
toasts: s.toasts.map(x => (x.id === id ? { ...x, open } : x)),
|
|
75
|
+
}))
|
|
76
|
+
if (!open) {
|
|
77
|
+
setTimeout(() => {
|
|
78
|
+
set(s => ({ toasts: s.toasts.filter(x => x.id !== id) }))
|
|
79
|
+
}, REMOVE_DELAY)
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
}))
|
|
83
|
+
|
|
84
|
+
// Адаптер: notify(...) из инфраструктуры → запись в очередь.
|
|
85
|
+
function showToast({
|
|
86
|
+
title,
|
|
87
|
+
description,
|
|
88
|
+
variant = 'info',
|
|
89
|
+
timeout,
|
|
90
|
+
}: NotifyOptions) {
|
|
91
|
+
useToastStore.getState().push({
|
|
92
|
+
title,
|
|
93
|
+
description,
|
|
94
|
+
variant,
|
|
95
|
+
duration: timeout ?? DEFAULT_DURATION,
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Анимации входа/выхода и свайпа — через data-state/data-swipe (Radix) + tw-animate-css.
|
|
100
|
+
const TOAST_MOTION =
|
|
101
|
+
'data-[state=open]:animate-in data-[state=open]:fade-in data-[state=open]:slide-in-from-right-full ' +
|
|
102
|
+
'data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:slide-out-to-right-full ' +
|
|
103
|
+
'data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none ' +
|
|
104
|
+
'data-[swipe=cancel]:translate-x-0 data-[swipe=cancel]:transition-transform ' +
|
|
105
|
+
'data-[swipe=end]:animate-out data-[swipe=end]:slide-out-to-right-full'
|
|
106
|
+
|
|
107
|
+
// Рендерит область тостов и подключает наш notify-порт. Монтируется один раз в
|
|
108
|
+
// провайдерах. Radix даёт таймер автозакрытия, свайп, паузу при наведении и aria-live.
|
|
109
|
+
export function Toaster() {
|
|
110
|
+
const toasts = useToastStore(s => s.toasts)
|
|
111
|
+
const setOpen = useToastStore(s => s.setOpen)
|
|
112
|
+
|
|
113
|
+
React.useEffect(() => registerNotifyHandler(showToast), [])
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<ToastPrimitive.Provider swipeDirection='right' label='Уведомление'>
|
|
117
|
+
{toasts.map(t => {
|
|
118
|
+
const v = TOAST_VARIANT[t.variant]
|
|
119
|
+
return (
|
|
120
|
+
<ToastPrimitive.Root
|
|
121
|
+
key={t.id}
|
|
122
|
+
open={t.open}
|
|
123
|
+
onOpenChange={open => setOpen(t.id, open)}
|
|
124
|
+
duration={t.duration}
|
|
125
|
+
className={cn(
|
|
126
|
+
'pointer-events-auto flex items-center gap-fl-10/12 rounded-lg border p-fl-12/16',
|
|
127
|
+
v.wrap,
|
|
128
|
+
TOAST_MOTION,
|
|
129
|
+
)}
|
|
130
|
+
>
|
|
131
|
+
{/* фикс. квадратный слот: иконки разных пропорций (warning 20×18)
|
|
132
|
+
центрируются и не сбивают отступ текста */}
|
|
133
|
+
<span
|
|
134
|
+
className={cn(
|
|
135
|
+
'flex size-6 shrink-0 items-center justify-center',
|
|
136
|
+
v.tone,
|
|
137
|
+
)}
|
|
138
|
+
>
|
|
139
|
+
<Icon type={v.icon} isCurrentColor size={20} />
|
|
140
|
+
</span>
|
|
141
|
+
<div className='flex min-w-0 flex-col gap-0.5 text-left'>
|
|
142
|
+
<ToastPrimitive.Title className='text-body-m text-foreground'>
|
|
143
|
+
{t.title}
|
|
144
|
+
</ToastPrimitive.Title>
|
|
145
|
+
{t.description ? (
|
|
146
|
+
<ToastPrimitive.Description className='text-body-s text-muted-foreground'>
|
|
147
|
+
{t.description}
|
|
148
|
+
</ToastPrimitive.Description>
|
|
149
|
+
) : null}
|
|
150
|
+
</div>
|
|
151
|
+
</ToastPrimitive.Root>
|
|
152
|
+
)
|
|
153
|
+
})}
|
|
154
|
+
{/* fluid-ширина с потолком: плавно растёт от ширины экрана (минус поля 16px)
|
|
155
|
+
и упирается в 25rem (400px). Без скачка на брейкпоинте; всегда у правого края. */}
|
|
156
|
+
<ToastPrimitive.Viewport className='fixed top-4 right-4 z-100 flex max-h-screen w-[min(calc(100vw_-_2rem),25rem)] flex-col gap-2 outline-none' />
|
|
157
|
+
</ToastPrimitive.Provider>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export const stringFormat = (template: string, ...values: string[]) =>
|
|
2
|
+
template.replace(/{(\d+)}/g, (match, index) => values[index] || '')
|
|
3
|
+
|
|
4
|
+
export const stringFormatNamed = (template: string, values: Record<string, string | number>) =>
|
|
5
|
+
template.replace(/{(\w+)}/g, (_, key) => (values[key] !== undefined ? String(values[key]) : ''))
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import cl, { ClassValue } from 'clsx'
|
|
2
|
+
|
|
3
|
+
// Расширенный tailwind-merge (знает наши кастомные text-*-токены типографики),
|
|
4
|
+
// чтобы он не путал text-button-*/text-h* с text-{color}. Источник — @/lib/utils.
|
|
5
|
+
import { twMerge } from '../lib/utils'
|
|
6
|
+
|
|
7
|
+
function expandTuples(inputs: ClassValue[]): ClassValue[] {
|
|
8
|
+
return inputs.map((input) => {
|
|
9
|
+
if (Array.isArray(input) && input.length === 2 && typeof input[0] === 'string') {
|
|
10
|
+
return input[1] ? input[0] : undefined
|
|
11
|
+
}
|
|
12
|
+
return input
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const clsx = (...inputs: ClassValue[]) => {
|
|
17
|
+
return twMerge(cl(expandTuples(inputs)))
|
|
18
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// UI-агностичный порт подтверждений — «window.confirm по-нашему», асинхронный.
|
|
2
|
+
// Любой код зовёт `await confirmDialog({...})` и получает Promise<boolean>; UI-слой
|
|
3
|
+
// один раз регистрирует обработчик (ConfirmDialogHost с кит-модалкой) — тот же
|
|
4
|
+
// паттерн, что у тостов (см. utils/notify.ts). Пока обработчик не зарегистрирован —
|
|
5
|
+
// фолбэк в нативный window.confirm, чтобы ничего не падало.
|
|
6
|
+
|
|
7
|
+
// Только тип (стирается при компиляции) — рантайм-агностичность порта сохраняется:
|
|
8
|
+
// передаём ИМЯ иконки, а сам <Icon> строит UI-слой (ConfirmDialogHost).
|
|
9
|
+
import type { IconNameType } from '../ui/Icon'
|
|
10
|
+
|
|
11
|
+
export interface ConfirmOptions {
|
|
12
|
+
title: string
|
|
13
|
+
description?: string
|
|
14
|
+
/** Подпись кнопки подтверждения. По умолчанию — ui.confirm.confirm из словаря. */
|
|
15
|
+
confirmLabel?: string
|
|
16
|
+
/** Подпись кнопки отмены. По умолчанию — ui.confirm.cancel из словаря. */
|
|
17
|
+
cancelLabel?: string
|
|
18
|
+
/** Опасное действие (удаление/отвязка): красная кнопка подтверждения. */
|
|
19
|
+
danger?: boolean
|
|
20
|
+
/**
|
|
21
|
+
* Иконка справа в кнопке подтверждения. По умолчанию — стрелка вправо
|
|
22
|
+
* (`arrow_right`). Передай своё имя иконки, либо `null`, чтобы убрать иконку.
|
|
23
|
+
*/
|
|
24
|
+
confirmIcon?: IconNameType | null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type ConfirmHandler = (options: ConfirmOptions) => Promise<boolean>
|
|
28
|
+
|
|
29
|
+
let handler: ConfirmHandler | null = null
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Подключить реальный показ модалки. Возвращает функцию-отписку — удобно звать
|
|
33
|
+
* из `useEffect` в хосте.
|
|
34
|
+
*/
|
|
35
|
+
export const registerConfirmHandler = (next: ConfirmHandler): (() => void) => {
|
|
36
|
+
handler = next
|
|
37
|
+
return () => {
|
|
38
|
+
if (handler === next) handler = null
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const confirmDialog = (options: ConfirmOptions): Promise<boolean> => {
|
|
43
|
+
if (handler) return handler(options)
|
|
44
|
+
|
|
45
|
+
// Фолбэк до подключения UI-обработчика (SSR → отказ без падения).
|
|
46
|
+
if (typeof window === 'undefined') return Promise.resolve(false)
|
|
47
|
+
return Promise.resolve(window.confirm(options.title))
|
|
48
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Копирует текст без необработанного Promise rejection, если браузер запретил clipboard.
|
|
3
|
+
* Возвращаем результат, чтобы вызывающий UI при необходимости мог показать уведомление.
|
|
4
|
+
*/
|
|
5
|
+
export async function copyToClipboard(value: string): Promise<boolean> {
|
|
6
|
+
try {
|
|
7
|
+
await navigator.clipboard.writeText(value)
|
|
8
|
+
return true
|
|
9
|
+
} catch {
|
|
10
|
+
return false
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { format as formatDate } from 'date-fns'
|
|
2
|
+
|
|
3
|
+
import { dateFnsLocales } from './locales'
|
|
4
|
+
|
|
5
|
+
type FormatType =
|
|
6
|
+
| 'HH:mm'
|
|
7
|
+
| 'd MMMM, yyyy'
|
|
8
|
+
| 'd MMMM, yyyy HH:mm'
|
|
9
|
+
| 'd MMMM, yyyy HH:mm:ss'
|
|
10
|
+
| 'HH:mm dd.MM.yyyy'
|
|
11
|
+
| 'dd.MM.yyyy'
|
|
12
|
+
| 'HH:mm:ss'
|
|
13
|
+
| 'd MMMM yyyy'
|
|
14
|
+
| 'd MMMM'
|
|
15
|
+
|
|
16
|
+
export const convertTimestamp = (
|
|
17
|
+
timestamp: string | number | Date,
|
|
18
|
+
lang: string,
|
|
19
|
+
format?: FormatType
|
|
20
|
+
): string => {
|
|
21
|
+
if (!timestamp) return ''
|
|
22
|
+
|
|
23
|
+
let date: Date
|
|
24
|
+
|
|
25
|
+
// Если timestamp — число (UNIX)
|
|
26
|
+
if (typeof timestamp === 'number') {
|
|
27
|
+
date = new Date(timestamp * 1000)
|
|
28
|
+
}
|
|
29
|
+
// Если timestamp — строка (ISO)
|
|
30
|
+
else if (typeof timestamp === 'string') {
|
|
31
|
+
date = new Date(timestamp)
|
|
32
|
+
}
|
|
33
|
+
// Если уже объект Date
|
|
34
|
+
else if (timestamp instanceof Date) {
|
|
35
|
+
date = timestamp
|
|
36
|
+
} else {
|
|
37
|
+
return ''
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const locale = dateFnsLocales.find((x) => x.key === lang)
|
|
41
|
+
if (!locale) return 'Unsupported language'
|
|
42
|
+
|
|
43
|
+
return formatDate(date, format || 'd MMMM, yyyy', { locale: locale.localeFns })
|
|
44
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Обратный отсчёт «мм:сс» для таймеров (TTL ссылки бота, повторная отправка кода).
|
|
2
|
+
// padMinutes — минуты с ведущим нулём («03:44»); без — как есть («0:43»).
|
|
3
|
+
export function formatCountdown(ms: number, padMinutes = false): string {
|
|
4
|
+
const totalSeconds = Math.max(0, Math.ceil(ms / 1000))
|
|
5
|
+
const minutes = Math.floor(totalSeconds / 60)
|
|
6
|
+
const seconds = totalSeconds % 60
|
|
7
|
+
const mm = padMinutes ? String(minutes).padStart(2, '0') : String(minutes)
|
|
8
|
+
return `${mm}:${String(seconds).padStart(2, '0')}`
|
|
9
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { convertTimestamp } from './convertTimestamp'
|
|
2
|
+
|
|
3
|
+
// Подпись «разреза» между днями в ленте сообщений: «Сегодня» / «Вчера» / «23 июня»
|
|
4
|
+
// (текущий год) / «23 июня 2024» (прошлые годы). Локаль и тексты сегодня/вчера — снаружи
|
|
5
|
+
// (i18n): утилка про формат, не про переводы. Даты через convertTimestamp (date-fns).
|
|
6
|
+
export function formatDayLabel(
|
|
7
|
+
timestamp: string | number | Date,
|
|
8
|
+
lang: string,
|
|
9
|
+
labels: { today: string; yesterday: string },
|
|
10
|
+
): string {
|
|
11
|
+
if (!timestamp) return ''
|
|
12
|
+
|
|
13
|
+
// number трактуем как UNIX-секунды — так же, как convertTimestamp (×1000).
|
|
14
|
+
const date =
|
|
15
|
+
typeof timestamp === 'number'
|
|
16
|
+
? new Date(timestamp * 1000)
|
|
17
|
+
: new Date(timestamp)
|
|
18
|
+
|
|
19
|
+
// Разница в КАЛЕНДАРНЫХ днях (по началу суток), а не в 24ч — «Вчера» по дате.
|
|
20
|
+
const startOfDay = (d: Date) =>
|
|
21
|
+
new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
|
|
22
|
+
const now = new Date()
|
|
23
|
+
const dayDiff = Math.round(
|
|
24
|
+
(startOfDay(now) - startOfDay(date)) / 86_400_000,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
if (dayDiff === 0) return labels.today
|
|
28
|
+
if (dayDiff === 1) return labels.yesterday
|
|
29
|
+
|
|
30
|
+
// Тот же год — без года; прошлые годы — с годом.
|
|
31
|
+
const format = date.getFullYear() === now.getFullYear() ? 'd MMMM' : 'd MMMM yyyy'
|
|
32
|
+
return convertTimestamp(timestamp, lang, format)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Одни ли календарные сутки у двух отметок времени (для решения «показать ли разрез»).
|
|
36
|
+
export const isSameCalendarDay = (
|
|
37
|
+
a?: string | number | Date | null,
|
|
38
|
+
b?: string | number | Date | null,
|
|
39
|
+
): boolean => {
|
|
40
|
+
if (!a || !b) return false
|
|
41
|
+
const toDate = (v: string | number | Date) =>
|
|
42
|
+
typeof v === 'number' ? new Date(v * 1000) : new Date(v)
|
|
43
|
+
const da = toDate(a)
|
|
44
|
+
const db = toDate(b)
|
|
45
|
+
return (
|
|
46
|
+
da.getFullYear() === db.getFullYear() &&
|
|
47
|
+
da.getMonth() === db.getMonth() &&
|
|
48
|
+
da.getDate() === db.getDate()
|
|
49
|
+
)
|
|
50
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { convertTimestamp } from './convertTimestamp'
|
|
2
|
+
|
|
3
|
+
// Относительная дата+время: «Сегодня/Вчера, ЧЧ:ММ» либо «11 июня, ЧЧ:ММ» (без года).
|
|
4
|
+
// Локаль + относительные подписи приходят снаружи (i18n) — утилка про формат, не про
|
|
5
|
+
// тексты. Само форматирование делегируем convertTimestamp (date-fns), здесь только
|
|
6
|
+
// выбор «сегодня/вчера/дата».
|
|
7
|
+
export function formatRelativeDateTime(
|
|
8
|
+
timestamp: string | number | Date,
|
|
9
|
+
lang: string,
|
|
10
|
+
labels: { today: string; yesterday: string },
|
|
11
|
+
): string {
|
|
12
|
+
if (!timestamp) return ''
|
|
13
|
+
|
|
14
|
+
const time = convertTimestamp(timestamp, lang, 'HH:mm')
|
|
15
|
+
|
|
16
|
+
// number трактуем как UNIX-секунды — так же, как convertTimestamp (×1000).
|
|
17
|
+
const date =
|
|
18
|
+
typeof timestamp === 'number'
|
|
19
|
+
? new Date(timestamp * 1000)
|
|
20
|
+
: new Date(timestamp)
|
|
21
|
+
|
|
22
|
+
// Разница в КАЛЕНДАРНЫХ днях (по началу суток), а не в 24ч — чтобы «Вчера»
|
|
23
|
+
// считалось по дате, а не по «минус 24 часа».
|
|
24
|
+
const startOfDay = (d: Date) =>
|
|
25
|
+
new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
|
|
26
|
+
const dayDiff = Math.round(
|
|
27
|
+
(startOfDay(new Date()) - startOfDay(date)) / 86_400_000,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
if (dayDiff === 0) return `${labels.today}, ${time}`
|
|
31
|
+
if (dayDiff === 1) return `${labels.yesterday}, ${time}`
|
|
32
|
+
|
|
33
|
+
return `${convertTimestamp(timestamp, lang, 'd MMMM')}, ${time}`
|
|
34
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { enUS, ru as ruDateFns } from 'date-fns/locale'
|
|
2
|
+
|
|
3
|
+
// Локали date-fns, которые поддерживает кит. Ключи совпадают с KitLocale (i18n-порт).
|
|
4
|
+
// Кабинет держит такой же список в @/locales/config — там он ещё несёт title для
|
|
5
|
+
// переключателя языка; ките нужен только объект локали для форматирования дат.
|
|
6
|
+
export const dateFnsLocales = [
|
|
7
|
+
{ key: 'ru', localeFns: ruDateFns },
|
|
8
|
+
{ key: 'en', localeFns: enUS },
|
|
9
|
+
]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { FieldValues, Resolver } from 'react-hook-form'
|
|
2
|
+
|
|
3
|
+
import { yupResolver } from '@hookform/resolvers/yup'
|
|
4
|
+
import isEmail from 'validator/lib/isEmail'
|
|
5
|
+
import isUUID from 'validator/lib/isUUID'
|
|
6
|
+
import * as yup from 'yup'
|
|
7
|
+
|
|
8
|
+
// Общая декларативная фабрика валидации форм (react-hook-form + yup).
|
|
9
|
+
// Описываешь поля — получаешь resolver. Не плодим частные валидаторы под каждый кейс:
|
|
10
|
+
// resolver: validationSchema<FormValues>({
|
|
11
|
+
// fields: { email: { type: 'email', error: t('error.email'), required: true } },
|
|
12
|
+
// })
|
|
13
|
+
// `error` — уже локализованное сообщение (одно на поле, как в trade-space).
|
|
14
|
+
|
|
15
|
+
export type FieldType = 'text' | 'email' | 'uuid'
|
|
16
|
+
|
|
17
|
+
export interface FieldSchema {
|
|
18
|
+
type?: FieldType
|
|
19
|
+
/** Локализованный текст ошибки поля. */
|
|
20
|
+
error: string
|
|
21
|
+
required?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ValidationSchemaProps<T extends FieldValues> {
|
|
25
|
+
fields: Partial<Record<keyof T, FieldSchema>>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const buildField = ({ type = 'text', error, required }: FieldSchema): yup.StringSchema =>
|
|
29
|
+
yup
|
|
30
|
+
.string()
|
|
31
|
+
.transform((value) => (typeof value === 'string' ? value.trim() : value))
|
|
32
|
+
.test('required', error, (value) => (required ? !!value && value.length > 0 : true))
|
|
33
|
+
.test('email', error, (value) =>
|
|
34
|
+
type === 'email' && value ? isEmail(value, { require_tld: true }) : true
|
|
35
|
+
)
|
|
36
|
+
.test('uuid', error, (value) => (type === 'uuid' && value ? isUUID(value) : true))
|
|
37
|
+
|
|
38
|
+
export function validationSchema<T extends FieldValues>({
|
|
39
|
+
fields,
|
|
40
|
+
}: ValidationSchemaProps<T>): Resolver<T> {
|
|
41
|
+
const shape: Record<string, yup.StringSchema> = {}
|
|
42
|
+
for (const name of Object.keys(fields)) {
|
|
43
|
+
shape[name] = buildField(fields[name as keyof T] as FieldSchema)
|
|
44
|
+
}
|
|
45
|
+
return yupResolver(yup.object().shape(shape)) as unknown as Resolver<T>
|
|
46
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Достаёт значение по «точечному» пути ('a.b.c') из объекта.
|
|
2
|
+
export const getByKeyString = (obj: Record<string, unknown>, keyString: string) => {
|
|
3
|
+
if (!keyString) return null
|
|
4
|
+
|
|
5
|
+
const keys = keyString.split('.')
|
|
6
|
+
let result: unknown = obj
|
|
7
|
+
for (const key of keys) {
|
|
8
|
+
if (result && typeof result === 'object' && key in result) {
|
|
9
|
+
result = (result as Record<string, unknown>)[key]
|
|
10
|
+
} else {
|
|
11
|
+
return undefined
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return result
|
|
15
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type QueryParamsProps = {
|
|
2
|
+
pageNumber?: number
|
|
3
|
+
query?: string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export const getQueryKeysArray = (query?: Record<string, unknown> | null) => {
|
|
7
|
+
if (!query) return []
|
|
8
|
+
|
|
9
|
+
return Object.entries(query)
|
|
10
|
+
.filter(([key, value]) => {
|
|
11
|
+
if (
|
|
12
|
+
typeof value === 'undefined' ||
|
|
13
|
+
(typeof value === 'string' && !value.length) ||
|
|
14
|
+
(key === 'sortType' && value === 0)
|
|
15
|
+
)
|
|
16
|
+
return false
|
|
17
|
+
|
|
18
|
+
return true
|
|
19
|
+
})
|
|
20
|
+
.map(([key, value]) => `${key}_${value}`)
|
|
21
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { getTelegram, getTelegramWebApp } from '../hooks/useTelegram'
|
|
2
|
+
|
|
3
|
+
export type MobileOS = 'ios' | 'android' | 'other'
|
|
4
|
+
|
|
5
|
+
export function getMobileOS(): MobileOS {
|
|
6
|
+
if (typeof navigator === 'undefined') return 'other'
|
|
7
|
+
const ua = navigator.userAgent || ''
|
|
8
|
+
if (/iPhone|iPad|iPod/i.test(ua)) return 'ios'
|
|
9
|
+
// iPadOS 13+ маскируется под Macintosh — отличаем по тач-поинтам.
|
|
10
|
+
if (/Macintosh/.test(ua) && (navigator.maxTouchPoints ?? 0) > 1) return 'ios'
|
|
11
|
+
if (/Android/i.test(ua)) return 'android'
|
|
12
|
+
return 'other'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Встроенные браузеры приложений (webview), где ломаются вход через Google,
|
|
16
|
+
// всплывающие окна оплаты, cookies и т.п. Ключ — для логов/аналитики.
|
|
17
|
+
const IN_APP_PATTERNS: ReadonlyArray<readonly [string, RegExp]> = [
|
|
18
|
+
['instagram', /Instagram/i],
|
|
19
|
+
['facebook', /FBAN|FBAV|FB_IAB/i],
|
|
20
|
+
['line', /\bLine\//i],
|
|
21
|
+
['whatsapp', /WhatsApp/i],
|
|
22
|
+
['snapchat', /Snapchat/i],
|
|
23
|
+
['tiktok', /musical_ly|BytedanceWebview|TikTok/i],
|
|
24
|
+
['twitter', /Twitter/i],
|
|
25
|
+
['vk', /VKAndroidApp|VKClient/i],
|
|
26
|
+
['pinterest', /Pinterest/i],
|
|
27
|
+
['google-app', /\bGSA\//i], // приложение Google на iOS
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
export interface InAppDetection {
|
|
31
|
+
inApp: boolean
|
|
32
|
+
/** Идентификатор приложения-обёртки (для логов), либо null. */
|
|
33
|
+
app: string | null
|
|
34
|
+
os: MobileOS
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isStandalonePwa(): boolean {
|
|
38
|
+
if (typeof window === 'undefined') return false
|
|
39
|
+
return (
|
|
40
|
+
window.matchMedia?.('(display-mode: standalone)').matches ||
|
|
41
|
+
(window.navigator as unknown as { standalone?: boolean }).standalone === true
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** tgWebApp*-параметры в URL — присутствуют только при запуске как Mini App. */
|
|
46
|
+
function hasTgWebAppParams(): boolean {
|
|
47
|
+
if (typeof window === 'undefined') return false
|
|
48
|
+
return /[?#&]tgWebApp(Data|Version|Platform)=/i.test(window.location.href)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Telegram Mini App — штатное окружение кабинета, его НЕ редиректим.
|
|
53
|
+
*
|
|
54
|
+
* Различать Mini App и встроенный браузер Telegram можно ТОЛЬКО по:
|
|
55
|
+
* - `initDataUnsafe.user` (getTelegram) — заполнен только в Mini App;
|
|
56
|
+
* - `tgWebApp*` в URL — только при запуске как Mini App.
|
|
57
|
+
*
|
|
58
|
+
* `Telegram.WebApp` и `TelegramWebviewProxy` присутствуют в ОБОИХ окружениях
|
|
59
|
+
* (в инапп-браузере SDK подгружается лоадером, а мост инжектит клиент), поэтому
|
|
60
|
+
* как признак Mini App они НЕ годятся.
|
|
61
|
+
*/
|
|
62
|
+
function isTelegramMiniApp(): boolean {
|
|
63
|
+
return getTelegram() !== null || hasTgWebAppParams()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Признак Telegram-окружения вообще (Mini App ИЛИ встроенный браузер Telegram):
|
|
68
|
+
* присутствует мост/SDK Telegram или «Telegram» в UA. На iOS UA встроенного
|
|
69
|
+
* браузера неотличим от Safari, поэтому ловим именно по мосту/SDK.
|
|
70
|
+
*/
|
|
71
|
+
function hasTelegramEnv(): boolean {
|
|
72
|
+
if (typeof window === 'undefined') return false
|
|
73
|
+
const w = window as unknown as { Telegram?: { WebApp?: unknown }; TelegramWebviewProxy?: unknown }
|
|
74
|
+
if (typeof w.TelegramWebviewProxy !== 'undefined') return true
|
|
75
|
+
if (w.Telegram?.WebApp) return true
|
|
76
|
+
return /Telegram/i.test(navigator.userAgent || '')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function detectInAppBrowser(): InAppDetection {
|
|
80
|
+
const os = getMobileOS()
|
|
81
|
+
if (typeof navigator === 'undefined') return { inApp: false, app: null, os }
|
|
82
|
+
|
|
83
|
+
// Установленную PWA и реальный Mini App не трогаем — это поддерживаемые окружения.
|
|
84
|
+
if (isStandalonePwa() || isTelegramMiniApp()) return { inApp: false, app: null, os }
|
|
85
|
+
|
|
86
|
+
// Встроенный браузер Telegram: окружение Telegram есть, но это НЕ Mini App
|
|
87
|
+
// (исключён выше). Его UA на iOS равен Safari, поэтому ловим именно так.
|
|
88
|
+
if (hasTelegramEnv()) return { inApp: true, app: 'telegram', os }
|
|
89
|
+
|
|
90
|
+
const ua = navigator.userAgent || ''
|
|
91
|
+
for (const [app, re] of IN_APP_PATTERNS) {
|
|
92
|
+
if (re.test(ua)) return { inApp: true, app, os }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Generic iOS WKWebView (встроенный в приложение): UA содержит `AppleWebKit`, но
|
|
96
|
+
// НЕ содержит `Safari`. У самого Safari и standalone Chrome/Firefox iOS токен
|
|
97
|
+
// `Safari` есть, поэтому они сюда не попадают.
|
|
98
|
+
if (os === 'ios' && /AppleWebKit/i.test(ua) && !/Safari/i.test(ua)) {
|
|
99
|
+
return { inApp: true, app: 'ios-webview', os }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Generic Android WebView — UA содержит `; wv)`.
|
|
103
|
+
if (os === 'android' && /;\s*wv\)/.test(ua)) {
|
|
104
|
+
return { inApp: true, app: 'android-webview', os }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { inApp: false, app: null, os }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Попытка открыть текущий URL в системном браузере.
|
|
112
|
+
* - Android Telegram: `WebApp.openLink` — клиент открывает системный браузер.
|
|
113
|
+
* - Android (прочее): `intent://` с дефолтным браузером (browser_fallback_url).
|
|
114
|
+
* - iOS: `x-safari-https://` — уводит в Safari из webview (в т.ч. инапп Telegram,
|
|
115
|
+
* где `openLink` бесполезно открывает ещё один встроенный браузер).
|
|
116
|
+
* Возвращает true, если попытка инициирована (не гарантия успеха).
|
|
117
|
+
*/
|
|
118
|
+
export function tryOpenInExternalBrowser(): boolean {
|
|
119
|
+
if (typeof window === 'undefined') return false
|
|
120
|
+
const os = getMobileOS()
|
|
121
|
+
const { href, host, pathname, search, hash } = window.location
|
|
122
|
+
|
|
123
|
+
// На iOS Telegram `WebApp.openLink` открывает ссылку СНОВА во встроенном браузере —
|
|
124
|
+
// бесполезно. Внешний браузер на iOS уводит трюк `x-safari-https://` (ниже),
|
|
125
|
+
// поэтому openLink используем только на Android (там он открывает системный браузер).
|
|
126
|
+
const tgWebApp = getTelegramWebApp() as {
|
|
127
|
+
openLink?: (url: string, options?: { try_instant_view?: boolean }) => void
|
|
128
|
+
} | null
|
|
129
|
+
if (tgWebApp?.openLink && os !== 'ios') {
|
|
130
|
+
try {
|
|
131
|
+
tgWebApp.openLink(href, { try_instant_view: false })
|
|
132
|
+
return true
|
|
133
|
+
} catch {
|
|
134
|
+
// fallthrough на платформенные способы ниже.
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (os === 'android') {
|
|
139
|
+
const fallback = encodeURIComponent(href)
|
|
140
|
+
const intent = `intent://${host}${pathname}${search}${hash}#Intent;scheme=https;S.browser_fallback_url=${fallback};end`
|
|
141
|
+
window.location.href = intent
|
|
142
|
+
return true
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (os === 'ios') {
|
|
146
|
+
window.location.href = `x-safari-https://${host}${pathname}${search}${hash}`
|
|
147
|
+
return true
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return false
|
|
151
|
+
}
|