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.
Files changed (128) hide show
  1. package/README.md +168 -0
  2. package/bin/cli.mjs +136 -0
  3. package/fonts/Manrope/Manrope-VariableFont_wght.woff2 +0 -0
  4. package/fonts/Manrope/OFL.txt +93 -0
  5. package/fonts/index.ts +30 -0
  6. package/package.json +77 -0
  7. package/src/config.ts +11 -0
  8. package/src/hooks/index.ts +14 -0
  9. package/src/hooks/useAutoHideOnScroll.ts +26 -0
  10. package/src/hooks/useCountdown.ts +44 -0
  11. package/src/hooks/useDevice.ts +63 -0
  12. package/src/hooks/useIsTelegramMiniApp.ts +17 -0
  13. package/src/hooks/useIsTouch.ts +23 -0
  14. package/src/hooks/useKeyboardOpen.ts +35 -0
  15. package/src/hooks/useMediaQuery.ts +24 -0
  16. package/src/hooks/usePlatform.ts +102 -0
  17. package/src/hooks/useQueryParams.tsx +70 -0
  18. package/src/hooks/useRevealOnce.ts +21 -0
  19. package/src/hooks/useScrolledPast.ts +19 -0
  20. package/src/hooks/useSettleOnce.ts +26 -0
  21. package/src/hooks/useTelegram.tsx +57 -0
  22. package/src/i18n/index.tsx +92 -0
  23. package/src/index.ts +35 -0
  24. package/src/lib/device.ts +28 -0
  25. package/src/lib/platform.ts +19 -0
  26. package/src/lib/utils.ts +36 -0
  27. package/src/logo/LogoFull.tsx +59 -0
  28. package/src/logo/LogoMark.tsx +31 -0
  29. package/src/logo/index.ts +2 -0
  30. package/src/next.ts +13 -0
  31. package/src/server.ts +4 -0
  32. package/src/theme-color-sync.tsx +43 -0
  33. package/src/theme-provider.tsx +31 -0
  34. package/src/types/css.d.ts +6 -0
  35. package/src/types/globals.d.ts +47 -0
  36. package/src/ui/Icon/Icon.module.css +25 -0
  37. package/src/ui/Icon/Icon.tsx +45 -0
  38. package/src/ui/Icon/Icon.types.ts +60 -0
  39. package/src/ui/Icon/data/brand.data.tsx +75 -0
  40. package/src/ui/Icon/data/index.ts +2 -0
  41. package/src/ui/Icon/data/ui.data.tsx +780 -0
  42. package/src/ui/Icon/index.ts +3 -0
  43. package/src/ui/badge/badge.tsx +185 -0
  44. package/src/ui/badge/index.ts +1 -0
  45. package/src/ui/breadcrumbs/breadcrumbs.tsx +63 -0
  46. package/src/ui/breadcrumbs/index.ts +1 -0
  47. package/src/ui/burger-icon/burger-icon.tsx +22 -0
  48. package/src/ui/burger-icon/index.ts +1 -0
  49. package/src/ui/button/button.tsx +179 -0
  50. package/src/ui/button/index.ts +1 -0
  51. package/src/ui/card/card.tsx +100 -0
  52. package/src/ui/card/index.ts +1 -0
  53. package/src/ui/confirm/confirm-dialog-host.tsx +89 -0
  54. package/src/ui/confirm/index.ts +1 -0
  55. package/src/ui/count-up/count-up.tsx +96 -0
  56. package/src/ui/count-up/index.ts +1 -0
  57. package/src/ui/data-list/data-list.tsx +331 -0
  58. package/src/ui/data-list/index.ts +1 -0
  59. package/src/ui/drawer/drawer.tsx +166 -0
  60. package/src/ui/drawer/index.ts +1 -0
  61. package/src/ui/flag/flag.data.ts +240 -0
  62. package/src/ui/flag/flag.tsx +57 -0
  63. package/src/ui/flag/index.ts +1 -0
  64. package/src/ui/image/image.tsx +67 -0
  65. package/src/ui/image/index.ts +1 -0
  66. package/src/ui/input/amount-input.tsx +55 -0
  67. package/src/ui/input/index.ts +2 -0
  68. package/src/ui/input/input.tsx +391 -0
  69. package/src/ui/label/index.ts +1 -0
  70. package/src/ui/label/label.tsx +34 -0
  71. package/src/ui/method-card/index.ts +1 -0
  72. package/src/ui/method-card/method-card.tsx +101 -0
  73. package/src/ui/modal/index.ts +1 -0
  74. package/src/ui/modal/modal.tsx +128 -0
  75. package/src/ui/otp-input/index.ts +1 -0
  76. package/src/ui/otp-input/otp-input.tsx +78 -0
  77. package/src/ui/pagination/index.ts +1 -0
  78. package/src/ui/pagination/pagination.tsx +138 -0
  79. package/src/ui/radio-group/index.ts +1 -0
  80. package/src/ui/radio-group/radio-group.tsx +81 -0
  81. package/src/ui/select/index.ts +1 -0
  82. package/src/ui/select/select.tsx +215 -0
  83. package/src/ui/separator/index.ts +1 -0
  84. package/src/ui/separator/separator.tsx +54 -0
  85. package/src/ui/skeleton/index.ts +1 -0
  86. package/src/ui/skeleton/skeleton.tsx +44 -0
  87. package/src/ui/spinner/index.ts +1 -0
  88. package/src/ui/spinner/spinner.tsx +33 -0
  89. package/src/ui/switch/index.ts +1 -0
  90. package/src/ui/switch/switch.tsx +48 -0
  91. package/src/ui/tabs/index.ts +1 -0
  92. package/src/ui/tabs/tabs.tsx +190 -0
  93. package/src/ui/textarea/index.ts +1 -0
  94. package/src/ui/textarea/textarea.tsx +151 -0
  95. package/src/ui/theme-switch/index.ts +1 -0
  96. package/src/ui/theme-switch/theme-switch.tsx +161 -0
  97. package/src/ui/toast/index.ts +1 -0
  98. package/src/ui/toast/toast.tsx +159 -0
  99. package/src/utils/api/stringFormat.ts +5 -0
  100. package/src/utils/clearAllCookies.ts +7 -0
  101. package/src/utils/clsx.ts +18 -0
  102. package/src/utils/confirm.ts +48 -0
  103. package/src/utils/copy-to-clipboard.ts +12 -0
  104. package/src/utils/date/convertTimestamp.ts +44 -0
  105. package/src/utils/date/formatCountdown.ts +9 -0
  106. package/src/utils/date/formatDayLabel.ts +50 -0
  107. package/src/utils/date/formatRelativeDateTime.ts +34 -0
  108. package/src/utils/date/locales.ts +9 -0
  109. package/src/utils/form/validationSchema.ts +46 -0
  110. package/src/utils/getByKeyString.ts +15 -0
  111. package/src/utils/getCssVar.ts +2 -0
  112. package/src/utils/getDeepType.ts +3 -0
  113. package/src/utils/getQueryParams.ts +21 -0
  114. package/src/utils/inAppBrowser.ts +151 -0
  115. package/src/utils/index.ts +28 -0
  116. package/src/utils/isRTL.ts +29 -0
  117. package/src/utils/notify.ts +46 -0
  118. package/src/utils/openExternal.ts +27 -0
  119. package/src/utils/system/getMillisecondsPeriod.ts +31 -0
  120. package/src/utils/validation/authValidators.ts +23 -0
  121. package/src/utils/validation/refCode.ts +30 -0
  122. package/styles/base.css +97 -0
  123. package/styles/brand.css +36 -0
  124. package/styles/index.css +19 -0
  125. package/styles/motion.css +207 -0
  126. package/styles/semantic.css +151 -0
  127. package/styles/theme.css +143 -0
  128. 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,7 @@
1
+ export const clearAllCookies = () => {
2
+ document.cookie.split(';').forEach(function (c) {
3
+ document.cookie = c
4
+ .replace(/^ +/, '')
5
+ .replace(/=.*/, '=;expires=' + new Date().toUTCString() + ';path=/')
6
+ })
7
+ }
@@ -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,2 @@
1
+ export const getCssVar = (name: string) =>
2
+ getComputedStyle(document.documentElement).getPropertyValue(name).trim()
@@ -0,0 +1,3 @@
1
+ export type DeepType = {
2
+ [key in string]: string | number | DeepType | DeepType[] | (<T extends string>(id: T) => string)
3
+ }
@@ -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
+ }