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
JavaScript
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 };