UNPKG

@ai-growth/nextjs

Version:

Seamlessly integrate Sanity CMS with Next.js applications for automated blog routing and rendering

349 lines (348 loc) 18.2 kB
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; /** * @fileoverview Fallback Content Components & Context * * This module provides components and context for handling fallback content * when primary content fails to load. Includes caching, error handling, * and graceful degradation. */ import { createContext, useContext, useState, useCallback, useEffect } from 'react'; import { logError } from '../utils'; // ============================================================================ // CONSTANTS & UTILITIES // ============================================================================ const STORAGE_KEY = 'ai-growth-fallback-content'; const MAX_STORAGE_SIZE = 5 * 1024 * 1024; // 5MB const MAX_ITEMS = 100; function getStoredFallbackContent() { try { if (typeof window === 'undefined' || !window.localStorage) { return new Map(); } const stored = localStorage.getItem(STORAGE_KEY); if (!stored) return new Map(); const data = JSON.parse(stored); const map = new Map(); Object.entries(data).forEach(([key, value]) => { if (value && typeof value === 'object') { map.set(key, { ...value, cachedAt: new Date(value.cachedAt), expiresAt: value.expiresAt ? new Date(value.expiresAt) : undefined, }); } }); return map; } catch (error) { console.warn('Failed to load fallback content from storage:', error); return new Map(); } } function storeFallbackContentToStorage(content) { try { if (typeof window === 'undefined' || !window.localStorage) { return; } const serializable = Object.fromEntries(content); const serialized = JSON.stringify(serializable); // Check size limit if (serialized.length > MAX_STORAGE_SIZE) { console.warn('Fallback content exceeds size limit, clearing old entries'); return; } localStorage.setItem(STORAGE_KEY, serialized); } catch (error) { console.warn('Failed to store fallback content:', error); logError(error, { custom: { component: 'FallbackContent', action: 'storage' } }); } } function cleanupOldEntries(content) { const now = new Date(); const entries = Array.from(content.entries()); // Remove expired entries const validEntries = entries.filter(([, item]) => { return !item.expiresAt || item.expiresAt > now; }); // If still too many, remove oldest if (validEntries.length > MAX_ITEMS) { validEntries.sort((a, b) => b[1].cachedAt.getTime() - a[1].cachedAt.getTime()); validEntries.splice(MAX_ITEMS); } content.clear(); validEntries.forEach(([key, value]) => content.set(key, value)); } export function getErrorMessage(error, _context) { // Network errors if (error.name === 'NetworkError' || error.message.includes('fetch')) { return { title: 'Connection Problem', message: 'There was a problem connecting to our servers. Please check your internet connection and try again.', suggestions: [ 'Check your internet connection', 'Try refreshing the page', 'Wait a moment and try again' ], showRetry: true, showRefresh: true, icon: 'offline' }; } // Authentication/Authorization errors if (error.message.includes('Unauthorized') || error.message.includes('auth') || error.message.includes('Access denied')) { return { title: 'Access Issue', message: 'You don\'t have permission to access this content.', suggestions: [ 'Try logging out and logging back in', 'Check if your session has expired', 'Contact support if you should have access' ], showRetry: true, showRefresh: false, icon: 'warning' }; } // Not found errors (404) if (error.message.includes('404') || error.message.includes('not found')) { return { title: 'Content Not Found', message: 'The content you\'re looking for could not be found.', suggestions: [ 'Check the URL for typos', 'Go back and try a different link', 'Search for what you\'re looking for' ], showRetry: false, showRefresh: false, icon: 'warning' }; } // Timeout errors if (error.name === 'TimeoutError' || error.message.includes('timeout')) { return { title: 'Request Timed Out', message: 'The request took too long to complete. This might be due to a slow connection or server issues.', suggestions: [ 'Try again with a better connection', 'Wait a moment before retrying', 'Check if the service is temporarily unavailable' ], showRetry: true, showRefresh: false, icon: 'loading' }; } // Rate limiting if (error.message.includes('429') || error.message.includes('rate limit') || error.message.includes('too many requests')) { return { title: 'Too Many Requests', message: 'You\'ve made too many requests in a short time. Please wait a moment before trying again.', suggestions: [ 'Wait a few minutes before trying again', 'Reduce the frequency of your requests' ], showRetry: false, showRefresh: false, icon: 'warning' }; } // Server errors (5xx) if (error.message.includes('500') || error.message.includes('502') || error.message.includes('503') || error.message.includes('server error')) { return { title: 'Server Issue', message: 'Our servers are experiencing temporary difficulties. Our team has been notified and is working on a fix.', suggestions: [ 'Wait a few minutes and try again', 'Check our status page for updates', 'Contact support if the problem persists' ], showRetry: true, showRefresh: true, icon: 'error' }; } // Generic error return { title: 'Something Went Wrong', message: 'We encountered an unexpected problem. This has been reported to our team and we\'re working on a fix.', suggestions: [ 'Try refreshing the page', 'Go back and try again', 'Contact support if the problem continues' ], showRetry: true, showRefresh: true, icon: 'error' }; } // ============================================================================ // CONTEXT PROVIDER // ============================================================================ const FallbackContentContext = createContext(null); export function FallbackContentProvider({ children, maxStorageSize: _maxStorageSize = MAX_STORAGE_SIZE, maxItems = MAX_ITEMS, defaultExpiryTime = 24 * 60 * 60 * 1000, // 24 hours }) { const [fallbackContent, setFallbackContent] = useState(() => getStoredFallbackContent()); // Save to localStorage whenever content changes useEffect(() => { storeFallbackContentToStorage(fallbackContent); }, [fallbackContent]); const storeFallbackContent = useCallback((item) => { setFallbackContent(prev => { const newContent = new Map(prev); // Set default expiry if not provided if (!item.expiresAt) { item.expiresAt = new Date(Date.now() + defaultExpiryTime); } newContent.set(item.id, item); // Cleanup if needed if (newContent.size > maxItems) { cleanupOldEntries(newContent); } return newContent; }); }, [defaultExpiryTime, maxItems]); const getFallbackContent = useCallback((id, options = {}) => { const item = fallbackContent.get(id); if (!item) return null; const now = new Date(); // Check if expired if (item.expiresAt && item.expiresAt < now && !options.includeExpired) { return null; } // Check max age if (options.maxAge) { const age = now.getTime() - item.cachedAt.getTime(); if (age > options.maxAge) { return null; } } // Check content type if (options.contentType && item.type !== options.contentType) { return null; } // Check priority if (options.priority && item.priority !== options.priority) { return null; } return item; }, [fallbackContent]); const getFallbacksByType = useCallback((type, options = {}) => { const items = Array.from(fallbackContent.values()) .filter(item => item.type === type); const now = new Date(); return items .filter(item => { // Check if expired if (item.expiresAt && item.expiresAt < now && !options.includeExpired) { return false; } // Check max age if (options.maxAge) { const age = now.getTime() - item.cachedAt.getTime(); if (age > options.maxAge) { return false; } } // Check priority if (options.priority && item.priority !== options.priority) { return false; } return true; }) .sort((a, b) => { // Sort by priority first, then by cache date const priorityOrder = { high: 3, medium: 2, low: 1 }; const aPriority = priorityOrder[a.priority || 'medium']; const bPriority = priorityOrder[b.priority || 'medium']; if (aPriority !== bPriority) { return bPriority - aPriority; } return b.cachedAt.getTime() - a.cachedAt.getTime(); }); }, [fallbackContent]); const clearFallbackContent = useCallback((id) => { setFallbackContent(prev => { if (id) { const newContent = new Map(prev); newContent.delete(id); return newContent; } return new Map(); }); }, []); const hasFallbackContent = useCallback((id) => { return fallbackContent.has(id); }, [fallbackContent]); const getStorageInfo = useCallback(() => { const serialized = JSON.stringify(Object.fromEntries(fallbackContent)); return { count: fallbackContent.size, size: new Blob([serialized]).size, }; }, [fallbackContent]); const value = { storeFallbackContent, getFallbackContent, getFallbacksByType, clearFallbackContent, hasFallbackContent, getStorageInfo, }; return (_jsx(FallbackContentContext.Provider, { value: value, children: children })); } // ============================================================================ // HOOKS // ============================================================================ export function useFallbackContent() { const context = useContext(FallbackContentContext); if (!context) { throw new Error('useFallbackContent must be used within a FallbackContentProvider'); } return context; } export function FallbackContent({ error, contentId, contentType, fallbackData, onRetry, onRefresh, customErrorMessage, className = '', }) { const { getFallbackContent, getFallbacksByType } = useFallbackContent(); // Try to get fallback content let fallbackContent = null; if (contentId) { fallbackContent = getFallbackContent(contentId); } else if (contentType) { const fallbacks = getFallbacksByType(contentType); fallbackContent = fallbacks[0] || null; } // Get error message const errorMessage = customErrorMessage || (error ? getErrorMessage(error) : { title: 'Content Unavailable', message: 'The content you\'re looking for is currently unavailable.', suggestions: ['Try refreshing the page', 'Check back later'], showRetry: true, showRefresh: true, icon: 'warning', }); // If we have fallback content, display it if (fallbackContent || fallbackData) { const data = fallbackData || fallbackContent?.data; return (_jsxs("div", { className: `fallback-content ${className}`, children: [_jsxs("div", { className: "fallback-content__header", children: [_jsxs("div", { className: "fallback-content__notice", children: [_jsx("span", { className: "fallback-content__icon", children: "\u26A0\uFE0F" }), _jsx("span", { children: "Showing cached content" })] }), (onRetry || onRefresh) && (_jsxs("div", { className: "fallback-content__actions", children: [onRetry && (_jsx("button", { onClick: onRetry, className: "fallback-content__button fallback-content__button--retry", children: "Try Again" })), onRefresh && (_jsx("button", { onClick: onRefresh, className: "fallback-content__button fallback-content__button--refresh", children: "Refresh" }))] }))] }), _jsx("div", { className: "fallback-content__body", children: typeof data === 'string' ? (_jsx("div", { dangerouslySetInnerHTML: { __html: data } })) : fallbackContent ? (_jsxs("div", { children: [_jsx("h3", { children: fallbackContent.title }), fallbackContent.description && _jsx("p", { children: fallbackContent.description }), typeof data?.content === 'string' && (_jsx("div", { dangerouslySetInnerHTML: { __html: data.content } }))] })) : (_jsx("div", { children: JSON.stringify(data, null, 2) })) }), fallbackContent && (_jsx("div", { className: "fallback-content__meta", children: _jsxs("small", { children: ["Last updated: ", fallbackContent.cachedAt.toLocaleDateString(), " at", ' ', fallbackContent.cachedAt.toLocaleTimeString()] }) }))] })); } // Display error message return (_jsx("div", { className: `error-message ${className}`, children: _jsxs("div", { className: "error-message__content", children: [_jsxs("div", { className: "error-message__icon", children: [errorMessage.icon === 'warning' && '⚠️', errorMessage.icon === 'error' && '❌', errorMessage.icon === 'offline' && '📡', errorMessage.icon === 'loading' && '⏳'] }), _jsx("h3", { className: "error-message__title", children: errorMessage.title }), _jsx("p", { className: "error-message__description", children: errorMessage.message }), errorMessage.suggestions && errorMessage.suggestions.length > 0 && (_jsxs("div", { className: "error-message__suggestions", children: [_jsx("h4", { children: "What you can try:" }), _jsx("ul", { children: errorMessage.suggestions.map((suggestion, index) => (_jsx("li", { children: suggestion }, index))) })] })), _jsxs("div", { className: "error-message__actions", children: [errorMessage.showRetry && onRetry && (_jsx("button", { onClick: onRetry, className: "error-message__button error-message__button--primary", children: "Try Again" })), errorMessage.showRefresh && onRefresh && (_jsx("button", { onClick: onRefresh, className: "error-message__button error-message__button--secondary", children: "Refresh Page" }))] })] }) })); } export function ContentFallback({ contentType, count = 3, error, onRetry, className = '', }) { const { getFallbacksByType } = useFallbackContent(); const fallbacks = getFallbacksByType(contentType).slice(0, count); if (fallbacks.length === 0) { return (_jsx(FallbackContent, { ...(error && { error }), contentType: contentType, ...(onRetry && { onRetry }), className: className })); } return (_jsxs("div", { className: `content-fallback ${className}`, children: [_jsxs("div", { className: "content-fallback__header", children: [_jsxs("div", { className: "content-fallback__notice", children: [_jsx("span", { className: "content-fallback__icon", children: "\u26A0\uFE0F" }), _jsxs("span", { children: ["Showing cached ", contentType, " content"] })] }), onRetry && (_jsx("button", { onClick: onRetry, className: "content-fallback__retry", children: "Try Again" }))] }), _jsx("div", { className: "content-fallback__grid", children: fallbacks.map((fallback) => (_jsxs("div", { className: "content-fallback__item", children: [_jsx("h4", { className: "content-fallback__title", children: fallback.title }), fallback.description && (_jsx("p", { className: "content-fallback__description", children: fallback.description })), _jsx("div", { className: "content-fallback__meta", children: _jsxs("small", { children: ["Cached: ", fallback.cachedAt.toLocaleDateString()] }) })] }, fallback.id))) })] })); } export function PartialContentFallback({ partialData, error: _error, onRetry, missingFields = [], className = '', }) { return (_jsxs("div", { className: `partial-content-fallback ${className}`, children: [_jsxs("div", { className: "partial-content-fallback__notice", children: [_jsx("span", { className: "partial-content-fallback__icon", children: "\u26A0\uFE0F" }), _jsx("span", { children: "Some content couldn't be loaded" }), onRetry && (_jsx("button", { onClick: onRetry, className: "partial-content-fallback__retry", children: "Try Again" }))] }), _jsx("div", { className: "partial-content-fallback__content", children: typeof partialData === 'object' ? (Object.entries(partialData).map(([key, value]) => (_jsxs("div", { className: "partial-content-fallback__field", children: [_jsxs("strong", { children: [key, ":"] }), " ", String(value)] }, key)))) : (_jsx("div", { children: String(partialData) })) }), missingFields.length > 0 && (_jsx("div", { className: "partial-content-fallback__missing", children: _jsxs("small", { children: ["Missing: ", missingFields.join(', ')] }) }))] })); } export default FallbackContent;