@bigdigital/kiosk-content-sdk
Version:
A Firebase-powered Content Management System SDK optimized for kiosks with offline support, template management, and real-time connection monitoring
670 lines (557 loc) • 20 kB
text/typescript
import { useState, useEffect } from "react";
import { KioskClient } from "./client";
import type { Content, Template, KioskConfig, CacheOptions, SyncOptions, TemplateValues } from "./types";
const CACHE_KEY_PREFIX = 'kiosk_content_cache_';
const CACHE_TIMESTAMP_KEY = 'kiosk_content_last_sync';
function getCacheKey(projectId: string) {
return `${CACHE_KEY_PREFIX}${projectId}`;
}
// Helper function to find field by id, consistent with client.ts
function findFieldById(fields: Template['fields'], fieldId: string) {
return fields.find(field => field.id === fieldId);
}
function processTemplateContent(template: Template, content: Content): Content {
if (!template) return content;
console.log("Processing content with template:", {
contentId: content.id,
templateId: template.id,
templateValues: content.templateValues
});
// Start with existing template values if available
const templateValues: TemplateValues = content.templateValues || {
groups: {},
ungrouped: {}
};
// Process fields in groups
Object.entries(template.groups || {}).forEach(([_, group]) => {
if (!group || !group.normalizedName) {
console.error(`Group ${group?.name ?? 'unknown'} is missing normalizedName`);
return;
}
const normalizedName = group.normalizedName;
console.log(`Processing group ${group.name} -> ${normalizedName}`);
// Initialize group if it doesn't exist
if (!templateValues.groups[normalizedName]) {
templateValues.groups[normalizedName] = {};
}
// Process fields in this group
(group.fieldIds || []).forEach(fieldId => {
const field = findFieldById(template.fields, fieldId);
if (field?.name) {
// Only set default value if the field doesn't exist
if (templateValues.groups[normalizedName][field.name] === undefined) {
templateValues.groups[normalizedName][field.name] = field.defaultValue ?? null;
}
}
});
});
// Process ungrouped fields
(template.ungroupedFieldIds || []).forEach(fieldId => {
const field = findFieldById(template.fields, fieldId);
if (field?.name) {
// Only set default value if the field doesn't exist
if (templateValues.ungrouped[field.name] === undefined) {
templateValues.ungrouped[field.name] = field.defaultValue ?? null;
}
}
});
console.log("Processed template values:", templateValues);
return {
...content,
templateValues
};
}
function isOffline(): boolean {
return !navigator.onLine;
}
function saveToCache(projectId: string, data: Content[]) {
try {
const cacheData = {
timestamp: Date.now(),
content: data
};
localStorage.setItem(getCacheKey(projectId), JSON.stringify(cacheData));
localStorage.setItem(CACHE_TIMESTAMP_KEY, Date.now().toString());
} catch (err) {
console.warn('Failed to save to cache:', err);
}
}
function getFromCache(projectId: string): { content: Content[]; timestamp: number } | null {
try {
const cached = localStorage.getItem(getCacheKey(projectId));
if (!cached) return null;
const parsed = JSON.parse(cached);
if (!parsed || !parsed.content || !Array.isArray(parsed.content)) {
return null;
}
return {
content: parsed.content,
timestamp: parsed.timestamp || 0
};
} catch (err) {
console.warn('Failed to read from cache:', err);
return null;
}
}
function getCacheTimestamp(): number {
return parseInt(localStorage.getItem(CACHE_TIMESTAMP_KEY) || '0', 10);
}
function isCacheValid(timestamp: number, maxAge: number): boolean {
return Date.now() - timestamp <= maxAge;
}
function isCacheStale(maxAge: number): boolean {
const lastSync = getCacheTimestamp();
return Date.now() - lastSync > maxAge;
}
function filterPublishedProjectContent(content: Content[]): Content[] {
return content.filter(item =>
item.status === "published" &&
item.projectIds &&
item.projectIds.length > 0
);
}
export function useKioskContent(config: KioskConfig) {
const [content, setContent] = useState<Content[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [isSyncing, setIsSyncing] = useState(false);
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
function handleOnline() {
setIsOnline(true);
}
function handleOffline() {
setIsOnline(false);
}
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
useEffect(() => {
const client = new KioskClient(config);
let isMounted = true;
async function fetchContentAndTemplates() {
if (!isOnline && config.offlineSupport) {
const cached = getFromCache(config.projectId);
if (cached) {
const templates = await client.getTemplates().catch(() => []);
const processedContent = filterPublishedProjectContent(cached.content).map(item => {
if (item.templateId) {
const template = templates.find(t => t.id === item.templateId);
if (template) {
return processTemplateContent(template, item);
}
}
return item;
});
if (isMounted) {
setContent(processedContent);
setLoading(false);
}
return;
}
}
try {
setIsSyncing(true);
const [contentData, templates] = await Promise.all([
client.getPublishedContent(),
client.getTemplates()
]);
if (isMounted) {
console.log("Raw content data:", contentData);
console.log("Available templates:", templates);
const processedContent = filterPublishedProjectContent(contentData).map(item => {
if (item.templateId) {
const template = templates.find(t => t.id === item.templateId);
if (template) {
console.log("Processing content with template:", {
contentId: item.id,
templateId: template.id,
beforeProcess: item.templateValues
});
const processed = processTemplateContent(template, item);
console.log("After processing:", processed.templateValues);
return processed;
}
}
return item;
});
console.log("Final processed content:", processedContent);
setContent(processedContent);
if (config.offlineSupport) {
saveToCache(config.projectId, processedContent);
}
}
} catch (err) {
console.error("Error fetching content:", err);
if (isMounted) {
if (config.offlineSupport) {
const cached = getFromCache(config.projectId);
if (cached) {
setContent(filterPublishedProjectContent(cached.content));
} else {
setError(err instanceof Error ? err : new Error('Failed to fetch content'));
}
} else {
setError(err instanceof Error ? err : new Error('Failed to fetch content'));
}
}
} finally {
if (isMounted) {
setIsSyncing(false);
setLoading(false);
}
}
}
fetchContentAndTemplates();
let syncInterval: NodeJS.Timeout | undefined;
if (config.syncInterval && isOnline) {
syncInterval = setInterval(fetchContentAndTemplates, config.syncInterval);
}
return () => {
isMounted = false;
if (syncInterval) {
clearInterval(syncInterval);
}
};
}, [config.projectId, config.syncInterval, config.offlineSupport, config.cacheMaxAge, isOnline]);
return { content, loading, error, isSyncing, isOnline };
}
export function useProjectContent(config: KioskConfig, projectId: string) {
const [content, setContent] = useState<Content[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
function handleOnline() {
setIsOnline(true);
}
function handleOffline() {
setIsOnline(false);
}
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
useEffect(() => {
const client = new KioskClient(config);
let isMounted = true;
async function fetchContent() {
if (!isOnline && config.offlineSupport) {
const cached = getFromCache(`${config.projectId}_project_${projectId}`);
if (cached) {
setContent(cached.content);
setLoading(false);
return;
}
}
try {
const data = await client.getContentByProject(projectId);
if (isMounted) {
const filteredContent = data.filter(item =>
filterPublishedProjectContent([item]).length > 0 &&
item.projectIds?.includes(projectId)
);
setContent(filteredContent);
if (config.offlineSupport) {
saveToCache(`${config.projectId}_project_${projectId}`, filteredContent);
}
}
} catch (err) {
if (isMounted && config.offlineSupport) {
const cached = getFromCache(`${config.projectId}_project_${projectId}`);
if (cached) {
setContent(cached.content);
} else {
setError(err instanceof Error ? err : new Error('Failed to fetch project content'));
}
} else if (isMounted) {
setError(err instanceof Error ? err : new Error('Failed to fetch project content'));
}
} finally {
if (isMounted) {
setLoading(false);
}
}
}
if (!isOnline && config.offlineSupport) {
const cached = getFromCache(`${config.projectId}_project_${projectId}`);
if (cached) {
setContent(cached.content);
setLoading(false);
}
}
if (isOnline) {
fetchContent();
}
return () => {
isMounted = false;
};
}, [config.projectId, projectId, config.offlineSupport, isOnline]);
return { content, loading, error, isOnline };
}
export function useContentWithTemplate(config: KioskConfig, contentId: string) {
const [content, setContent] = useState<Content | null>(null);
const [template, setTemplate] = useState<Template | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
function handleOnline() {
setIsOnline(true);
}
function handleOffline() {
setIsOnline(false);
}
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
useEffect(() => {
const client = new KioskClient(config);
let isMounted = true;
async function fetchContent() {
if (!isOnline && config.offlineSupport) {
// Add offline cache handling here if needed. This hook doesn't currently use caching.
}
try {
const { content: contentData, template: templateData } = await client.getContentWithTemplate(contentId);
setContent(contentData);
setTemplate(templateData);
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to fetch content with template'));
} finally {
setLoading(false);
}
}
fetchContent();
}, [config.projectId, contentId, isOnline]);
return { content, template, loading, error, isOnline };
}
export function useTemplates(config: KioskConfig) {
const [templates, setTemplates] = useState<Template[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
function handleOnline() {
setIsOnline(true);
}
function handleOffline() {
setIsOnline(false);
}
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
useEffect(() => {
const client = new KioskClient(config);
let isMounted = true;
async function fetchTemplates() {
if (!isOnline && config.offlineSupport) {
// Add offline cache handling here if needed. This hook doesn't currently use caching.
}
try {
const data = await client.getTemplates();
setTemplates(data);
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to fetch templates'));
} finally {
setLoading(false);
}
}
fetchTemplates();
}, [config.projectId, isOnline]);
return { templates, loading, error, isOnline };
}
export function useTemplate(config: KioskConfig, templateId: string) {
const [template, setTemplate] = useState<Template | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
function handleOnline() {
setIsOnline(true);
}
function handleOffline() {
setIsOnline(false);
}
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
useEffect(() => {
const client = new KioskClient(config);
let isMounted = true;
async function fetchTemplate() {
if (!isOnline && config.offlineSupport) {
// Add offline cache handling here if needed. This hook doesn't currently use caching.
}
try {
const data = await client.getTemplateById(templateId);
setTemplate(data);
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to fetch template'));
} finally {
setLoading(false);
}
}
fetchTemplate();
}, [config.projectId, templateId, isOnline]);
return { template, loading, error, isOnline };
}
export function useTemplateContent(config: KioskConfig, templateId: string) {
const [content, setContent] = useState<Content[]>([]);
const [template, setTemplate] = useState<Template | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
function handleOnline() {
setIsOnline(true);
}
function handleOffline() {
setIsOnline(false);
}
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
useEffect(() => {
const client = new KioskClient(config);
let isMounted = true;
async function fetchTemplateContent() {
if (!isOnline && config.offlineSupport) {
// Add offline cache handling here if needed. This hook doesn't currently use caching.
}
try {
const templateData = await client.getTemplateById(templateId);
setTemplate(templateData);
if (templateData) {
const publishedContent = await client.getPublishedContent();
const templateContent = publishedContent.filter(c => c.templateId === templateId);
setContent(templateContent);
}
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to fetch template content'));
} finally {
setLoading(false);
}
}
fetchTemplateContent();
}, [config.projectId, templateId, isOnline]);
return { content, template, loading, error, isOnline };
}
export function useOfflineContent(config: KioskConfig) {
const [content, setContent] = useState<Content[]>([]);
const [syncStatus, setSyncStatus] = useState<"synced" | "pending" | "failed">("pending");
const [lastSynced, setLastSynced] = useState<number | null>(null);
const [error, setError] = useState<Error | null>(null);
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
function handleOnline() {
setIsOnline(true);
}
function handleOffline() {
setIsOnline(false);
}
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
useEffect(() => {
const client = new KioskClient(config);
let syncInterval: NodeJS.Timeout;
async function syncContent() {
if (!isOnline) return;
setSyncStatus("pending");
try {
const data = await client.getPublishedContent();
saveToCache(config.projectId, data);
setContent(data);
setLastSynced(Date.now());
setSyncStatus("synced");
} catch (err) {
setSyncStatus("failed");
setError(err instanceof Error ? err : new Error('Content sync failed'));
}
}
syncContent();
if (config.syncInterval) {
syncInterval = setInterval(syncContent, config.syncInterval);
}
return () => {
if (syncInterval) {
clearInterval(syncInterval);
}
};
}, [config.projectId, config.syncInterval, isOnline]);
return { content, syncStatus, lastSynced, error, isOnline };
}
export function useContentSync(config: KioskConfig) {
const [syncStatus, setSyncStatus] = useState<"idle" | "syncing" | "success" | "error">("idle");
const [progress, setProgress] = useState(0);
const [error, setError] = useState<Error | null>(null);
const [isOnline, setIsOnline] = useState(navigator.onLine);
useEffect(() => {
function handleOnline() {
setIsOnline(true);
}
function handleOffline() {
setIsOnline(false);
}
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
const sync = async (options?: SyncOptions) => {
if (!isOnline) {
setError(new Error("Cannot sync while offline"));
return;
}
setSyncStatus("syncing");
setProgress(0);
setError(null);
try {
const client = new KioskClient(config);
const progressInterval = setInterval(() => {
setProgress(prev => Math.min(prev + 10, 90));
}, 500);
await client.getPublishedContent();
clearInterval(progressInterval);
setProgress(100);
setSyncStatus("success");
} catch (err) {
setSyncStatus("error");
setError(err instanceof Error ? err : new Error('Sync failed'));
if (options?.onError) {
options.onError(err instanceof Error ? err : new Error('Sync failed'));
}
}
};
return { sync, syncStatus, progress, error, isOnline };
}