156 lines
4.8 KiB
TypeScript
156 lines
4.8 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
|
|
export const DEFAULT_PHONE_MAX = 768;
|
|
export const DEFAULT_TABLET_MAX = 1024;
|
|
|
|
type DeviceType = "phone" | "tablet" | "desktop";
|
|
|
|
type DeviceInfo = {
|
|
isMobile: boolean;
|
|
deviceType: DeviceType;
|
|
|
|
// Señales útiles para decisiones finas de UX:
|
|
widthNarrow: boolean; // <= phoneMax
|
|
widthTablet: boolean; // > phoneMax && <= tabletMax
|
|
pointerCoarse: boolean; // puntero táctil grueso
|
|
hoverNone: boolean; // no hay hover
|
|
maxTouchPoints: number;
|
|
|
|
// Info de plataforma cuando está disponible
|
|
platform?: string;
|
|
model?: string;
|
|
uaMobileHint?: boolean; // de UA/Client Hints si se puede
|
|
};
|
|
|
|
type Options = {
|
|
phoneMax?: number; // por defecto DEFAULT_PHONE_MAX
|
|
tabletMax?: number; // por defecto DEFAULT_TABLET_MAX
|
|
|
|
// Para SSR/Next: primera suposición que evitará saltos visuales
|
|
serverGuess?: Partial<Pick<DeviceInfo, "deviceType" | "isMobile">>;
|
|
};
|
|
|
|
/** Hook base para media queries */
|
|
function useMediaQuery(query: string, fallback = false) {
|
|
const [matches, setMatches] = useState(fallback);
|
|
|
|
useEffect(() => {
|
|
if (typeof window === "undefined") return; // SSR
|
|
const mql = window.matchMedia(query);
|
|
const onChange = (e: MediaQueryListEvent) => setMatches(e.matches);
|
|
setMatches(mql.matches);
|
|
mql.addEventListener?.("change", onChange);
|
|
return () => mql.removeEventListener?.("change", onChange);
|
|
}, [query]);
|
|
|
|
return matches;
|
|
}
|
|
|
|
/** Clasificador a partir de señales */
|
|
function classifyDevice(
|
|
widthNarrow: boolean,
|
|
widthTablet: boolean,
|
|
pointerCoarse: boolean,
|
|
hoverNone: boolean
|
|
): DeviceType {
|
|
// Heurística práctica:
|
|
if (pointerCoarse && (widthNarrow || hoverNone)) return "phone";
|
|
if (pointerCoarse && widthTablet) return "tablet";
|
|
return "desktop";
|
|
}
|
|
|
|
export function useDeviceInfo(opts: Options = {}): DeviceInfo {
|
|
const phoneMax = opts.phoneMax ?? DEFAULT_PHONE_MAX;
|
|
const tabletMax = opts.tabletMax ?? DEFAULT_TABLET_MAX;
|
|
|
|
// Inicialización segura para SSR para evitar hydration mismatch.
|
|
// Si hay serverGuess, úsalo; si no, asume desktop hasta montar.
|
|
const [initial, setInitial] = useState<DeviceInfo | null>(() => {
|
|
if (typeof window === "undefined") {
|
|
const deviceType = opts.serverGuess?.deviceType ?? "desktop";
|
|
const isMobile = opts.serverGuess?.isMobile ?? deviceType !== "desktop";
|
|
return {
|
|
isMobile,
|
|
deviceType,
|
|
widthNarrow: deviceType === "phone",
|
|
widthTablet: deviceType === "tablet",
|
|
pointerCoarse: deviceType !== "desktop",
|
|
hoverNone: deviceType !== "desktop",
|
|
maxTouchPoints: 0,
|
|
};
|
|
}
|
|
return null; // en cliente, calcularemos real en useEffect
|
|
});
|
|
|
|
const widthNarrow = useMediaQuery(`(max-width: ${phoneMax}px)`, initial?.widthNarrow ?? false);
|
|
const widthTablet = useMediaQuery(
|
|
`(min-width: ${phoneMax + 1}px) and (max-width: ${tabletMax}px)`,
|
|
initial?.widthTablet ?? false
|
|
);
|
|
const pointerCoarse = useMediaQuery("(pointer: coarse)", initial?.pointerCoarse ?? false);
|
|
const hoverNone = useMediaQuery("(hover: none)", initial?.hoverNone ?? false);
|
|
|
|
const [maxTouchPoints, setMaxTouchPoints] = useState(initial?.maxTouchPoints ?? 0);
|
|
const [uaHints, setUaHints] = useState<{ platform?: string; model?: string; mobile?: boolean }>(
|
|
{}
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (typeof window === "undefined") return;
|
|
// maxTouchPoints es útil para detectar iPadOS que se disfraza de Mac
|
|
setMaxTouchPoints(navigator.maxTouchPoints || 0);
|
|
|
|
// Client Hints (Chromium). No bloquea si no existen.
|
|
(async () => {
|
|
const nav = navigator as any;
|
|
if (nav.userAgentData?.getHighEntropyValues) {
|
|
try {
|
|
const { platform, mobile, model } = await nav.userAgentData.getHighEntropyValues([
|
|
"platform",
|
|
"mobile",
|
|
"model",
|
|
]);
|
|
setUaHints({ platform, mobile, model });
|
|
} catch {
|
|
// ignorar excepciones
|
|
}
|
|
}
|
|
})();
|
|
}, []);
|
|
|
|
const deviceType = useMemo(() => {
|
|
// Si es Mac pero con touchPoints > 1 => probablemente iPadOS en modo desktop
|
|
const forceTouch =
|
|
typeof window !== "undefined" &&
|
|
/Macintosh/i.test(navigator.userAgent || "") &&
|
|
maxTouchPoints > 1;
|
|
|
|
return classifyDevice(
|
|
widthNarrow,
|
|
widthTablet,
|
|
pointerCoarse || forceTouch,
|
|
hoverNone || forceTouch
|
|
);
|
|
}, [widthNarrow, widthTablet, pointerCoarse, hoverNone, maxTouchPoints]);
|
|
|
|
const isMobile = deviceType !== "desktop";
|
|
|
|
// Si teníamos estado inicial en SSR, al primer render en cliente lo reemplazamos por el real
|
|
useEffect(() => {
|
|
if (initial) setInitial(null);
|
|
}, [initial]);
|
|
|
|
return {
|
|
isMobile,
|
|
deviceType,
|
|
widthNarrow,
|
|
widthTablet,
|
|
pointerCoarse,
|
|
hoverNone,
|
|
maxTouchPoints,
|
|
platform: uaHints.platform,
|
|
model: uaHints.model,
|
|
uaMobileHint: uaHints.mobile,
|
|
};
|
|
}
|