UNPKG

denwa-web-shared

Version:

A shared library for Next.js App Router projects containing reusable components, hooks, schemas, and utilities.

586 lines (585 loc) • 21.6 kB
import { useEffect, useRef, useState } from "react"; import { useIntersection, useMedia, useWindowScroll } from "react-use"; import { useInView } from "motion/react"; import { create } from "zustand"; import { clsx } from "clsx"; import { twMerge } from "tailwind-merge"; import { jsx, jsxs } from "react/jsx-runtime"; //#region src/client/hooks/use-disable-scroll.ts var useDisableScroll = (isDisableScroll) => { useEffect(() => { if (!isDisableScroll || typeof document === "undefined") return; const originalOverflow = document.body.style.overflow; const originalPaddingRight = document.body.style.paddingRight; const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth; if (scrollBarWidth > 0) document.body.style.paddingRight = `${scrollBarWidth}px`; document.body.style.overflow = "hidden"; return () => { document.body.style.overflow = originalOverflow; document.body.style.paddingRight = originalPaddingRight; }; }, [isDisableScroll]); }; //#endregion //#region src/client/hooks/use-is-client.ts var useIsClient = () => { const [isClient, setIsClient] = useState(false); useEffect(() => { setIsClient(true); }, []); return isClient; }; //#endregion //#region src/shared/constants/index.ts var THEME = { VIEW_PORT: { EXTRA_SMALL: 280, SMALL: 320, SMALL_MOBILE: 380, MOBILE: 450, MEDIUM: 500, EXTRA_MEDIUM: 600, TABLET: 768, LAPTOP: 1024, LAPTOP_BIG: 1200, BIG: 1440, VERY_BIG: 1920 }, OFFSET: { 1: 8, 2: 16, 3: 24, 4: 32, 5: 40, 6: 48, 7: 56, 8: 64 } }; //#endregion //#region src/client/hooks/use-view-port.ts var useLaptopBigViewPort = () => { return { isLaptopBigMinWidth: useMedia(`(min-width: ${THEME.VIEW_PORT.LAPTOP_BIG}px)`), isLaptopBigMaxWidth: useMedia(`(max-width: ${THEME.VIEW_PORT.LAPTOP_BIG}px)`) }; }; var useLaptopViewPort = () => { return { isLaptopMinWidth: useMedia(`(min-width: ${THEME.VIEW_PORT.LAPTOP}px)`), isLaptopMaxWidth: useMedia(`(max-width: ${THEME.VIEW_PORT.LAPTOP}px)`) }; }; var useTabletViewPort = () => { return { isTabletMinWidth: useMedia(`(min-width: ${THEME.VIEW_PORT.TABLET}px)`), isTabletMaxWidth: useMedia(`(max-width: ${THEME.VIEW_PORT.TABLET}px)`) }; }; var useExtraMediumViewPort = () => { return { isExtraMediumMinWidth: useMedia(`(min-width: ${THEME.VIEW_PORT.EXTRA_MEDIUM}px)`), isExtraMediumMaxWidth: useMedia(`(max-width: ${THEME.VIEW_PORT.EXTRA_MEDIUM}px)`) }; }; var useMobileViewPort = () => { return { isMobileMinWidth: useMedia(`(min-width: ${THEME.VIEW_PORT.MOBILE}px)`), isMobileMaxWidth: useMedia(`(max-width: ${THEME.VIEW_PORT.MOBILE}px)`) }; }; var useSmallViewPort = () => { return { isSmallMinWidth: useMedia(`(min-width: ${THEME.VIEW_PORT.SMALL}px)`), isSmallMaxWidth: useMedia(`(max-width: ${THEME.VIEW_PORT.SMALL}px)`) }; }; //#endregion //#region src/client/hooks/use-appear-animation.ts var useAppearAnimation = ({ once = true, margin = "-100px", delay = .1, direction = "up", animationDistance = 50, duration = .8, ease = "easeOut" } = {}) => { const ref = useRef(null); const isInView = useInView(ref, { once, margin }); const getInitialState = () => { switch (direction) { case "up": return { opacity: 0, y: animationDistance }; case "down": return { opacity: 0, y: -animationDistance }; case "left": return { opacity: 0, x: animationDistance }; case "right": return { opacity: 0, x: -animationDistance }; case "scale": return { opacity: 0, scale: .8 }; case "fade": return { opacity: 0 }; default: return { opacity: 0, y: animationDistance }; } }; const getAnimateState = () => { switch (direction) { case "up": case "down": return { opacity: 1, y: 0 }; case "left": case "right": return { opacity: 1, x: 0 }; case "scale": return { opacity: 1, scale: 1 }; case "fade": return { opacity: 1 }; default: return { opacity: 1, y: 0 }; } }; return { ref, isInView, motionProps: { initial: getInitialState(), animate: isInView ? getAnimateState() : once ? void 0 : getInitialState(), transition: { duration, ease, delay } } }; }; //#endregion //#region src/shared/lib/schema.ts /** * Минимальная замена zod для внутренних схем пакета. * * Почему не zod: обе точки входа пакета (`/server` и `/client`) импортируют его * статически, а Vite собирает каждую точку входа в один файл. Поэтому у * потребителя tree-shaking не может выбросить неиспользуемые схемы, и zod * (~390 КБ в несжатом виде) попадал в бандл каждой страницы любого проекта, * где из пакета берут хоть что-нибудь — например, `cn`. * * Здесь реализован только тот срез API, который нужен схемам пакета: * `parse` и `safeParse`. Семантика повторяет zod: * - неизвестные ключи отбрасываются; * - значения по умолчанию подставляются (`default`); * - ключ с неопределённым значением в результат не попадает; * - при несоответствии `parse` бросает `ValidationError`, а `safeParse` * возвращает `{ success: false, error }`. * * Чего нет по сравнению с zod: `z.infer`, `.refine()`, трансформаций и * разбора строк. Если понадобится что-то из этого — лучше вернуть zod * отдельной точкой входа, а не растить этот файл. */ /** Ошибка валидации. Заменяет `ZodError`. */ var ValidationError = class extends Error { constructor(message) { super(message); this.name = "ValidationError"; } }; var TYPE_OBJECT = "объект"; var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value); var isString = (value) => typeof value === "string"; /** `z.number()` в zod не пропускает NaN — повторяем. */ var isNumber = (value) => typeof value === "number" && !Number.isNaN(value); /** Прерывает валидацию с сообщением, указывающим путь до поля. */ var fail = (path, expected) => { throw new ValidationError(`${path || "value"}: ожидалось ${expected}`); }; /** `z.string()` */ var str = (value, path) => isString(value) ? value : fail(path, "строка"); /** `z.number()` */ var num = (value, path) => isNumber(value) ? value : fail(path, "число"); /** `z.string().array()` */ var arr = (field) => (value, path) => Array.isArray(value) ? value.map((item, index) => field(item, `${path}[${index}]`)) : fail(path, "массив"); /** `z.object({ ... })` — собирает объект только из объявленных ключей. */ var obj = (shape) => (value, path) => { if (!isObject(value)) return fail(path, TYPE_OBJECT); const entries = Object.entries(shape); const result = {}; for (const [key, field] of entries) { const parsed = field(value[key], path ? `${path}.${key}` : key); if (parsed !== void 0) result[key] = parsed; } return result; }; /** `.optional()` */ var optional = (field) => (value, path) => value === void 0 ? void 0 : field(value, path); /** `.nullable()` */ var nullable = (field) => (value, path) => value === null ? null : field(value, path); /** `.default(...)` — подставляет значение только вместо `undefined`. */ var withDefault = (field, fallback) => (value, path) => value === void 0 ? fallback() : field(value, path); /** Оборачивает проверку в объект с `parse` / `safeParse`. */ var createSchema = (field) => ({ parse: (value) => field(value, ""), safeParse: (value) => { try { return { success: true, data: field(value, "") }; } catch (error) { return { success: false, error: error instanceof ValidationError ? error : new ValidationError(String(error)) }; } } }); //#endregion //#region src/client/session/session-schema.ts var tokenField = obj({ token: withDefault(optional(str), () => ""), exp: optional(num) }); var tokensField = obj({ accessToken: optional(tokenField), refreshToken: optional(tokenField) }); var sessionTokensSchema = createSchema(tokensField); var sessionUserSchema = createSchema(obj({ id: str, profileId: str, phone: optional(nullable(str)), email: optional(nullable(str)), name: optional(nullable(str)), surname: optional(nullable(str)), roles: withDefault(optional(arr(str)), () => []), maxRolePriority: optional(nullable(num)), tokens: optional(tokensField) })); /** * Парсит сырое значение сессионной куки (JSON) и валидирует схемой. * Возвращает null при отсутствии значения или невалидных данных. */ var parseSessionCookie = (rawValue) => { if (!rawValue) return null; try { return sessionUserSchema.parse(JSON.parse(rawValue)); } catch { return null; } }; /** * Инициалы пользователя: ФамилияИмя → «ИФ», при отсутствии — первая буква email. */ var getUserInitials = (user) => { if (!user) return ""; const surnameLetter = user.surname?.trim()?.[0]?.toUpperCase() ?? ""; const nameLetter = user.name?.trim()?.[0]?.toUpperCase() ?? ""; if (surnameLetter && nameLetter) return `${surnameLetter}${nameLetter}`; if (surnameLetter) return surnameLetter; if (nameLetter) return nameLetter; if (user.email?.trim()) return user.email.trim()[0].toUpperCase(); return ""; }; /** * Отображаемое имя: «Фамилия Имя», при отсутствии — email или телефон. */ var getUserDisplayName = (user) => { if (!user) return ""; const parts = [user.surname?.trim(), user.name?.trim()].filter(Boolean); if (parts.length > 0) return parts.join(" "); return user.email || user.phone || ""; }; //#endregion //#region src/client/session/token-manager.ts var sessionConfig = { refreshUrl: "", cookieDomain: void 0, isNotSecure: false, sessionCookieName: "session", sessionTempCookieName: "session-temp", accessTokenCookieName: "access_token", refreshTokenCookieName: "refresh_token" }; /** * Конфигурация сессии. Вызывается один раз при инициализации приложения: * configureSession({ refreshUrl: '/api-rest/auth/v1/refresh' }) */ var configureSession = (config) => { sessionConfig = { ...sessionConfig, ...config, sessionCookieName: config.sessionCookieName || sessionConfig.sessionCookieName, sessionTempCookieName: config.sessionTempCookieName || sessionConfig.sessionTempCookieName, accessTokenCookieName: config.accessTokenCookieName || sessionConfig.accessTokenCookieName, refreshTokenCookieName: config.refreshTokenCookieName || sessionConfig.refreshTokenCookieName }; }; var getCookieValue = (name) => { if (typeof document === "undefined") return null; const cookies = document.cookie.split(";"); for (const cookie of cookies) { const trimmed = cookie.trim(); const separatorIndex = trimmed.indexOf("="); if (separatorIndex === -1) continue; if (trimmed.slice(0, separatorIndex) === name) return decodeURIComponent(trimmed.slice(separatorIndex + 1)); } return null; }; var setCookieValue = (name, value, options = {}) => { if (typeof document === "undefined") return; const maxAge = options.maxAgeSeconds ?? 720 * 60 * 60; const isHttps = typeof window !== "undefined" && window.location.protocol === "https:" && !options.isNotSecure && !sessionConfig.isNotSecure; const domain = options.domain ?? sessionConfig.cookieDomain; const domainPart = domain ? `; Domain=${domain}` : ""; document.cookie = `${name}=${encodeURIComponent(value)}; Path=/; Max-Age=${maxAge}; SameSite=Lax${isHttps ? "; Secure" : ""}${domainPart}`; }; var deleteCookieValue = (name, options = {}) => { setCookieValue(name, "", { ...options, maxAgeSeconds: -1 }); if (options.domain || sessionConfig.cookieDomain) setCookieValue(name, "", { ...options, domain: void 0, maxAgeSeconds: -1 }); }; /** * Удаляет все cookies сессии и токенов и оповещает слушателей о логауте. */ var clearSessionCookies = (options = {}) => { if (typeof document === "undefined") return; const opts = { domain: options.domain ?? sessionConfig.cookieDomain, isNotSecure: options.isNotSecure ?? sessionConfig.isNotSecure }; deleteCookieValue(sessionConfig.sessionCookieName, opts); deleteCookieValue(sessionConfig.sessionTempCookieName, opts); deleteCookieValue(sessionConfig.accessTokenCookieName, opts); deleteCookieValue(sessionConfig.refreshTokenCookieName, opts); if (typeof window !== "undefined") window.dispatchEvent(new CustomEvent("session:updated", { detail: null })); }; /** * Полная сессия из куки с указанием имени куки (session или session-temp). */ var getFullSessionFromCookie = () => { if (typeof document === "undefined") return null; const sessionStr = getCookieValue(sessionConfig.sessionCookieName); if (sessionStr) { const data = parseSessionCookie(sessionStr); if (data) return { cookieName: sessionConfig.sessionCookieName, data }; } const sessionTempStr = getCookieValue(sessionConfig.sessionTempCookieName); if (sessionTempStr) { const data = parseSessionCookie(sessionTempStr); if (data) return { cookieName: sessionConfig.sessionTempCookieName, data }; } return null; }; /** * Пользователь сессии из куки (без токенов-фолбэков). */ var getSessionFromCookie = () => { if (typeof document === "undefined") return null; return parseSessionCookie(getCookieValue(sessionConfig.sessionCookieName)) || parseSessionCookie(getCookieValue(sessionConfig.sessionTempCookieName)); }; var parseJwtExp = (token) => { if (!token) return null; try { const parts = token.split("."); if (parts.length !== 3) return null; const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); const payload = JSON.parse(atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, "="))); return typeof payload.exp === "number" ? payload.exp * 1e3 : null; } catch { return null; } }; var isTokenExpired = (exp) => { if (!exp) return true; return exp - Date.now() <= 3e4; }; var refreshPromise = null; var getMaxAgeSeconds = (refreshExp, isTemp = false) => { if (refreshExp) { const secondsLeft = Math.floor((refreshExp - Date.now()) / 1e3); if (secondsLeft > 0) return secondsLeft; } return isTemp ? 1440 * 60 : 720 * 60 * 60; }; /** * Обновление пары токенов. Single-flight: параллельные вызовы разделяют один запрос. */ var refreshSessionTokens = async () => { if (typeof window === "undefined") return null; if (refreshPromise) return refreshPromise; refreshPromise = (async () => { try { const current = getFullSessionFromCookie(); const directRefreshToken = getCookieValue(sessionConfig.refreshTokenCookieName); const refreshToken = current?.data?.tokens?.refreshToken?.token || directRefreshToken; if (!refreshToken || !sessionConfig.refreshUrl) return null; const refreshExp = current?.data?.tokens?.refreshToken?.exp || parseJwtExp(refreshToken || void 0); if (refreshExp && refreshExp <= Date.now()) { clearSessionCookies(); return null; } const response = await fetch(sessionConfig.refreshUrl, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${refreshToken}` } }); if (response.status === 401 || response.status === 403) { clearSessionCookies(); return null; } if (!response.ok) return null; const payload = (await response.json())?.data; if (!payload) return null; const newAccessToken = payload.tokens?.accessToken?.token; const newRefreshToken = payload.tokens?.refreshToken?.token; const newAccessExp = payload.tokens?.accessToken?.exp; const newRefreshExp = payload.tokens?.refreshToken?.exp; if (!newAccessToken) return null; const cookieName = current?.cookieName || sessionConfig.sessionCookieName; const updatedSessionData = { id: payload.id || current?.data?.id || "", profileId: payload.profileId || current?.data?.profileId || "", phone: payload.phone ?? current?.data?.phone, email: payload.email ?? current?.data?.email, name: payload.name ?? current?.data?.name, surname: payload.surname ?? current?.data?.surname, roles: payload.roles ?? current?.data?.roles ?? [], maxRolePriority: payload.maxRolePriority ?? current?.data?.maxRolePriority, tokens: { accessToken: { token: newAccessToken, exp: newAccessExp }, refreshToken: { token: newRefreshToken || refreshToken, exp: newRefreshExp } } }; const maxAgeSeconds = getMaxAgeSeconds(newRefreshExp, cookieName === sessionConfig.sessionTempCookieName); setCookieValue(cookieName, JSON.stringify(updatedSessionData), { maxAgeSeconds }); if (newAccessToken) { const accessMaxAge = newAccessExp ? Math.max(0, Math.floor((newAccessExp - Date.now()) / 1e3)) : 900; setCookieValue(sessionConfig.accessTokenCookieName, newAccessToken, { maxAgeSeconds: accessMaxAge }); } if (newRefreshToken) setCookieValue(sessionConfig.refreshTokenCookieName, newRefreshToken, { maxAgeSeconds }); window.dispatchEvent(new CustomEvent("session:updated", { detail: updatedSessionData })); return newAccessToken; } catch { return null; } finally { refreshPromise = null; } })(); return refreshPromise; }; /** * Возвращает валидный access-токен: актуальный — сразу, протухший — через refresh. */ var getValidAccessToken = async () => { if (typeof window === "undefined") return null; const current = getFullSessionFromCookie(); const directAccessToken = getCookieValue(sessionConfig.accessTokenCookieName); const directRefreshToken = getCookieValue(sessionConfig.refreshTokenCookieName); const accessToken = current?.data?.tokens?.accessToken?.token || directAccessToken; const refreshToken = current?.data?.tokens?.refreshToken?.token || directRefreshToken; if (!accessToken && !refreshToken) return null; const refreshExp = current?.data?.tokens?.refreshToken?.exp || parseJwtExp(refreshToken || void 0); if (refreshExp && refreshExp <= Date.now()) { clearSessionCookies(); return null; } const exp = current?.data?.tokens?.accessToken?.exp || parseJwtExp(accessToken || void 0); if (accessToken && !isTokenExpired(exp)) return accessToken; if (refreshToken) return refreshSessionTokens(); return null; }; //#endregion //#region src/client/session/use-session.ts var useSession = create((set) => { if (typeof window !== "undefined") window.addEventListener("session:updated", (event) => { set({ user: event.detail ?? null, isLoaded: true }); }); return { user: null, isLoaded: false, initSession: () => { set({ user: getSessionFromCookie(), isLoaded: true }); }, setUser: (user) => set({ user, isLoaded: true }) }; }); //#endregion //#region src/shared/lib/css.ts function cn(...inputs) { return twMerge(clsx(inputs)); } //#endregion //#region src/client/ui/header-wrapper.tsx var HeaderWrapper = ({ children, className, hideClassName, scrollPosition = 100, hideBackgroundOnScroll = true }) => { const { y } = useWindowScroll(); const isClient = useIsClient(); const isHiddenBackground = hideBackgroundOnScroll && y > scrollPosition && isClient; return /* @__PURE__ */ jsx("header", { className: cn("fixed z-30 w-full transition bg-background", className, { "bg-transparent": isHiddenBackground }, isHiddenBackground && hideClassName), children }); }; //#endregion //#region src/client/ui/infinity-list.tsx var InfinityList = ({ children, intersectionElementClassName, isNext, intersectionElement, onIntersection }) => { const intersectionRef = useRef(null); const intersection = useIntersection(intersectionRef, { root: null, rootMargin: "0px", threshold: 1 }); useEffect(() => { if (!intersection?.isIntersecting || !isNext) return; onIntersection(); }, [intersection?.isIntersecting, isNext]); return /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("div", { children }), /* @__PURE__ */ jsx("div", { className: cn(intersectionElementClassName, { hidden: !isNext }), ref: intersectionRef, children: typeof intersectionElement === "function" ? intersectionElement() : intersectionElement })] }); }; //#endregion export { HeaderWrapper, InfinityList, clearSessionCookies, configureSession, deleteCookieValue, getCookieValue, getFullSessionFromCookie, getSessionFromCookie, getUserDisplayName, getUserInitials, getValidAccessToken, parseSessionCookie, refreshSessionTokens, sessionTokensSchema, sessionUserSchema, setCookieValue, useAppearAnimation, useDisableScroll, useExtraMediumViewPort, useIsClient, useLaptopBigViewPort, useLaptopViewPort, useMobileViewPort, useSession, useSmallViewPort, useTabletViewPort };