Extract custom hooks for reusable logic

This commit is contained in:
adilallo
2026-01-26 12:51:27 -07:00
parent f513aecacc
commit 86d7cff5d4
21 changed files with 1590 additions and 141 deletions
+19 -26
View File
@@ -3,6 +3,7 @@
import { memo } from "react";
import ContentLockup from "./ContentLockup";
import Button from "./Button";
import { useAnalytics } from "../hooks";
interface AskOrganizerProps {
title?: string;
@@ -22,16 +23,6 @@ interface AskOrganizerProps {
}) => void;
}
declare global {
interface Window {
gtag?: (
command: string,
eventName: string,
params?: Record<string, unknown>,
) => void;
}
}
const AskOrganizer = memo<AskOrganizerProps>(
({
title,
@@ -43,30 +34,32 @@ const AskOrganizer = memo<AskOrganizerProps>(
variant = "centered",
onContactClick,
}) => {
const { trackEvent, trackCustomEvent } = useAnalytics();
// Analytics tracking for contact button clicks
const handleContactClick = (
_event: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>,
) => {
// Track contact button interaction
if (onContactClick) {
onContactClick({
event: "contact_button_click",
// Track with standard analytics
trackEvent({
event: "contact_button_click",
category: "engagement",
label: "ask_organizer",
component: "AskOrganizer",
variant,
});
// Also call custom callback if provided
trackCustomEvent(
"contact_button_click",
{
component: "AskOrganizer",
variant,
buttonText,
buttonHref,
timestamp: new Date().toISOString(),
});
}
// Additional analytics tracking (can be expanded)
if (typeof window !== "undefined" && window.gtag) {
window.gtag("event", "contact_button_click", {
event_category: "engagement",
event_label: "ask_organizer",
value: 1,
});
}
},
onContactClick,
);
};
// Variant-specific styling
+5 -5
View File
@@ -1,6 +1,7 @@
"use client";
import { memo, useId } from "react";
import { memo } from "react";
import { useComponentId } from "../hooks";
interface CheckboxProps {
checked?: boolean;
@@ -95,8 +96,7 @@ const Checkbox = memo<CheckboxProps>(
};
// Generate unique ID for accessibility if not provided
const generatedId = useId();
const checkboxId = id || `checkbox-${generatedId}`;
const { id: checkboxId, labelId } = useComponentId("checkbox", id);
const accessibilityProps = {
role: "checkbox" as const,
@@ -104,7 +104,7 @@ const Checkbox = memo<CheckboxProps>(
...(disabled && { "aria-disabled": true, tabIndex: -1 }),
...(!disabled && { tabIndex: 0 }),
...(ariaLabel && { "aria-label": ariaLabel }),
...(label && !ariaLabel && { "aria-labelledby": `${checkboxId}-label` }),
...(label && !ariaLabel && { "aria-labelledby": labelId }),
id: checkboxId,
...props,
};
@@ -151,7 +151,7 @@ const Checkbox = memo<CheckboxProps>(
</span>
{label && (
<span
id={`${checkboxId}-label`}
id={labelId}
className="font-inter text-[14px] leading-[18px]"
style={{ color: labelColor }}
>
+12 -29
View File
@@ -1,6 +1,7 @@
"use client";
import { memo, useCallback, forwardRef, useId } from "react";
import { memo, forwardRef } from "react";
import { useComponentId, useFormField } from "../hooks";
interface InputProps extends Omit<
React.InputHTMLAttributes<HTMLInputElement>,
@@ -43,8 +44,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
ref,
) => {
// Generate unique ID for accessibility if not provided
const generatedId = useId();
const inputId = id || `input-${generatedId}`;
const { id: inputId, labelId } = useComponentId("input", id);
// Size variants
const sizeStyles: Record<
@@ -150,37 +150,20 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
${className}
`.trim();
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (!disabled && onChange) {
onChange(e);
}
},
[disabled, onChange],
);
const handleFocus = useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
if (!disabled && onFocus) {
onFocus(e);
}
},
[disabled, onFocus],
);
const handleBlur = useCallback(
(e: React.FocusEvent<HTMLInputElement>) => {
if (!disabled && onBlur) {
onBlur(e);
}
},
[disabled, onBlur],
);
// Form field handlers with disabled state handling
const { handleChange, handleFocus, handleBlur } = useFormField<
HTMLInputElement
>(disabled, {
onChange,
onFocus,
onBlur,
});
return (
<div className={containerClasses}>
{label && (
<label
id={labelId}
htmlFor={inputId}
className={`${labelClasses} font-inter font-medium text-[var(--color-content-default-secondary)]`}
>
+12 -17
View File
@@ -1,9 +1,10 @@
"use client";
import { memo, useMemo } from "react";
import { memo } from "react";
import NumberedCard from "./NumberedCard";
import SectionHeader from "./SectionHeader";
import Button from "./Button";
import { useSchemaData } from "../hooks";
interface Card {
text: string;
@@ -18,22 +19,16 @@ interface NumberedCardsProps {
}
const NumberedCards = memo<NumberedCardsProps>(({ title, subtitle, cards }) => {
// Memoize schema data to prevent unnecessary re-computations
const schemaData = useMemo(
() => ({
"@context": "https://schema.org",
"@type": "HowTo",
name: title,
description: subtitle,
step: cards.map((card, index) => ({
"@type": "HowToStep",
position: index + 1,
name: card.text,
text: card.text,
})),
}),
[title, subtitle, cards],
);
// Generate schema data using hook
const schemaData = useSchemaData({
type: "HowTo",
name: title,
description: subtitle,
steps: cards.map((card) => ({
name: card.text,
text: card.text,
})),
});
return (
<>
+3 -11
View File
@@ -3,6 +3,7 @@
import { useState, useEffect, memo, useMemo, useCallback } from "react";
import ContentThumbnailTemplate from "./ContentThumbnailTemplate";
import type { BlogPost } from "../../lib/content";
import { useIsMobile } from "../hooks";
interface RelatedArticlesProps {
relatedPosts: BlogPost[];
@@ -20,7 +21,7 @@ const RelatedArticles = memo<RelatedArticlesProps>(
const [currentIndex, setCurrentIndex] = useState(0);
const [progress, setProgress] = useState(0);
const [isMobile, setIsMobile] = useState(true);
const isMobile = useIsMobile();
// Memoize the mouse down handler to prevent unnecessary re-renders
const handleMouseDown = useCallback(
@@ -72,16 +73,7 @@ const RelatedArticles = memo<RelatedArticlesProps>(
[currentIndex, progress],
);
// Check if we're on mobile (below lg breakpoint)
useEffect(() => {
const checkScreenSize = () => {
setIsMobile(window.innerWidth < 1024); // lg breakpoint is 1024px
};
checkScreenSize();
window.addEventListener("resize", checkScreenSize);
return () => window.removeEventListener("resize", checkScreenSize);
}, []);
// Mobile detection is now handled by useIsMobile hook
// Auto-advance every 3 seconds (only on mobile)
useEffect(() => {
+2 -19
View File
@@ -8,11 +8,11 @@ import React, {
useId,
useState,
useRef,
useEffect,
useCallback,
memo,
useImperativeHandle,
} from "react";
import { useClickOutside } from "../hooks";
import SelectDropdown from "./SelectDropdown";
import SelectOption from "./SelectOption";
@@ -71,24 +71,7 @@ const Select = forwardRef<HTMLButtonElement, SelectProps>(
);
// Handle click outside to close menu
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
menuRef.current &&
!menuRef.current.contains(event.target as Node) &&
selectRef.current &&
!selectRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
return () =>
document.removeEventListener("mousedown", handleClickOutside);
}
}, [isOpen]);
useClickOutside([menuRef, selectRef], () => setIsOpen(false), isOpen);
// Handle option selection
const handleOptionSelect = useCallback(
+12 -29
View File
@@ -1,6 +1,7 @@
"use client";
import { memo, useCallback, forwardRef, useId } from "react";
import { memo, forwardRef } from "react";
import { useComponentId, useFormField } from "../hooks";
interface TextAreaProps extends Omit<
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
@@ -44,8 +45,7 @@ const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
ref,
) => {
// Generate unique ID for accessibility if not provided
const generatedId = useId();
const textareaId = id || `textarea-${generatedId}`;
const { id: textareaId, labelId } = useComponentId("textarea", id);
// Size variants with specific heights and radius for TextArea
const sizeStyles: Record<
@@ -154,37 +154,20 @@ const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
${className}
`.trim();
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
if (!disabled && onChange) {
onChange(e);
}
},
[disabled, onChange],
);
const handleFocus = useCallback(
(e: React.FocusEvent<HTMLTextAreaElement>) => {
if (!disabled && onFocus) {
onFocus(e);
}
},
[disabled, onFocus],
);
const handleBlur = useCallback(
(e: React.FocusEvent<HTMLTextAreaElement>) => {
if (!disabled && onBlur) {
onBlur(e);
}
},
[disabled, onBlur],
);
// Form field handlers with disabled state handling
const { handleChange, handleFocus, handleBlur } = useFormField<
HTMLTextAreaElement
>(disabled, {
onChange,
onFocus,
onBlur,
});
return (
<div className={containerClasses}>
{label && (
<label
id={labelId}
htmlFor={textareaId}
className={`${labelClasses} font-inter font-medium text-[var(--color-content-default-secondary)]`}
>
+33
View File
@@ -0,0 +1,33 @@
/**
* Custom hooks for reusable component logic
*
* This module exports all custom hooks used throughout the application.
* Hooks encapsulate complex logic and state management that can be reused
* across multiple components.
*/
export { useClickOutside } from "./useClickOutside";
export { useAnalytics } from "./useAnalytics";
export { useComponentId } from "./useComponentId";
export { useFormField } from "./useFormField";
export { useComponentStyles } from "./useComponentStyles";
export { useSchemaData } from "./useSchemaData";
export { useMediaQuery, useIsMobile, useIsDesktop, BREAKPOINTS } from "./useMediaQuery";
export { useFormValidation, validationRules } from "./useFormValidation";
export type {
SizeStyleConfig,
StateStyleConfig,
UseComponentStylesOptions,
} from "./useComponentStyles";
export type {
SchemaOrganization,
SchemaWebSite,
SchemaHowTo,
SchemaArticle,
SchemaBreadcrumbList,
} from "./useSchemaData";
export type {
ValidationRule,
FieldValidation,
UseFormValidationOptions,
} from "./useFormValidation";
+90
View File
@@ -0,0 +1,90 @@
/**
* Analytics tracking hook for component interactions
* Supports both custom callback tracking and Google Analytics (gtag)
*
* @example
* ```tsx
* const { trackEvent } = useAnalytics();
*
* const handleClick = () => {
* trackEvent({
* event: "button_click",
* category: "engagement",
* label: "contact_button",
* component: "AskOrganizer",
* });
* };
* ```
*/
interface AnalyticsEvent {
event: string;
category?: string;
label?: string;
value?: number;
component?: string;
variant?: string;
[key: string]: unknown;
}
interface UseAnalyticsReturn {
trackEvent: (event: AnalyticsEvent) => void;
trackCustomEvent: (
event: string,
data: Record<string, unknown>,
callback?: (data: Record<string, unknown>) => void,
) => void;
}
declare global {
interface Window {
gtag?: (
command: string,
eventName: string,
params?: Record<string, unknown>,
) => void;
}
}
export function useAnalytics(): UseAnalyticsReturn {
const trackEvent = (eventData: AnalyticsEvent) => {
const { event, category = "engagement", label, value, ...rest } = eventData;
// Track with Google Analytics if available
if (typeof window !== "undefined" && window.gtag) {
window.gtag("event", event, {
event_category: category,
event_label: label,
value: value ?? 1,
...rest,
});
}
};
const trackCustomEvent = (
event: string,
data: Record<string, unknown>,
callback?: (data: Record<string, unknown>) => void,
) => {
// Execute custom callback if provided
if (callback) {
callback({
event,
...data,
timestamp: new Date().toISOString(),
});
}
// Also track with Google Analytics
trackEvent({
event,
category: "custom",
...data,
});
};
return {
trackEvent,
trackCustomEvent,
};
}
+52
View File
@@ -0,0 +1,52 @@
import { useEffect, RefObject } from "react";
/**
* Hook to detect clicks outside of specified elements
* Useful for closing dropdowns, modals, or menus when clicking outside
*
* @param refs - Array of refs to elements that should not trigger the callback
* @param handler - Callback function to execute when clicking outside
* @param enabled - Whether the hook is enabled (default: true)
*
* @example
* ```tsx
* const menuRef = useRef<HTMLDivElement>(null);
* const buttonRef = useRef<HTMLButtonElement>(null);
* const [isOpen, setIsOpen] = useState(false);
*
* useClickOutside([menuRef, buttonRef], () => setIsOpen(false), isOpen);
* ```
*/
export function useClickOutside(
refs: Array<RefObject<HTMLElement>>,
handler: (event: MouseEvent | TouchEvent) => void,
enabled: boolean = true,
): void {
useEffect(() => {
if (!enabled) {
return;
}
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
// Check if click is outside all provided refs
const isOutside = refs.every((ref) => {
const element = ref.current;
if (!element) return true;
return !element.contains(event.target as Node);
});
if (isOutside) {
handler(event);
}
};
// Use mousedown instead of click for better UX (catches drag events)
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("touchstart", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("touchstart", handleClickOutside);
};
}, [refs, handler, enabled]);
}
+28
View File
@@ -0,0 +1,28 @@
import { useId } from "react";
/**
* Hook to generate unique component IDs for accessibility
* Provides consistent ID generation pattern across components
*
* @param prefix - Prefix for the generated ID (e.g., "input", "select")
* @param providedId - Optional ID provided via props (takes precedence)
*
* @returns Object with component ID and associated label ID
*
* @example
* ```tsx
* const { id, labelId } = useComponentId("input", props.id);
* // id: "input-123" or props.id if provided
* // labelId: "input-123-label"
* ```
*/
export function useComponentId(
prefix: string,
providedId?: string,
): { id: string; labelId: string } {
const generatedId = useId();
const id = providedId || `${prefix}-${generatedId}`;
const labelId = `${id}-label`;
return { id, labelId };
}
+111
View File
@@ -0,0 +1,111 @@
import { useMemo } from "react";
/**
* Configuration for component size styles
*/
export interface SizeStyleConfig {
[key: string]: string | Record<string, string>;
}
/**
* Configuration for component state styles
*/
export interface StateStyleConfig {
[key: string]: string | Record<string, string>;
}
/**
* Options for useComponentStyles hook
*/
export interface UseComponentStylesOptions {
size: string;
state?: string;
disabled?: boolean;
error?: boolean;
sizeStyles: SizeStyleConfig;
stateStyles: StateStyleConfig;
getStateStyles?: (params: {
state?: string;
disabled: boolean;
error: boolean;
}) => Record<string, string>;
}
/**
* Hook for managing component size and state styles
* Provides a consistent pattern for styling components based on size, state, and error/disabled status
*
* @param options - Configuration object with size, state, and style definitions
*
* @returns Object with computed style classes
*
* @example
* ```tsx
* const sizeStyles = {
* small: { input: "h-8 text-xs", label: "text-xs" },
* medium: { input: "h-10 text-sm", label: "text-sm" },
* };
*
* const stateStyles = {
* default: { input: "border-gray-300", label: "text-gray-700" },
* focus: { input: "border-blue-500", label: "text-gray-700" },
* };
*
* const { sizeClasses, stateClasses } = useComponentStyles({
* size: "medium",
* state: "default",
* disabled: false,
* error: false,
* sizeStyles,
* stateStyles,
* });
* ```
*/
export function useComponentStyles(
options: UseComponentStylesOptions,
): {
sizeClasses: Record<string, string>;
stateClasses: Record<string, string>;
} {
const {
size,
state = "default",
disabled = false,
error = false,
sizeStyles,
stateStyles,
getStateStyles,
} = options;
const sizeClasses = useMemo(() => {
const sizeConfig = sizeStyles[size] || sizeStyles.medium || {};
return typeof sizeConfig === "string"
? { base: sizeConfig }
: (sizeConfig as Record<string, string>);
}, [size, sizeStyles]);
const stateClasses = useMemo(() => {
if (getStateStyles) {
return getStateStyles({ state, disabled, error });
}
// Default state style resolution
if (disabled) {
return (stateStyles.disabled || {}) as Record<string, string>;
}
if (error) {
return (stateStyles.error || {}) as Record<string, string>;
}
const stateConfig = stateStyles[state] || stateStyles.default || {};
return typeof stateConfig === "string"
? { base: stateConfig }
: (stateConfig as Record<string, string>);
}, [state, disabled, error, stateStyles, getStateStyles]);
return {
sizeClasses,
stateClasses,
};
}
+71
View File
@@ -0,0 +1,71 @@
import { useCallback } from "react";
/**
* Hook for managing form field event handlers with disabled state handling
* Ensures handlers respect disabled state and provides consistent behavior
*
* @param disabled - Whether the field is disabled
* @param handlers - Object containing onChange, onFocus, onBlur handlers
*
* @returns Object with wrapped handlers that respect disabled state
*
* @example
* ```tsx
* const { handleChange, handleFocus, handleBlur } = useFormField(disabled, {
* onChange: (e) => setValue(e.target.value),
* onFocus: (e) => setFocused(true),
* onBlur: (e) => setFocused(false),
* });
* ```
*/
interface FormFieldHandlers<T = HTMLElement> {
onChange?: (e: React.ChangeEvent<T>) => void;
onFocus?: (e: React.FocusEvent<T>) => void;
onBlur?: (e: React.FocusEvent<T>) => void;
}
interface UseFormFieldReturn<T = HTMLElement> {
handleChange: (e: React.ChangeEvent<T>) => void;
handleFocus: (e: React.FocusEvent<T>) => void;
handleBlur: (e: React.FocusEvent<T>) => void;
}
export function useFormField<T extends HTMLElement = HTMLElement>(
disabled: boolean,
handlers: FormFieldHandlers<T>,
): UseFormFieldReturn<T> {
const { onChange, onFocus, onBlur } = handlers;
const handleChange = useCallback(
(e: React.ChangeEvent<T>) => {
if (!disabled && onChange) {
onChange(e);
}
},
[disabled, onChange],
);
const handleFocus = useCallback(
(e: React.FocusEvent<T>) => {
if (!disabled && onFocus) {
onFocus(e);
}
},
[disabled, onFocus],
);
const handleBlur = useCallback(
(e: React.FocusEvent<T>) => {
if (!disabled && onBlur) {
onBlur(e);
}
},
[disabled, onBlur],
);
return {
handleChange,
handleFocus,
handleBlur,
};
}
+204
View File
@@ -0,0 +1,204 @@
import { useState, useCallback, useMemo } from "react";
/**
* Validation rule function type
*/
export type ValidationRule<T = string> = (value: T) => string | null;
/**
* Validation rules for common patterns
*/
export const validationRules = {
required: <T>(value: T): string | null => {
if (value === null || value === undefined || value === "") {
return "This field is required";
}
return null;
},
email: (value: string): string | null => {
if (!value) return null; // Let required handle empty values
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(value) ? null : "Please enter a valid email address";
},
minLength: (min: number) => (value: string): string | null => {
if (!value) return null;
return value.length >= min
? null
: `Must be at least ${min} characters long`;
},
maxLength: (max: number) => (value: string): string | null => {
if (!value) return null;
return value.length <= max
? null
: `Must be no more than ${max} characters long`;
},
pattern: (regex: RegExp, message: string) => (value: string): string | null => {
if (!value) return null;
return regex.test(value) ? null : message;
},
};
/**
* Form field validation state
*/
export interface FieldValidation {
value: string;
error: string | null;
touched: boolean;
dirty: boolean;
}
/**
* Options for useFormValidation hook
*/
export interface UseFormValidationOptions {
initialValues: Record<string, string>;
validationRules?: Record<string, ValidationRule[]>;
validateOnChange?: boolean;
validateOnBlur?: boolean;
}
/**
* Hook for form validation
* Provides validation state and handlers for form fields
*
* @param options - Configuration object with initial values and validation rules
*
* @returns Object with validation state, handlers, and utilities
*
* @example
* ```tsx
* const {
* values,
* errors,
* touched,
* handleChange,
* handleBlur,
* validate,
* isValid,
* } = useFormValidation({
* initialValues: { email: "", password: "" },
* validationRules: {
* email: [validationRules.required, validationRules.email],
* password: [validationRules.required, validationRules.minLength(8)],
* },
* });
*
* <input
* value={values.email}
* onChange={handleChange}
* onBlur={handleBlur}
* name="email"
* />
* {errors.email && <span>{errors.email}</span>}
* ```
*/
export function useFormValidation(options: UseFormValidationOptions) {
const {
initialValues,
validationRules: rules = {},
validateOnChange = true,
validateOnBlur = true,
} = options;
const [values, setValues] = useState<Record<string, string>>(initialValues);
const [touched, setTouched] = useState<Record<string, boolean>>({});
const [errors, setErrors] = useState<Record<string, string | null>>({});
// Validate a single field
const validateField = useCallback(
(name: string, value: string): string | null => {
const fieldRules = rules[name] || [];
for (const rule of fieldRules) {
const error = rule(value);
if (error) return error;
}
return null;
},
[rules],
);
// Validate all fields
const validate = useCallback((): boolean => {
const newErrors: Record<string, string | null> = {};
let isValid = true;
Object.keys(values).forEach((name) => {
const error = validateField(name, values[name]);
if (error) {
newErrors[name] = error;
isValid = false;
} else {
newErrors[name] = null;
}
});
setErrors(newErrors);
return isValid;
}, [values, validateField]);
// Handle field change
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setValues((prev) => ({ ...prev, [name]: value }));
if (validateOnChange) {
const error = validateField(name, value);
setErrors((prev) => ({ ...prev, [name]: error }));
}
},
[validateOnChange, validateField],
);
// Handle field blur
const handleBlur = useCallback(
(e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name } = e.target;
setTouched((prev) => ({ ...prev, [name]: true }));
if (validateOnBlur) {
const error = validateField(name, values[name]);
setErrors((prev) => ({ ...prev, [name]: error }));
}
},
[validateOnBlur, validateField, values],
);
// Check if form is valid
const isValid = useMemo(() => {
return Object.values(errors).every((error) => error === null);
}, [errors]);
// Reset form
const reset = useCallback(() => {
setValues(initialValues);
setTouched({});
setErrors({});
}, [initialValues]);
// Set field value programmatically
const setValue = useCallback((name: string, value: string) => {
setValues((prev) => ({ ...prev, [name]: value }));
if (validateOnChange) {
const error = validateField(name, value);
setErrors((prev) => ({ ...prev, [name]: error }));
}
}, [validateOnChange, validateField]);
return {
values,
errors,
touched,
handleChange,
handleBlur,
validate,
isValid,
reset,
setValue,
};
}
+87
View File
@@ -0,0 +1,87 @@
import { useState, useEffect } from "react";
/**
* Tailwind CSS breakpoints (matching Tailwind defaults)
*/
export const BREAKPOINTS = {
sm: 640,
md: 768,
lg: 1024,
xl: 1280,
"2xl": 1536,
} as const;
/**
* Hook for responsive breakpoint detection
* Uses window.matchMedia for efficient media query detection
*
* @param query - Media query string (e.g., "(min-width: 1024px)") or breakpoint key
*
* @returns Boolean indicating if the media query matches
*
* @example
* ```tsx
* // Using breakpoint key
* const isMobile = useMediaQuery("lg", "max");
* // Returns true if screen width < 1024px
*
* // Using custom query
* const isDesktop = useMediaQuery("(min-width: 1024px)");
* ```
*/
export function useMediaQuery(
query: string | keyof typeof BREAKPOINTS,
direction: "min" | "max" = "min",
): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
// Convert breakpoint key to media query string
let mediaQuery: string;
if (query in BREAKPOINTS) {
const breakpoint = BREAKPOINTS[query as keyof typeof BREAKPOINTS];
mediaQuery = `(${direction}-width: ${breakpoint}px)`;
} else {
mediaQuery = query;
}
// Check if window is available (SSR safety)
if (typeof window === "undefined") {
return;
}
const media = window.matchMedia(mediaQuery);
setMatches(media.matches);
// Create listener for changes
const listener = (event: MediaQueryListEvent) => {
setMatches(event.matches);
};
// Modern browsers support addEventListener
if (media.addEventListener) {
media.addEventListener("change", listener);
return () => media.removeEventListener("change", listener);
} else {
// Fallback for older browsers
media.addListener(listener);
return () => media.removeListener(listener);
}
}, [query, direction]);
return matches;
}
/**
* Convenience hook for mobile detection (below lg breakpoint)
*/
export function useIsMobile(): boolean {
return useMediaQuery("lg", "max");
}
/**
* Convenience hook for desktop detection (lg breakpoint and above)
*/
export function useIsDesktop(): boolean {
return useMediaQuery("lg", "min");
}
+236
View File
@@ -0,0 +1,236 @@
import { useMemo } from "react";
/**
* Types for Schema.org structured data
*/
export interface SchemaOrganization {
"@context": "https://schema.org";
"@type": "Organization";
name: string;
email?: string;
url: string;
sameAs?: string[];
}
export interface SchemaWebSite {
"@context": "https://schema.org";
"@type": "WebSite";
name: string;
url: string;
potentialAction?: {
"@type": "SearchAction";
target: string;
"query-input": string;
};
}
export interface SchemaHowTo {
"@context": "https://schema.org";
"@type": "HowTo";
name: string;
description: string;
step: Array<{
"@type": "HowToStep";
position: number;
name: string;
text: string;
}>;
}
export interface SchemaArticle {
"@context": "https://schema.org";
"@type": "Article";
headline: string;
description: string;
author: {
"@type": "Person";
name: string;
};
publisher: {
"@type": "Organization";
name: string;
url: string;
logo?: {
"@type": "ImageObject";
url: string;
};
};
datePublished: string;
dateModified: string;
mainEntityOfPage?: {
"@type": "WebPage";
"@id": string;
};
url: string;
articleSection?: string;
keywords?: string[];
}
export interface SchemaBreadcrumbList {
"@context": "https://schema.org";
"@type": "BreadcrumbList";
itemListElement: Array<{
"@type": "ListItem";
position: number;
name: string;
item: string;
}>;
}
/**
* Hook for generating Schema.org structured data (JSON-LD)
* Provides type-safe schema generation for SEO and search engines
*
* @example
* ```tsx
* const schemaData = useSchemaData({
* type: "HowTo",
* name: "How to build a community",
* description: "Step-by-step guide",
* steps: [
* { name: "Step 1", text: "Start here" },
* { name: "Step 2", text: "Continue here" },
* ],
* });
*
* <script
* type="application/ld+json"
* dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaData) }}
* />
* ```
*/
export function useSchemaData(
config:
| {
type: "Organization";
name: string;
email?: string;
url: string;
sameAs?: string[];
}
| {
type: "WebSite";
name: string;
url: string;
potentialAction?: {
target: string;
"query-input": string;
};
}
| {
type: "HowTo";
name: string;
description: string;
steps: Array<{ name: string; text: string }>;
}
| {
type: "Article";
headline: string;
description: string;
author: string;
publisher: {
name: string;
url: string;
logo?: string;
};
datePublished: string;
dateModified: string;
url: string;
mainEntityOfPage?: string;
articleSection?: string;
keywords?: string[];
}
| {
type: "BreadcrumbList";
items: Array<{ name: string; url: string }>;
},
): SchemaOrganization | SchemaWebSite | SchemaHowTo | SchemaArticle | SchemaBreadcrumbList {
return useMemo(() => {
switch (config.type) {
case "Organization":
return {
"@context": "https://schema.org",
"@type": "Organization",
name: config.name,
...(config.email && { email: config.email }),
url: config.url,
...(config.sameAs && { sameAs: config.sameAs }),
} as SchemaOrganization;
case "WebSite":
return {
"@context": "https://schema.org",
"@type": "WebSite",
name: config.name,
url: config.url,
...(config.potentialAction && {
potentialAction: {
"@type": "SearchAction",
target: config.potentialAction.target,
"query-input": config.potentialAction["query-input"],
},
}),
} as SchemaWebSite;
case "HowTo":
return {
"@context": "https://schema.org",
"@type": "HowTo",
name: config.name,
description: config.description,
step: config.steps.map((step, index) => ({
"@type": "HowToStep",
position: index + 1,
name: step.name,
text: step.text,
})),
} as SchemaHowTo;
case "Article":
return {
"@context": "https://schema.org",
"@type": "Article",
headline: config.headline,
description: config.description,
author: {
"@type": "Person",
name: config.author,
},
publisher: {
"@type": "Organization",
name: config.publisher.name,
url: config.publisher.url,
...(config.publisher.logo && {
logo: {
"@type": "ImageObject",
url: config.publisher.logo,
},
}),
},
datePublished: config.datePublished,
dateModified: config.dateModified,
url: config.url,
...(config.mainEntityOfPage && {
mainEntityOfPage: {
"@type": "WebPage",
"@id": config.mainEntityOfPage,
},
}),
...(config.articleSection && { articleSection: config.articleSection }),
...(config.keywords && { keywords: config.keywords }),
} as SchemaArticle;
case "BreadcrumbList":
return {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
itemListElement: config.items.map((item, index) => ({
"@type": "ListItem",
position: index + 1,
name: item.name,
item: item.url,
})),
} as SchemaBreadcrumbList;
}
}, [config]);
}