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,63 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
createElement,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useState,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from 'react'
|
|
11
|
+
|
|
12
|
+
import Cookies from 'js-cookie'
|
|
13
|
+
|
|
14
|
+
import { KitCookies } from '../config'
|
|
15
|
+
|
|
16
|
+
// Тип устройства по ширине вьюпорта. Граница — Tailwind `md` (768px): < 768 → mobile.
|
|
17
|
+
export type DeviceType = 'mobile' | 'desktop'
|
|
18
|
+
|
|
19
|
+
const MOBILE_QUERY = '(max-width: 767px)'
|
|
20
|
+
|
|
21
|
+
// Дефолт 'desktop' — если компонент вне DeviceProvider.
|
|
22
|
+
const DeviceContext = createContext<DeviceType>('desktop')
|
|
23
|
+
|
|
24
|
+
// Кука живёт год, доступна на всех путях, переживает навигации (Lax). Сервер читает её
|
|
25
|
+
// в getInitialDevice → SSR сразу отдаёт правильную версию.
|
|
26
|
+
const COOKIE_OPTS = { expires: 365, sameSite: 'lax', path: '/' } as const
|
|
27
|
+
|
|
28
|
+
// Провайдер. initialDevice приходит с СЕРВЕРА: сперва из cookie `device` (её клиент пишет
|
|
29
|
+
// ниже по реальной ширине), иначе по User-Agent — см. getInitialDevice в @/lib/device.
|
|
30
|
+
// Поэтому SSR и первый клиентский рендер СОВПАДАЮТ → нет вспышки/прыжков desktop↔mobile
|
|
31
|
+
// при загрузке и F5. После маунта matchMedia сверяет реальную ширину (десктоп с узким
|
|
32
|
+
// окном, ресайз, поворот), правит state И ОБНОВЛЯЕТ КУКУ — чтобы следующий SSR был точным.
|
|
33
|
+
export function DeviceProvider({
|
|
34
|
+
initialDevice,
|
|
35
|
+
children,
|
|
36
|
+
}: {
|
|
37
|
+
initialDevice: DeviceType
|
|
38
|
+
children: ReactNode
|
|
39
|
+
}) {
|
|
40
|
+
const [device, setDevice] = useState<DeviceType>(initialDevice)
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const mql = window.matchMedia(MOBILE_QUERY)
|
|
44
|
+
// setState с тем же значением — no-op (ре-рендера нет). Куку пишем всегда: на первом
|
|
45
|
+
// визите так СИДИМ её (на сервере был UA-fallback), дальше держим в синхроне с шириной.
|
|
46
|
+
const apply = () => {
|
|
47
|
+
const next: DeviceType = mql.matches ? 'mobile' : 'desktop'
|
|
48
|
+
setDevice(next)
|
|
49
|
+
Cookies.set(KitCookies.DEVICE, next, COOKIE_OPTS)
|
|
50
|
+
}
|
|
51
|
+
apply()
|
|
52
|
+
mql.addEventListener('change', apply)
|
|
53
|
+
return () => mql.removeEventListener('change', apply)
|
|
54
|
+
}, [])
|
|
55
|
+
|
|
56
|
+
return createElement(DeviceContext.Provider, { value: device }, children)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Текущий тип устройства (реактивно на resize). Использовать для УСЛОВНОГО рендера
|
|
60
|
+
// версий (mobile/desktop), чтобы неактивная версия не попадала в DOM.
|
|
61
|
+
export function useDevice(): DeviceType {
|
|
62
|
+
return useContext(DeviceContext)
|
|
63
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useSyncExternalStore } from 'react'
|
|
4
|
+
|
|
5
|
+
import { getTelegramWebApp } from './useTelegram'
|
|
6
|
+
|
|
7
|
+
const subscribe = () => () => {}
|
|
8
|
+
|
|
9
|
+
/** true только внутри Telegram Mini App (есть `Telegram.WebApp`), не обычный браузер.
|
|
10
|
+
* Читаем внешнее значение через useSyncExternalStore (SSR-safe, без setState-в-эффекте). */
|
|
11
|
+
export function useIsTelegramMiniApp(): boolean {
|
|
12
|
+
return useSyncExternalStore(
|
|
13
|
+
subscribe,
|
|
14
|
+
() => !!getTelegramWebApp(),
|
|
15
|
+
() => false
|
|
16
|
+
)
|
|
17
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useSyncExternalStore } from 'react'
|
|
4
|
+
|
|
5
|
+
// Сенсорный ввод (грубый указатель — палец, не мышь). Через useSyncExternalStore (SSR-safe):
|
|
6
|
+
// на сервере false, на клиенте — реальное значение matchMedia, с подпиской на смену
|
|
7
|
+
// (например, планшет с подключённой/отключённой мышью). Нужен, чтобы показывать свайп-хинты
|
|
8
|
+
// только там, где свайп реально доступен.
|
|
9
|
+
const QUERY = '(pointer: coarse)'
|
|
10
|
+
|
|
11
|
+
function subscribe(onChange: () => void) {
|
|
12
|
+
if (typeof window === 'undefined') return () => {}
|
|
13
|
+
const mq = window.matchMedia(QUERY)
|
|
14
|
+
mq.addEventListener('change', onChange)
|
|
15
|
+
return () => mq.removeEventListener('change', onChange)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const getSnapshot = () => window.matchMedia(QUERY).matches
|
|
19
|
+
const getServerSnapshot = () => false
|
|
20
|
+
|
|
21
|
+
export function useIsTouch(): boolean {
|
|
22
|
+
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|
|
23
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useSyncExternalStore } from 'react'
|
|
4
|
+
|
|
5
|
+
// Открыта ли экранная клавиатура. Определяем по сжатию visual viewport относительно
|
|
6
|
+
// layout viewport: при фокусе на поле мобильный браузер / Telegram MiniApp поднимает
|
|
7
|
+
// клавиатуру и уменьшает visualViewport.height, тогда как window.innerHeight (layout)
|
|
8
|
+
// остаётся прежним. Порог отсекает мелкие расхождения (адресная строка, тулбары).
|
|
9
|
+
//
|
|
10
|
+
// SSR-safe (useSyncExternalStore): на сервере и в отсутствие visualViewport → false.
|
|
11
|
+
// Нужно, чтобы прятать нижний таб-бар, пока идёт набор (иначе sticky-бар пиннится к низу
|
|
12
|
+
// видимой области ПОВЕРХ поля ввода — см. TelegramBottomNav).
|
|
13
|
+
const KEYBOARD_THRESHOLD_PX = 150
|
|
14
|
+
|
|
15
|
+
const subscribe = (onChange: () => void) => {
|
|
16
|
+
const vv = typeof window !== 'undefined' ? window.visualViewport : null
|
|
17
|
+
if (!vv) return () => {}
|
|
18
|
+
// resize — открытие/закрытие клавиатуры; scroll — сдвиг visual viewport при ней.
|
|
19
|
+
vv.addEventListener('resize', onChange)
|
|
20
|
+
vv.addEventListener('scroll', onChange)
|
|
21
|
+
return () => {
|
|
22
|
+
vv.removeEventListener('resize', onChange)
|
|
23
|
+
vv.removeEventListener('scroll', onChange)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const getSnapshot = (): boolean => {
|
|
28
|
+
const vv = typeof window !== 'undefined' ? window.visualViewport : null
|
|
29
|
+
if (!vv) return false
|
|
30
|
+
return window.innerHeight - vv.height > KEYBOARD_THRESHOLD_PX
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function useKeyboardOpen(): boolean {
|
|
34
|
+
return useSyncExternalStore(subscribe, getSnapshot, () => false)
|
|
35
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
// Реактивное совпадение произвольного media-query. SSR-safe: и на сервере, и на ПЕРВОМ
|
|
6
|
+
// клиентском рендере возвращает false → гидрация без mismatch; сразу после маунта
|
|
7
|
+
// matchMedia проставляет реальное значение (и следит за resize/поворотом).
|
|
8
|
+
//
|
|
9
|
+
// Для раскладки mobile/desktop (порог 767px) бери useDevice — он SSR-точный через cookie
|
|
10
|
+
// и не мигает. useMediaQuery — для порогов, которых у useDevice нет (напр. очень узкие
|
|
11
|
+
// экраны ≤360px), где мгновенная точность на первом кадре некритична.
|
|
12
|
+
export function useMediaQuery(query: string): boolean {
|
|
13
|
+
const [matches, setMatches] = useState(false)
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
const mql = window.matchMedia(query)
|
|
17
|
+
const apply = () => setMatches(mql.matches)
|
|
18
|
+
apply()
|
|
19
|
+
mql.addEventListener('change', apply)
|
|
20
|
+
return () => mql.removeEventListener('change', apply)
|
|
21
|
+
}, [query])
|
|
22
|
+
|
|
23
|
+
return matches
|
|
24
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext } from 'react'
|
|
4
|
+
|
|
5
|
+
import { getTelegram } from './useTelegram'
|
|
6
|
+
|
|
7
|
+
export type PlatformType = 'telegram' | 'browser'
|
|
8
|
+
|
|
9
|
+
// Launch-параметры Mini App (tgWebApp* в query/hash) — их подставляет ТОЛЬКО запуск
|
|
10
|
+
// мини-аппа, в inapp-браузере Telegram их нет. Нужны как ранний сигнал, пока SDK ещё
|
|
11
|
+
// не распарсил initData (первый рендер до/во время загрузки telegram-web-app.js).
|
|
12
|
+
function hasMiniAppLaunchParams(): boolean {
|
|
13
|
+
if (typeof window === 'undefined') return false
|
|
14
|
+
const href = window.location?.href || ''
|
|
15
|
+
return /[?#&]tgWebApp(Data|Version|Platform)=/i.test(href)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Мок платформы для локальной отладки MiniApp-веток в браузере (таб-бар, telegram-only и т.д.).
|
|
19
|
+
// Включается в .env.local: NEXT_PUBLIC_MOCK_TELEGRAM=true. В прод-окружении переменной нет.
|
|
20
|
+
const MOCK_TELEGRAM = process.env.NEXT_PUBLIC_MOCK_TELEGRAM === 'true'
|
|
21
|
+
|
|
22
|
+
// 'telegram' = ТОЛЬКО настоящий Mini App: initData с user (SDK) либо launch-параметры
|
|
23
|
+
// tgWebApp*. UA «Telegram» и window.TelegramWebviewProxy сигналами НЕ являются — они
|
|
24
|
+
// есть и в inapp-браузере Telegram (открытие обычной ссылки), где нет ни initData,
|
|
25
|
+
// ни сессии: такой «telegram» давал фейковую авторизацию и цикл login↔home.
|
|
26
|
+
function computePlatform(): PlatformType {
|
|
27
|
+
if (MOCK_TELEGRAM) return 'telegram'
|
|
28
|
+
return getTelegram() || hasMiniAppLaunchParams() ? 'telegram' : 'browser'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── Внешнее хранилище платформы ──────────────────────────────────────────────
|
|
32
|
+
// SDK Telegram грузится по сети со стороннего домена (см. лоадер в layout.tsx), поэтому
|
|
33
|
+
// на первом клиентском рендере window.Telegram может ещё не существовать. Раньше здесь
|
|
34
|
+
// стояла пустая подписка: значение считалось один раз и застревало на 'browser' навсегда,
|
|
35
|
+
// даже когда SDK через мгновение приезжал. Мини-апп в этом случае оставался без маркера
|
|
36
|
+
// сессии, и сервер уводил его на /login — «раз через раз», в зависимости от того, успел
|
|
37
|
+
// ли скрипт до гидрации. Теперь ждём SDK и уведомляем подписчиков.
|
|
38
|
+
const POLL_INTERVAL_MS = 100
|
|
39
|
+
const POLL_TIMEOUT_MS = 10_000
|
|
40
|
+
|
|
41
|
+
let cachedPlatform: PlatformType | null = null
|
|
42
|
+
const listeners = new Set<() => void>()
|
|
43
|
+
let stopWaiting: (() => void) | null = null
|
|
44
|
+
|
|
45
|
+
/** Снимок платформы. Значение стабильно: 'telegram' обратно в 'browser' не превращается. */
|
|
46
|
+
export function getPlatform(): PlatformType {
|
|
47
|
+
if (cachedPlatform === null) {
|
|
48
|
+
cachedPlatform = computePlatform()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return cachedPlatform
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function waitForTelegram(): void {
|
|
55
|
+
if (stopWaiting || cachedPlatform === 'telegram' || typeof window === 'undefined') {
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const startedAt = Date.now()
|
|
60
|
+
const timer = window.setInterval(() => {
|
|
61
|
+
if (computePlatform() === 'telegram') {
|
|
62
|
+
cachedPlatform = 'telegram'
|
|
63
|
+
stopWaiting?.()
|
|
64
|
+
listeners.forEach((listener) => listener())
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Не крутим таймер вечно: вне Telegram SDK не появится никогда.
|
|
69
|
+
if (Date.now() - startedAt >= POLL_TIMEOUT_MS) {
|
|
70
|
+
stopWaiting?.()
|
|
71
|
+
}
|
|
72
|
+
}, POLL_INTERVAL_MS)
|
|
73
|
+
|
|
74
|
+
stopWaiting = () => {
|
|
75
|
+
window.clearInterval(timer)
|
|
76
|
+
stopWaiting = null
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function subscribePlatform(listener: () => void): () => void {
|
|
81
|
+
listeners.add(listener)
|
|
82
|
+
waitForTelegram()
|
|
83
|
+
|
|
84
|
+
return () => {
|
|
85
|
+
listeners.delete(listener)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Снимок для SSR и гидрации: на сервере Telegram-окружения нет. */
|
|
90
|
+
export function getServerPlatform(): PlatformType {
|
|
91
|
+
return 'browser'
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const PlatformContext = createContext<PlatformType>('browser')
|
|
95
|
+
|
|
96
|
+
export function usePlatform(): PlatformType {
|
|
97
|
+
return useContext(PlatformContext)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function isTelegram(): boolean {
|
|
101
|
+
return getTelegram() !== null || hasMiniAppLaunchParams()
|
|
102
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
|
2
|
+
|
|
3
|
+
export const useQueryParams = () => {
|
|
4
|
+
const router = useRouter()
|
|
5
|
+
const pathname = usePathname()
|
|
6
|
+
const searchParams = useSearchParams()
|
|
7
|
+
|
|
8
|
+
const setQuery = (params: Record<string, string | number | undefined>, isReplace?: boolean) => {
|
|
9
|
+
const currentParams = new URLSearchParams()
|
|
10
|
+
|
|
11
|
+
Object.entries(params).forEach((value) => {
|
|
12
|
+
if (typeof value[1] !== 'undefined') currentParams.set(value[0], value[1].toString())
|
|
13
|
+
else currentParams.delete(value[0])
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
if (isReplace) return router.replace(`${pathname}?${currentParams.toString()}`)
|
|
17
|
+
router.push(`${pathname}?${currentParams.toString()}`)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const pushQuery = (params: Record<string, string | number | undefined>, isReplace?: boolean) => {
|
|
21
|
+
const currentParams = new URLSearchParams(searchParams.toString())
|
|
22
|
+
|
|
23
|
+
Object.entries(params).forEach((value) => {
|
|
24
|
+
if (typeof value[1] !== 'undefined') currentParams.set(value[0], value[1].toString())
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
if (isReplace) return router.replace(`${pathname}?${currentParams.toString()}`)
|
|
28
|
+
router.push(`${pathname}?${currentParams.toString()}`)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const getQuery = (key: string) => {
|
|
32
|
+
const query = searchParams.get(key)
|
|
33
|
+
return query
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const getAllQuery = (key: string) => {
|
|
37
|
+
const query = searchParams.getAll(key)
|
|
38
|
+
return query
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const removeQuery = (key: string, isReplace?: boolean) => {
|
|
42
|
+
const currentParams = new URLSearchParams(searchParams.toString())
|
|
43
|
+
currentParams.delete(key)
|
|
44
|
+
|
|
45
|
+
if (isReplace) return router.replace(`${pathname}?${currentParams.toString()}`)
|
|
46
|
+
router.push(`${pathname}?${currentParams.toString()}`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const removeAllQuery = (isReplace?: boolean) => {
|
|
50
|
+
const currentParams = new URLSearchParams()
|
|
51
|
+
|
|
52
|
+
if (isReplace) return router.replace(`${pathname}?${currentParams.toString()}`)
|
|
53
|
+
router.push(`${pathname}?${currentParams.toString()}`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const getAllQueries = () => {
|
|
57
|
+
const query = searchParams.toString()
|
|
58
|
+
return { string: query, object: Object.fromEntries(searchParams) }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
setQuery,
|
|
63
|
+
pushQuery,
|
|
64
|
+
getQuery,
|
|
65
|
+
getAllQuery,
|
|
66
|
+
removeQuery,
|
|
67
|
+
removeAllQuery,
|
|
68
|
+
getAllQueries,
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
// Ключи, чьё появление уже сыграло за сессию (пока жив JS).
|
|
6
|
+
const revealedKeys = new Set<string>()
|
|
7
|
+
|
|
8
|
+
// true — если для этого ключа появление ещё НЕ проигрывалось (первый показ за сессию). После
|
|
9
|
+
// монтирования ключ отмечается отыгранным → повторный заход на страницу вернёт false, и список
|
|
10
|
+
// показывается сразу, без каскада. Значение фиксируется на первом рендере (useState-инициализатор),
|
|
11
|
+
// поэтому не «мигает» при последующих ре-рендерах того же экрана. Без ключа (undefined) —
|
|
12
|
+
// всегда false: каскад выключен (удобно для опционального пропа revealKey).
|
|
13
|
+
export function useRevealOnce(key?: string): boolean {
|
|
14
|
+
const [play] = useState(() => key != null && !revealedKeys.has(key))
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (key != null) revealedKeys.add(key)
|
|
18
|
+
}, [key])
|
|
19
|
+
|
|
20
|
+
return play
|
|
21
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
// true, когда страница проскроллена ниже threshold (px). Слушатель passive; setState
|
|
6
|
+
// с тем же boolean ре-рендер не вызывает, так что на каждый пиксель скролла не рендерим.
|
|
7
|
+
// До гидрации — false (SSR window недоступен), первый эффект сверяет реальную позицию.
|
|
8
|
+
export function useScrolledPast(threshold: number): boolean {
|
|
9
|
+
const [past, setPast] = useState(false)
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const onScroll = () => setPast(window.scrollY > threshold)
|
|
13
|
+
onScroll()
|
|
14
|
+
window.addEventListener('scroll', onScroll, { passive: true })
|
|
15
|
+
return () => window.removeEventListener('scroll', onScroll)
|
|
16
|
+
}, [threshold])
|
|
17
|
+
|
|
18
|
+
return past
|
|
19
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
// Ключи, у которых «доводка» уже сыграла за сессию (пока жив JS). Повторный заход на
|
|
6
|
+
// страницу с тем же ключом сразу отдаёт финальное значение — анимация одноразовая.
|
|
7
|
+
const settledKeys = new Set<string>()
|
|
8
|
+
|
|
9
|
+
// Значение «доезжает» до target через CSS-переход у ПОТРЕБИТЕЛЯ (ширина полосы, дуга donut):
|
|
10
|
+
// на первом показе стартует с 0 → на следующий кадр становится target (переход анимирует),
|
|
11
|
+
// а при повторном заходе (ключ уже отыгран) — сразу target, без анимации. reduced-motion не
|
|
12
|
+
// трогаем: CSS-переход глушит глобальный @media в globals.css.
|
|
13
|
+
export function useSettleOnce(target: number, replayKey?: string): number {
|
|
14
|
+
const alreadySettled = replayKey != null && settledKeys.has(replayKey)
|
|
15
|
+
const [value, setValue] = useState(alreadySettled ? target : 0)
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
const id = requestAnimationFrame(() => {
|
|
19
|
+
setValue(target)
|
|
20
|
+
if (replayKey != null) settledKeys.add(replayKey)
|
|
21
|
+
})
|
|
22
|
+
return () => cancelAnimationFrame(id)
|
|
23
|
+
}, [target, replayKey])
|
|
24
|
+
|
|
25
|
+
return value
|
|
26
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useEffect, useSyncExternalStore } from 'react'
|
|
2
|
+
|
|
3
|
+
/** WebApp только если в initData уже есть user (авторизация через TG). */
|
|
4
|
+
export function getTelegram(): TelegramWebApp | null {
|
|
5
|
+
if (typeof window === 'undefined') return null
|
|
6
|
+
return window.Telegram?.WebApp?.initDataUnsafe?.user ? window.Telegram.WebApp : null
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Объект Telegram.WebApp при наличии (в т.ч. без user в initData) — для openLink и т.п. */
|
|
10
|
+
export function getTelegramWebApp(): TelegramWebApp | null {
|
|
11
|
+
if (typeof window === 'undefined') return null
|
|
12
|
+
return window.Telegram?.WebApp ?? null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* ЕДИНЫЙ источник initData для АВТОРИЗАЦИИ (REST-заголовок InitData + query WS-хабов).
|
|
17
|
+
* Реальный MiniApp — из SDK. Локальная отладка: NEXT_PUBLIC_MOCK_TELEGRAM=true +
|
|
18
|
+
* NEXT_PUBLIC_MOCK_TELEGRAM_INITDATA=<строка из DevTools реального Telegram> — тогда API/хабы
|
|
19
|
+
* авторизуются как из мини-аппа прямо в браузере. Без строки мок остаётся UI-only.
|
|
20
|
+
*/
|
|
21
|
+
export function getInitData(): string | null {
|
|
22
|
+
const real = getTelegram()?.initData
|
|
23
|
+
if (real) return real
|
|
24
|
+
if (process.env.NEXT_PUBLIC_MOCK_TELEGRAM === 'true') {
|
|
25
|
+
return process.env.NEXT_PUBLIC_MOCK_TELEGRAM_INITDATA || null
|
|
26
|
+
}
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Значение читается из внешней системы (window.Telegram) — берём его через
|
|
31
|
+
// useSyncExternalStore (SSR-safe), без setState-в-эффекте с каскадными ререндерами.
|
|
32
|
+
// SDK инжектится синхронно лоадером, поэтому подписка не нужна (snapshot стабилен).
|
|
33
|
+
const subscribeTelegram = () => () => {}
|
|
34
|
+
|
|
35
|
+
export function useTelegram(): TelegramWebApp | null {
|
|
36
|
+
const tg = useSyncExternalStore(subscribeTelegram, getTelegram, () => null)
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
tg?.ready()
|
|
40
|
+
}, [tg])
|
|
41
|
+
|
|
42
|
+
return tg
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function applyTelegramChromeColor(color: string) {
|
|
46
|
+
const tg = getTelegram()
|
|
47
|
+
if (!tg || !color) return
|
|
48
|
+
|
|
49
|
+
tg.setHeaderColor?.(color)
|
|
50
|
+
tg.setBackgroundColor?.(color)
|
|
51
|
+
tg.setBottomBarColor?.(color)
|
|
52
|
+
|
|
53
|
+
// Fallback for older clients where setters can be flaky.
|
|
54
|
+
tg.headerColor = color
|
|
55
|
+
tg.backgroundColor = color
|
|
56
|
+
tg.bottomBarColor = color
|
|
57
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
// Порт локализации кита — по образцу @/utils/notify: кит НЕ знает, какой библиотекой
|
|
4
|
+
// приложение переводит тексты. Он объявляет контракт (locale + t), а приложение один раз
|
|
5
|
+
// подключает свою реализацию через <KitI18nProvider>.
|
|
6
|
+
//
|
|
7
|
+
// Зачем порт, а не пропсы: подписи вроде «Отмена» на кнопке confirm-диалога нужны глубоко
|
|
8
|
+
// внутри компонентов, и протаскивать их пропсами через каждый вызов — шум. Зачем контекст,
|
|
9
|
+
// а не модульная переменная (как в notify): смена локали обязана перерисовать компоненты,
|
|
10
|
+
// а запись в модульную переменную ре-рендер не вызывает.
|
|
11
|
+
//
|
|
12
|
+
// Без провайдера кит работает на встроенном словаре (ru/en) — новый лендинг заводится
|
|
13
|
+
// без настройки i18n вообще, а подключает её, когда дорастёт.
|
|
14
|
+
|
|
15
|
+
import * as React from 'react'
|
|
16
|
+
|
|
17
|
+
export type KitLocale = 'ru' | 'en'
|
|
18
|
+
|
|
19
|
+
/** Разделы словаря, которые использует сам кит. */
|
|
20
|
+
export type KitScope = 'ui.confirm' | 'ui.actions' | 'ui.toast'
|
|
21
|
+
|
|
22
|
+
export interface KitI18nValue {
|
|
23
|
+
locale: KitLocale
|
|
24
|
+
/** Вернуть перевод. Если ключа нет — вернуть undefined, кит подставит свой дефолт. */
|
|
25
|
+
t: (scope: KitScope, key: string) => string | undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Встроенный словарь — фолбэк и одновременно полный перечень ключей, которые
|
|
29
|
+
// приложение может переопределить. Ключи совпадают с dictionaries кабинета
|
|
30
|
+
// (ui.confirm.cancel, ui.actions.copy …), поэтому подключение сводится к одной строке.
|
|
31
|
+
const FALLBACK: Record<KitLocale, Record<KitScope, Record<string, string>>> = {
|
|
32
|
+
ru: {
|
|
33
|
+
'ui.confirm': { cancel: 'Отмена', confirm: 'Подтвердить' },
|
|
34
|
+
'ui.actions': {
|
|
35
|
+
clear: 'Очистить',
|
|
36
|
+
paste: 'Вставить',
|
|
37
|
+
copy: 'Скопировать',
|
|
38
|
+
copied: 'Скопировано',
|
|
39
|
+
},
|
|
40
|
+
'ui.toast': { copied: 'Скопировано' },
|
|
41
|
+
},
|
|
42
|
+
en: {
|
|
43
|
+
'ui.confirm': { cancel: 'Cancel', confirm: 'Confirm' },
|
|
44
|
+
'ui.actions': {
|
|
45
|
+
clear: 'Clear',
|
|
46
|
+
paste: 'Paste',
|
|
47
|
+
copy: 'Copy',
|
|
48
|
+
copied: 'Copied',
|
|
49
|
+
},
|
|
50
|
+
'ui.toast': { copied: 'Copied' },
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const DEFAULT_LOCALE: KitLocale = 'ru'
|
|
55
|
+
|
|
56
|
+
const KitI18nContext = React.createContext<KitI18nValue | null>(null)
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Подключить локализацию приложения к киту.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* const t = useScopedI18n('ui')
|
|
63
|
+
* <KitI18nProvider locale={locale} t={(scope, key) => t(`${scope.slice(3)}.${key}`)}>
|
|
64
|
+
*/
|
|
65
|
+
export function KitI18nProvider({
|
|
66
|
+
locale,
|
|
67
|
+
t,
|
|
68
|
+
children,
|
|
69
|
+
}: KitI18nValue & { children: React.ReactNode }) {
|
|
70
|
+
const value = React.useMemo(() => ({ locale, t }), [locale, t])
|
|
71
|
+
return <KitI18nContext.Provider value={value}>{children}</KitI18nContext.Provider>
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Текущая локаль кита. Вне провайдера — DEFAULT_LOCALE. */
|
|
75
|
+
export function useKitLocale(): KitLocale {
|
|
76
|
+
return React.useContext(KitI18nContext)?.locale ?? DEFAULT_LOCALE
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Переводчик раздела. Всегда возвращает строку: приложение → встроенный словарь → сам ключ.
|
|
81
|
+
* Ключ как последний фолбэк лучше пустоты — по нему видно, чего не хватает в словаре.
|
|
82
|
+
*/
|
|
83
|
+
export function useKitT(scope: KitScope): (key: string) => string {
|
|
84
|
+
const ctx = React.useContext(KitI18nContext)
|
|
85
|
+
const locale = ctx?.locale ?? DEFAULT_LOCALE
|
|
86
|
+
const t = ctx?.t
|
|
87
|
+
|
|
88
|
+
return React.useCallback(
|
|
89
|
+
(key: string) => t?.(scope, key) ?? FALLBACK[locale]?.[scope]?.[key] ?? key,
|
|
90
|
+
[t, scope, locale],
|
|
91
|
+
)
|
|
92
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Публичный вход кита: UI-компоненты, логотип и обвязка темы.
|
|
2
|
+
// Next-специфика (Link, Image, useQueryParams) — 'hallo-kit/next'.
|
|
3
|
+
// Хуки — 'hallo-kit/hooks', утилиты — 'hallo-kit/utils', серверное — 'hallo-kit/server'.
|
|
4
|
+
|
|
5
|
+
export * from './ui/Icon'
|
|
6
|
+
export * from './ui/badge'
|
|
7
|
+
export * from './ui/burger-icon'
|
|
8
|
+
export * from './ui/button'
|
|
9
|
+
export * from './ui/card'
|
|
10
|
+
export * from './ui/confirm'
|
|
11
|
+
export * from './ui/count-up'
|
|
12
|
+
export * from './ui/data-list'
|
|
13
|
+
export * from './ui/drawer'
|
|
14
|
+
export * from './ui/flag'
|
|
15
|
+
export * from './ui/input'
|
|
16
|
+
export * from './ui/label'
|
|
17
|
+
export * from './ui/method-card'
|
|
18
|
+
export * from './ui/modal'
|
|
19
|
+
export * from './ui/otp-input'
|
|
20
|
+
export * from './ui/pagination'
|
|
21
|
+
export * from './ui/radio-group'
|
|
22
|
+
export * from './ui/select'
|
|
23
|
+
export * from './ui/separator'
|
|
24
|
+
export * from './ui/skeleton'
|
|
25
|
+
export * from './ui/spinner'
|
|
26
|
+
export * from './ui/switch'
|
|
27
|
+
export * from './ui/tabs'
|
|
28
|
+
export * from './ui/textarea'
|
|
29
|
+
export * from './ui/theme-switch'
|
|
30
|
+
export * from './ui/toast'
|
|
31
|
+
|
|
32
|
+
export * from './logo'
|
|
33
|
+
export * from './theme-color-sync'
|
|
34
|
+
export * from './theme-provider'
|
|
35
|
+
export * from './config'
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { cookies, headers } from 'next/headers'
|
|
2
|
+
|
|
3
|
+
import { KitCookies } from '../config'
|
|
4
|
+
import type { DeviceType } from '../hooks/useDevice'
|
|
5
|
+
import { type Platform, platformFromUA } from './platform'
|
|
6
|
+
|
|
7
|
+
// Серверное определение устройства — чтобы SSR сразу отдал ПРАВИЛЬНУЮ версию
|
|
8
|
+
// (mobile/desktop) и не было вспышки/прыжков при F5.
|
|
9
|
+
//
|
|
10
|
+
// 1) Сначала пробуем cookie `device`: её пишет клиент (DeviceProvider) по РЕАЛЬНОЙ ширине
|
|
11
|
+
// вьюпорта (matchMedia). Если кука есть — сервер отдаёт ровно ту версию, что покажет
|
|
12
|
+
// клиент → расхождения UA↔viewport нет, переключения после гидратации нет.
|
|
13
|
+
// 2) Куки ещё нет (самый первый визит) → fallback на User-Agent. `/Mobi/` — рекомендованный
|
|
14
|
+
// MDN минимальный тест (его содержат UA телефонных браузеров). Десктоп с узким окном
|
|
15
|
+
// UA не поймает, но клиент тут же измерит ширину и поставит куку → со следующего рендера
|
|
16
|
+
// всё чётко.
|
|
17
|
+
export async function getInitialDevice(): Promise<DeviceType> {
|
|
18
|
+
const cookie = (await cookies()).get(KitCookies.DEVICE)?.value
|
|
19
|
+
if (cookie === 'mobile' || cookie === 'desktop') return cookie
|
|
20
|
+
|
|
21
|
+
const ua = (await headers()).get('user-agent') ?? ''
|
|
22
|
+
return /Mobi/i.test(ua) ? 'mobile' : 'desktop'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function getServerPlatform(): Promise<Platform> {
|
|
26
|
+
const ua = (await headers()).get('user-agent') ?? ''
|
|
27
|
+
return platformFromUA(ua)
|
|
28
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type Platform = 'android' | 'ios' | 'macos' | 'windows' | 'linux' | 'web'
|
|
2
|
+
|
|
3
|
+
export function platformFromUA(ua: string): Platform {
|
|
4
|
+
const s = ua.toLowerCase()
|
|
5
|
+
if (/android/.test(s)) return 'android'
|
|
6
|
+
if (/iphone|ipad|ipod/.test(s)) return 'ios'
|
|
7
|
+
if (/macintosh|mac os x/.test(s)) return 'macos'
|
|
8
|
+
if (/windows/.test(s)) return 'windows'
|
|
9
|
+
if (/linux/.test(s)) return 'linux'
|
|
10
|
+
return 'web'
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const OS_LABEL: Record<Exclude<Platform, 'web'>, string> = {
|
|
14
|
+
android: 'Android',
|
|
15
|
+
ios: 'iOS',
|
|
16
|
+
macos: 'macOS',
|
|
17
|
+
windows: 'Windows',
|
|
18
|
+
linux: 'Linux',
|
|
19
|
+
}
|