diff --git a/app/manuals/page.tsx b/app/manuals/page.tsx index 13d94a29..9321d46e 100644 --- a/app/manuals/page.tsx +++ b/app/manuals/page.tsx @@ -3,16 +3,10 @@ export const dynamic = "force-dynamic" import { existsSync } from 'fs' import { join } from 'path' import { Metadata } from 'next' -import { PublicInset, PublicPageHeader } from '@/components/public-surface' import { businessConfig } from '@/lib/seo-config' -import { ManualsPageShell } from '@/components/manuals-page-shell' +import { ManualsPageExperience } from '@/components/manuals-page-experience' import { listConvexManuals } from '@/lib/convex-service' -import { - scanManuals, - groupManuals, - getManufacturers, - getCategories, -} from '@/lib/manuals' +import { scanManuals } from '@/lib/manuals' import { selectManualsForSite } from '@/lib/manuals-site-selection' import { generateStructuredData } from '@/lib/seo' import { getManualsThumbnailsRoot } from '@/lib/manuals-paths' @@ -98,11 +92,6 @@ export default async function ManualsPage() { : { ...manual, thumbnailUrl: undefined } }) - // 4. Group and get unique lists - const groupedManuals = groupManuals(manuals) - const manufacturers = getManufacturers(manuals) - const categories = getCategories(manuals) - // Generate structured data for SEO const structuredData = generateStructuredData({ title: 'Vending Machine Manuals', @@ -147,26 +136,7 @@ export default async function ManualsPage() { dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionSchema) }} />
- - - - {manuals.length} manuals available from {manufacturers.length} manufacturers - - - - -
- -
+
) diff --git a/components/manuals-dashboard-client.tsx b/components/manuals-dashboard-client.tsx index 5d5c2abf..e5e3864b 100644 --- a/components/manuals-dashboard-client.tsx +++ b/components/manuals-dashboard-client.tsx @@ -1,8 +1,9 @@ 'use client' -import { useState, useMemo } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { fetchPublishedManualsDashboard } from '@/lib/manuals-live-catalog' import { BarChart3, FileText, @@ -32,29 +33,51 @@ interface ManualsDashboardClientProps { export function ManualsDashboardClient({ data }: ManualsDashboardClientProps) { const [searchTerm, setSearchTerm] = useState('') + const [liveData, setLiveData] = useState(data) + + useEffect(() => { + let cancelled = false + + void (async () => { + const refreshed = await fetchPublishedManualsDashboard() + if (cancelled || !refreshed) { + return + } + + setLiveData((current) => { + const currentCount = Array.isArray(current?.metadata) ? current.metadata.length : 0 + const refreshedCount = Array.isArray(refreshed?.metadata) ? refreshed.metadata.length : 0 + return refreshedCount > currentCount ? refreshed : current + }) + })() + + return () => { + cancelled = true + } + }, []) // Calculate statistics const stats = useMemo(() => { - const missing = data.missingManuals?.summary || {} - const qaCategories = data.qaData.reduce((acc: any, item: any) => { + const missing = liveData.missingManuals?.summary || {} + const qaCategories = liveData.qaData.reduce((acc: any, item: any) => { const cat = item.category || 'unknown' acc[cat] = (acc[cat] || 0) + 1 return acc }, {}) - const metadataWithSpecs = data.metadata.filter((m: any) => + const metadataWithSpecs = liveData.metadata.filter((m: any) => m.specifications?.dimensions && Object.keys(m.specifications.dimensions).length > 0 ).length - const metadataWithParts = data.metadata.filter((m: any) => + const metadataWithParts = liveData.metadata.filter((m: any) => m.parts_list && m.parts_list.length > 0 ).length - const metadataWithTroubleshooting = data.metadata.filter((m: any) => + const metadataWithTroubleshooting = liveData.metadata.filter((m: any) => m.troubleshooting && m.troubleshooting.length > 0 ).length - const schemaTypes = data.structuredData.reduce((acc: any, item: any) => { + const schemaTypes = liveData.structuredData.reduce((acc: any, item: any) => { item.schemas?.forEach((schema: any) => { const type = schema['@type'] || 'unknown' acc[type] = (acc[type] || 0) + 1 @@ -66,36 +89,36 @@ export function ManualsDashboardClient({ data }: ManualsDashboardClientProps) { totalModels: missing.total_expected_models || 0, missingAll: missing.models_missing_all || 0, partial: missing.models_partial || 0, - totalQAPairs: data.qaData.length, + totalQAPairs: liveData.qaData.length, qaCategories, - totalManuals: data.metadata.length, + totalManuals: liveData.metadata.length, metadataWithSpecs, metadataWithParts, metadataWithTroubleshooting, - totalChunks: data.semanticIndex?.total_chunks || 0, + totalChunks: liveData.semanticIndex?.total_chunks || 0, schemaTypes, - highPriority: data.acquisitionList?.high_priority || 0, - mediumPriority: data.acquisitionList?.medium_priority || 0, - lowPriority: data.acquisitionList?.low_priority || 0, + highPriority: liveData.acquisitionList?.high_priority || 0, + mediumPriority: liveData.acquisitionList?.medium_priority || 0, + lowPriority: liveData.acquisitionList?.low_priority || 0, } - }, [data]) + }, [liveData]) // Filter Q&A data const filteredQA = useMemo(() => { - if (!searchTerm) return data.qaData.slice(0, 50) + if (!searchTerm) return liveData.qaData.slice(0, 50) const term = searchTerm.toLowerCase() - return data.qaData.filter((item: any) => + return liveData.qaData.filter((item: any) => item.question?.toLowerCase().includes(term) || item.answer?.toLowerCase().includes(term) ).slice(0, 50) - }, [data.qaData, searchTerm]) + }, [liveData.qaData, searchTerm]) // Get high priority missing manuals const highPriorityMissing = useMemo(() => { - return (data.acquisitionList?.acquisition_list || []) + return (liveData.acquisitionList?.acquisition_list || []) .filter((item: any) => item.priority === 'high') .slice(0, 20) - }, [data.acquisitionList]) + }, [liveData.acquisitionList]) return (
@@ -391,13 +414,13 @@ export function ManualsDashboardClient({ data }: ManualsDashboardClientProps) { Sample Manual Metadata - {data.metadata.length > 0 && ( + {liveData.metadata.length > 0 && (
-

Manufacturer: {data.metadata[0].manufacturer}

-

Model: {data.metadata[0].model_number || 'N/A'}

-

Type: {data.metadata[0].manual_type}

- {data.metadata[0].specifications?.interfaces && ( -

Interfaces: {data.metadata[0].specifications.interfaces.join(', ')}

+

Manufacturer: {liveData.metadata[0].manufacturer}

+

Model: {liveData.metadata[0].model_number || 'N/A'}

+

Type: {liveData.metadata[0].manual_type}

+ {liveData.metadata[0].specifications?.interfaces && ( +

Interfaces: {liveData.metadata[0].specifications.interfaces.join(', ')}

)}
)} @@ -416,7 +439,7 @@ export function ManualsDashboardClient({ data }: ManualsDashboardClientProps) {

- Total Schemas: {data.structuredData.length} + Total Schemas: {liveData.structuredData.length}

Schema Types:

@@ -443,7 +466,7 @@ export function ManualsDashboardClient({ data }: ManualsDashboardClientProps) {
Manuals Indexed - {data.semanticIndex?.total_manuals || 0} + {liveData.semanticIndex?.total_manuals || 0}

Note: Using placeholder embeddings. Ready for production integration. diff --git a/components/manuals-page-experience.tsx b/components/manuals-page-experience.tsx new file mode 100644 index 00000000..4c6fbf39 --- /dev/null +++ b/components/manuals-page-experience.tsx @@ -0,0 +1,68 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import { PublicInset, PublicPageHeader } from '@/components/public-surface' +import { ManualsPageShell } from '@/components/manuals-page-shell' +import { groupManuals, getCategories, getManufacturers } from '@/lib/manuals-catalog' +import { fetchPublishedManualsCatalog } from '@/lib/manuals-live-catalog' +import type { Manual } from '@/lib/manuals-types' + +interface ManualsPageExperienceProps { + initialManuals: Manual[] +} + +export function ManualsPageExperience({ initialManuals }: ManualsPageExperienceProps) { + const [manuals, setManuals] = useState(initialManuals) + + useEffect(() => { + let cancelled = false + + void (async () => { + const liveManuals = await fetchPublishedManualsCatalog() + if (cancelled || liveManuals.length === 0) { + return + } + + setManuals((current) => { + if (liveManuals.length === current.length) { + return current + } + + return liveManuals + }) + })() + + return () => { + cancelled = true + } + }, []) + + const groupedManuals = useMemo(() => groupManuals(manuals), [manuals]) + const manufacturers = useMemo(() => getManufacturers(manuals), [manuals]) + const categories = useMemo(() => getCategories(manuals), [manuals]) + + return ( + <> + + + + {manuals.length} manuals available from {manufacturers.length} manufacturers + + + + +

+ +
+ + ) +} diff --git a/lib/manuals-catalog.ts b/lib/manuals-catalog.ts new file mode 100644 index 00000000..34fa534a --- /dev/null +++ b/lib/manuals-catalog.ts @@ -0,0 +1,32 @@ +import type { Manual, ManualGroup } from '@/lib/manuals-types' + +export function groupManuals(manuals: Manual[]): ManualGroup[] { + const grouped: Record> = {} + + for (const manual of manuals) { + if (!grouped[manual.manufacturer]) { + grouped[manual.manufacturer] = {} + } + if (!grouped[manual.manufacturer][manual.category]) { + grouped[manual.manufacturer][manual.category] = [] + } + grouped[manual.manufacturer][manual.category].push(manual) + } + + return Object.entries(grouped) + .map(([manufacturer, categories]) => ({ + manufacturer, + categories: Object.fromEntries( + Object.entries(categories).sort(([left], [right]) => left.localeCompare(right)), + ), + })) + .sort((left, right) => left.manufacturer.localeCompare(right.manufacturer)) +} + +export function getManufacturers(manuals: Manual[]): string[] { + return Array.from(new Set(manuals.map((manual) => manual.manufacturer))).sort() +} + +export function getCategories(manuals: Manual[]): string[] { + return Array.from(new Set(manuals.map((manual) => manual.category))).sort() +} diff --git a/lib/manuals-live-catalog.ts b/lib/manuals-live-catalog.ts new file mode 100644 index 00000000..e4953ce9 --- /dev/null +++ b/lib/manuals-live-catalog.ts @@ -0,0 +1,67 @@ +import { ConvexHttpClient } from 'convex/browser' +import { api } from '@/convex/_generated/api' +import type { Manual } from '@/lib/manuals-types' + +type ConvexManualDoc = { + filename: string + path: string + manufacturer: string + category: string + size?: number + lastModified?: number + searchTerms?: string[] + commonNames?: string[] + thumbnailUrl?: string +} + +function getClient() { + const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL + if (!convexUrl) { + return null + } + + return new ConvexHttpClient(convexUrl) +} + +function mapManual(manual: ConvexManualDoc): Manual { + return { + filename: manual.filename, + path: manual.path, + manufacturer: manual.manufacturer, + category: manual.category, + size: manual.size, + lastModified: manual.lastModified ? new Date(manual.lastModified) : undefined, + searchTerms: manual.searchTerms, + commonNames: manual.commonNames, + thumbnailUrl: manual.thumbnailUrl, + } +} + +export async function fetchPublishedManualsCatalog(): Promise { + const client = getClient() + if (!client) { + return [] + } + + try { + const manuals = await client.query(api.manuals.list, {}) + return (manuals as ConvexManualDoc[]).map(mapManual) + } catch (error) { + console.error('[manuals-live-catalog] catalog refresh failed', error) + return [] + } +} + +export async function fetchPublishedManualsDashboard() { + const client = getClient() + if (!client) { + return null + } + + try { + return await client.query(api.manuals.dashboard, {}) + } catch (error) { + console.error('[manuals-live-catalog] dashboard refresh failed', error) + return null + } +}