Extract custom hooks for reusable logic
This commit is contained in:
@@ -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";
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user