diff --git a/.dockerignore b/.dockerignore index 38d699f9..350a49a1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -73,8 +73,11 @@ jspm_packages/ tmp/ temp/ .pnpm-store/ -.formatting-backups/ .cursor/ +.playwright-cli/ +output/ +docs/ +artifacts/ # Logs logs diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..8c52ff93 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore index 0742840b..622a33d0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,10 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - # dependencies /node_modules # next.js /.next/ /out/ +/output/ # production /build @@ -15,10 +14,12 @@ npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* +/dev.log # env files .env* !.env.example +!.env.staging.example # vercel .vercel @@ -26,3 +27,10 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# local tooling caches +/.playwright-cli/ +/.pnpm-store/ + +# package manager drift +/package-lock.json diff --git a/.lighthouserc.js b/.lighthouserc.js index 4aeeced7..cf47e31f 100644 --- a/.lighthouserc.js +++ b/.lighthouserc.js @@ -2,45 +2,42 @@ module.exports = { ci: { collect: { - url: ['http://localhost:3000'], + url: ["http://localhost:3000"], numberOfRuns: 3, - startServerCommand: 'npm run start', - startServerReadyPattern: 'ready', + startServerCommand: "npm run start", + startServerReadyPattern: "ready", startServerReadyTimeout: 30000, }, assert: { assertions: { - 'categories:performance': ['error', { minScore: 1 }], - 'categories:accessibility': ['error', { minScore: 1 }], - 'categories:best-practices': ['error', { minScore: 1 }], - 'categories:seo': ['error', { minScore: 1 }], + "categories:performance": ["error", { minScore: 1 }], + "categories:accessibility": ["error", { minScore: 1 }], + "categories:best-practices": ["error", { minScore: 1 }], + "categories:seo": ["error", { minScore: 1 }], // Core Web Vitals - 'first-contentful-paint': ['error', { maxNumericValue: 1800 }], - 'largest-contentful-paint': ['error', { maxNumericValue: 2500 }], - 'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }], - 'total-blocking-time': ['error', { maxNumericValue: 200 }], - 'speed-index': ['error', { maxNumericValue: 3400 }], + "first-contentful-paint": ["error", { maxNumericValue: 1800 }], + "largest-contentful-paint": ["error", { maxNumericValue: 2500 }], + "cumulative-layout-shift": ["error", { maxNumericValue: 0.1 }], + "total-blocking-time": ["error", { maxNumericValue: 200 }], + "speed-index": ["error", { maxNumericValue: 3400 }], // Performance metrics - 'interactive': ['error', { maxNumericValue: 3800 }], - 'uses-optimized-images': 'error', - 'uses-text-compression': 'error', - 'uses-responsive-images': 'error', - 'modern-image-formats': 'error', - 'offscreen-images': 'error', - 'render-blocking-resources': 'error', - 'unused-css-rules': 'error', - 'unused-javascript': 'error', - 'efficient-animated-content': 'error', - 'preload-lcp-image': 'error', - 'uses-long-cache-ttl': 'error', - 'total-byte-weight': ['error', { maxNumericValue: 1600000 }], + interactive: ["error", { maxNumericValue: 3800 }], + "uses-optimized-images": "error", + "uses-text-compression": "error", + "uses-responsive-images": "error", + "modern-image-formats": "error", + "offscreen-images": "error", + "render-blocking-resources": "error", + "unused-css-rules": "error", + "unused-javascript": "error", + "efficient-animated-content": "error", + "preload-lcp-image": "error", + "uses-long-cache-ttl": "error", + "total-byte-weight": ["error", { maxNumericValue: 1600000 }], }, }, upload: { - target: 'temporary-public-storage', + target: "temporary-public-storage", }, }, -}; - - - +} diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..808ae6a3 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,15 @@ +.cursor +.next +.playwright-cli +.pnpm-store +artifacts +node_modules +out +output +pnpm-lock.yaml +public/json-ld +public/manual_inventory.json +public/manual_pages_full.json +public/manual_pages_parts.json +public/manual_pages_text.json +public/manual_parts_lookup.json diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 00000000..083ae08f --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,5 @@ +{ + "semi": false, + "singleQuote": false, + "trailingComma": "es5" +} diff --git a/app/about-us/page.tsx b/app/about-us/page.tsx index 54cd926e..c7d06156 100644 --- a/app/about-us/page.tsx +++ b/app/about-us/page.tsx @@ -1,6 +1,5 @@ import { notFound } from "next/navigation" -import { loadImageMapping } from "@/lib/wordpress-content" -import { generateSEOMetadata, generateStructuredData } from "@/lib/seo" +import { generateRegistryMetadata, generateRegistryStructuredData } from "@/lib/seo" import { getPageBySlug } from "@/lib/wordpress-data-loader" import { AboutPage } from "@/components/about-page" import type { Metadata } from "next" @@ -16,14 +15,10 @@ export async function generateMetadata(): Promise { } } - return generateSEOMetadata({ - title: page.title || "About Us", - description: page.seoDescription || page.excerpt || "", - excerpt: page.excerpt, + return generateRegistryMetadata("aboutUs", { date: page.date, modified: page.modified, image: page.images?.[0]?.localPath, - path: "/about-us", }) } @@ -35,28 +30,10 @@ export default async function AboutUsPage() { notFound() } - let structuredData - try { - structuredData = generateStructuredData({ - title: page.title || "About Us", - description: page.seoDescription || page.excerpt || "", - url: - page.link || - page.urlPath || - `https://rockymountainvending.com/about-us/`, - datePublished: page.date, - dateModified: page.modified || page.date, - type: "WebPage", - }) - } catch (e) { - structuredData = { - "@context": "https://schema.org", - "@type": "WebPage", - headline: page.title || "About Us", - description: page.seoDescription || "", - url: `https://rockymountainvending.com/about-us/`, - } - } + const structuredData = generateRegistryStructuredData("aboutUs", { + datePublished: page.date, + dateModified: page.modified || page.date, + }) return ( <> diff --git a/app/about/faqs/page.tsx b/app/about/faqs/page.tsx index 7789c04b..0778e470 100644 --- a/app/about/faqs/page.tsx +++ b/app/about/faqs/page.tsx @@ -1,83 +1,74 @@ -import { notFound } from 'next/navigation'; -import { loadImageMapping } from '@/lib/wordpress-content'; -import { generateSEOMetadata, generateStructuredData } from '@/lib/seo'; -import { getPageBySlug } from '@/lib/wordpress-data-loader'; -import { FAQSchema } from '@/components/faq-schema'; -import { FAQSection } from '@/components/faq-section'; -import type { Metadata } from 'next'; +import { notFound } from "next/navigation" +import { buildAbsoluteUrl } from "@/lib/seo-registry" +import { generateRegistryMetadata, generateRegistryStructuredData } from "@/lib/seo" +import { Breadcrumbs } from "@/components/breadcrumbs" +import { getPageBySlug } from "@/lib/wordpress-data-loader" +import { FAQSchema } from "@/components/faq-schema" +import { FAQSection } from "@/components/faq-section" +import type { Metadata } from "next" -const WORDPRESS_SLUG = 'faqs'; +const WORDPRESS_SLUG = "faqs" export async function generateMetadata(): Promise { - const page = getPageBySlug(WORDPRESS_SLUG); + const page = getPageBySlug(WORDPRESS_SLUG) if (!page) { return { - title: 'Page Not Found | Rocky Mountain Vending', - }; + title: "Page Not Found | Rocky Mountain Vending", + } } - return generateSEOMetadata({ - title: page.title || 'FAQs', - description: page.seoDescription || page.excerpt || '', - excerpt: page.excerpt, + return generateRegistryMetadata("faqs", { date: page.date, modified: page.modified, image: page.images?.[0]?.localPath, - }); + }) } export default async function FAQsPage() { try { - const page = getPageBySlug(WORDPRESS_SLUG); + const page = getPageBySlug(WORDPRESS_SLUG) if (!page) { - notFound(); + notFound() } // Extract FAQs from content - const faqs: Array<{ question: string; answer: string }> = []; + const faqs: Array<{ question: string; answer: string }> = [] if (page.content) { - const contentStr = String(page.content); + const contentStr = String(page.content) // Extract FAQ items from accordion structure - const questionMatches = contentStr.matchAll(/([^<]+)<\/span>/g); + const questionMatches = contentStr.matchAll( + /([^<]+)<\/span>/g + ) // Extract full answer content - const answerMatches = contentStr.matchAll(/
([\s\S]*?)<\/div>\s*<\/div>\s*/g); - - const questions = Array.from(questionMatches).map(m => m[1].trim()); - const answers = Array.from(answerMatches).map(m => { - let answer = m[1].trim(); - answer = answer.replace(/\n\s*\n/g, '\n').replace(/>\s+<').trim(); - return answer; - }); - + const answerMatches = contentStr.matchAll( + /
([\s\S]*?)<\/div>\s*<\/div>\s*/g + ) + + const questions = Array.from(questionMatches).map((m) => m[1].trim()) + const answers = Array.from(answerMatches).map((m) => { + let answer = m[1].trim() + answer = answer + .replace(/\n\s*\n/g, "\n") + .replace(/>\s+<") + .trim() + return answer + }) + // Match questions with answers questions.forEach((question, index) => { if (answers[index]) { - faqs.push({ question, answer: answers[index] }); + faqs.push({ question, answer: answers[index] }) } - }); + }) } - let structuredData; - try { - structuredData = generateStructuredData({ - title: page.title || 'FAQs', - description: page.seoDescription || page.excerpt || '', - url: page.link || page.urlPath || `https://rockymountainvending.com/about/faqs/`, - datePublished: page.date, - dateModified: page.modified || page.date, - type: 'WebPage', - }); - } catch (e) { - structuredData = { - '@context': 'https://schema.org', - '@type': 'WebPage', - headline: page.title || 'FAQs', - description: page.seoDescription || '', - url: `https://rockymountainvending.com/about/faqs/`, - }; - } + const pageUrl = buildAbsoluteUrl("/about/faqs") + const structuredData = generateRegistryStructuredData("faqs", { + datePublished: page.date, + dateModified: page.modified || page.date, + }) return ( <> @@ -86,28 +77,27 @@ export default async function FAQsPage() { dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }} /> {faqs.length > 0 && ( - <> - + - - + + +
)} - ); + ) } catch (error) { - if (process.env.NODE_ENV === 'development') { - console.error('Error rendering FAQs page:', error); + if (process.env.NODE_ENV === "development") { + console.error("Error rendering FAQs page:", error) } - notFound(); + notFound() } } - - - - - - - - diff --git a/app/about/page.tsx b/app/about/page.tsx index 35d8d44e..b4b10a94 100644 --- a/app/about/page.tsx +++ b/app/about/page.tsx @@ -1,16 +1,11 @@ -import { generateSEOMetadata, generateStructuredData } from "@/lib/seo" +import { generateRegistryMetadata, generateRegistryStructuredData } from "@/lib/seo" import { AboutPage } from "@/components/about-page" import type { Metadata } from "next" import { businessConfig } from "@/lib/seo-config" export async function generateMetadata(): Promise { return { - ...generateSEOMetadata({ - title: "About Rocky Mountain Vending | Utah Vending Company", - description: - "Learn about Rocky Mountain Vending, the Utah service-area business behind our vending placement, repair, sales, and support services.", - path: "/about", - }), + ...generateRegistryMetadata("aboutLegacy"), alternates: { canonical: `${businessConfig.website}/about-us`, }, @@ -22,13 +17,7 @@ export async function generateMetadata(): Promise { } export default function About() { - const structuredData = generateStructuredData({ - title: "About Rocky Mountain Vending", - description: - "Learn about Rocky Mountain Vending, the Utah service-area business behind our vending placement, repair, sales, and support services.", - url: "https://rockymountainvending.com/about-us/", - type: "WebPage", - }) + const structuredData = generateRegistryStructuredData("aboutUs") return ( <> diff --git a/app/admin/calls/[id]/page.tsx b/app/admin/calls/[id]/page.tsx index 5626c6a0..bdbd88fb 100644 --- a/app/admin/calls/[id]/page.tsx +++ b/app/admin/calls/[id]/page.tsx @@ -1,31 +1,37 @@ -import Link from "next/link"; -import { notFound } from "next/navigation"; -import { fetchQuery } from "convex/nextjs"; -import { ArrowLeft, ExternalLink, Phone } from "lucide-react"; -import { api } from "@/convex/_generated/api"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import Link from "next/link" +import { notFound } from "next/navigation" +import { fetchQuery } from "convex/nextjs" +import { ArrowLeft, ExternalLink, Phone } from "lucide-react" +import { api } from "@/convex/_generated/api" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" import { formatPhoneCallDuration, formatPhoneCallTimestamp, normalizePhoneFromIdentity, -} from "@/lib/phone-calls"; +} from "@/lib/phone-calls" type PageProps = { params: Promise<{ - id: string; - }>; -}; + id: string + }> +} export default async function AdminCallDetailPage({ params }: PageProps) { - const { id } = await params; + const { id } = await params const detail = await fetchQuery(api.voiceSessions.getAdminPhoneCallDetail, { callId: id, - }); + }) if (!detail) { - notFound(); + notFound() } return ( @@ -33,13 +39,19 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
- + Back to calls -

Phone Call Detail

+

+ Phone Call Detail +

- {normalizePhoneFromIdentity(detail.call.participantIdentity) || detail.call.participantIdentity} + {normalizePhoneFromIdentity(detail.call.participantIdentity) || + detail.call.participantIdentity}

@@ -51,58 +63,107 @@ export default async function AdminCallDetailPage({ params }: PageProps) { Call Status - Operational detail for this direct phone session. + + Operational detail for this direct phone session. +
-

Started

-

{formatPhoneCallTimestamp(detail.call.startedAt)}

+

+ Started +

+

+ {formatPhoneCallTimestamp(detail.call.startedAt)} +

-

Room

+

+ Room +

{detail.call.roomName}

-

Duration

-

{formatPhoneCallDuration(detail.call.durationMs)}

+

+ Duration +

+

+ {formatPhoneCallDuration(detail.call.durationMs)} +

-

Participant Identity

-

{detail.call.participantIdentity || "Unknown"}

+

+ Participant Identity +

+

+ {detail.call.participantIdentity || "Unknown"} +

-

Call Status

- +

+ Call Status +

+ {detail.call.callStatus}
-

Jessica Answered

-

{detail.call.answered ? "Yes" : "No"}

+

+ Jessica Answered +

+

+ {detail.call.answered ? "Yes" : "No"} +

-

Lead Outcome

+

+ Lead Outcome +

{detail.call.leadOutcome}

-

Email Summary

+

+ Email Summary +

{detail.call.notificationStatus}

-

Summary

-

{detail.call.summaryText || "No summary available yet."}

+

+ Summary +

+

+ {detail.call.summaryText || "No summary available yet."} +

-

Recording Status

-

{detail.call.recordingStatus || "Unavailable"}

+

+ Recording Status +

+

+ {detail.call.recordingStatus || "Unavailable"} +

-

Transcript Turns

+

+ Transcript Turns +

{detail.call.transcriptTurnCount}

{detail.call.recordingUrl ? (
- + Open recording @@ -110,8 +171,12 @@ export default async function AdminCallDetailPage({ params }: PageProps) { ) : null} {detail.call.notificationError ? (
-

Email Error

-

{detail.call.notificationError}

+

+ Email Error +

+

+ {detail.call.notificationError} +

) : null} @@ -121,37 +186,56 @@ export default async function AdminCallDetailPage({ params }: PageProps) { Linked Lead - {detail.linkedLead ? "Lead created from this phone call." : "No lead was created from this call."} + {detail.linkedLead + ? "Lead created from this phone call." + : "No lead was created from this call."} {detail.linkedLead ? ( <>
-

Contact

+

+ Contact +

{detail.linkedLead.firstName} {detail.linkedLead.lastName}

-

Lead Type

+

+ Lead Type +

{detail.linkedLead.type}

-

Email

-

{detail.linkedLead.email}

+

+ Email +

+

+ {detail.linkedLead.email} +

-

Phone

+

+ Phone +

{detail.linkedLead.phone}

-

Message

-

{detail.linkedLead.message || "—"}

+

+ Message +

+

+ {detail.linkedLead.message || "—"} +

) : ( -

Jessica handled the call, but it did not result in a submitted lead.

+

+ Jessica handled the call, but it did not result in a submitted + lead. +

)}
@@ -160,11 +244,15 @@ export default async function AdminCallDetailPage({ params }: PageProps) { Transcript - Complete mirrored transcript for this phone call. + + Complete mirrored transcript for this phone call. + {detail.turns.length === 0 ? ( -

No transcript turns were captured for this call.

+

+ No transcript turns were captured for this call. +

) : ( detail.turns.map((turn: any) => (
@@ -180,10 +268,11 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
- ); + ) } export const metadata = { title: "Phone Call Detail | Admin", - description: "Review a mirrored direct phone call transcript and linked lead details", -}; + description: + "Review a mirrored direct phone call transcript and linked lead details", +} diff --git a/app/admin/calls/page.tsx b/app/admin/calls/page.tsx index 44da7c78..c7b89dc1 100644 --- a/app/admin/calls/page.tsx +++ b/app/admin/calls/page.tsx @@ -1,58 +1,67 @@ -import Link from "next/link"; -import { fetchQuery } from "convex/nextjs"; -import { Phone, Search } from "lucide-react"; -import { api } from "@/convex/_generated/api"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; +import Link from "next/link" +import { fetchQuery } from "convex/nextjs" +import { Phone, Search } from "lucide-react" +import { api } from "@/convex/_generated/api" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { Input } from "@/components/ui/input" import { formatPhoneCallDuration, formatPhoneCallTimestamp, normalizePhoneFromIdentity, -} from "@/lib/phone-calls"; +} from "@/lib/phone-calls" type PageProps = { searchParams: Promise<{ - search?: string; - status?: "started" | "completed" | "failed"; - page?: string; - }>; -}; + search?: string + status?: "started" | "completed" | "failed" + page?: string + }> +} function getStatusVariant(status: "started" | "completed" | "failed") { if (status === "failed") { - return "destructive" as const; + return "destructive" as const } if (status === "started") { - return "secondary" as const; + return "secondary" as const } - return "default" as const; + return "default" as const } export default async function AdminCallsPage({ searchParams }: PageProps) { - const params = await searchParams; - const page = Math.max(1, Number.parseInt(params.page || "1", 10) || 1); - const status = params.status; - const search = params.search?.trim() || undefined; + const params = await searchParams + const page = Math.max(1, Number.parseInt(params.page || "1", 10) || 1) + const status = params.status + const search = params.search?.trim() || undefined const data = await fetchQuery(api.voiceSessions.listAdminPhoneCalls, { search, status, page, limit: 25, - }); + }) return (
-

Phone Calls

+

+ Phone Calls +

- Every direct LiveKit phone call mirrored into RMV admin, including partial and non-lead calls. + Every direct LiveKit phone call mirrored into RMV admin, including + partial and non-lead calls.

@@ -66,13 +75,20 @@ export default async function AdminCallsPage({ searchParams }: PageProps) { Call Inbox - Search by caller number, room, summary, or linked lead ID. + + Search by caller number, room, summary, or linked lead ID. +
- +