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