Compare commits
32 commits
codex/phon
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 656b78bf8e | |||
| 7144aa4943 | |||
| 508a8bbe5e | |||
| 5d3ee2c4d7 | |||
| 23f1ed6297 | |||
| f077966bb2 | |||
| c5e40c5caf | |||
| 013a908d92 | |||
| e294117e6e | |||
| 14cb8ce1fc | |||
| 9dfee33e49 | |||
| 7786336cfb | |||
| 133ed6d6f3 | |||
| a1799715c6 | |||
| 4828f044fa | |||
| e326cc6bba | |||
| c0914c92b4 | |||
| e2953a382b | |||
| bcc39664de | |||
| 5b6ad66c24 | |||
| 1f46c2b390 | |||
| b67bb1e183 | |||
| bc2edc04f2 | |||
| 8fff380b24 | |||
| 087fda7ce6 | |||
| 96ad13d6a9 | |||
| d496a58935 | |||
| 0be731e474 | |||
| 1c1c01069c | |||
| 1948fd564e | |||
| 60b70e46ab | |||
| 975fc06136 |
436 changed files with 38039 additions and 13379 deletions
|
|
@ -73,8 +73,11 @@ jspm_packages/
|
|||
tmp/
|
||||
temp/
|
||||
.pnpm-store/
|
||||
.formatting-backups/
|
||||
.cursor/
|
||||
.playwright-cli/
|
||||
output/
|
||||
docs/
|
||||
artifacts/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
|
|
|
|||
12
.editorconfig
Normal file
12
.editorconfig
Normal file
|
|
@ -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
|
||||
|
|
@ -33,6 +33,15 @@ ADMIN_EMAIL=
|
|||
# Direct phone-call visibility
|
||||
PHONE_AGENT_INTERNAL_TOKEN=
|
||||
PHONE_CALL_SUMMARY_FROM_EMAIL=
|
||||
ENABLE_GHL_SYNC=false
|
||||
GOOGLE_CALENDAR_CLIENT_ID=
|
||||
GOOGLE_CALENDAR_CLIENT_SECRET=
|
||||
GOOGLE_CALENDAR_REFRESH_TOKEN=
|
||||
GOOGLE_CALENDAR_ID=
|
||||
GOOGLE_CALENDAR_TIMEZONE=America/Denver
|
||||
GOOGLE_CALENDAR_CALLBACK_SLOT_MINUTES=15
|
||||
GOOGLE_CALENDAR_CALLBACK_START_HOUR=8
|
||||
GOOGLE_CALENDAR_CALLBACK_END_HOUR=17
|
||||
|
||||
# Placeholder for a later LiveKit rollout
|
||||
LIVEKIT_URL=
|
||||
|
|
|
|||
|
|
@ -1,28 +1,81 @@
|
|||
NEXT_PUBLIC_SITE_DOMAIN=rmv.abundancepartners.app
|
||||
NEXT_PUBLIC_SITE_URL=https://rmv.abundancepartners.app
|
||||
# Current Rocky Mountain Vending staging env contract.
|
||||
# Fill these in through Coolify-managed environment variables only.
|
||||
|
||||
CONVEX_URL=
|
||||
CONVEX_SELF_HOSTED_URL=
|
||||
CONVEX_SELF_HOSTED_ADMIN_KEY=
|
||||
CONVEX_TENANT_SLUG=rocky_mountain_vending
|
||||
CONVEX_TENANT_NAME=Rocky Mountain Vending
|
||||
|
||||
ADMIN_UI_ENABLED=true
|
||||
ADMIN_API_TOKEN=
|
||||
ADMIN_EMAIL=
|
||||
|
||||
PHONE_AGENT_INTERNAL_TOKEN=
|
||||
PHONE_CALL_SUMMARY_FROM_EMAIL=
|
||||
|
||||
USESEND_API_KEY=
|
||||
USESEND_BASE_URL=
|
||||
USESEND_FROM_EMAIL=info@rockymountainvending.com
|
||||
CONTACT_FORM_TO_EMAIL=info@rockymountainvending.com
|
||||
|
||||
GHL_API_TOKEN=
|
||||
GHL_LOCATION_ID=
|
||||
# Core site
|
||||
NEXT_PUBLIC_SITE_URL=https://rockymountainvending.com
|
||||
NEXT_PUBLIC_SITE_DOMAIN=rockymountainvending.com
|
||||
NEXT_PUBLIC_CONVEX_URL=
|
||||
|
||||
# Voice and chat
|
||||
LIVEKIT_URL=
|
||||
LIVEKIT_API_KEY=
|
||||
LIVEKIT_API_SECRET=
|
||||
VOICE_ASSISTANT_SITE_URL=https://rmv.abundancepartners.app
|
||||
XAI_API_KEY=
|
||||
XAI_REALTIME_MODEL=grok-4-1-fast-non-reasoning
|
||||
VOICE_ASSISTANT_SITE_URL=https://rockymountainvending.com
|
||||
PHONE_AGENT_INTERNAL_TOKEN=
|
||||
NEXT_PUBLIC_CALL_PHONE_DISPLAY=(435) 233-9668
|
||||
NEXT_PUBLIC_CALL_PHONE_E164=+14352339668
|
||||
NEXT_PUBLIC_SMS_PHONE_DISPLAY=(435) 233-9668
|
||||
NEXT_PUBLIC_SMS_PHONE_E164=+14352339668
|
||||
NEXT_PUBLIC_MANUALS_BASE_URL=
|
||||
NEXT_PUBLIC_THUMBNAILS_BASE_URL=
|
||||
VOICE_RECORDING_ENABLED=false
|
||||
VOICE_RECORDING_BUCKET=
|
||||
VOICE_RECORDING_ENDPOINT=
|
||||
VOICE_RECORDING_PUBLIC_BASE_URL=
|
||||
VOICE_RECORDING_ACCESS_KEY_ID=
|
||||
VOICE_RECORDING_SECRET_ACCESS_KEY=
|
||||
VOICE_RECORDING_REGION=auto
|
||||
|
||||
# Admin and auth
|
||||
ADMIN_EMAIL=
|
||||
ADMIN_PASSWORD=
|
||||
RESEND_API_KEY=
|
||||
PHONE_CALL_SUMMARY_FROM_EMAIL=
|
||||
ENABLE_GHL_SYNC=false
|
||||
GOOGLE_CALENDAR_CLIENT_ID=
|
||||
GOOGLE_CALENDAR_CLIENT_SECRET=
|
||||
GOOGLE_CALENDAR_REFRESH_TOKEN=
|
||||
GOOGLE_CALENDAR_ID=
|
||||
GOOGLE_CALENDAR_TIMEZONE=America/Denver
|
||||
GOOGLE_CALENDAR_CALLBACK_SLOT_MINUTES=15
|
||||
GOOGLE_CALENDAR_CALLBACK_START_HOUR=8
|
||||
GOOGLE_CALENDAR_CALLBACK_END_HOUR=17
|
||||
NEXT_PUBLIC_SUPABASE_URL=
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=
|
||||
|
||||
# Stripe
|
||||
STRIPE_SECRET_KEY=
|
||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
|
||||
# GHL handoff + sync (API-only mode)
|
||||
GHL_PRIVATE_INTEGRATION_TOKEN=
|
||||
GHL_LOCATION_ID=YAoWLgNSid8oG44j9BjG
|
||||
GHL_API_VERSION=2021-07-28
|
||||
GHL_API_BASE_URL=https://services.leadconnectorhq.com
|
||||
GHL_SYNC_CRON_TOKEN=
|
||||
GHL_SYNC_INTERVAL_MINUTES=15
|
||||
GHL_SYNC_PAGE_SIZE=100
|
||||
|
||||
# Optional/deprecated in API-only mode
|
||||
GHL_WEBHOOK_SHARED_SECRET=
|
||||
GHL_CONTACT_WEBHOOK_URL=
|
||||
GHL_REQUEST_MACHINE_WEBHOOK_URL=
|
||||
|
||||
# eBay API credentials for manuals-side fallback
|
||||
EBAY_APP_ID=
|
||||
EBAY_DEV_ID=
|
||||
EBAY_CERT_ID=
|
||||
EBAY_SANDBOX_TOKEN=
|
||||
EBAY_AFFILIATE_CAMPAIGN_ID=
|
||||
|
||||
# eBay marketplace account deletion notifications
|
||||
# Use the exact public HTTPS endpoint that eBay validates in the developer portal.
|
||||
EBAY_NOTIFICATION_ENDPOINT=https://rmv.abundancepartners.app/api/ebay/notifications
|
||||
EBAY_NOTIFICATION_VERIFICATION_TOKEN=
|
||||
EBAY_NOTIFICATION_APP_ID=
|
||||
EBAY_NOTIFICATION_CERT_ID=
|
||||
EBAY_NOTIFICATION_API_BASE_URL=https://api.ebay.com
|
||||
EBAY_NOTIFICATION_SCOPE=https://api.ebay.com/oauth/api_scope
|
||||
|
|
|
|||
12
.gitignore
vendored
12
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
15
.prettierignore
Normal file
15
.prettierignore
Normal file
|
|
@ -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
|
||||
5
.prettierrc.json
Normal file
5
.prettierrc.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"semi": false,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,58 +1,39 @@
|
|||
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 { AboutPage } from '@/components/about-page';
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from "next/navigation"
|
||||
import { generateRegistryMetadata, generateRegistryStructuredData } from "@/lib/seo"
|
||||
import { getPageBySlug } from "@/lib/wordpress-data-loader"
|
||||
import { AboutPage } from "@/components/about-page"
|
||||
import type { Metadata } from "next"
|
||||
|
||||
const WORDPRESS_SLUG = 'about-us';
|
||||
const WORDPRESS_SLUG = "about-us"
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
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 || 'About Us',
|
||||
description: page.seoDescription || page.excerpt || '',
|
||||
excerpt: page.excerpt,
|
||||
return generateRegistryMetadata("aboutUs", {
|
||||
date: page.date,
|
||||
modified: page.modified,
|
||||
image: page.images?.[0]?.localPath,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export default async function AboutUsPage() {
|
||||
try {
|
||||
const page = getPageBySlug(WORDPRESS_SLUG);
|
||||
const page = getPageBySlug(WORDPRESS_SLUG)
|
||||
|
||||
if (!page) {
|
||||
notFound();
|
||||
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 (
|
||||
<>
|
||||
|
|
@ -62,19 +43,11 @@ export default async function AboutUsPage() {
|
|||
/>
|
||||
<AboutPage />
|
||||
</>
|
||||
);
|
||||
)
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Error rendering About Us page:', error);
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.error("Error rendering About Us page:", error)
|
||||
}
|
||||
notFound();
|
||||
notFound()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Metadata> {
|
||||
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 class="ekit-accordion-title">([^<]+)<\/span>/g);
|
||||
const questionMatches = contentStr.matchAll(
|
||||
/<span class="ekit-accordion-title">([^<]+)<\/span>/g
|
||||
)
|
||||
// Extract full answer content
|
||||
const answerMatches = contentStr.matchAll(/<div class="elementskit-card-body ekit-accordion--content">([\s\S]*?)<\/div>\s*<\/div>\s*<!-- \.elementskit-card END -->/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+</g, '><').trim();
|
||||
return answer;
|
||||
});
|
||||
|
||||
const answerMatches = contentStr.matchAll(
|
||||
/<div class="elementskit-card-body ekit-accordion--content">([\s\S]*?)<\/div>\s*<\/div>\s*<!-- \.elementskit-card END -->/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+</g, "><")
|
||||
.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 && (
|
||||
<>
|
||||
<FAQSchema
|
||||
faqs={faqs}
|
||||
pageUrl={page.link || page.urlPath || `https://rockymountainvending.com/about/faqs/`}
|
||||
<div className="public-page">
|
||||
<Breadcrumbs
|
||||
className="mb-6"
|
||||
items={[
|
||||
{ label: "About", href: "/about" },
|
||||
{ label: "FAQs", href: "/about/faqs" },
|
||||
]}
|
||||
/>
|
||||
<FAQSection faqs={faqs} />
|
||||
</>
|
||||
<FAQSchema
|
||||
faqs={faqs}
|
||||
pageUrl={pageUrl}
|
||||
/>
|
||||
<FAQSection faqs={faqs} className="pt-0" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
)
|
||||
} 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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,21 +1,23 @@
|
|||
import { generateSEOMetadata, generateStructuredData } from '@/lib/seo';
|
||||
import { AboutPage } from '@/components/about-page';
|
||||
import type { Metadata } from 'next';
|
||||
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<Metadata> {
|
||||
return generateSEOMetadata({
|
||||
title: 'About Us | Rocky Mountain Vending',
|
||||
description: 'Learn more about Rocky Mountain Vending, a family-owned business dedicated to providing exceptional vending services across Utah',
|
||||
});
|
||||
return {
|
||||
...generateRegistryMetadata("aboutLegacy"),
|
||||
alternates: {
|
||||
canonical: `${businessConfig.website}/about-us`,
|
||||
},
|
||||
robots: {
|
||||
index: false,
|
||||
follow: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default function About() {
|
||||
const structuredData = generateStructuredData({
|
||||
title: 'About Us',
|
||||
description: 'Learn more about Rocky Mountain Vending, a family-owned business dedicated to providing exceptional vending services across Utah',
|
||||
url: 'https://rockymountainvending.com/about/',
|
||||
type: 'WebPage',
|
||||
});
|
||||
const structuredData = generateRegistryStructuredData("aboutUs")
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -25,5 +27,5 @@ export default function About() {
|
|||
/>
|
||||
<AboutPage />
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,20 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
|
|||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-2">
|
||||
<Link href="/admin/calls" className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
|
||||
<Link
|
||||
href="/admin/calls"
|
||||
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to calls
|
||||
</Link>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-balance">Phone Call Detail</h1>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-balance">
|
||||
Phone Call Detail
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{normalizePhoneFromIdentity(detail.call.participantIdentity) || detail.call.participantIdentity}
|
||||
{detail.call.contactDisplayName ||
|
||||
normalizePhoneFromIdentity(detail.call.participantIdentity) ||
|
||||
detail.call.participantIdentity}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -51,58 +64,159 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
|
|||
<Phone className="h-5 w-5" />
|
||||
Call Status
|
||||
</CardTitle>
|
||||
<CardDescription>Operational detail for this direct phone session.</CardDescription>
|
||||
<CardDescription>
|
||||
Operational detail for this direct phone session.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Started</p>
|
||||
<p className="font-medium">{formatPhoneCallTimestamp(detail.call.startedAt)}</p>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Started
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{formatPhoneCallTimestamp(detail.call.startedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Room</p>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Room
|
||||
</p>
|
||||
<p className="font-medium break-all">{detail.call.roomName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Duration</p>
|
||||
<p className="font-medium">{formatPhoneCallDuration(detail.call.durationMs)}</p>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Duration
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{formatPhoneCallDuration(detail.call.durationMs)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Participant Identity</p>
|
||||
<p className="font-medium break-all">{detail.call.participantIdentity || "Unknown"}</p>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Participant Identity
|
||||
</p>
|
||||
<p className="font-medium break-all">
|
||||
{detail.call.participantIdentity || "Unknown"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Call Status</p>
|
||||
<Badge className="mt-1" variant={detail.call.callStatus === "failed" ? "destructive" : detail.call.callStatus === "started" ? "secondary" : "default"}>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Caller Phone
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{detail.call.callerPhone ||
|
||||
normalizePhoneFromIdentity(detail.call.participantIdentity) ||
|
||||
"Unknown"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Company
|
||||
</p>
|
||||
<p className="font-medium">{detail.call.contactCompany || "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Call Status
|
||||
</p>
|
||||
<Badge
|
||||
className="mt-1"
|
||||
variant={
|
||||
detail.call.callStatus === "failed"
|
||||
? "destructive"
|
||||
: detail.call.callStatus === "started"
|
||||
? "secondary"
|
||||
: "default"
|
||||
}
|
||||
>
|
||||
{detail.call.callStatus}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Jessica Answered</p>
|
||||
<p className="font-medium">{detail.call.answered ? "Yes" : "No"}</p>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Jessica Answered
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{detail.call.answered ? "Yes" : "No"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Lead Outcome</p>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Lead Outcome
|
||||
</p>
|
||||
<p className="font-medium">{detail.call.leadOutcome}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Email Summary</p>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Email Summary
|
||||
</p>
|
||||
<p className="font-medium">{detail.call.notificationStatus}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Reminder
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{detail.call.reminderStatus || "none"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Warm Transfer
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{detail.call.warmTransferStatus || "none"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Summary</p>
|
||||
<p className="text-sm whitespace-pre-wrap">{detail.call.summaryText || "No summary available yet."}</p>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Summary
|
||||
</p>
|
||||
<p className="text-sm whitespace-pre-wrap">
|
||||
{detail.call.summaryText || "No summary available yet."}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Recording Status</p>
|
||||
<p className="font-medium">{detail.call.recordingStatus || "Unavailable"}</p>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Recording Status
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{detail.call.recordingStatus || "Unavailable"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Transcript Turns</p>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Transcript Turns
|
||||
</p>
|
||||
<p className="font-medium">{detail.call.transcriptTurnCount}</p>
|
||||
</div>
|
||||
{detail.call.reminderStartAt ? (
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Reminder Time
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{formatPhoneCallTimestamp(detail.call.reminderStartAt)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{detail.call.warmTransferFailureReason ? (
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Transfer Detail
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{detail.call.warmTransferFailureReason}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{detail.call.recordingUrl ? (
|
||||
<div className="md:col-span-2">
|
||||
<Link href={detail.call.recordingUrl} target="_blank" className="inline-flex items-center gap-2 text-sm text-primary hover:underline">
|
||||
<Link
|
||||
href={detail.call.recordingUrl}
|
||||
target="_blank"
|
||||
className="inline-flex items-center gap-2 text-sm text-primary hover:underline"
|
||||
>
|
||||
Open recording
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Link>
|
||||
|
|
@ -110,8 +224,24 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
|
|||
) : null}
|
||||
{detail.call.notificationError ? (
|
||||
<div className="md:col-span-2">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Email Error</p>
|
||||
<p className="text-sm text-destructive">{detail.call.notificationError}</p>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Email Error
|
||||
</p>
|
||||
<p className="text-sm text-destructive">
|
||||
{detail.call.notificationError}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{detail.call.reminderCalendarHtmlLink ? (
|
||||
<div className="md:col-span-2">
|
||||
<Link
|
||||
href={detail.call.reminderCalendarHtmlLink}
|
||||
target="_blank"
|
||||
className="inline-flex items-center gap-2 text-sm text-primary hover:underline"
|
||||
>
|
||||
Open reminder
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
|
|
@ -121,38 +251,74 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
|
|||
<CardHeader>
|
||||
<CardTitle>Linked Lead</CardTitle>
|
||||
<CardDescription>
|
||||
{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."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{detail.linkedLead ? (
|
||||
<>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Contact</p>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Contact
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{detail.linkedLead.firstName} {detail.linkedLead.lastName}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Lead Type</p>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Lead Type
|
||||
</p>
|
||||
<p className="font-medium">{detail.linkedLead.type}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Email</p>
|
||||
<p className="font-medium break-all">{detail.linkedLead.email}</p>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Email
|
||||
</p>
|
||||
<p className="font-medium break-all">
|
||||
{detail.linkedLead.email}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Phone</p>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Phone
|
||||
</p>
|
||||
<p className="font-medium">{detail.linkedLead.phone}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Message</p>
|
||||
<p className="text-sm whitespace-pre-wrap">{detail.linkedLead.message || "—"}</p>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Message
|
||||
</p>
|
||||
<p className="text-sm whitespace-pre-wrap">
|
||||
{detail.linkedLead.message || "—"}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Jessica handled the call, but it did not result in a submitted lead.</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Jessica handled the call, but it did not result in a submitted
|
||||
lead.
|
||||
</p>
|
||||
)}
|
||||
{detail.contactProfile ? (
|
||||
<div className="border-t pt-3">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Contact Profile
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{detail.contactProfile.displayName ||
|
||||
[detail.contactProfile.firstName, detail.contactProfile.lastName]
|
||||
.filter(Boolean)
|
||||
.join(" ") ||
|
||||
"Known caller"}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{detail.contactProfile.company || detail.contactProfile.email || "No company or email yet"}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
@ -160,11 +326,15 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
|
|||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Transcript</CardTitle>
|
||||
<CardDescription>Complete mirrored transcript for this phone call.</CardDescription>
|
||||
<CardDescription>
|
||||
Complete mirrored transcript for this phone call.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{detail.turns.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No transcript turns were captured for this call.</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No transcript turns were captured for this call.
|
||||
</p>
|
||||
) : (
|
||||
detail.turns.map((turn: any) => (
|
||||
<div key={turn.id} className="rounded-lg border p-3">
|
||||
|
|
@ -180,10 +350,11 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
|
|||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-balance">Phone Calls</h1>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-balance">
|
||||
Phone Calls
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/admin">
|
||||
|
|
@ -66,13 +75,20 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
|
|||
<Phone className="h-5 w-5" />
|
||||
Call Inbox
|
||||
</CardTitle>
|
||||
<CardDescription>Search by caller number, room, summary, or linked lead ID.</CardDescription>
|
||||
<CardDescription>
|
||||
Search by caller number, room, summary, or linked lead ID.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<form className="grid gap-3 md:grid-cols-[minmax(0,1fr)_180px_auto]">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input name="search" defaultValue={search || ""} placeholder="Search calls" className="pl-9" />
|
||||
<Input
|
||||
name="search"
|
||||
defaultValue={search || ""}
|
||||
placeholder="Search calls"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
name="status"
|
||||
|
|
@ -88,7 +104,7 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
|
|||
</form>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[1050px] text-sm">
|
||||
<table className="w-full min-w-[1240px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-3 pr-4 font-medium">Caller</th>
|
||||
|
|
@ -100,6 +116,8 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
|
|||
<th className="py-3 pr-4 font-medium">Recording</th>
|
||||
<th className="py-3 pr-4 font-medium">Lead</th>
|
||||
<th className="py-3 pr-4 font-medium">Email</th>
|
||||
<th className="py-3 pr-4 font-medium">Reminder</th>
|
||||
<th className="py-3 pr-4 font-medium">Transfer</th>
|
||||
<th className="py-3 pr-4 font-medium">Summary</th>
|
||||
<th className="py-3 font-medium">Open</th>
|
||||
</tr>
|
||||
|
|
@ -107,35 +125,81 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
|
|||
<tbody>
|
||||
{data.items.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={11} className="py-8 text-center text-muted-foreground">
|
||||
<td
|
||||
colSpan={13}
|
||||
className="py-8 text-center text-muted-foreground"
|
||||
>
|
||||
No phone calls matched this filter.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.items.map((call: any) => (
|
||||
<tr key={call.id} className="border-b align-top last:border-b-0">
|
||||
<tr
|
||||
key={call.id}
|
||||
className="border-b align-top last:border-b-0"
|
||||
>
|
||||
<td className="py-3 pr-4 font-medium">
|
||||
<div>{normalizePhoneFromIdentity(call.participantIdentity) || call.participantIdentity}</div>
|
||||
<div className="text-xs text-muted-foreground">{call.roomName}</div>
|
||||
<div>
|
||||
{call.contactDisplayName ||
|
||||
normalizePhoneFromIdentity(
|
||||
call.participantIdentity
|
||||
) ||
|
||||
call.participantIdentity}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{call.contactCompany ||
|
||||
normalizePhoneFromIdentity(
|
||||
call.participantIdentity
|
||||
) ||
|
||||
call.roomName}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4">{formatPhoneCallTimestamp(call.startedAt)}</td>
|
||||
<td className="py-3 pr-4">{formatPhoneCallDuration(call.durationMs)}</td>
|
||||
<td className="py-3 pr-4">
|
||||
<Badge variant={getStatusVariant(call.callStatus)}>{call.callStatus}</Badge>
|
||||
{formatPhoneCallTimestamp(call.startedAt)}
|
||||
</td>
|
||||
<td className="py-3 pr-4">{call.answered ? "Yes" : "No"}</td>
|
||||
<td className="py-3 pr-4">
|
||||
{call.transcriptTurnCount > 0 ? `${call.transcriptTurnCount} turns` : "No transcript"}
|
||||
{formatPhoneCallDuration(call.durationMs)}
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<Badge variant={getStatusVariant(call.callStatus)}>
|
||||
{call.callStatus}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
{call.answered ? "Yes" : "No"}
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
{call.transcriptTurnCount > 0
|
||||
? `${call.transcriptTurnCount} turns`
|
||||
: "No transcript"}
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
{call.recordingStatus || "Unavailable"}
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
{call.leadOutcome === "none" ? "—" : call.leadOutcome}
|
||||
</td>
|
||||
<td className="py-3 pr-4">{call.recordingStatus || "Unavailable"}</td>
|
||||
<td className="py-3 pr-4">{call.leadOutcome === "none" ? "—" : call.leadOutcome}</td>
|
||||
<td className="py-3 pr-4">{call.notificationStatus}</td>
|
||||
<td className="py-3 pr-4">
|
||||
{call.reminderStatus === "none"
|
||||
? "—"
|
||||
: call.reminderStatus}
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
{call.warmTransferStatus === "none"
|
||||
? "—"
|
||||
: call.warmTransferStatus}
|
||||
</td>
|
||||
<td className="max-w-[320px] py-3 pr-4 text-muted-foreground">
|
||||
<span className="line-clamp-2">{call.summaryText || "No summary yet"}</span>
|
||||
<span className="line-clamp-2">
|
||||
{call.summaryText || "No summary yet"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<Link href={`/admin/calls/${call.id}`}>
|
||||
<Button size="sm" variant="outline">View</Button>
|
||||
<Button size="sm" variant="outline">
|
||||
View
|
||||
</Button>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -147,7 +211,8 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
|
|||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing page {data.pagination.page} of {data.pagination.totalPages} ({data.pagination.total} calls)
|
||||
Showing page {data.pagination.page} of{" "}
|
||||
{data.pagination.totalPages} ({data.pagination.total} calls)
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
{data.pagination.page > 1 ? (
|
||||
|
|
@ -158,7 +223,9 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
|
|||
page: String(data.pagination.page - 1),
|
||||
}).toString()}`}
|
||||
>
|
||||
<Button variant="outline" size="sm">Previous</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
Previous
|
||||
</Button>
|
||||
</Link>
|
||||
) : null}
|
||||
{data.pagination.page < data.pagination.totalPages ? (
|
||||
|
|
@ -169,7 +236,9 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
|
|||
page: String(data.pagination.page + 1),
|
||||
}).toString()}`}
|
||||
>
|
||||
<Button variant="outline" size="sm">Next</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
Next
|
||||
</Button>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
@ -178,10 +247,10 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
|
|||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: "Phone Calls | Admin",
|
||||
description: "View direct phone calls, transcript history, and lead outcomes",
|
||||
};
|
||||
}
|
||||
|
|
|
|||
199
app/admin/contacts/[id]/page.tsx
Normal file
199
app/admin/contacts/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import Link from "next/link"
|
||||
import { notFound } from "next/navigation"
|
||||
import { fetchQuery } from "convex/nextjs"
|
||||
import { ArrowLeft, ContactRound, MessageSquare } from "lucide-react"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{
|
||||
id: string
|
||||
}>
|
||||
}
|
||||
|
||||
function formatTimestamp(value?: number) {
|
||||
if (!value) {
|
||||
return "—"
|
||||
}
|
||||
|
||||
return new Date(value).toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
export default async function AdminContactDetailPage({ params }: PageProps) {
|
||||
const { id } = await params
|
||||
const detail = await fetchQuery(api.crm.getAdminContactDetail, {
|
||||
contactId: id,
|
||||
})
|
||||
|
||||
if (!detail) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-2">
|
||||
<Link
|
||||
href="/admin/contacts"
|
||||
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to contacts
|
||||
</Link>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-balance">
|
||||
{detail.contact.displayName}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Contact details and activity history.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[0.95fr_1.05fr]">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ContactRound className="h-5 w-5" />
|
||||
Contact Profile
|
||||
</CardTitle>
|
||||
<CardDescription>Basic details and connected records.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Email
|
||||
</p>
|
||||
<p className="font-medium break-all">
|
||||
{detail.contact.email || "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Phone
|
||||
</p>
|
||||
<p className="font-medium">{detail.contact.phone || "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Company
|
||||
</p>
|
||||
<p className="font-medium">{detail.contact.company || "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Status
|
||||
</p>
|
||||
<Badge className="mt-1" variant="secondary">
|
||||
{detail.contact.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
GHL Contact ID
|
||||
</p>
|
||||
<p className="font-medium break-all">
|
||||
{detail.contact.ghlContactId || "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Last Activity
|
||||
</p>
|
||||
<p className="font-medium">
|
||||
{formatTimestamp(detail.contact.lastActivityAt)}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
Conversations
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Conversations linked to this contact.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{detail.conversations.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No conversations are linked to this contact yet.
|
||||
</p>
|
||||
) : (
|
||||
detail.conversations.map((conversation: any) => (
|
||||
<div key={conversation.id} className="rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{conversation.title || detail.contact.displayName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{conversation.channel} •{" "}
|
||||
{formatTimestamp(conversation.lastMessageAt)}
|
||||
</p>
|
||||
</div>
|
||||
<Link href={`/admin/conversations/${conversation.id}`}>
|
||||
<Badge variant="outline">{conversation.status}</Badge>
|
||||
</Link>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{conversation.lastMessagePreview || "No preview yet"}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Timeline</CardTitle>
|
||||
<CardDescription>
|
||||
Calls, messages, recordings, and lead events in one stream.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{detail.timeline.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No timeline activity for this contact yet.
|
||||
</p>
|
||||
) : (
|
||||
detail.timeline.map((item: any) => (
|
||||
<div key={`${item.type}-${item.id}`} className="rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
|
||||
<span className="uppercase tracking-wide">{item.type}</span>
|
||||
<span>{formatTimestamp(item.timestamp)}</span>
|
||||
</div>
|
||||
<p className="mt-1 font-medium">{item.title || "Untitled"}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{item.body || "—"}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: "Contact Detail | Admin",
|
||||
description: "Review a contact and full interaction timeline",
|
||||
}
|
||||
202
app/admin/contacts/page.tsx
Normal file
202
app/admin/contacts/page.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import Link from "next/link"
|
||||
import { fetchQuery } from "convex/nextjs"
|
||||
import { ContactRound, 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"
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<{
|
||||
search?: string
|
||||
page?: string
|
||||
}>
|
||||
}
|
||||
|
||||
function formatTimestamp(value?: number) {
|
||||
if (!value) {
|
||||
return "—"
|
||||
}
|
||||
|
||||
return new Date(value).toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
function getSyncMessage(sync: any) {
|
||||
if (!sync.ghlConfigured) {
|
||||
return "Connect GHL to load contacts and conversations."
|
||||
}
|
||||
if (sync.stages.contacts.status === "running") {
|
||||
return "Contacts are syncing now."
|
||||
}
|
||||
if (sync.stages.contacts.error) {
|
||||
return "Contacts could not be loaded from GHL yet."
|
||||
}
|
||||
if (!sync.latestSyncAt) {
|
||||
return "No contacts yet."
|
||||
}
|
||||
return "Your contact list stays up to date from forms, calls, and GHL."
|
||||
}
|
||||
|
||||
export default async function AdminContactsPage({ searchParams }: PageProps) {
|
||||
const params = await searchParams
|
||||
const page = Math.max(1, Number.parseInt(params.page || "1", 10) || 1)
|
||||
const search = params.search?.trim() || undefined
|
||||
|
||||
const data = await fetchQuery(api.crm.listAdminContacts, {
|
||||
search,
|
||||
page,
|
||||
limit: 25,
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="space-y-8">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-balance">
|
||||
Contacts
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
All customer contacts in one place.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/admin">
|
||||
<Button variant="outline">Back to Admin</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Sync Status</CardTitle>
|
||||
<CardDescription>{getSyncMessage(data.sync)}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-3 text-sm text-muted-foreground">
|
||||
<Badge variant="outline">{data.sync.overallStatus}</Badge>
|
||||
<span>
|
||||
Last sync: {formatTimestamp(data.sync.latestSyncAt || undefined)}
|
||||
</span>
|
||||
{!data.sync.ghlConfigured ? (
|
||||
<span>GHL is not connected.</span>
|
||||
) : null}
|
||||
{data.sync.stages.contacts.error ? (
|
||||
<span>{data.sync.stages.contacts.error}</span>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ContactRound className="h-5 w-5" />
|
||||
Contact Directory
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Search by name, email, phone, company, or tag.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<form className="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto]">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
name="search"
|
||||
defaultValue={search || ""}
|
||||
placeholder="Search contacts"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit">Filter</Button>
|
||||
</form>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[980px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-3 pr-4 font-medium">Contact</th>
|
||||
<th className="py-3 pr-4 font-medium">Company</th>
|
||||
<th className="py-3 pr-4 font-medium">Status</th>
|
||||
<th className="py-3 pr-4 font-medium">Conversations</th>
|
||||
<th className="py-3 pr-4 font-medium">Leads</th>
|
||||
<th className="py-3 pr-4 font-medium">Last Activity</th>
|
||||
<th className="py-3 font-medium">Open</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={7}
|
||||
className="py-8 text-center text-muted-foreground"
|
||||
>
|
||||
{search
|
||||
? "No contacts matched this search."
|
||||
: getSyncMessage(data.sync)}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.items.map((contact: any) => (
|
||||
<tr
|
||||
key={contact.id}
|
||||
className="border-b align-top last:border-b-0"
|
||||
>
|
||||
<td className="py-3 pr-4">
|
||||
<div className="font-medium">{contact.displayName}</div>
|
||||
{contact.email ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{contact.email}
|
||||
</div>
|
||||
) : null}
|
||||
{contact.phone ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{contact.phone}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
{contact.company || "—"}
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
<Badge variant="secondary">{contact.status}</Badge>
|
||||
</td>
|
||||
<td className="py-3 pr-4">{contact.conversationCount}</td>
|
||||
<td className="py-3 pr-4">{contact.leadCount}</td>
|
||||
<td className="py-3 pr-4">
|
||||
{formatTimestamp(contact.lastActivityAt)}
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<Link href={`/admin/contacts/${contact.id}`}>
|
||||
<Button size="sm" variant="outline">
|
||||
View
|
||||
</Button>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: "Contacts | Admin",
|
||||
description: "View Rocky customer contacts",
|
||||
}
|
||||
19
app/admin/conversations/[id]/page.tsx
Normal file
19
app/admin/conversations/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { redirect } from "next/navigation"
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{
|
||||
id: string
|
||||
}>
|
||||
}
|
||||
|
||||
export default async function AdminConversationDetailRedirect({
|
||||
params,
|
||||
}: PageProps) {
|
||||
const { id } = await params
|
||||
redirect(`/admin/conversations?conversationId=${encodeURIComponent(id)}`)
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: "Conversation Detail | Admin",
|
||||
description: "Open a conversation in the inbox view",
|
||||
}
|
||||
518
app/admin/conversations/page.tsx
Normal file
518
app/admin/conversations/page.tsx
Normal file
|
|
@ -0,0 +1,518 @@
|
|||
import Link from "next/link"
|
||||
import { fetchAction, fetchQuery } from "convex/nextjs"
|
||||
import { MessageSquare, 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 { ScrollArea } from "@/components/ui/scroll-area"
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<{
|
||||
search?: string
|
||||
channel?: "call" | "sms" | "chat" | "unknown"
|
||||
status?: "open" | "closed" | "archived"
|
||||
conversationId?: string
|
||||
error?: string
|
||||
page?: string
|
||||
}>
|
||||
}
|
||||
|
||||
function formatTimestamp(value?: number) {
|
||||
if (!value) {
|
||||
return "—"
|
||||
}
|
||||
|
||||
return new Date(value).toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
function formatSidebarTimestamp(value?: number) {
|
||||
if (!value) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const date = new Date(value)
|
||||
const now = new Date()
|
||||
const sameDay = date.toDateString() === now.toDateString()
|
||||
|
||||
return sameDay
|
||||
? date.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
})
|
||||
: date.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
}
|
||||
|
||||
function formatDuration(value?: number) {
|
||||
if (!value) {
|
||||
return "—"
|
||||
}
|
||||
|
||||
const totalSeconds = Math.max(0, Math.round(value / 1000))
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
return `${minutes}:${String(seconds).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
function getSyncMessage(sync: any) {
|
||||
if (!sync.ghlConfigured) {
|
||||
return "Connect GHL to load contacts and conversations."
|
||||
}
|
||||
if (sync.stages.conversations.status === "running") {
|
||||
return "Conversations are syncing now."
|
||||
}
|
||||
if (sync.stages.conversations.error) {
|
||||
return "Conversations could not be loaded from GHL yet."
|
||||
}
|
||||
if (!sync.latestSyncAt) {
|
||||
return "No conversations yet."
|
||||
}
|
||||
return "Browse contacts and conversations in one inbox."
|
||||
}
|
||||
|
||||
function getInitials(value?: string) {
|
||||
const text = String(value || "").trim()
|
||||
if (!text) {
|
||||
return "RM"
|
||||
}
|
||||
|
||||
const parts = text.split(/\s+/).filter(Boolean)
|
||||
if (parts.length === 1) {
|
||||
return parts[0].slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
return `${parts[0][0] || ""}${parts[1][0] || ""}`.toUpperCase()
|
||||
}
|
||||
|
||||
function buildConversationHref(params: {
|
||||
search?: string
|
||||
channel?: string
|
||||
status?: string
|
||||
conversationId?: string
|
||||
}) {
|
||||
const nextParams = new URLSearchParams()
|
||||
if (params.search) {
|
||||
nextParams.set("search", params.search)
|
||||
}
|
||||
if (params.channel) {
|
||||
nextParams.set("channel", params.channel)
|
||||
}
|
||||
if (params.status) {
|
||||
nextParams.set("status", params.status)
|
||||
}
|
||||
if (params.conversationId) {
|
||||
nextParams.set("conversationId", params.conversationId)
|
||||
}
|
||||
|
||||
const query = nextParams.toString()
|
||||
return query ? `/admin/conversations?${query}` : "/admin/conversations"
|
||||
}
|
||||
|
||||
export default async function AdminConversationsPage({
|
||||
searchParams,
|
||||
}: PageProps) {
|
||||
const params = await searchParams
|
||||
const search = params.search?.trim() || undefined
|
||||
|
||||
const data = await fetchQuery(api.crm.listAdminConversations, {
|
||||
search,
|
||||
page: 1,
|
||||
limit: 100,
|
||||
channel: params.channel,
|
||||
status: params.status,
|
||||
})
|
||||
|
||||
const selectedConversationId =
|
||||
(params.conversationId &&
|
||||
data.items.find((item: any) => item.id === params.conversationId)?.id) ||
|
||||
data.items[0]?.id
|
||||
|
||||
const detail = selectedConversationId
|
||||
? await fetchQuery(api.crm.getAdminConversationDetail, {
|
||||
conversationId: selectedConversationId,
|
||||
})
|
||||
: null
|
||||
|
||||
const hydratedDetail =
|
||||
detail &&
|
||||
detail.messages.length === 0 &&
|
||||
detail.conversation.ghlConversationId
|
||||
? await fetchAction(api.crm.hydrateConversationHistory, {
|
||||
conversationId: detail.conversation.id,
|
||||
}).then(async (result) => {
|
||||
if (result?.imported) {
|
||||
return await fetchQuery(api.crm.getAdminConversationDetail, {
|
||||
conversationId: detail.conversation.id,
|
||||
})
|
||||
}
|
||||
return detail
|
||||
})
|
||||
: detail
|
||||
|
||||
const timeline = hydratedDetail
|
||||
? [
|
||||
...hydratedDetail.messages.map((message: any) => ({
|
||||
id: `message-${message.id}`,
|
||||
type: "message" as const,
|
||||
timestamp: message.sentAt || 0,
|
||||
message,
|
||||
})),
|
||||
...hydratedDetail.recordings.map((recording: any) => ({
|
||||
id: `recording-${recording.id}`,
|
||||
type: "recording" as const,
|
||||
timestamp: recording.startedAt || recording.endedAt || 0,
|
||||
recording,
|
||||
})),
|
||||
].sort((a, b) => a.timestamp - b.timestamp)
|
||||
: []
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold tracking-tight text-balance">
|
||||
Conversations
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Review calls and messages in one inbox.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/admin">
|
||||
<Button variant="outline">Back to Admin</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card className="rounded-[2rem]">
|
||||
<CardContent className="flex flex-wrap items-center gap-3 px-6 py-4 text-sm text-muted-foreground">
|
||||
<Badge variant="outline">{data.sync.overallStatus}</Badge>
|
||||
<span>{getSyncMessage(data.sync)}</span>
|
||||
<span>Last sync: {formatTimestamp(data.sync.latestSyncAt || undefined)}</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden rounded-[2rem] p-0">
|
||||
<div className="grid min-h-[720px] lg:grid-cols-[360px_minmax(0,1fr)]">
|
||||
<div className="border-b bg-white lg:border-b-0 lg:border-r">
|
||||
<div className="space-y-4 border-b px-5 py-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<h2 className="font-semibold">Conversation Inbox</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Search and pick a conversation to review.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="space-y-3">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
name="search"
|
||||
defaultValue={search || ""}
|
||||
placeholder="Search contacts or messages"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2">
|
||||
<select
|
||||
name="channel"
|
||||
defaultValue={params.channel || ""}
|
||||
className="flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">All channels</option>
|
||||
<option value="call">Call</option>
|
||||
<option value="sms">SMS</option>
|
||||
<option value="chat">Chat</option>
|
||||
<option value="unknown">Unknown</option>
|
||||
</select>
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={params.status || ""}
|
||||
className="flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">All statuses</option>
|
||||
<option value="open">Open</option>
|
||||
<option value="closed">Closed</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
<Button type="submit">Filter</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[520px] lg:h-[640px]">
|
||||
<div className="divide-y">
|
||||
{data.items.length === 0 ? (
|
||||
<div className="px-5 py-8 text-sm text-muted-foreground">
|
||||
{search || params.channel || params.status
|
||||
? "No conversations matched this search."
|
||||
: getSyncMessage(data.sync)}
|
||||
</div>
|
||||
) : (
|
||||
data.items.map((conversation: any) => {
|
||||
const isSelected = conversation.id === selectedConversationId
|
||||
return (
|
||||
<Link
|
||||
key={conversation.id}
|
||||
href={buildConversationHref({
|
||||
search,
|
||||
channel: params.channel,
|
||||
status: params.status,
|
||||
conversationId: conversation.id,
|
||||
})}
|
||||
className={[
|
||||
"flex gap-3 px-5 py-4 transition-colors",
|
||||
isSelected
|
||||
? "bg-primary/5 ring-1 ring-inset ring-primary/20"
|
||||
: "hover:bg-muted/40",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">
|
||||
{getInitials(conversation.displayName)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">
|
||||
{conversation.displayName}
|
||||
</p>
|
||||
{conversation.secondaryLine ? (
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{conversation.secondaryLine}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{formatSidebarTimestamp(conversation.lastMessageAt)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-2 text-sm text-muted-foreground">
|
||||
{conversation.lastMessagePreview ||
|
||||
"No messages or call notes yet."}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{conversation.channel}</Badge>
|
||||
<Badge variant="secondary">{conversation.status}</Badge>
|
||||
{conversation.recordingCount ? (
|
||||
<Badge variant="outline">
|
||||
{conversation.recordingCount} recording
|
||||
{conversation.recordingCount === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#faf8f3]">
|
||||
{hydratedDetail ? (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b bg-white px-6 py-5">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold">
|
||||
{hydratedDetail.contact?.name ||
|
||||
hydratedDetail.conversation.title ||
|
||||
"Conversation"}
|
||||
</h2>
|
||||
{hydratedDetail.contact?.secondaryLine ||
|
||||
hydratedDetail.contact?.email ||
|
||||
hydratedDetail.contact?.phone ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{hydratedDetail.contact?.secondaryLine ||
|
||||
hydratedDetail.contact?.phone ||
|
||||
hydratedDetail.contact?.email}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">
|
||||
{hydratedDetail.conversation.channel}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
{hydratedDetail.conversation.status}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{timeline.filter((item) => item.type === "message").length}{" "}
|
||||
messages
|
||||
</Badge>
|
||||
{hydratedDetail.recordings.length ? (
|
||||
<Badge variant="outline">
|
||||
{hydratedDetail.recordings.length} recording
|
||||
{hydratedDetail.recordings.length === 1 ? "" : "s"}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Last activity:{" "}
|
||||
{formatTimestamp(hydratedDetail.conversation.lastMessageAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||
<form
|
||||
action={`/api/admin/conversations/${hydratedDetail.conversation.id}/sync`}
|
||||
method="post"
|
||||
>
|
||||
<Button type="submit" variant="outline" size="sm">
|
||||
Refresh history
|
||||
</Button>
|
||||
</form>
|
||||
{params.error === "send" ? (
|
||||
<p className="text-sm text-destructive">
|
||||
Rocky could not send that message through GHL.
|
||||
</p>
|
||||
) : null}
|
||||
{params.error === "sync" ? (
|
||||
<p className="text-sm text-destructive">
|
||||
Rocky could not refresh that conversation from GHL.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[520px] px-4 py-5 lg:h-[640px] lg:px-6">
|
||||
<div className="space-y-4 pb-2">
|
||||
{timeline.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed bg-white/70 px-6 py-10 text-center text-sm text-muted-foreground">
|
||||
No messages or recordings have been mirrored into this
|
||||
conversation yet. Use refresh history to pull the latest
|
||||
thread from GHL.
|
||||
</div>
|
||||
) : (
|
||||
timeline.map((item: any) => {
|
||||
if (item.type === "recording") {
|
||||
const recording = item.recording
|
||||
return (
|
||||
<div key={item.id} className="max-w-2xl rounded-2xl border bg-white p-4 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<Phone className="h-4 w-4 text-muted-foreground" />
|
||||
Call recording
|
||||
<Badge variant="outline" className="ml-2">
|
||||
{recording.recordingStatus || "recording"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-4 text-xs text-muted-foreground">
|
||||
<span>{formatTimestamp(recording.startedAt)}</span>
|
||||
<span>Duration: {formatDuration(recording.durationMs)}</span>
|
||||
</div>
|
||||
{recording.recordingUrl ? (
|
||||
<div className="mt-3">
|
||||
<a
|
||||
href={recording.recordingUrl}
|
||||
target="_blank"
|
||||
className="text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
Open recording
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
{recording.transcriptionText ? (
|
||||
<div className="mt-3 rounded-xl border bg-muted/30 p-3 text-sm whitespace-pre-wrap text-foreground/90">
|
||||
{recording.transcriptionText}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const message = item.message
|
||||
const isOutbound = message.direction === "outbound"
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`flex ${isOutbound ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
<div
|
||||
className={[
|
||||
"max-w-[85%] rounded-3xl px-4 py-3 shadow-sm",
|
||||
isOutbound
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "border bg-white",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2 text-[11px] uppercase tracking-wide opacity-70">
|
||||
<span>{message.channel}</span>
|
||||
<span>{message.direction}</span>
|
||||
{message.status ? <span>{message.status}</span> : null}
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap text-sm leading-6">
|
||||
{message.body}
|
||||
</p>
|
||||
<div className="mt-2 text-right text-xs opacity-70">
|
||||
{formatTimestamp(message.sentAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div className="border-t bg-white px-4 py-4 lg:px-6">
|
||||
<form
|
||||
action={`/api/admin/conversations/${hydratedDetail.conversation.id}/messages`}
|
||||
method="post"
|
||||
className="space-y-3"
|
||||
>
|
||||
<textarea
|
||||
name="body"
|
||||
rows={4}
|
||||
placeholder="Reply to this conversation"
|
||||
className="border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 min-h-24 w-full rounded-2xl border bg-background px-4 py-3 text-sm shadow-sm outline-none focus-visible:ring-[3px]"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Sends through GHL and mirrors the reply back into Rocky.
|
||||
</p>
|
||||
<Button type="submit">Send message</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full min-h-[520px] items-center justify-center px-6 py-16">
|
||||
<div className="max-w-md text-center">
|
||||
<h2 className="text-2xl font-semibold">No conversation selected</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Choose a conversation from the left to open the full thread.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: "Conversations | Admin",
|
||||
description: "View Rocky customer conversations",
|
||||
}
|
||||
|
|
@ -1,5 +1,9 @@
|
|||
import { redirect } from "next/navigation";
|
||||
import { isAdminUiEnabled } from "@/lib/server/admin-auth";
|
||||
import Link from "next/link"
|
||||
import { redirect } from "next/navigation"
|
||||
import {
|
||||
getAdminUserFromCookies,
|
||||
isAdminUiEnabled,
|
||||
} from "@/lib/server/admin-auth"
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
|
|
@ -7,8 +11,32 @@ export default async function AdminLayout({
|
|||
children: React.ReactNode
|
||||
}) {
|
||||
if (!isAdminUiEnabled()) {
|
||||
redirect("/");
|
||||
redirect("/")
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
const adminUser = await getAdminUserFromCookies()
|
||||
if (!adminUser) {
|
||||
redirect("/sign-in")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/30">
|
||||
<div className="border-b bg-background">
|
||||
<div className="container mx-auto flex items-center justify-between px-4 py-3 text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link href="/admin" className="font-semibold hover:text-primary">
|
||||
Rocky Admin
|
||||
</Link>
|
||||
<span className="text-muted-foreground">{adminUser.email}</span>
|
||||
</div>
|
||||
<form action="/api/admin/auth/logout" method="post">
|
||||
<button className="text-muted-foreground hover:text-foreground">
|
||||
Sign out
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { OrderManagement } from '@/components/order-management'
|
||||
import { OrderManagement } from "@/components/order-management"
|
||||
|
||||
export default function AdminOrdersPage() {
|
||||
return (
|
||||
|
|
@ -9,6 +9,6 @@ export default function AdminOrdersPage() {
|
|||
}
|
||||
|
||||
export const metadata = {
|
||||
title: 'Order Management | Admin',
|
||||
description: 'View and manage customer orders',
|
||||
title: "Order Management | Admin",
|
||||
description: "View and manage customer orders",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,32 @@
|
|||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
ShoppingCart,
|
||||
Package,
|
||||
Users,
|
||||
TrendingUp,
|
||||
DollarSign,
|
||||
import Link from "next/link"
|
||||
import { fetchQuery } from "convex/nextjs"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
ShoppingCart,
|
||||
Package,
|
||||
Users,
|
||||
TrendingUp,
|
||||
DollarSign,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
Truck,
|
||||
AlertTriangle,
|
||||
Settings,
|
||||
BarChart3,
|
||||
Phone
|
||||
} from 'lucide-react'
|
||||
import { fetchAllProducts } from '@/lib/stripe/products'
|
||||
Phone,
|
||||
MessageSquare,
|
||||
ContactRound,
|
||||
} from "lucide-react"
|
||||
import { fetchAllProducts } from "@/lib/stripe/products"
|
||||
|
||||
// Mock analytics data for demo
|
||||
const mockAnalytics = {
|
||||
|
|
@ -27,7 +37,7 @@ const mockAnalytics = {
|
|||
lowStockProducts: 3,
|
||||
avgOrderValue: 311.46,
|
||||
conversionRate: 2.8,
|
||||
monthlyGrowth: 15.2
|
||||
monthlyGrowth: 15.2,
|
||||
}
|
||||
|
||||
async function getProductsCount() {
|
||||
|
|
@ -41,7 +51,7 @@ async function getProductsCount() {
|
|||
|
||||
async function getOrdersCount() {
|
||||
try {
|
||||
const response = await fetch('/api/orders')
|
||||
const response = await fetch("/api/orders")
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
return data.pagination.total || 0
|
||||
|
|
@ -50,130 +60,145 @@ async function getOrdersCount() {
|
|||
return mockAnalytics.totalOrders
|
||||
}
|
||||
|
||||
function formatTimestamp(value?: number | null) {
|
||||
if (!value) {
|
||||
return "—"
|
||||
}
|
||||
|
||||
return new Date(value).toLocaleString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
export default async function AdminDashboard() {
|
||||
const [productsCount, ordersCount] = await Promise.all([
|
||||
const [productsCount, ordersCount, sync] = await Promise.all([
|
||||
getProductsCount(),
|
||||
getOrdersCount()
|
||||
getOrdersCount(),
|
||||
fetchQuery(api.crm.getAdminSyncOverview, {}),
|
||||
])
|
||||
|
||||
const dashboardCards = [
|
||||
{
|
||||
title: 'Total Revenue',
|
||||
title: "Total Revenue",
|
||||
value: `$${mockAnalytics.totalRevenue.toLocaleString()}`,
|
||||
description: 'Total revenue from all orders',
|
||||
description: "Total revenue from all orders",
|
||||
icon: DollarSign,
|
||||
trend: '+15.2%',
|
||||
trend: "+15.2%",
|
||||
trendPositive: true,
|
||||
color: 'text-green-600'
|
||||
color: "text-green-600",
|
||||
},
|
||||
{
|
||||
title: 'Total Orders',
|
||||
title: "Total Orders",
|
||||
value: mockAnalytics.totalOrders.toString(),
|
||||
description: 'Total number of orders',
|
||||
description: "Total number of orders",
|
||||
icon: ShoppingCart,
|
||||
trend: '+12.8%',
|
||||
trend: "+12.8%",
|
||||
trendPositive: true,
|
||||
color: 'text-blue-600'
|
||||
color: "text-blue-600",
|
||||
},
|
||||
{
|
||||
title: 'Products',
|
||||
title: "Products",
|
||||
value: productsCount.toString(),
|
||||
description: 'Active products in inventory',
|
||||
description: "Active products in inventory",
|
||||
icon: Package,
|
||||
trend: '+5',
|
||||
trend: "+5",
|
||||
trendPositive: true,
|
||||
color: 'text-purple-600'
|
||||
color: "text-purple-600",
|
||||
},
|
||||
{
|
||||
title: 'Pending Orders',
|
||||
title: "Pending Orders",
|
||||
value: mockAnalytics.pendingOrders.toString(),
|
||||
description: 'Orders awaiting processing',
|
||||
description: "Orders awaiting processing",
|
||||
icon: Clock,
|
||||
trend: '-3',
|
||||
trend: "-3",
|
||||
trendPositive: false,
|
||||
color: 'text-orange-600'
|
||||
}
|
||||
color: "text-orange-600",
|
||||
},
|
||||
]
|
||||
|
||||
const quickStats = [
|
||||
{
|
||||
title: 'Average Order Value',
|
||||
title: "Average Order Value",
|
||||
value: `$${mockAnalytics.avgOrderValue.toFixed(2)}`,
|
||||
description: 'Average value per order',
|
||||
icon: TrendingUp
|
||||
description: "Average value per order",
|
||||
icon: TrendingUp,
|
||||
},
|
||||
{
|
||||
title: 'Conversion Rate',
|
||||
title: "Conversion Rate",
|
||||
value: `${mockAnalytics.conversionRate}%`,
|
||||
description: 'Visitors to orders ratio',
|
||||
icon: Users
|
||||
description: "Visitors to orders ratio",
|
||||
icon: Users,
|
||||
},
|
||||
{
|
||||
title: 'Monthly Growth',
|
||||
title: "Monthly Growth",
|
||||
value: `${mockAnalytics.monthlyGrowth}%`,
|
||||
description: 'Revenue growth this month',
|
||||
icon: BarChart3
|
||||
description: "Revenue growth this month",
|
||||
icon: BarChart3,
|
||||
},
|
||||
{
|
||||
title: 'Low Stock Alert',
|
||||
title: "Low Stock Alert",
|
||||
value: mockAnalytics.lowStockProducts.toString(),
|
||||
description: 'Products need restocking',
|
||||
icon: AlertTriangle
|
||||
}
|
||||
description: "Products need restocking",
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
]
|
||||
|
||||
const recentOrders = [
|
||||
{
|
||||
id: 'ORD-001234',
|
||||
customer: 'john.doe@email.com',
|
||||
id: "ORD-001234",
|
||||
customer: "john.doe@email.com",
|
||||
amount: 2799.98,
|
||||
status: 'paid',
|
||||
date: '2024-01-15 10:30'
|
||||
status: "paid",
|
||||
date: "2024-01-15 10:30",
|
||||
},
|
||||
{
|
||||
id: 'ORD-001233',
|
||||
customer: 'jane.smith@email.com',
|
||||
id: "ORD-001233",
|
||||
customer: "jane.smith@email.com",
|
||||
amount: 1499.99,
|
||||
status: 'fulfilled',
|
||||
date: '2024-01-15 09:45'
|
||||
status: "fulfilled",
|
||||
date: "2024-01-15 09:45",
|
||||
},
|
||||
{
|
||||
id: 'ORD-001232',
|
||||
customer: 'bob.johnson@email.com',
|
||||
id: "ORD-001232",
|
||||
customer: "bob.johnson@email.com",
|
||||
amount: 899.97,
|
||||
status: 'pending',
|
||||
date: '2024-01-15 08:20'
|
||||
status: "pending",
|
||||
date: "2024-01-15 08:20",
|
||||
},
|
||||
{
|
||||
id: 'ORD-001231',
|
||||
customer: 'alice.wilson@email.com',
|
||||
id: "ORD-001231",
|
||||
customer: "alice.wilson@email.com",
|
||||
amount: 3499.99,
|
||||
status: 'cancelled',
|
||||
date: '2024-01-14 16:15'
|
||||
}
|
||||
status: "cancelled",
|
||||
date: "2024-01-14 16:15",
|
||||
},
|
||||
]
|
||||
|
||||
const popularProducts = [
|
||||
{
|
||||
name: 'SEAGA HY900 Vending Machine',
|
||||
name: "SEAGA HY900 Vending Machine",
|
||||
orders: 45,
|
||||
revenue: 112499.55
|
||||
revenue: 112499.55,
|
||||
},
|
||||
{
|
||||
name: 'Vending Machine Stand',
|
||||
name: "Vending Machine Stand",
|
||||
orders: 38,
|
||||
revenue: 11399.62
|
||||
revenue: 11399.62,
|
||||
},
|
||||
{
|
||||
name: 'Snack Vending Machine Combo',
|
||||
name: "Snack Vending Machine Combo",
|
||||
orders: 23,
|
||||
revenue: 45999.77
|
||||
revenue: 45999.77,
|
||||
},
|
||||
{
|
||||
name: 'Drink Vending Machine',
|
||||
name: "Drink Vending Machine",
|
||||
orders: 19,
|
||||
revenue: 37999.81
|
||||
}
|
||||
revenue: 37999.81,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
|
|
@ -182,12 +207,26 @@ export default async function AdminDashboard() {
|
|||
{/* Header */}
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-balance">Admin Dashboard</h1>
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-balance">
|
||||
Admin Dashboard
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Overview of your store performance and management tools
|
||||
Manage orders, contacts, conversations, and calls
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link href="/admin/contacts">
|
||||
<Button variant="outline">
|
||||
<ContactRound className="h-4 w-4 mr-2" />
|
||||
Contacts
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/admin/conversations">
|
||||
<Button variant="outline">
|
||||
<MessageSquare className="h-4 w-4 mr-2" />
|
||||
Conversations
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/admin/calls">
|
||||
<Button variant="outline">
|
||||
<Phone className="h-4 w-4 mr-2" />
|
||||
|
|
@ -204,6 +243,25 @@ export default async function AdminDashboard() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>CRM Sync Status</CardTitle>
|
||||
<CardDescription>
|
||||
{!sync.ghlConfigured
|
||||
? "Connect GHL to load contacts and conversations."
|
||||
: "Customer data is mirrored here from GHL and your call flows."}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-wrap gap-3 text-sm text-muted-foreground">
|
||||
<Badge variant="outline">{sync.overallStatus}</Badge>
|
||||
<span>Last sync: {formatTimestamp(sync.latestSyncAt)}</span>
|
||||
{!sync.ghlConfigured ? <span>GHL is not connected.</span> : null}
|
||||
{!sync.livekitConfigured ? (
|
||||
<span>LiveKit recordings are not connected yet.</span>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Main Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{dashboardCards.map((card, index) => {
|
||||
|
|
@ -220,7 +278,7 @@ export default async function AdminDashboard() {
|
|||
<div className="text-2xl font-bold">{card.value}</div>
|
||||
<div className="flex items-center gap-1 mt-2">
|
||||
<span className={`text-sm ${card.color}`}>
|
||||
{card.trend} {card.trendPositive ? '↑' : '↓'}
|
||||
{card.trend} {card.trendPositive ? "↑" : "↓"}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
from last month
|
||||
|
|
@ -266,7 +324,9 @@ export default async function AdminDashboard() {
|
|||
<CardTitle className="flex items-center justify-between">
|
||||
Recent Orders
|
||||
<Link href="/admin/orders">
|
||||
<Button variant="outline" size="sm">View All</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
View All
|
||||
</Button>
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
|
|
@ -276,7 +336,10 @@ export default async function AdminDashboard() {
|
|||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{recentOrders.map((order) => (
|
||||
<div key={order.id} className="flex items-center justify-between py-3 border-b last:border-b-0">
|
||||
<div
|
||||
key={order.id}
|
||||
className="flex items-center justify-between py-3 border-b last:border-b-0"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium">{order.id}</div>
|
||||
|
|
@ -289,16 +352,23 @@ export default async function AdminDashboard() {
|
|||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-medium">${order.amount.toFixed(2)}</div>
|
||||
<div className="font-medium">
|
||||
${order.amount.toFixed(2)}
|
||||
</div>
|
||||
<Badge
|
||||
variant={
|
||||
order.status === 'paid' ? 'default' :
|
||||
order.status === 'fulfilled' ? 'default' :
|
||||
order.status === 'pending' ? 'secondary' : 'destructive'
|
||||
order.status === "paid"
|
||||
? "default"
|
||||
: order.status === "fulfilled"
|
||||
? "default"
|
||||
: order.status === "pending"
|
||||
? "secondary"
|
||||
: "destructive"
|
||||
}
|
||||
className="mt-1"
|
||||
>
|
||||
{order.status.charAt(0).toUpperCase() + order.status.slice(1)}
|
||||
{order.status.charAt(0).toUpperCase() +
|
||||
order.status.slice(1)}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -313,17 +383,20 @@ export default async function AdminDashboard() {
|
|||
<CardTitle className="flex items-center justify-between">
|
||||
Popular Products
|
||||
<Link href="/admin/products">
|
||||
<Button variant="outline" size="sm">View All</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
View All
|
||||
</Button>
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Top-selling products this month
|
||||
</CardDescription>
|
||||
<CardDescription>Top-selling products this month</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{popularProducts.map((product, index) => (
|
||||
<div key={index} className="flex items-center justify-between py-3 border-b last:border-b-0">
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between py-3 border-b last:border-b-0"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-md bg-muted flex items-center justify-center text-xs font-bold text-muted-foreground">
|
||||
{index + 1}
|
||||
|
|
@ -338,7 +411,9 @@ export default async function AdminDashboard() {
|
|||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-medium">${product.revenue.toLocaleString()}</div>
|
||||
<div className="font-medium">
|
||||
${product.revenue.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
${(product.revenue / product.orders).toFixed(2)} avg
|
||||
</div>
|
||||
|
|
@ -371,7 +446,7 @@ export default async function AdminDashboard() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
|
||||
<Link href="/admin/products">
|
||||
<Card className="h-full cursor-pointer hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-6 flex flex-col items-center text-center">
|
||||
|
|
@ -383,7 +458,7 @@ export default async function AdminDashboard() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
|
||||
<Link href="/orders">
|
||||
<Card className="h-full cursor-pointer hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-6 flex flex-col items-center text-center">
|
||||
|
|
@ -395,7 +470,7 @@ export default async function AdminDashboard() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
|
||||
<Card className="h-full hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-6 flex flex-col items-center text-center">
|
||||
<CheckCircle className="h-8 w-8 text-orange-600 mb-3" />
|
||||
|
|
@ -414,6 +489,7 @@ export default async function AdminDashboard() {
|
|||
}
|
||||
|
||||
export const metadata = {
|
||||
title: 'Admin Dashboard | Rocky Mountain Vending',
|
||||
description: 'Administrative dashboard for managing your vending machine business',
|
||||
title: "Admin Dashboard | Rocky Mountain Vending",
|
||||
description:
|
||||
"Administrative dashboard for managing your vending machine business",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { ProductAdmin } from '@/components/product-admin'
|
||||
import { ProductAdmin } from "@/components/product-admin"
|
||||
|
||||
export default function AdminProductsPage() {
|
||||
return (
|
||||
|
|
@ -9,6 +9,6 @@ export default function AdminProductsPage() {
|
|||
}
|
||||
|
||||
export const metadata = {
|
||||
title: 'Product Management | Admin',
|
||||
description: 'Manage your Stripe products and inventory',
|
||||
title: "Product Management | Admin",
|
||||
description: "Manage your Stripe products and inventory",
|
||||
}
|
||||
|
|
|
|||
70
app/api/admin/auth/login/route.ts
Normal file
70
app/api/admin/auth/login/route.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { headers } from "next/headers"
|
||||
import { NextResponse } from "next/server"
|
||||
import {
|
||||
ADMIN_SESSION_COOKIE,
|
||||
createAdminSession,
|
||||
isAdminCredentialLoginConfigured,
|
||||
isAdminCredentialMatch,
|
||||
} from "@/lib/server/admin-auth"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!isAdminCredentialLoginConfigured()) {
|
||||
return NextResponse.redirect(
|
||||
new URL("/sign-in?error=config", await getPublicOrigin(request))
|
||||
)
|
||||
}
|
||||
|
||||
const formData = await request.formData()
|
||||
const email = String(formData.get("email") || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
const password = String(formData.get("password") || "")
|
||||
|
||||
if (!isAdminCredentialMatch(email, password)) {
|
||||
return NextResponse.redirect(
|
||||
new URL("/sign-in?error=invalid", await getPublicOrigin(request))
|
||||
)
|
||||
}
|
||||
|
||||
const session = await createAdminSession(email)
|
||||
const response = NextResponse.redirect(
|
||||
new URL("/admin", await getPublicOrigin(request))
|
||||
)
|
||||
response.cookies.set(ADMIN_SESSION_COOKIE, session.token, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: true,
|
||||
path: "/",
|
||||
expires: new Date(session.expiresAt),
|
||||
})
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
async function getPublicOrigin(request: Request) {
|
||||
const headerStore = await headers()
|
||||
const origin = headerStore.get("origin")
|
||||
if (origin) {
|
||||
return origin
|
||||
}
|
||||
|
||||
const referer = headerStore.get("referer")
|
||||
if (referer) {
|
||||
return new URL(referer).origin
|
||||
}
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL
|
||||
if (siteUrl) {
|
||||
return siteUrl
|
||||
}
|
||||
|
||||
const forwardedProto = headerStore.get("x-forwarded-proto")
|
||||
const forwardedHost = headerStore.get("x-forwarded-host")
|
||||
const host = forwardedHost || headerStore.get("host")
|
||||
|
||||
if (host) {
|
||||
return `${forwardedProto || "https"}://${host}`
|
||||
}
|
||||
|
||||
return new URL(request.url).origin
|
||||
}
|
||||
53
app/api/admin/auth/logout/route.ts
Normal file
53
app/api/admin/auth/logout/route.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { cookies, headers } from "next/headers"
|
||||
import {
|
||||
ADMIN_SESSION_COOKIE,
|
||||
destroyAdminSession,
|
||||
} from "@/lib/server/admin-auth"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const cookieStore = await cookies()
|
||||
const rawToken = cookieStore.get(ADMIN_SESSION_COOKIE)?.value || null
|
||||
await destroyAdminSession(rawToken)
|
||||
|
||||
const response = NextResponse.redirect(
|
||||
new URL("/sign-in", await getPublicOrigin(request))
|
||||
)
|
||||
response.cookies.set(ADMIN_SESSION_COOKIE, "", {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: true,
|
||||
path: "/",
|
||||
expires: new Date(0),
|
||||
})
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
async function getPublicOrigin(request: Request) {
|
||||
const headerStore = await headers()
|
||||
const origin = headerStore.get("origin")
|
||||
if (origin) {
|
||||
return origin
|
||||
}
|
||||
|
||||
const referer = headerStore.get("referer")
|
||||
if (referer) {
|
||||
return new URL(referer).origin
|
||||
}
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL
|
||||
if (siteUrl) {
|
||||
return siteUrl
|
||||
}
|
||||
|
||||
const forwardedProto = headerStore.get("x-forwarded-proto")
|
||||
const forwardedHost = headerStore.get("x-forwarded-host")
|
||||
const host = forwardedHost || headerStore.get("host")
|
||||
|
||||
if (host) {
|
||||
return `${forwardedProto || "https"}://${host}`
|
||||
}
|
||||
|
||||
return new URL(request.url).origin
|
||||
}
|
||||
|
|
@ -1,33 +1,39 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { fetchQuery } from "convex/nextjs";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { requireAdminToken } from "@/lib/server/admin-auth";
|
||||
import { NextResponse } from "next/server"
|
||||
import { fetchQuery } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requireAdminToken } from "@/lib/server/admin-auth"
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
};
|
||||
id: string
|
||||
}>
|
||||
}
|
||||
|
||||
export async function GET(request: Request, { params }: RouteContext) {
|
||||
const authError = requireAdminToken(request);
|
||||
const authError = requireAdminToken(request)
|
||||
if (authError) {
|
||||
return authError;
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { id } = await params
|
||||
const detail = await fetchQuery(api.voiceSessions.getAdminPhoneCallDetail, {
|
||||
callId: id,
|
||||
});
|
||||
})
|
||||
|
||||
if (!detail) {
|
||||
return NextResponse.json({ error: "Phone call not found" }, { status: 404 });
|
||||
return NextResponse.json(
|
||||
{ error: "Phone call not found" },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(detail);
|
||||
return NextResponse.json(detail)
|
||||
} catch (error) {
|
||||
console.error("Failed to load admin phone call detail:", error);
|
||||
return NextResponse.json({ error: "Failed to load phone call detail" }, { status: 500 });
|
||||
console.error("Failed to load admin phone call detail:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to load phone call detail" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,31 +1,37 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { fetchQuery } from "convex/nextjs";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { requireAdminToken } from "@/lib/server/admin-auth";
|
||||
import { NextResponse } from "next/server"
|
||||
import { fetchQuery } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requireAdminToken } from "@/lib/server/admin-auth"
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authError = requireAdminToken(request);
|
||||
const authError = requireAdminToken(request)
|
||||
if (authError) {
|
||||
return authError;
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const search = searchParams.get("search")?.trim() || undefined;
|
||||
const status = searchParams.get("status");
|
||||
const page = Number.parseInt(searchParams.get("page") || "1", 10) || 1;
|
||||
const limit = Number.parseInt(searchParams.get("limit") || "25", 10) || 25;
|
||||
const { searchParams } = new URL(request.url)
|
||||
const search = searchParams.get("search")?.trim() || undefined
|
||||
const status = searchParams.get("status")
|
||||
const page = Number.parseInt(searchParams.get("page") || "1", 10) || 1
|
||||
const limit = Number.parseInt(searchParams.get("limit") || "25", 10) || 25
|
||||
|
||||
const data = await fetchQuery(api.voiceSessions.listAdminPhoneCalls, {
|
||||
search,
|
||||
status: status === "started" || status === "completed" || status === "failed" ? status : undefined,
|
||||
status:
|
||||
status === "started" || status === "completed" || status === "failed"
|
||||
? status
|
||||
: undefined,
|
||||
page,
|
||||
limit,
|
||||
});
|
||||
})
|
||||
|
||||
return NextResponse.json(data);
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error("Failed to load admin phone calls:", error);
|
||||
return NextResponse.json({ error: "Failed to load phone calls" }, { status: 500 });
|
||||
console.error("Failed to load admin phone calls:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to load phone calls" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
36
app/api/admin/contacts/[id]/route.ts
Normal file
36
app/api/admin/contacts/[id]/route.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchQuery } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requireAdminToken } from "@/lib/server/admin-auth"
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{
|
||||
id: string
|
||||
}>
|
||||
}
|
||||
|
||||
export async function GET(request: Request, { params }: RouteContext) {
|
||||
const authError = requireAdminToken(request)
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params
|
||||
const detail = await fetchQuery(api.crm.getAdminContactDetail, {
|
||||
contactId: id,
|
||||
})
|
||||
|
||||
if (!detail) {
|
||||
return NextResponse.json({ error: "Contact not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json(detail)
|
||||
} catch (error) {
|
||||
console.error("Failed to load admin contact detail:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to load contact detail" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
32
app/api/admin/contacts/route.ts
Normal file
32
app/api/admin/contacts/route.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchQuery } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requireAdminToken } from "@/lib/server/admin-auth"
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authError = requireAdminToken(request)
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const search = searchParams.get("search")?.trim() || undefined
|
||||
const page = Number.parseInt(searchParams.get("page") || "1", 10) || 1
|
||||
const limit = Number.parseInt(searchParams.get("limit") || "25", 10) || 25
|
||||
|
||||
const data = await fetchQuery(api.crm.listAdminContacts, {
|
||||
search,
|
||||
page,
|
||||
limit,
|
||||
})
|
||||
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error("Failed to load admin contacts:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to load contacts" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
49
app/api/admin/conversations/[id]/messages/route.ts
Normal file
49
app/api/admin/conversations/[id]/messages/route.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchAction } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requireAdminSession } from "@/lib/server/admin-auth"
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{
|
||||
id: string
|
||||
}>
|
||||
}
|
||||
|
||||
export async function POST(request: Request, { params }: RouteContext) {
|
||||
const adminUser = await requireAdminSession(request)
|
||||
if (!adminUser) {
|
||||
return NextResponse.redirect(new URL("/sign-in", request.url))
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
const formData = await request.formData()
|
||||
const body = String(formData.get("body") || "").trim()
|
||||
|
||||
if (!body) {
|
||||
return NextResponse.redirect(
|
||||
new URL(`/admin/conversations?conversationId=${encodeURIComponent(id)}`, request.url)
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await fetchAction(api.crm.sendAdminConversationMessage, {
|
||||
conversationId: id,
|
||||
body,
|
||||
})
|
||||
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/admin/conversations?conversationId=${encodeURIComponent(id)}`,
|
||||
request.url
|
||||
)
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Failed to send admin conversation message:", error)
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/admin/conversations?conversationId=${encodeURIComponent(id)}&error=send`,
|
||||
request.url
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
39
app/api/admin/conversations/[id]/route.ts
Normal file
39
app/api/admin/conversations/[id]/route.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchQuery } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requireAdminToken } from "@/lib/server/admin-auth"
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{
|
||||
id: string
|
||||
}>
|
||||
}
|
||||
|
||||
export async function GET(request: Request, { params }: RouteContext) {
|
||||
const authError = requireAdminToken(request)
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await params
|
||||
const detail = await fetchQuery(api.crm.getAdminConversationDetail, {
|
||||
conversationId: id,
|
||||
})
|
||||
|
||||
if (!detail) {
|
||||
return NextResponse.json(
|
||||
{ error: "Conversation not found" },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(detail)
|
||||
} catch (error) {
|
||||
console.error("Failed to load admin conversation detail:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to load conversation detail" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
40
app/api/admin/conversations/[id]/sync/route.ts
Normal file
40
app/api/admin/conversations/[id]/sync/route.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchAction } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requireAdminSession } from "@/lib/server/admin-auth"
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{
|
||||
id: string
|
||||
}>
|
||||
}
|
||||
|
||||
export async function POST(request: Request, { params }: RouteContext) {
|
||||
const adminUser = await requireAdminSession(request)
|
||||
if (!adminUser) {
|
||||
return NextResponse.redirect(new URL("/sign-in", request.url))
|
||||
}
|
||||
|
||||
const { id } = await params
|
||||
|
||||
try {
|
||||
await fetchAction(api.crm.hydrateConversationHistory, {
|
||||
conversationId: id,
|
||||
})
|
||||
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/admin/conversations?conversationId=${encodeURIComponent(id)}`,
|
||||
request.url
|
||||
)
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh conversation history:", error)
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
`/admin/conversations?conversationId=${encodeURIComponent(id)}&error=sync`,
|
||||
request.url
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
45
app/api/admin/conversations/route.ts
Normal file
45
app/api/admin/conversations/route.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchQuery } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requireAdminToken } from "@/lib/server/admin-auth"
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authError = requireAdminToken(request)
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const search = searchParams.get("search")?.trim() || undefined
|
||||
const page = Number.parseInt(searchParams.get("page") || "1", 10) || 1
|
||||
const limit = Number.parseInt(searchParams.get("limit") || "25", 10) || 25
|
||||
const channel = searchParams.get("channel")
|
||||
const status = searchParams.get("status")
|
||||
|
||||
const data = await fetchQuery(api.crm.listAdminConversations, {
|
||||
search,
|
||||
page,
|
||||
limit,
|
||||
channel:
|
||||
channel === "call" ||
|
||||
channel === "sms" ||
|
||||
channel === "chat" ||
|
||||
channel === "unknown"
|
||||
? channel
|
||||
: undefined,
|
||||
status:
|
||||
status === "open" || status === "closed" || status === "archived"
|
||||
? status
|
||||
: undefined,
|
||||
})
|
||||
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error("Failed to load admin conversations:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to load conversations" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
31
app/api/admin/ebay/refresh/route.ts
Normal file
31
app/api/admin/ebay/refresh/route.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchAction } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requireAdminToken } from "@/lib/server/admin-auth"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = requireAdminToken(request)
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await fetchAction(api.ebay.refreshCache, {
|
||||
reason: "admin",
|
||||
force: true,
|
||||
})
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh eBay cache:", error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to refresh eBay cache",
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
39
app/api/admin/ghl/sync/route.ts
Normal file
39
app/api/admin/ghl/sync/route.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchAction } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requireAdminToken } from "@/lib/server/admin-auth"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = requireAdminToken(request)
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const result = await fetchAction(api.crm.runGhlMirror, {
|
||||
reason: "admin",
|
||||
forceFullBackfill: Boolean(body.forceFullBackfill),
|
||||
maxPagesPerRun:
|
||||
typeof body.maxPagesPerRun === "number" ? body.maxPagesPerRun : undefined,
|
||||
contactsLimit:
|
||||
typeof body.contactsLimit === "number" ? body.contactsLimit : undefined,
|
||||
messagesLimit:
|
||||
typeof body.messagesLimit === "number" ? body.messagesLimit : undefined,
|
||||
recordingsPageSize:
|
||||
typeof body.recordingsPageSize === "number"
|
||||
? body.recordingsPageSize
|
||||
: undefined,
|
||||
})
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
console.error("Failed to run admin GHL sync:", error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : "Failed to run GHL sync",
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
48
app/api/admin/manuals-knowledge/route.test.ts
Normal file
48
app/api/admin/manuals-knowledge/route.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { GET } from "@/app/api/admin/manuals-knowledge/route"
|
||||
|
||||
const ORIGINAL_ADMIN_API_TOKEN = process.env.ADMIN_API_TOKEN
|
||||
|
||||
test.afterEach(() => {
|
||||
if (typeof ORIGINAL_ADMIN_API_TOKEN === "string") {
|
||||
process.env.ADMIN_API_TOKEN = ORIGINAL_ADMIN_API_TOKEN
|
||||
} else {
|
||||
delete process.env.ADMIN_API_TOKEN
|
||||
}
|
||||
})
|
||||
|
||||
test("manuals knowledge admin route requires admin auth", async () => {
|
||||
process.env.ADMIN_API_TOKEN = "secret-token"
|
||||
|
||||
const response = await GET(
|
||||
new Request("http://localhost/api/admin/manuals-knowledge?query=rvv+660")
|
||||
)
|
||||
|
||||
assert.equal(response.status, 401)
|
||||
})
|
||||
|
||||
test("manuals knowledge admin route returns retrieval details for authorized queries", async () => {
|
||||
process.env.ADMIN_API_TOKEN = "secret-token"
|
||||
|
||||
const response = await GET(
|
||||
new Request(
|
||||
"http://localhost/api/admin/manuals-knowledge?query=RVV+660+service+manual",
|
||||
{
|
||||
headers: {
|
||||
"x-admin-token": "secret-token",
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert.equal(response.status, 200)
|
||||
|
||||
const body = await response.json()
|
||||
|
||||
assert.equal(body.summary.ran, true)
|
||||
assert.equal(Array.isArray(body.result.manualCandidates), true)
|
||||
assert.equal(body.result.manualCandidates.length > 0, true)
|
||||
assert.equal(Array.isArray(body.result.topChunks), true)
|
||||
assert.equal(Array.isArray(body.summary.topChunkCitations), true)
|
||||
})
|
||||
79
app/api/admin/manuals-knowledge/route.ts
Normal file
79
app/api/admin/manuals-knowledge/route.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import {
|
||||
getManualCitationContext,
|
||||
retrieveManualContext,
|
||||
summarizeManualRetrieval,
|
||||
} from "@/lib/manuals-knowledge"
|
||||
import { requireAdminToken } from "@/lib/server/admin-auth"
|
||||
|
||||
function normalizeQuery(value: string | null) {
|
||||
return (value || "").trim().slice(0, 400)
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const authError = requireAdminToken(request)
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const query = normalizeQuery(searchParams.get("query"))
|
||||
const manufacturer = normalizeQuery(searchParams.get("manufacturer")) || null
|
||||
const model = normalizeQuery(searchParams.get("model")) || null
|
||||
const manualId = normalizeQuery(searchParams.get("manualId")) || null
|
||||
const pageParam = searchParams.get("page")
|
||||
const pageNumber =
|
||||
pageParam && Number.isFinite(Number(pageParam))
|
||||
? Number.parseInt(pageParam, 10)
|
||||
: undefined
|
||||
|
||||
if (!query) {
|
||||
return NextResponse.json(
|
||||
{ error: "A query parameter is required." },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await retrieveManualContext(query, {
|
||||
manufacturer,
|
||||
model,
|
||||
manualId,
|
||||
})
|
||||
const citationContext =
|
||||
manualId || result.bestManual?.manualId
|
||||
? await getManualCitationContext(
|
||||
manualId || result.bestManual?.manualId || "",
|
||||
pageNumber
|
||||
)
|
||||
: null
|
||||
|
||||
return NextResponse.json({
|
||||
query,
|
||||
filters: {
|
||||
manufacturer,
|
||||
model,
|
||||
manualId,
|
||||
pageNumber: pageNumber ?? null,
|
||||
},
|
||||
summary: summarizeManualRetrieval({
|
||||
ran: true,
|
||||
query,
|
||||
result,
|
||||
}),
|
||||
result,
|
||||
citationContext,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to inspect manuals knowledge:", error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to inspect manuals knowledge",
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
181
app/api/chat/route.test.ts
Normal file
181
app/api/chat/route.test.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { NextRequest } from "next/server"
|
||||
import { POST } from "@/app/api/chat/route"
|
||||
|
||||
type CapturedPayload = {
|
||||
model: string
|
||||
messages: Array<{ role: string; content: string }>
|
||||
}
|
||||
|
||||
const ORIGINAL_FETCH = globalThis.fetch
|
||||
const ORIGINAL_XAI_KEY = process.env.XAI_API_KEY
|
||||
|
||||
function buildVisitor(intent: string) {
|
||||
return {
|
||||
name: "Taylor",
|
||||
phone: "(801) 555-1000",
|
||||
email: "taylor@example.com",
|
||||
intent,
|
||||
serviceTextConsent: true,
|
||||
marketingTextConsent: false,
|
||||
consentVersion: "sms-consent-v1-2026-03-26",
|
||||
consentCapturedAt: "2026-03-25T00:00:00.000Z",
|
||||
consentSourcePage: "/contact-us",
|
||||
}
|
||||
}
|
||||
|
||||
function buildRequest(message: string, intent = "Manuals") {
|
||||
return new NextRequest("http://localhost/api/chat", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
pathname: "/manuals",
|
||||
sessionId: "test-session",
|
||||
visitor: buildVisitor(intent),
|
||||
messages: [{ role: "user", content: message }],
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
async function runChatRouteWithSpy(
|
||||
message: string,
|
||||
intent = "Manuals"
|
||||
): Promise<{ response: Response; payload: CapturedPayload }> {
|
||||
process.env.XAI_API_KEY = "test-xai-key"
|
||||
let capturedPayload: CapturedPayload | null = null
|
||||
|
||||
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
capturedPayload = JSON.parse(String(init?.body || "{}")) as CapturedPayload
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: "Mock Jessica reply.",
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
}
|
||||
)
|
||||
}) as typeof fetch
|
||||
|
||||
const response = await POST(buildRequest(message, intent))
|
||||
|
||||
assert.ok(capturedPayload)
|
||||
return { response, payload: capturedPayload }
|
||||
}
|
||||
|
||||
test.afterEach(() => {
|
||||
globalThis.fetch = ORIGINAL_FETCH
|
||||
|
||||
if (typeof ORIGINAL_XAI_KEY === "string") {
|
||||
process.env.XAI_API_KEY = ORIGINAL_XAI_KEY
|
||||
} else {
|
||||
delete process.env.XAI_API_KEY
|
||||
}
|
||||
})
|
||||
|
||||
test("chat route includes grounded manual context for RVV alias lookups", async () => {
|
||||
const { response, payload } = await runChatRouteWithSpy(
|
||||
"RVV 660 service manual"
|
||||
)
|
||||
|
||||
assert.equal(response.status, 200)
|
||||
assert.equal(
|
||||
payload.messages.some(
|
||||
(message) =>
|
||||
message.role === "system" &&
|
||||
message.content.includes("Manual knowledge context:")
|
||||
),
|
||||
true
|
||||
)
|
||||
assert.equal(
|
||||
payload.messages.some(
|
||||
(message) =>
|
||||
message.role === "system" &&
|
||||
/Royal Vendors|660/i.test(message.content)
|
||||
),
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test("chat route resolves Narco alias lookups into manual context", async () => {
|
||||
const { payload } = await runChatRouteWithSpy("Narco bevmax not cooling")
|
||||
|
||||
const manualContext = payload.messages.find(
|
||||
(message) =>
|
||||
message.role === "system" &&
|
||||
message.content.includes("Manual knowledge context:")
|
||||
)
|
||||
|
||||
assert.ok(manualContext)
|
||||
assert.match(manualContext.content, /Dixie-Narco|Narco/i)
|
||||
})
|
||||
|
||||
test("chat route low-confidence manual queries instruct Jessica to ask for brand model or photo", async () => {
|
||||
const { payload } = await runChatRouteWithSpy(
|
||||
"manual for flibbertigibbet machine"
|
||||
)
|
||||
|
||||
const manualContext = payload.messages.find(
|
||||
(message) =>
|
||||
message.role === "system" &&
|
||||
message.content.includes("Manual knowledge context:")
|
||||
)
|
||||
|
||||
assert.ok(manualContext)
|
||||
assert.match(
|
||||
manualContext.content,
|
||||
/brand on the front|model sticker|photo\/video/i
|
||||
)
|
||||
})
|
||||
|
||||
test("chat route risky technical manual queries inject conservative safety context", async () => {
|
||||
const { payload } = await runChatRouteWithSpy(
|
||||
"Royal wiring diagram voltage manual",
|
||||
"Repairs"
|
||||
)
|
||||
|
||||
const systemPrompt = payload.messages[0]?.content || ""
|
||||
const manualContext = payload.messages.find(
|
||||
(message) =>
|
||||
message.role === "system" &&
|
||||
message.content.includes("Manual knowledge context:")
|
||||
)
|
||||
|
||||
assert.match(
|
||||
systemPrompt,
|
||||
/Do not provide step-by-step repair procedures, wiring guidance, voltage guidance/i
|
||||
)
|
||||
assert.ok(manualContext)
|
||||
assert.match(manualContext.content, /technical or risky/i)
|
||||
})
|
||||
|
||||
test("chat route skips manuals retrieval for non-manual conversations", async () => {
|
||||
const { payload } = await runChatRouteWithSpy(
|
||||
"Can someone call me back about free placement?",
|
||||
"Free Placement"
|
||||
)
|
||||
|
||||
const systemMessages = payload.messages.filter(
|
||||
(message) => message.role === "system"
|
||||
)
|
||||
|
||||
assert.equal(systemMessages.length, 1)
|
||||
assert.equal(
|
||||
systemMessages.some((message) =>
|
||||
message.content.includes("Manual knowledge context:")
|
||||
),
|
||||
false
|
||||
)
|
||||
})
|
||||
|
|
@ -17,8 +17,18 @@ import {
|
|||
SITE_CHAT_TEMPERATURE,
|
||||
isSiteChatSuppressedRoute,
|
||||
} from "@/lib/site-chat/config"
|
||||
import { SITE_CHAT_SYSTEM_PROMPT } from "@/lib/site-chat/prompt"
|
||||
import { consumeChatOutput, consumeChatRequest, getChatRateLimitStatus } from "@/lib/site-chat/rate-limit"
|
||||
import { buildSiteChatSystemPrompt } from "@/lib/site-chat/prompt"
|
||||
import {
|
||||
consumeChatOutput,
|
||||
consumeChatRequest,
|
||||
getChatRateLimitStatus,
|
||||
} from "@/lib/site-chat/rate-limit"
|
||||
import {
|
||||
formatManualContextForPrompt,
|
||||
retrieveManualContext,
|
||||
shouldUseManualKnowledgeForChat,
|
||||
summarizeManualRetrieval,
|
||||
} from "@/lib/manuals-knowledge"
|
||||
import { createSmsConsentPayload } from "@/lib/sms-compliance"
|
||||
|
||||
type ChatRole = "user" | "assistant"
|
||||
|
|
@ -81,7 +91,10 @@ function normalizeSessionId(rawSessionId: string | undefined | null) {
|
|||
}
|
||||
|
||||
function normalizePathname(rawPathname: string | undefined) {
|
||||
const pathname = typeof rawPathname === "string" && rawPathname.trim() ? rawPathname.trim() : "/"
|
||||
const pathname =
|
||||
typeof rawPathname === "string" && rawPathname.trim()
|
||||
? rawPathname.trim()
|
||||
: "/"
|
||||
return pathname.startsWith("/") ? pathname : `/${pathname}`
|
||||
}
|
||||
|
||||
|
|
@ -89,24 +102,46 @@ function normalizeMessages(messages: ChatMessage[] | undefined) {
|
|||
const safeMessages = Array.isArray(messages) ? messages : []
|
||||
|
||||
return safeMessages
|
||||
.filter((message) => message && (message.role === "user" || message.role === "assistant"))
|
||||
.filter(
|
||||
(message) =>
|
||||
message && (message.role === "user" || message.role === "assistant")
|
||||
)
|
||||
.map((message) => ({
|
||||
role: message.role,
|
||||
content: String(message.content || "").replace(/\s+/g, " ").trim().slice(0, SITE_CHAT_MAX_MESSAGE_CHARS),
|
||||
content: String(message.content || "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, SITE_CHAT_MAX_MESSAGE_CHARS),
|
||||
}))
|
||||
.filter((message) => message.content.length > 0)
|
||||
.slice(-SITE_CHAT_MAX_HISTORY_MESSAGES)
|
||||
}
|
||||
|
||||
function normalizeVisitorProfile(rawVisitor: ChatRequestBody["visitor"], pathname: string): ChatVisitorProfile | null {
|
||||
function normalizeVisitorProfile(
|
||||
rawVisitor: ChatRequestBody["visitor"],
|
||||
pathname: string
|
||||
): ChatVisitorProfile | null {
|
||||
if (!rawVisitor) {
|
||||
return null
|
||||
}
|
||||
|
||||
const name = String(rawVisitor.name || "").replace(/\s+/g, " ").trim().slice(0, 80)
|
||||
const phone = String(rawVisitor.phone || "").replace(/\s+/g, " ").trim().slice(0, 40)
|
||||
const email = String(rawVisitor.email || "").replace(/\s+/g, " ").trim().slice(0, 120).toLowerCase()
|
||||
const intent = String(rawVisitor.intent || "").replace(/\s+/g, " ").trim().slice(0, 80)
|
||||
const name = String(rawVisitor.name || "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 80)
|
||||
const phone = String(rawVisitor.phone || "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 40)
|
||||
const email = String(rawVisitor.email || "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 120)
|
||||
.toLowerCase()
|
||||
const intent = String(rawVisitor.intent || "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 80)
|
||||
|
||||
if (!name || !phone || !email || !intent) {
|
||||
return null
|
||||
|
|
@ -179,6 +214,15 @@ function extractAssistantText(data: any) {
|
|||
return ""
|
||||
}
|
||||
|
||||
function buildManualKnowledgeQuery(messages: ChatMessage[]) {
|
||||
return messages
|
||||
.filter((message) => message.role === "user")
|
||||
.slice(-3)
|
||||
.map((message) => message.content.trim())
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const responseHeaders: Record<string, string> = {
|
||||
"Cache-Control": "no-store",
|
||||
|
|
@ -190,25 +234,36 @@ export async function POST(request: NextRequest) {
|
|||
const visitor = normalizeVisitorProfile(body.visitor, pathname)
|
||||
|
||||
if (isSiteChatSuppressedRoute(pathname)) {
|
||||
return NextResponse.json({ error: "Chat is not available on this route." }, { status: 403, headers: responseHeaders })
|
||||
return NextResponse.json(
|
||||
{ error: "Chat is not available on this route." },
|
||||
{ status: 403, headers: responseHeaders }
|
||||
)
|
||||
}
|
||||
|
||||
if (!visitor) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Name, phone, email, intent, and required service SMS consent are needed to start chat.",
|
||||
error:
|
||||
"Name, phone, email, intent, and required service SMS consent are needed to start chat.",
|
||||
},
|
||||
{ status: 400, headers: responseHeaders },
|
||||
{ status: 400, headers: responseHeaders }
|
||||
)
|
||||
}
|
||||
|
||||
const sessionId = normalizeSessionId(body.sessionId || request.cookies.get(SITE_CHAT_SESSION_COOKIE)?.value)
|
||||
const sessionId = normalizeSessionId(
|
||||
body.sessionId || request.cookies.get(SITE_CHAT_SESSION_COOKIE)?.value
|
||||
)
|
||||
const ip = getClientIp(request)
|
||||
const messages = normalizeMessages(body.messages)
|
||||
const latestUserMessage = [...messages].reverse().find((message) => message.role === "user")
|
||||
const latestUserMessage = [...messages]
|
||||
.reverse()
|
||||
.find((message) => message.role === "user")
|
||||
|
||||
if (!latestUserMessage) {
|
||||
return NextResponse.json({ error: "A user message is required.", sessionId }, { status: 400, headers: responseHeaders })
|
||||
return NextResponse.json(
|
||||
{ error: "A user message is required.", sessionId },
|
||||
{ status: 400, headers: responseHeaders }
|
||||
)
|
||||
}
|
||||
|
||||
if (latestUserMessage.content.length > SITE_CHAT_MAX_INPUT_CHARS) {
|
||||
|
|
@ -217,7 +272,7 @@ export async function POST(request: NextRequest) {
|
|||
error: `Please keep each message under ${SITE_CHAT_MAX_INPUT_CHARS} characters.`,
|
||||
sessionId,
|
||||
},
|
||||
{ status: 400, headers: responseHeaders },
|
||||
{ status: 400, headers: responseHeaders }
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -234,11 +289,12 @@ export async function POST(request: NextRequest) {
|
|||
if (limitStatus.blocked) {
|
||||
const blockedResponse = NextResponse.json(
|
||||
{
|
||||
error: "Chat is temporarily limited right now. Please wait a bit or call Rocky Mountain Vending directly.",
|
||||
error:
|
||||
"Chat is temporarily limited right now. Please wait a bit or call Rocky Mountain Vending directly.",
|
||||
sessionId,
|
||||
limits: limitStatus,
|
||||
},
|
||||
{ status: 429, headers: responseHeaders },
|
||||
{ status: 429, headers: responseHeaders }
|
||||
)
|
||||
|
||||
blockedResponse.cookies.set(SITE_CHAT_SESSION_COOKIE, sessionId, {
|
||||
|
|
@ -252,7 +308,41 @@ export async function POST(request: NextRequest) {
|
|||
return blockedResponse
|
||||
}
|
||||
|
||||
consumeChatRequest({ ip, requestWindowMs: SITE_CHAT_REQUEST_WINDOW_MS, sessionId })
|
||||
consumeChatRequest({
|
||||
ip,
|
||||
requestWindowMs: SITE_CHAT_REQUEST_WINDOW_MS,
|
||||
sessionId,
|
||||
})
|
||||
|
||||
const manualKnowledgeQuery = buildManualKnowledgeQuery(messages)
|
||||
const shouldUseManualKnowledge = shouldUseManualKnowledgeForChat(
|
||||
visitor.intent,
|
||||
manualKnowledgeQuery
|
||||
)
|
||||
let manualKnowledge = null
|
||||
let manualKnowledgeError: unknown = null
|
||||
if (shouldUseManualKnowledge) {
|
||||
try {
|
||||
manualKnowledge = await retrieveManualContext(manualKnowledgeQuery)
|
||||
} catch (error) {
|
||||
manualKnowledgeError = error
|
||||
console.error("[site-chat] manuals knowledge lookup failed", {
|
||||
pathname,
|
||||
sessionId,
|
||||
error,
|
||||
})
|
||||
}
|
||||
}
|
||||
console.info(
|
||||
"[site-chat] manuals retrieval",
|
||||
summarizeManualRetrieval({
|
||||
ran: shouldUseManualKnowledge,
|
||||
query: manualKnowledgeQuery,
|
||||
result: manualKnowledge,
|
||||
error: manualKnowledgeError,
|
||||
})
|
||||
)
|
||||
const systemPrompt = buildSiteChatSystemPrompt()
|
||||
|
||||
const xaiApiKey = getOptionalEnv("XAI_API_KEY")
|
||||
if (!xaiApiKey) {
|
||||
|
|
@ -263,32 +353,46 @@ export async function POST(request: NextRequest) {
|
|||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Jessica is temporarily unavailable right now. Please call us or use the contact form.",
|
||||
error:
|
||||
"Jessica is temporarily unavailable right now. Please call us or use the contact form.",
|
||||
sessionId,
|
||||
},
|
||||
{ status: 503, headers: responseHeaders },
|
||||
{ status: 503, headers: responseHeaders }
|
||||
)
|
||||
}
|
||||
|
||||
const completionResponse = await fetch("https://api.x.ai/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${xaiApiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: SITE_CHAT_MODEL,
|
||||
temperature: SITE_CHAT_TEMPERATURE,
|
||||
max_tokens: SITE_CHAT_MAX_OUTPUT_TOKENS,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `${SITE_CHAT_SYSTEM_PROMPT}\n\nConversation context:\n- Current pathname: ${pathname}\n- Source: ${SITE_CHAT_SOURCE}\n- Visitor name: ${visitor.name}\n- Visitor email: ${visitor.email}\n- Visitor phone: ${visitor.phone}\n- Visitor intent: ${visitor.intent}\n- Service SMS consent: ${visitor.serviceTextConsent ? "yes" : "no"}\n- Marketing SMS consent: ${visitor.marketingTextConsent ? "yes" : "no"}`,
|
||||
},
|
||||
...messages,
|
||||
],
|
||||
}),
|
||||
})
|
||||
const completionResponse = await fetch(
|
||||
"https://api.x.ai/v1/chat/completions",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${xaiApiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: SITE_CHAT_MODEL,
|
||||
temperature: SITE_CHAT_TEMPERATURE,
|
||||
max_tokens: SITE_CHAT_MAX_OUTPUT_TOKENS,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `${systemPrompt}\n\nConversation context:\n- Current pathname: ${pathname}\n- Source: ${SITE_CHAT_SOURCE}\n- Visitor name: ${visitor.name}\n- Visitor email: ${visitor.email}\n- Visitor phone: ${visitor.phone}\n- Visitor intent: ${visitor.intent}\n- Service SMS consent: ${visitor.serviceTextConsent ? "yes" : "no"}\n- Marketing SMS consent: ${visitor.marketingTextConsent ? "yes" : "no"}`,
|
||||
},
|
||||
...(shouldUseManualKnowledge
|
||||
? [
|
||||
{
|
||||
role: "system" as const,
|
||||
content: manualKnowledge
|
||||
? formatManualContextForPrompt(manualKnowledge)
|
||||
: "Manual knowledge context:\n- A manual lookup was attempted, but no reliable manual context is available.\n- Do not guess. Ask for the brand, model sticker, or a clear photo/video that can be texted in.",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...messages,
|
||||
],
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
const completionData = await completionResponse.json().catch(() => ({}))
|
||||
|
||||
|
|
@ -302,10 +406,11 @@ export async function POST(request: NextRequest) {
|
|||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Jessica is having trouble replying right now. Please try again or call us directly.",
|
||||
error:
|
||||
"Jessica is having trouble replying right now. Please try again or call us directly.",
|
||||
sessionId,
|
||||
},
|
||||
{ status: 502, headers: responseHeaders },
|
||||
{ status: 502, headers: responseHeaders }
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -317,11 +422,15 @@ export async function POST(request: NextRequest) {
|
|||
error: "Jessica did not return a usable reply. Please try again.",
|
||||
sessionId,
|
||||
},
|
||||
{ status: 502, headers: responseHeaders },
|
||||
{ status: 502, headers: responseHeaders }
|
||||
)
|
||||
}
|
||||
|
||||
consumeChatOutput({ chars: assistantReply.length, outputWindowMs: SITE_CHAT_OUTPUT_WINDOW_MS, sessionId })
|
||||
consumeChatOutput({
|
||||
chars: assistantReply.length,
|
||||
outputWindowMs: SITE_CHAT_OUTPUT_WINDOW_MS,
|
||||
sessionId,
|
||||
})
|
||||
|
||||
const nextLimitStatus = getChatRateLimitStatus({
|
||||
ip,
|
||||
|
|
@ -339,7 +448,7 @@ export async function POST(request: NextRequest) {
|
|||
sessionId,
|
||||
limits: nextLimitStatus,
|
||||
},
|
||||
{ headers: responseHeaders },
|
||||
{ headers: responseHeaders }
|
||||
)
|
||||
|
||||
response.cookies.set(SITE_CHAT_SESSION_COOKIE, sessionId, {
|
||||
|
|
@ -355,7 +464,10 @@ export async function POST(request: NextRequest) {
|
|||
console.error("[site-chat] request failed", error)
|
||||
|
||||
const safeError =
|
||||
error instanceof Error && error.message.startsWith("Missing required site chat environment variable:")
|
||||
error instanceof Error &&
|
||||
error.message.startsWith(
|
||||
"Missing required site chat environment variable:"
|
||||
)
|
||||
? "Jessica is temporarily unavailable right now. Please call us or use the contact form."
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
|
|
@ -365,7 +477,7 @@ export async function POST(request: NextRequest) {
|
|||
{
|
||||
error: safeError,
|
||||
},
|
||||
{ status: 500, headers: responseHeaders },
|
||||
{ status: 500, headers: responseHeaders }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import {
|
||||
processLeadSubmission,
|
||||
type ContactLeadPayload,
|
||||
type RequestMachineLeadPayload,
|
||||
} from "@/lib/server/contact-submission";
|
||||
} from "@/lib/server/contact-submission"
|
||||
|
||||
test("processLeadSubmission stores and syncs a contact lead", async () => {
|
||||
const calls: string[] = [];
|
||||
const calls: string[] = []
|
||||
const payload: ContactLeadPayload = {
|
||||
kind: "contact",
|
||||
firstName: "John",
|
||||
|
|
@ -26,7 +26,7 @@ test("processLeadSubmission stores and syncs a contact lead", async () => {
|
|||
page: "/contact",
|
||||
timestamp: "2026-03-25T00:00:00.000Z",
|
||||
url: "https://rmv.example/contact",
|
||||
};
|
||||
}
|
||||
|
||||
const result = await processLeadSubmission(payload, "rmv.example", {
|
||||
storageConfigured: true,
|
||||
|
|
@ -36,37 +36,37 @@ test("processLeadSubmission stores and syncs a contact lead", async () => {
|
|||
tenantName: "Rocky Mountain Vending",
|
||||
tenantDomains: ["rockymountainvending.com"],
|
||||
ingest: async () => {
|
||||
calls.push("ingest");
|
||||
calls.push("ingest")
|
||||
return {
|
||||
inserted: true,
|
||||
leadId: "lead_123",
|
||||
idempotencyKey: "abc",
|
||||
tenantId: "tenant_123",
|
||||
};
|
||||
}
|
||||
},
|
||||
updateLeadStatus: async () => {
|
||||
calls.push("update");
|
||||
return { ok: true };
|
||||
calls.push("update")
|
||||
return { ok: true }
|
||||
},
|
||||
sendEmail: async () => {
|
||||
calls.push("email");
|
||||
return {};
|
||||
calls.push("email")
|
||||
return {}
|
||||
},
|
||||
createContact: async () => {
|
||||
calls.push("ghl");
|
||||
return { contact: { id: "ghl_123" } };
|
||||
calls.push("ghl")
|
||||
return { contact: { id: "ghl_123" } }
|
||||
},
|
||||
logger: console,
|
||||
});
|
||||
})
|
||||
|
||||
assert.equal(result.status, 200);
|
||||
assert.equal(result.body.success, true);
|
||||
assert.deepEqual(result.body.deliveredVia, ["convex", "email", "ghl"]);
|
||||
assert.equal(calls.filter((call) => call === "email").length, 2);
|
||||
assert.ok(calls.includes("ingest"));
|
||||
assert.ok(calls.includes("update"));
|
||||
assert.ok(calls.includes("ghl"));
|
||||
});
|
||||
assert.equal(result.status, 200)
|
||||
assert.equal(result.body.success, true)
|
||||
assert.deepEqual(result.body.deliveredVia, ["convex", "email", "ghl"])
|
||||
assert.equal(calls.filter((call) => call === "email").length, 2)
|
||||
assert.ok(calls.includes("ingest"))
|
||||
assert.ok(calls.includes("update"))
|
||||
assert.ok(calls.includes("ghl"))
|
||||
})
|
||||
|
||||
test("processLeadSubmission validates request-machine submissions", async () => {
|
||||
const payload: RequestMachineLeadPayload = {
|
||||
|
|
@ -84,7 +84,7 @@ test("processLeadSubmission validates request-machine submissions", async () =>
|
|||
consentVersion: "sms-consent-v1-2026-03-26",
|
||||
consentCapturedAt: "2026-03-25T00:00:00.000Z",
|
||||
consentSourcePage: "/",
|
||||
};
|
||||
}
|
||||
|
||||
const result = await processLeadSubmission(payload, "rmv.example", {
|
||||
storageConfigured: false,
|
||||
|
|
@ -94,24 +94,24 @@ test("processLeadSubmission validates request-machine submissions", async () =>
|
|||
tenantName: "Rocky Mountain Vending",
|
||||
tenantDomains: [],
|
||||
ingest: async () => {
|
||||
throw new Error("should not run");
|
||||
throw new Error("should not run")
|
||||
},
|
||||
updateLeadStatus: async () => {
|
||||
throw new Error("should not run");
|
||||
throw new Error("should not run")
|
||||
},
|
||||
sendEmail: async () => {
|
||||
throw new Error("should not run");
|
||||
throw new Error("should not run")
|
||||
},
|
||||
createContact: async () => {
|
||||
throw new Error("should not run");
|
||||
throw new Error("should not run")
|
||||
},
|
||||
logger: console,
|
||||
});
|
||||
})
|
||||
|
||||
assert.equal(result.status, 400);
|
||||
assert.equal(result.body.success, false);
|
||||
assert.match(result.body.error || "", /Invalid number of employees/);
|
||||
});
|
||||
assert.equal(result.status, 400)
|
||||
assert.equal(result.body.success, false)
|
||||
assert.match(result.body.error || "", /Invalid number of employees/)
|
||||
})
|
||||
|
||||
test("processLeadSubmission returns deduped success when Convex already has the lead", async () => {
|
||||
const payload: ContactLeadPayload = {
|
||||
|
|
@ -126,7 +126,7 @@ test("processLeadSubmission returns deduped success when Convex already has the
|
|||
consentVersion: "sms-consent-v1-2026-03-26",
|
||||
consentCapturedAt: "2026-03-25T00:00:00.000Z",
|
||||
consentSourcePage: "/contact-us",
|
||||
};
|
||||
}
|
||||
|
||||
const result = await processLeadSubmission(payload, "rmv.example", {
|
||||
storageConfigured: true,
|
||||
|
|
@ -145,10 +145,10 @@ test("processLeadSubmission returns deduped success when Convex already has the
|
|||
sendEmail: async () => ({}),
|
||||
createContact: async () => null,
|
||||
logger: console,
|
||||
});
|
||||
})
|
||||
|
||||
assert.equal(result.status, 200);
|
||||
assert.equal(result.body.success, true);
|
||||
assert.equal(result.body.deduped, true);
|
||||
assert.deepEqual(result.body.deliveredVia, ["convex"]);
|
||||
});
|
||||
assert.equal(result.status, 200)
|
||||
assert.equal(result.body.success, true)
|
||||
assert.equal(result.body.deduped, true)
|
||||
assert.deepEqual(result.body.deliveredVia, ["convex"])
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { handleLeadRequest } from "@/lib/server/contact-submission";
|
||||
import { handleLeadRequest } from "@/lib/server/contact-submission"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return handleLeadRequest(request);
|
||||
return handleLeadRequest(request)
|
||||
}
|
||||
|
|
|
|||
213
app/api/ebay/manual-parts/route.ts
Normal file
213
app/api/ebay/manual-parts/route.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchQuery } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { hasConvexUrl } from "@/lib/convex-config"
|
||||
import {
|
||||
filterTrustedEbayListings,
|
||||
rankListingsForPart,
|
||||
type CachedEbayListing,
|
||||
type EbayCacheState,
|
||||
type ManualPartInput,
|
||||
} from "@/lib/ebay-parts-match"
|
||||
|
||||
type MatchPart = ManualPartInput & {
|
||||
key?: string
|
||||
ebayListings?: CachedEbayListing[]
|
||||
}
|
||||
|
||||
type ManualPartsMatchResponse = {
|
||||
manualFilename: string
|
||||
parts: Array<
|
||||
MatchPart & {
|
||||
ebayListings: CachedEbayListing[]
|
||||
}
|
||||
>
|
||||
cache: EbayCacheState
|
||||
cacheSource: "convex" | "fallback"
|
||||
error?: string
|
||||
}
|
||||
|
||||
type ManualPartsRequest = {
|
||||
manualFilename?: string
|
||||
parts?: unknown[]
|
||||
limit?: number
|
||||
}
|
||||
|
||||
function getDisabledCacheState(message: string): EbayCacheState {
|
||||
return {
|
||||
key: "manual-parts",
|
||||
status: "disabled",
|
||||
lastSuccessfulAt: null,
|
||||
lastAttemptAt: null,
|
||||
nextEligibleAt: null,
|
||||
lastError: message,
|
||||
consecutiveFailures: 0,
|
||||
queryCount: 0,
|
||||
itemCount: 0,
|
||||
sourceQueries: [],
|
||||
freshnessMs: null,
|
||||
isStale: true,
|
||||
listingCount: 0,
|
||||
activeListingCount: 0,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorCacheState(message: string): EbayCacheState {
|
||||
const now = Date.now()
|
||||
return {
|
||||
key: "manual-parts",
|
||||
status: "error",
|
||||
lastSuccessfulAt: null,
|
||||
lastAttemptAt: now,
|
||||
nextEligibleAt: null,
|
||||
lastError: message,
|
||||
consecutiveFailures: 1,
|
||||
queryCount: 0,
|
||||
itemCount: 0,
|
||||
sourceQueries: [],
|
||||
freshnessMs: null,
|
||||
isStale: true,
|
||||
listingCount: 0,
|
||||
activeListingCount: 0,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyListingsParts(parts: MatchPart[]) {
|
||||
return parts.map((part) => ({
|
||||
...part,
|
||||
ebayListings: [],
|
||||
}))
|
||||
}
|
||||
|
||||
function normalizePartInput(value: unknown): MatchPart | null {
|
||||
if (!value || typeof value !== "object") {
|
||||
return null
|
||||
}
|
||||
|
||||
const part = value as Record<string, unknown>
|
||||
const partNumber = typeof part.partNumber === "string" ? part.partNumber.trim() : ""
|
||||
const description = typeof part.description === "string" ? part.description.trim() : ""
|
||||
|
||||
if (!partNumber && !description) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
key: typeof part.key === "string" ? part.key : undefined,
|
||||
partNumber,
|
||||
description,
|
||||
manufacturer:
|
||||
typeof part.manufacturer === "string" ? part.manufacturer.trim() : undefined,
|
||||
category:
|
||||
typeof part.category === "string" ? part.category.trim() : undefined,
|
||||
manualFilename:
|
||||
typeof part.manualFilename === "string"
|
||||
? part.manualFilename.trim()
|
||||
: undefined,
|
||||
ebayListings: Array.isArray(part.ebayListings)
|
||||
? (part.ebayListings as CachedEbayListing[])
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let payload: ManualPartsRequest | null = null
|
||||
|
||||
try {
|
||||
payload = (await request.json()) as ManualPartsRequest
|
||||
} catch {
|
||||
payload = null
|
||||
}
|
||||
|
||||
const manualFilename = payload?.manualFilename?.trim() || ""
|
||||
const limit = Math.min(
|
||||
Math.max(Number.parseInt(String(payload?.limit ?? 5), 10) || 5, 1),
|
||||
10
|
||||
)
|
||||
const parts: MatchPart[] = (payload?.parts || [])
|
||||
.map(normalizePartInput)
|
||||
.filter((part): part is MatchPart => Boolean(part))
|
||||
|
||||
if (!manualFilename) {
|
||||
return NextResponse.json(
|
||||
{ error: "manualFilename is required" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!parts.length) {
|
||||
const message = "No manual parts were provided."
|
||||
return NextResponse.json({
|
||||
manualFilename,
|
||||
parts: [],
|
||||
cache: getDisabledCacheState(message),
|
||||
cacheSource: "fallback",
|
||||
error: message,
|
||||
} satisfies ManualPartsMatchResponse)
|
||||
}
|
||||
|
||||
if (!hasConvexUrl()) {
|
||||
const message =
|
||||
"Cached eBay backend is disabled because NEXT_PUBLIC_CONVEX_URL is not configured."
|
||||
return NextResponse.json({
|
||||
manualFilename,
|
||||
parts: createEmptyListingsParts(parts),
|
||||
cache: getDisabledCacheState(message),
|
||||
cacheSource: "fallback",
|
||||
error: message,
|
||||
} satisfies ManualPartsMatchResponse)
|
||||
}
|
||||
|
||||
try {
|
||||
const [overview, listings] = await Promise.all([
|
||||
fetchQuery(api.ebay.getCacheOverview, {}),
|
||||
fetchQuery(api.ebay.listCachedListings, { limit: 200 }),
|
||||
])
|
||||
const trustedListings = filterTrustedEbayListings(
|
||||
listings as CachedEbayListing[]
|
||||
)
|
||||
|
||||
const rankedParts = parts
|
||||
.map((part) => ({
|
||||
...part,
|
||||
ebayListings: rankListingsForPart(part, trustedListings, limit),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const aCount = a.ebayListings.length
|
||||
const bCount = b.ebayListings.length
|
||||
if (aCount !== bCount) {
|
||||
return bCount - aCount
|
||||
}
|
||||
|
||||
const aFreshness = a.ebayListings[0]?.lastSeenAt ?? a.ebayListings[0]?.fetchedAt ?? 0
|
||||
const bFreshness = b.ebayListings[0]?.lastSeenAt ?? b.ebayListings[0]?.fetchedAt ?? 0
|
||||
return bFreshness - aFreshness
|
||||
})
|
||||
.slice(0, limit)
|
||||
|
||||
return NextResponse.json({
|
||||
manualFilename,
|
||||
parts: rankedParts,
|
||||
cache: overview,
|
||||
cacheSource: "convex",
|
||||
} satisfies ManualPartsMatchResponse)
|
||||
} catch (error) {
|
||||
console.error("Failed to load cached eBay matches:", error)
|
||||
const message =
|
||||
error instanceof Error
|
||||
? `Cached eBay listings are unavailable: ${error.message}`
|
||||
: "Cached eBay listings are unavailable."
|
||||
return NextResponse.json(
|
||||
{
|
||||
manualFilename,
|
||||
parts: createEmptyListingsParts(parts),
|
||||
cache: getErrorCacheState(message),
|
||||
cacheSource: "fallback",
|
||||
error: message,
|
||||
} satisfies ManualPartsMatchResponse,
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
}
|
||||
159
app/api/ebay/notifications/route.ts
Normal file
159
app/api/ebay/notifications/route.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { NextRequest, NextResponse } from "next/server"
|
||||
import {
|
||||
computeEbayChallengeResponse,
|
||||
getEbayNotificationEndpoint,
|
||||
getEbayNotificationVerificationToken,
|
||||
verifyEbayNotificationSignature,
|
||||
} from "@/lib/ebay-notifications"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
type EbayNotificationPayload = {
|
||||
metadata?: {
|
||||
topic?: string
|
||||
schemaVersion?: string
|
||||
deprecated?: boolean
|
||||
}
|
||||
notification?: {
|
||||
notificationId?: string
|
||||
eventDate?: string
|
||||
publishDate?: string
|
||||
publishAttemptCount?: number
|
||||
data?: {
|
||||
username?: string
|
||||
userId?: string
|
||||
eiasToken?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseNotificationBody(rawBody: string) {
|
||||
if (!rawBody.trim()) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(rawBody) as EbayNotificationPayload
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getChallengeCode(request: NextRequest) {
|
||||
return (
|
||||
request.nextUrl.searchParams.get("challenge_code") ||
|
||||
request.nextUrl.searchParams.get("challengeCode") ||
|
||||
""
|
||||
).trim()
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const challengeCode = getChallengeCode(request)
|
||||
if (!challengeCode) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing challenge_code query parameter." },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const verificationToken = getEbayNotificationVerificationToken()
|
||||
if (!verificationToken) {
|
||||
return NextResponse.json(
|
||||
{ error: "EBAY_NOTIFICATION_VERIFICATION_TOKEN is not configured." },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const endpoint = getEbayNotificationEndpoint(request.url)
|
||||
if (!endpoint) {
|
||||
return NextResponse.json(
|
||||
{ error: "EBAY_NOTIFICATION_ENDPOINT is not configured." },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
const challengeResponse = computeEbayChallengeResponse({
|
||||
challengeCode,
|
||||
endpoint,
|
||||
verificationToken,
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{ challengeResponse },
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.text()
|
||||
const signatureHeader = request.headers.get("x-ebay-signature")
|
||||
|
||||
try {
|
||||
const verification = await verifyEbayNotificationSignature({
|
||||
body,
|
||||
signatureHeader,
|
||||
})
|
||||
|
||||
if (!verification.verified) {
|
||||
if (
|
||||
verification.reason ===
|
||||
"Notification verification credentials are not configured."
|
||||
) {
|
||||
console.warn(
|
||||
"[ebay/notifications] accepted notification without signature verification",
|
||||
{
|
||||
reason: verification.reason,
|
||||
}
|
||||
)
|
||||
const payload = parseNotificationBody(body)
|
||||
const notification = payload?.notification
|
||||
|
||||
console.info(
|
||||
"[ebay/notifications] accepted notification without verification",
|
||||
{
|
||||
topic: payload?.metadata?.topic || "unknown",
|
||||
notificationId: notification?.notificationId || "unknown",
|
||||
publishAttemptCount: notification?.publishAttemptCount ?? null,
|
||||
}
|
||||
)
|
||||
|
||||
return new NextResponse(null, { status: 204 })
|
||||
}
|
||||
|
||||
console.warn("[ebay/notifications] signature rejected", {
|
||||
reason: verification.reason,
|
||||
})
|
||||
return NextResponse.json({ error: verification.reason }, { status: 412 })
|
||||
}
|
||||
|
||||
const payload = parseNotificationBody(body)
|
||||
const notification = payload?.notification
|
||||
|
||||
console.info("[ebay/notifications] accepted notification", {
|
||||
keyId: verification.keyId,
|
||||
topic: payload?.metadata?.topic || "unknown",
|
||||
notificationId: notification?.notificationId || "unknown",
|
||||
publishAttemptCount: notification?.publishAttemptCount ?? null,
|
||||
})
|
||||
|
||||
return new NextResponse(null, { status: 204 })
|
||||
} catch (error) {
|
||||
console.error("[ebay/notifications] failed to process notification", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to verify eBay notification.",
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
120
app/api/ebay/search/route.ts
Normal file
120
app/api/ebay/search/route.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchQuery } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { hasConvexUrl } from "@/lib/convex-config"
|
||||
import {
|
||||
filterTrustedEbayListings,
|
||||
rankListingsForQuery,
|
||||
type CachedEbayListing,
|
||||
type EbayCacheState,
|
||||
} from "@/lib/ebay-parts-match"
|
||||
|
||||
type CacheSource = "convex" | "fallback"
|
||||
|
||||
function getDisabledCacheState(message: string): EbayCacheState {
|
||||
return {
|
||||
key: "manual-parts",
|
||||
status: "disabled",
|
||||
lastSuccessfulAt: null,
|
||||
lastAttemptAt: null,
|
||||
nextEligibleAt: null,
|
||||
lastError: message,
|
||||
consecutiveFailures: 0,
|
||||
queryCount: 0,
|
||||
itemCount: 0,
|
||||
sourceQueries: [],
|
||||
freshnessMs: null,
|
||||
isStale: true,
|
||||
listingCount: 0,
|
||||
activeListingCount: 0,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorCacheState(message: string): EbayCacheState {
|
||||
const now = Date.now()
|
||||
return {
|
||||
key: "manual-parts",
|
||||
status: "error",
|
||||
lastSuccessfulAt: null,
|
||||
lastAttemptAt: now,
|
||||
nextEligibleAt: null,
|
||||
lastError: message,
|
||||
consecutiveFailures: 1,
|
||||
queryCount: 0,
|
||||
itemCount: 0,
|
||||
sourceQueries: [],
|
||||
freshnessMs: null,
|
||||
isStale: true,
|
||||
listingCount: 0,
|
||||
activeListingCount: 0,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const keywords = searchParams.get("keywords")?.trim() || ""
|
||||
const maxResults = Math.min(
|
||||
Math.max(Number.parseInt(searchParams.get("maxResults") || "6", 10) || 6, 1),
|
||||
20
|
||||
)
|
||||
|
||||
if (!keywords) {
|
||||
return NextResponse.json(
|
||||
{ error: "Keywords parameter is required" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!hasConvexUrl()) {
|
||||
const message =
|
||||
"Cached eBay backend is disabled because NEXT_PUBLIC_CONVEX_URL is not configured."
|
||||
return NextResponse.json({
|
||||
query: keywords,
|
||||
results: [],
|
||||
cache: getDisabledCacheState(message),
|
||||
cacheSource: "fallback" satisfies CacheSource,
|
||||
error: message,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const [overview, listings] = await Promise.all([
|
||||
fetchQuery(api.ebay.getCacheOverview, {}),
|
||||
fetchQuery(api.ebay.listCachedListings, { limit: 200 }),
|
||||
])
|
||||
|
||||
const trustedListings = filterTrustedEbayListings(
|
||||
listings as CachedEbayListing[]
|
||||
)
|
||||
const ranked = rankListingsForQuery(
|
||||
keywords,
|
||||
trustedListings,
|
||||
maxResults
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
query: keywords,
|
||||
results: ranked,
|
||||
cache: overview,
|
||||
cacheSource: "convex" satisfies CacheSource,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to load cached eBay listings:", error)
|
||||
const message =
|
||||
error instanceof Error
|
||||
? `Cached eBay listings are unavailable: ${error.message}`
|
||||
: "Cached eBay listings are unavailable."
|
||||
return NextResponse.json(
|
||||
{
|
||||
query: keywords,
|
||||
results: [],
|
||||
cache: getErrorCacheState(message),
|
||||
cacheSource: "fallback" satisfies CacheSource,
|
||||
error: message,
|
||||
},
|
||||
{ status: 200 }
|
||||
)
|
||||
}
|
||||
}
|
||||
51
app/api/internal/ghl/shared.ts
Normal file
51
app/api/internal/ghl/shared.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { timingSafeEqual } from "node:crypto"
|
||||
import { NextResponse } from "next/server"
|
||||
import { hasConvexUrl } from "@/lib/convex-config"
|
||||
|
||||
function readBearerToken(request: Request) {
|
||||
const authHeader = request.headers.get("authorization") || ""
|
||||
if (!authHeader.toLowerCase().startsWith("bearer ")) {
|
||||
return ""
|
||||
}
|
||||
|
||||
return authHeader.slice("bearer ".length).trim()
|
||||
}
|
||||
|
||||
function tokensMatch(expected: string, provided: string) {
|
||||
const expectedBuffer = Buffer.from(expected)
|
||||
const providedBuffer = Buffer.from(provided)
|
||||
|
||||
if (expectedBuffer.length !== providedBuffer.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return timingSafeEqual(expectedBuffer, providedBuffer)
|
||||
}
|
||||
|
||||
export function getGhlSyncToken() {
|
||||
return String(process.env.GHL_SYNC_CRON_TOKEN || "").trim()
|
||||
}
|
||||
|
||||
export async function requireGhlSyncAuth(request: Request) {
|
||||
if (!hasConvexUrl()) {
|
||||
return NextResponse.json(
|
||||
{ error: "Convex is not configured for GHL sync" },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
const configuredToken = getGhlSyncToken()
|
||||
if (!configuredToken) {
|
||||
return NextResponse.json(
|
||||
{ error: "GHL sync token is not configured" },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
const providedToken = readBearerToken(request)
|
||||
if (!providedToken || !tokensMatch(configuredToken, providedToken)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
60
app/api/internal/ghl/sync/contacts/route.ts
Normal file
60
app/api/internal/ghl/sync/contacts/route.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchMutation } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requireGhlSyncAuth } from "@/app/api/internal/ghl/shared"
|
||||
import { fetchGhlContacts } from "@/lib/server/ghl-sync"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireGhlSyncAuth(request)
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const providedItems = Array.isArray(body.items) ? body.items : null
|
||||
const fetched = providedItems
|
||||
? {
|
||||
items: providedItems,
|
||||
nextCursor:
|
||||
typeof body.nextCursor === "string" ? body.nextCursor : undefined,
|
||||
}
|
||||
: await fetchGhlContacts({
|
||||
limit: typeof body.limit === "number" ? body.limit : undefined,
|
||||
cursor: body.cursor ? String(body.cursor) : undefined,
|
||||
})
|
||||
|
||||
const imported = []
|
||||
for (const item of fetched.items) {
|
||||
const result = await fetchMutation(api.crm.importContact, {
|
||||
provider: "ghl",
|
||||
entityId: String(item.id || ""),
|
||||
payload: item,
|
||||
})
|
||||
imported.push(result?._id || result?.id || null)
|
||||
}
|
||||
|
||||
await fetchMutation(api.crm.updateSyncCheckpoint, {
|
||||
provider: "ghl",
|
||||
entityType: "contacts",
|
||||
entityId: "contacts",
|
||||
cursor: fetched.nextCursor,
|
||||
status: "synced",
|
||||
metadata: JSON.stringify({
|
||||
imported: imported.length,
|
||||
}),
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
imported: imported.length,
|
||||
nextCursor: fetched.nextCursor,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to sync GHL contacts:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to sync GHL contacts" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
70
app/api/internal/ghl/sync/conversations/route.ts
Normal file
70
app/api/internal/ghl/sync/conversations/route.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchMutation } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requireGhlSyncAuth } from "@/app/api/internal/ghl/shared"
|
||||
import { fetchGhlMessages } from "@/lib/server/ghl-sync"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireGhlSyncAuth(request)
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const providedItems = Array.isArray(body.items) ? body.items : null
|
||||
const fetched = providedItems
|
||||
? {
|
||||
items: providedItems,
|
||||
nextCursor:
|
||||
typeof body.nextCursor === "string" ? body.nextCursor : undefined,
|
||||
}
|
||||
: await fetchGhlMessages({
|
||||
limit: typeof body.limit === "number" ? body.limit : undefined,
|
||||
cursor: body.cursor ? String(body.cursor) : undefined,
|
||||
channel: body.channel === "Call" ? "Call" : "SMS",
|
||||
})
|
||||
|
||||
const grouped = new Map<string, any>()
|
||||
for (const item of fetched.items) {
|
||||
const conversationId = String(item.conversationId || item.id || "")
|
||||
if (!conversationId || grouped.has(conversationId)) {
|
||||
continue
|
||||
}
|
||||
grouped.set(conversationId, item)
|
||||
}
|
||||
|
||||
let imported = 0
|
||||
for (const [entityId, item] of grouped.entries()) {
|
||||
await fetchMutation(api.crm.importConversation, {
|
||||
provider: "ghl",
|
||||
entityId,
|
||||
payload: item,
|
||||
})
|
||||
imported += 1
|
||||
}
|
||||
|
||||
await fetchMutation(api.crm.updateSyncCheckpoint, {
|
||||
provider: "ghl",
|
||||
entityType: "conversations",
|
||||
entityId: "conversations",
|
||||
cursor: fetched.nextCursor,
|
||||
status: "synced",
|
||||
metadata: JSON.stringify({
|
||||
imported,
|
||||
}),
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
imported,
|
||||
nextCursor: fetched.nextCursor,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to sync GHL conversations:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to sync GHL conversations" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
61
app/api/internal/ghl/sync/messages/route.ts
Normal file
61
app/api/internal/ghl/sync/messages/route.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchMutation } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requireGhlSyncAuth } from "@/app/api/internal/ghl/shared"
|
||||
import { fetchGhlMessages } from "@/lib/server/ghl-sync"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireGhlSyncAuth(request)
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const providedItems = Array.isArray(body.items) ? body.items : null
|
||||
const fetched = providedItems
|
||||
? {
|
||||
items: providedItems,
|
||||
nextCursor:
|
||||
typeof body.nextCursor === "string" ? body.nextCursor : undefined,
|
||||
}
|
||||
: await fetchGhlMessages({
|
||||
limit: typeof body.limit === "number" ? body.limit : undefined,
|
||||
cursor: body.cursor ? String(body.cursor) : undefined,
|
||||
channel: body.channel === "Call" ? "Call" : "SMS",
|
||||
})
|
||||
|
||||
let imported = 0
|
||||
for (const item of fetched.items) {
|
||||
await fetchMutation(api.crm.importMessage, {
|
||||
provider: "ghl",
|
||||
entityId: String(item.id || ""),
|
||||
payload: item,
|
||||
})
|
||||
imported += 1
|
||||
}
|
||||
|
||||
await fetchMutation(api.crm.updateSyncCheckpoint, {
|
||||
provider: "ghl",
|
||||
entityType: "messages",
|
||||
entityId: "messages",
|
||||
cursor: fetched.nextCursor,
|
||||
status: "synced",
|
||||
metadata: JSON.stringify({
|
||||
imported,
|
||||
}),
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
imported,
|
||||
nextCursor: fetched.nextCursor,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to sync GHL messages:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to sync GHL messages" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
29
app/api/internal/ghl/sync/reconcile/route.ts
Normal file
29
app/api/internal/ghl/sync/reconcile/route.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchMutation } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requireGhlSyncAuth } from "@/app/api/internal/ghl/shared"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireGhlSyncAuth(request)
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const result = await fetchMutation(api.crm.reconcileExternalState, {
|
||||
provider: body.provider ? String(body.provider) : "ghl",
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
...result,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to reconcile mirrored external state:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to reconcile mirrored external state" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
69
app/api/internal/ghl/sync/recordings/route.ts
Normal file
69
app/api/internal/ghl/sync/recordings/route.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchMutation } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requireGhlSyncAuth } from "@/app/api/internal/ghl/shared"
|
||||
import { fetchGhlCallLogs } from "@/lib/server/ghl-sync"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireGhlSyncAuth(request)
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const providedItems = Array.isArray(body.items) ? body.items : null
|
||||
const fetched = providedItems
|
||||
? {
|
||||
items: providedItems,
|
||||
page: typeof body.page === "number" ? body.page : 1,
|
||||
total: providedItems.length,
|
||||
pageSize: providedItems.length,
|
||||
}
|
||||
: await fetchGhlCallLogs({
|
||||
page: typeof body.page === "number" ? body.page : undefined,
|
||||
pageSize: typeof body.pageSize === "number" ? body.pageSize : undefined,
|
||||
})
|
||||
|
||||
let imported = 0
|
||||
for (const item of fetched.items) {
|
||||
await fetchMutation(api.crm.importRecording, {
|
||||
provider: "ghl",
|
||||
entityId: String(item.id || item.messageId || ""),
|
||||
payload: {
|
||||
...item,
|
||||
recordingId: item.messageId || item.id,
|
||||
transcript: item.transcript,
|
||||
recordingUrl: item.recordingUrl,
|
||||
recordingStatus: item.transcript ? "completed" : "pending",
|
||||
},
|
||||
})
|
||||
imported += 1
|
||||
}
|
||||
|
||||
await fetchMutation(api.crm.updateSyncCheckpoint, {
|
||||
provider: "ghl",
|
||||
entityType: "recordings",
|
||||
entityId: "recordings",
|
||||
cursor: `${fetched.page}`,
|
||||
status: "synced",
|
||||
metadata: JSON.stringify({
|
||||
imported,
|
||||
total: fetched.total,
|
||||
}),
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
imported,
|
||||
page: fetched.page,
|
||||
total: fetched.total,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to sync GHL recordings:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to sync GHL recordings" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
39
app/api/internal/ghl/sync/run/route.ts
Normal file
39
app/api/internal/ghl/sync/run/route.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchAction } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requireGhlSyncAuth } from "@/app/api/internal/ghl/shared"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requireGhlSyncAuth(request)
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const result = await fetchAction(api.crm.runGhlMirror, {
|
||||
reason: body.reason ? String(body.reason) : "internal",
|
||||
forceFullBackfill: Boolean(body.forceFullBackfill),
|
||||
maxPagesPerRun:
|
||||
typeof body.maxPagesPerRun === "number" ? body.maxPagesPerRun : undefined,
|
||||
contactsLimit:
|
||||
typeof body.contactsLimit === "number" ? body.contactsLimit : undefined,
|
||||
messagesLimit:
|
||||
typeof body.messagesLimit === "number" ? body.messagesLimit : undefined,
|
||||
recordingsPageSize:
|
||||
typeof body.recordingsPageSize === "number"
|
||||
? body.recordingsPageSize
|
||||
: undefined,
|
||||
})
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
console.error("Failed to run GHL sync:", error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : "Failed to run GHL sync",
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
40
app/api/internal/phone-agent/contact-lookup/route.ts
Normal file
40
app/api/internal/phone-agent/contact-lookup/route.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchQuery } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
|
||||
import { normalizePhoneE164 } from "@/lib/phone-normalization"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requirePhoneAgentInternalAuth(request)
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const normalizedPhone = normalizePhoneE164(body.phone)
|
||||
|
||||
if (!normalizedPhone) {
|
||||
return NextResponse.json(
|
||||
{ error: "phone is required" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const context = await fetchQuery(api.voiceSessions.getPhoneAgentContextByPhone, {
|
||||
normalizedPhone,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
normalizedPhone,
|
||||
...context,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to look up phone agent contact context:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to look up phone agent contact context" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
179
app/api/internal/phone-agent/followup-reminder/route.ts
Normal file
179
app/api/internal/phone-agent/followup-reminder/route.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { fetchMutation } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
|
||||
import {
|
||||
buildSameDayReminderWindow,
|
||||
createFollowupReminderEvent,
|
||||
isGoogleCalendarConfigured,
|
||||
} from "@/lib/google-calendar"
|
||||
import { normalizePhoneE164, splitDisplayName } from "@/lib/phone-normalization"
|
||||
|
||||
function buildReminderTitle(args: {
|
||||
kind: "scheduled" | "same-day"
|
||||
callerName?: string
|
||||
company?: string
|
||||
phone?: string
|
||||
}) {
|
||||
const label = args.kind === "same-day" ? "Same-day callback" : "Callback reminder"
|
||||
const identity = [args.callerName, args.company, args.phone]
|
||||
.map((value) => String(value || "").trim())
|
||||
.filter(Boolean)
|
||||
.join(" | ")
|
||||
|
||||
return identity ? `${label}: ${identity}` : label
|
||||
}
|
||||
|
||||
function buildReminderDescription(args: {
|
||||
callerName?: string
|
||||
company?: string
|
||||
phone?: string
|
||||
reason?: string
|
||||
summaryText?: string
|
||||
adminCallUrl: string
|
||||
}) {
|
||||
return [
|
||||
args.callerName ? `Caller: ${args.callerName}` : "",
|
||||
args.company ? `Company: ${args.company}` : "",
|
||||
args.phone ? `Phone: ${args.phone}` : "",
|
||||
args.reason ? `Reason: ${args.reason}` : "",
|
||||
args.summaryText ? `Summary: ${args.summaryText}` : "",
|
||||
`RMV admin call detail: ${args.adminCallUrl}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requirePhoneAgentInternalAuth(request)
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const sessionId = String(body.sessionId || "").trim()
|
||||
const kind =
|
||||
body.kind === "same-day" ? ("same-day" as const) : ("scheduled" as const)
|
||||
|
||||
if (!sessionId) {
|
||||
return NextResponse.json(
|
||||
{ error: "sessionId is required" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const url = new URL(request.url)
|
||||
const adminCallUrl = `${url.origin}/admin/calls/${sessionId}`
|
||||
const normalizedPhone = normalizePhoneE164(body.phone)
|
||||
const callerName = String(body.callerName || "").trim()
|
||||
const company = String(body.company || "").trim()
|
||||
const reason = String(body.reason || "").trim()
|
||||
const summaryText = String(body.summaryText || "").trim()
|
||||
const calendarConfigured = isGoogleCalendarConfigured()
|
||||
|
||||
let startAt: Date
|
||||
let endAt: Date
|
||||
if (kind === "same-day") {
|
||||
const reminderWindow = buildSameDayReminderWindow()
|
||||
startAt = reminderWindow.startAt
|
||||
endAt = reminderWindow.endAt
|
||||
} else {
|
||||
startAt = new Date(String(body.startAt || ""))
|
||||
endAt = new Date(String(body.endAt || ""))
|
||||
if (Number.isNaN(endAt.getTime()) && !Number.isNaN(startAt.getTime())) {
|
||||
endAt = new Date(startAt.getTime() + 15 * 60 * 1000)
|
||||
}
|
||||
if (Number.isNaN(startAt.getTime()) || Number.isNaN(endAt.getTime()) || startAt.getTime() <= Date.now()) {
|
||||
return NextResponse.json(
|
||||
{ error: "A future startAt and endAt are required" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (kind === "scheduled" && !calendarConfigured) {
|
||||
return NextResponse.json(
|
||||
{ error: "Google Calendar follow-up scheduling is not configured" },
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
const reminder = calendarConfigured
|
||||
? await createFollowupReminderEvent({
|
||||
title: buildReminderTitle({
|
||||
kind,
|
||||
callerName,
|
||||
company,
|
||||
phone: normalizedPhone || String(body.phone || "").trim(),
|
||||
}),
|
||||
description: buildReminderDescription({
|
||||
callerName,
|
||||
company,
|
||||
phone: normalizedPhone || String(body.phone || "").trim(),
|
||||
reason,
|
||||
summaryText,
|
||||
adminCallUrl,
|
||||
}),
|
||||
startAt,
|
||||
endAt,
|
||||
})
|
||||
: {
|
||||
eventId: "",
|
||||
htmlLink: "",
|
||||
}
|
||||
|
||||
let contactProfileId: string | undefined
|
||||
if (normalizedPhone) {
|
||||
const nameParts = splitDisplayName(callerName)
|
||||
const profile = await fetchMutation(api.contactProfiles.upsertByPhone, {
|
||||
normalizedPhone,
|
||||
displayName: callerName || undefined,
|
||||
firstName: nameParts.firstName || undefined,
|
||||
lastName: nameParts.lastName || undefined,
|
||||
company: company || undefined,
|
||||
lastSummaryText: summaryText || reason || undefined,
|
||||
lastReminderAt: Date.now(),
|
||||
reminderNotes: reason || undefined,
|
||||
source: "phone-agent",
|
||||
})
|
||||
contactProfileId = profile?._id
|
||||
}
|
||||
|
||||
const call = await fetchMutation(api.voiceSessions.linkPhoneCallLead, {
|
||||
sessionId,
|
||||
contactProfileId,
|
||||
contactDisplayName: callerName || undefined,
|
||||
contactCompany: company || undefined,
|
||||
reminderStatus: kind === "same-day" ? "sameDay" : "scheduled",
|
||||
reminderRequestedAt: Date.now(),
|
||||
reminderStartAt: startAt.getTime(),
|
||||
reminderEndAt: endAt.getTime(),
|
||||
reminderCalendarEventId: reminder.eventId || undefined,
|
||||
reminderCalendarHtmlLink: reminder.htmlLink || undefined,
|
||||
reminderNote:
|
||||
reason ||
|
||||
summaryText ||
|
||||
(!calendarConfigured ? "Manual follow-up reminder created without Google Calendar." : undefined),
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
calendarConfigured,
|
||||
reminder: {
|
||||
kind,
|
||||
startAt: startAt.toISOString(),
|
||||
endAt: endAt.toISOString(),
|
||||
eventId: reminder.eventId || null,
|
||||
htmlLink: reminder.htmlLink || null,
|
||||
},
|
||||
call,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to create phone agent follow-up reminder:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create phone agent follow-up reminder" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
42
app/api/internal/phone-agent/followup-slots/route.ts
Normal file
42
app/api/internal/phone-agent/followup-slots/route.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
|
||||
import {
|
||||
isGoogleCalendarConfigured,
|
||||
listFutureCallbackSlots,
|
||||
} from "@/lib/google-calendar"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requirePhoneAgentInternalAuth(request)
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const limit =
|
||||
typeof body.limit === "number" && body.limit > 0
|
||||
? Math.min(body.limit, 5)
|
||||
: 3
|
||||
|
||||
if (!isGoogleCalendarConfigured()) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
calendarConfigured: false,
|
||||
slots: [],
|
||||
})
|
||||
}
|
||||
|
||||
const slots = await listFutureCallbackSlots(limit)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
calendarConfigured: true,
|
||||
slots,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to list phone agent callback slots:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to list phone agent callback slots" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
37
app/api/internal/phone-agent/service-knowledge/route.ts
Normal file
37
app/api/internal/phone-agent/service-knowledge/route.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
|
||||
import { searchServiceKnowledge } from "@/lib/service-knowledge"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requirePhoneAgentInternalAuth(request)
|
||||
if (authError) {
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const query = String(body.query || "").trim()
|
||||
if (!query) {
|
||||
return NextResponse.json({ error: "query is required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const results = await searchServiceKnowledge({
|
||||
query,
|
||||
limit:
|
||||
typeof body.limit === "number" && body.limit > 0
|
||||
? Math.min(body.limit, 6)
|
||||
: 4,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
results,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to search phone agent service knowledge:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to search phone agent service knowledge" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +1,41 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { fetchMutation, fetchQuery } from "convex/nextjs";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared";
|
||||
import { buildPhoneCallSummary, sendPhoneCallSummaryEmail } from "@/lib/phone-calls";
|
||||
import { NextResponse } from "next/server"
|
||||
import { fetchMutation, fetchQuery } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
|
||||
import {
|
||||
buildPhoneCallSummary,
|
||||
sendPhoneCallSummaryEmail,
|
||||
} from "@/lib/phone-calls"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requirePhoneAgentInternalAuth(request);
|
||||
const authError = await requirePhoneAgentInternalAuth(request)
|
||||
if (authError) {
|
||||
return authError;
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const callId = String(body.sessionId || body.roomName || "");
|
||||
const body = await request.json()
|
||||
const callId = String(body.sessionId || body.roomName || "")
|
||||
if (!callId) {
|
||||
return NextResponse.json({ error: "sessionId or roomName is required" }, { status: 400 });
|
||||
return NextResponse.json(
|
||||
{ error: "sessionId or roomName is required" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const detail = await fetchQuery(api.voiceSessions.getAdminPhoneCallDetail, {
|
||||
callId,
|
||||
});
|
||||
})
|
||||
|
||||
if (!detail) {
|
||||
return NextResponse.json({ error: "Phone call not found" }, { status: 404 });
|
||||
return NextResponse.json(
|
||||
{ error: "Phone call not found" },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const summaryText = buildPhoneCallSummary(detail);
|
||||
const url = new URL(request.url)
|
||||
const summaryText = buildPhoneCallSummary(detail)
|
||||
const notificationResult = await sendPhoneCallSummaryEmail({
|
||||
detail: {
|
||||
...detail,
|
||||
|
|
@ -36,7 +45,7 @@ export async function POST(request: Request) {
|
|||
},
|
||||
},
|
||||
adminUrl: url.origin,
|
||||
});
|
||||
})
|
||||
|
||||
const result = await fetchMutation(api.voiceSessions.completeSession, {
|
||||
sessionId: detail.call.id,
|
||||
|
|
@ -45,20 +54,26 @@ export async function POST(request: Request) {
|
|||
recordingStatus: body.recordingStatus,
|
||||
recordingId: body.recordingId ? String(body.recordingId) : undefined,
|
||||
recordingUrl: body.recordingUrl ? String(body.recordingUrl) : undefined,
|
||||
recordingError: body.recordingError ? String(body.recordingError) : undefined,
|
||||
recordingError: body.recordingError
|
||||
? String(body.recordingError)
|
||||
: undefined,
|
||||
summaryText,
|
||||
notificationStatus: notificationResult.status,
|
||||
notificationSentAt: notificationResult.status === "sent" ? Date.now() : undefined,
|
||||
notificationSentAt:
|
||||
notificationResult.status === "sent" ? Date.now() : undefined,
|
||||
notificationError: notificationResult.error,
|
||||
});
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
call: result,
|
||||
notification: notificationResult,
|
||||
});
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to complete phone call sync:", error);
|
||||
return NextResponse.json({ error: "Failed to complete phone call sync" }, { status: 500 });
|
||||
console.error("Failed to complete phone call sync:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to complete phone call sync" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,77 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { fetchMutation } from "convex/nextjs";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared";
|
||||
import { NextResponse } from "next/server"
|
||||
import { fetchMutation } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requirePhoneAgentInternalAuth(request);
|
||||
const authError = await requirePhoneAgentInternalAuth(request)
|
||||
if (authError) {
|
||||
return authError;
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const body = await request.json()
|
||||
const result = await fetchMutation(api.voiceSessions.linkPhoneCallLead, {
|
||||
sessionId: body.sessionId,
|
||||
linkedLeadId: body.linkedLeadId ? String(body.linkedLeadId) : undefined,
|
||||
contactProfileId: body.contactProfileId || undefined,
|
||||
contactDisplayName: body.contactDisplayName
|
||||
? String(body.contactDisplayName)
|
||||
: undefined,
|
||||
contactCompany: body.contactCompany
|
||||
? String(body.contactCompany)
|
||||
: undefined,
|
||||
leadOutcome: body.leadOutcome || "none",
|
||||
handoffRequested: typeof body.handoffRequested === "boolean" ? body.handoffRequested : undefined,
|
||||
handoffReason: body.handoffReason ? String(body.handoffReason) : undefined,
|
||||
});
|
||||
handoffRequested:
|
||||
typeof body.handoffRequested === "boolean"
|
||||
? body.handoffRequested
|
||||
: undefined,
|
||||
handoffReason: body.handoffReason
|
||||
? String(body.handoffReason)
|
||||
: undefined,
|
||||
reminderStatus: body.reminderStatus || undefined,
|
||||
reminderRequestedAt:
|
||||
typeof body.reminderRequestedAt === "number"
|
||||
? body.reminderRequestedAt
|
||||
: undefined,
|
||||
reminderStartAt:
|
||||
typeof body.reminderStartAt === "number"
|
||||
? body.reminderStartAt
|
||||
: undefined,
|
||||
reminderEndAt:
|
||||
typeof body.reminderEndAt === "number"
|
||||
? body.reminderEndAt
|
||||
: undefined,
|
||||
reminderCalendarEventId: body.reminderCalendarEventId
|
||||
? String(body.reminderCalendarEventId)
|
||||
: undefined,
|
||||
reminderCalendarHtmlLink: body.reminderCalendarHtmlLink
|
||||
? String(body.reminderCalendarHtmlLink)
|
||||
: undefined,
|
||||
reminderNote: body.reminderNote ? String(body.reminderNote) : undefined,
|
||||
warmTransferStatus: body.warmTransferStatus || undefined,
|
||||
warmTransferTarget: body.warmTransferTarget
|
||||
? String(body.warmTransferTarget)
|
||||
: undefined,
|
||||
warmTransferAttemptedAt:
|
||||
typeof body.warmTransferAttemptedAt === "number"
|
||||
? body.warmTransferAttemptedAt
|
||||
: undefined,
|
||||
warmTransferConnectedAt:
|
||||
typeof body.warmTransferConnectedAt === "number"
|
||||
? body.warmTransferConnectedAt
|
||||
: undefined,
|
||||
warmTransferFailureReason: body.warmTransferFailureReason
|
||||
? String(body.warmTransferFailureReason)
|
||||
: undefined,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true, call: result });
|
||||
return NextResponse.json({ success: true, call: result })
|
||||
} catch (error) {
|
||||
console.error("Failed to link phone call lead:", error);
|
||||
return NextResponse.json({ error: "Failed to link phone call lead" }, { status: 500 });
|
||||
console.error("Failed to link phone call lead:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to link phone call lead" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,51 +1,51 @@
|
|||
import { timingSafeEqual } from "node:crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { hasConvexUrl } from "@/lib/convex-config";
|
||||
import { timingSafeEqual } from "node:crypto"
|
||||
import { NextResponse } from "next/server"
|
||||
import { hasConvexUrl } from "@/lib/convex-config"
|
||||
|
||||
function readBearerToken(request: Request) {
|
||||
const authHeader = request.headers.get("authorization") || "";
|
||||
const authHeader = request.headers.get("authorization") || ""
|
||||
if (!authHeader.toLowerCase().startsWith("bearer ")) {
|
||||
return "";
|
||||
return ""
|
||||
}
|
||||
|
||||
return authHeader.slice("bearer ".length).trim();
|
||||
return authHeader.slice("bearer ".length).trim()
|
||||
}
|
||||
|
||||
function tokensMatch(expected: string, provided: string) {
|
||||
const expectedBuffer = Buffer.from(expected);
|
||||
const providedBuffer = Buffer.from(provided);
|
||||
const expectedBuffer = Buffer.from(expected)
|
||||
const providedBuffer = Buffer.from(provided)
|
||||
|
||||
if (expectedBuffer.length !== providedBuffer.length) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
return timingSafeEqual(expectedBuffer, providedBuffer);
|
||||
return timingSafeEqual(expectedBuffer, providedBuffer)
|
||||
}
|
||||
|
||||
export function getPhoneAgentInternalToken() {
|
||||
return String(process.env.PHONE_AGENT_INTERNAL_TOKEN || "").trim();
|
||||
return String(process.env.PHONE_AGENT_INTERNAL_TOKEN || "").trim()
|
||||
}
|
||||
|
||||
export async function requirePhoneAgentInternalAuth(request: Request) {
|
||||
if (!hasConvexUrl()) {
|
||||
return NextResponse.json(
|
||||
{ error: "Convex is not configured for phone call sync" },
|
||||
{ status: 503 },
|
||||
);
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
const configuredToken = getPhoneAgentInternalToken();
|
||||
const configuredToken = getPhoneAgentInternalToken()
|
||||
if (!configuredToken) {
|
||||
return NextResponse.json(
|
||||
{ error: "Phone call sync token is not configured" },
|
||||
{ status: 503 },
|
||||
);
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
|
||||
const providedToken = readBearerToken(request);
|
||||
const providedToken = readBearerToken(request)
|
||||
if (!providedToken || !tokensMatch(configuredToken, providedToken)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||
}
|
||||
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,37 +1,79 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { fetchMutation } from "convex/nextjs";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared";
|
||||
import { NextResponse } from "next/server"
|
||||
import { fetchMutation, fetchQuery } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
|
||||
import { normalizePhoneE164 } from "@/lib/phone-normalization"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requirePhoneAgentInternalAuth(request);
|
||||
const authError = await requirePhoneAgentInternalAuth(request)
|
||||
if (authError) {
|
||||
return authError;
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const result = await fetchMutation(api.voiceSessions.upsertPhoneCallSession, {
|
||||
roomName: String(body.roomName || ""),
|
||||
participantIdentity: String(body.participantIdentity || ""),
|
||||
siteUrl: body.siteUrl ? String(body.siteUrl) : undefined,
|
||||
pathname: body.pathname ? String(body.pathname) : undefined,
|
||||
pageUrl: body.pageUrl ? String(body.pageUrl) : undefined,
|
||||
source: "phone-agent",
|
||||
metadata: body.metadata ? String(body.metadata) : undefined,
|
||||
startedAt: typeof body.startedAt === "number" ? body.startedAt : undefined,
|
||||
recordingDisclosureAt:
|
||||
typeof body.recordingDisclosureAt === "number" ? body.recordingDisclosureAt : undefined,
|
||||
recordingStatus: body.recordingStatus || "pending",
|
||||
});
|
||||
const body = await request.json()
|
||||
let metadata: Record<string, unknown> = {}
|
||||
if (typeof body.metadata === "string" && body.metadata.trim()) {
|
||||
try {
|
||||
metadata = JSON.parse(body.metadata)
|
||||
} catch {
|
||||
metadata = {}
|
||||
}
|
||||
}
|
||||
const callerPhone = normalizePhoneE164(
|
||||
metadata.participantPhone || body.participantIdentity
|
||||
)
|
||||
const contactContext = callerPhone
|
||||
? await fetchQuery(api.voiceSessions.getPhoneAgentContextByPhone, {
|
||||
normalizedPhone: callerPhone,
|
||||
})
|
||||
: null
|
||||
|
||||
const result = await fetchMutation(
|
||||
api.voiceSessions.upsertPhoneCallSession,
|
||||
{
|
||||
roomName: String(body.roomName || ""),
|
||||
participantIdentity: String(body.participantIdentity || ""),
|
||||
callerPhone: callerPhone || undefined,
|
||||
siteUrl: body.siteUrl ? String(body.siteUrl) : undefined,
|
||||
pathname: body.pathname ? String(body.pathname) : undefined,
|
||||
pageUrl: body.pageUrl ? String(body.pageUrl) : undefined,
|
||||
source: "phone-agent",
|
||||
metadata: body.metadata ? String(body.metadata) : undefined,
|
||||
contactProfileId: contactContext?.contactProfile?.id,
|
||||
contactDisplayName:
|
||||
contactContext?.contactProfile?.displayName ||
|
||||
(contactContext?.recentLead
|
||||
? `${contactContext.recentLead.firstName} ${contactContext.recentLead.lastName}`.trim()
|
||||
: undefined),
|
||||
contactCompany:
|
||||
contactContext?.contactProfile?.company ||
|
||||
contactContext?.recentLead?.company ||
|
||||
undefined,
|
||||
startedAt:
|
||||
typeof body.startedAt === "number" ? body.startedAt : undefined,
|
||||
recordingDisclosureAt:
|
||||
typeof body.recordingDisclosureAt === "number"
|
||||
? body.recordingDisclosureAt
|
||||
: undefined,
|
||||
recordingStatus: body.recordingStatus || "pending",
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
sessionId: result?._id,
|
||||
roomName: result?.roomName,
|
||||
});
|
||||
callerPhone,
|
||||
contactProfile: contactContext?.contactProfile || null,
|
||||
recentLead: contactContext?.recentLead || null,
|
||||
recentSession: contactContext?.recentSession || null,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("Failed to start phone call sync:", error);
|
||||
return NextResponse.json({ error: "Failed to start phone call sync" }, { status: 500 });
|
||||
console.error("Failed to start phone call sync:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to start phone call sync" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { fetchMutation } from "convex/nextjs";
|
||||
import { api } from "@/convex/_generated/api";
|
||||
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared";
|
||||
import { NextResponse } from "next/server"
|
||||
import { fetchMutation } from "convex/nextjs"
|
||||
import { api } from "@/convex/_generated/api"
|
||||
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = await requirePhoneAgentInternalAuth(request);
|
||||
const authError = await requirePhoneAgentInternalAuth(request)
|
||||
if (authError) {
|
||||
return authError;
|
||||
return authError
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const body = await request.json()
|
||||
await fetchMutation(api.voiceSessions.addTranscriptTurn, {
|
||||
sessionId: body.sessionId,
|
||||
roomName: String(body.roomName || ""),
|
||||
|
|
@ -21,12 +21,16 @@ export async function POST(request: Request) {
|
|||
isFinal: typeof body.isFinal === "boolean" ? body.isFinal : undefined,
|
||||
language: body.language ? String(body.language) : undefined,
|
||||
source: "phone-agent",
|
||||
createdAt: typeof body.createdAt === "number" ? body.createdAt : undefined,
|
||||
});
|
||||
createdAt:
|
||||
typeof body.createdAt === "number" ? body.createdAt : undefined,
|
||||
})
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
console.error("Failed to append phone call turn:", error);
|
||||
return NextResponse.json({ error: "Failed to append phone call turn" }, { status: 500 });
|
||||
console.error("Failed to append phone call turn:", error)
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to append phone call turn" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,11 +11,19 @@ type TokenRequestBody = {
|
|||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = (await request.json().catch(() => ({}))) as TokenRequestBody
|
||||
const pathname = typeof body.pathname === "string" && body.pathname.trim() ? body.pathname.trim() : "/"
|
||||
const pathname =
|
||||
typeof body.pathname === "string" && body.pathname.trim()
|
||||
? body.pathname.trim()
|
||||
: "/"
|
||||
|
||||
if (isVoiceAssistantSuppressedRoute(pathname)) {
|
||||
console.info("[voice-assistant/token] blocked on suppressed route", { pathname })
|
||||
return NextResponse.json({ error: "Voice assistant is not available on this route." }, { status: 403 })
|
||||
console.info("[voice-assistant/token] blocked on suppressed route", {
|
||||
pathname,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{ error: "Voice assistant is not available on this route." },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
const tokenResponse = await createVoiceAssistantTokenResponse(pathname)
|
||||
|
|
@ -30,9 +38,12 @@ export async function POST(request: NextRequest) {
|
|||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : "Failed to create voice assistant token",
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to create voice assistant token",
|
||||
},
|
||||
{ status: 500 },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ function invalidPath(pathValue: string) {
|
|||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ path: string[] }> },
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
try {
|
||||
const { path: pathArray } = await params
|
||||
|
|
@ -59,7 +59,9 @@ export async function GET(
|
|||
return new NextResponse("File not found", { status: 404 })
|
||||
}
|
||||
|
||||
const fileToRead = existsSync(normalizedFullPath) ? normalizedFullPath : fullPath
|
||||
const fileToRead = existsSync(normalizedFullPath)
|
||||
? normalizedFullPath
|
||||
: fullPath
|
||||
const resolvedPath = fileToRead.replace(/\\/g, "/")
|
||||
if (!resolvedPath.startsWith(normalizedManualsDir)) {
|
||||
return new NextResponse("Invalid path", { status: 400 })
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { requireAdminToken } from '@/lib/server/admin-auth'
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { requireAdminToken } from "@/lib/server/admin-auth"
|
||||
|
||||
// Order types
|
||||
interface OrderItem {
|
||||
|
|
@ -17,7 +17,7 @@ interface Order {
|
|||
items: OrderItem[]
|
||||
totalAmount: number
|
||||
currency: string
|
||||
status: 'pending' | 'paid' | 'fulfilled' | 'cancelled' | 'refunded'
|
||||
status: "pending" | "paid" | "fulfilled" | "cancelled" | "refunded"
|
||||
paymentIntentId: string | null
|
||||
stripeSessionId: string | null
|
||||
createdAt: string
|
||||
|
|
@ -38,7 +38,7 @@ let orders: Order[] = []
|
|||
|
||||
// Generate a simple ID for demo
|
||||
function generateOrderId(): string {
|
||||
return 'ORD-' + Date.now() + '-' + Math.floor(Math.random() * 1000)
|
||||
return "ORD-" + Date.now() + "-" + Math.floor(Math.random() * 1000)
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
|
|
@ -49,26 +49,29 @@ export async function GET(request: NextRequest) {
|
|||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
const limit = parseInt(searchParams.get('limit') || '10')
|
||||
const status = searchParams.get('status') || undefined
|
||||
const customerEmail = searchParams.get('customerEmail') || undefined
|
||||
const page = parseInt(searchParams.get("page") || "1")
|
||||
const limit = parseInt(searchParams.get("limit") || "10")
|
||||
const status = searchParams.get("status") || undefined
|
||||
const customerEmail = searchParams.get("customerEmail") || undefined
|
||||
|
||||
// Filter orders
|
||||
let filteredOrders = [...orders]
|
||||
|
||||
|
||||
if (status) {
|
||||
filteredOrders = filteredOrders.filter(order => order.status === status)
|
||||
filteredOrders = filteredOrders.filter((order) => order.status === status)
|
||||
}
|
||||
|
||||
|
||||
if (customerEmail) {
|
||||
filteredOrders = filteredOrders.filter(order =>
|
||||
filteredOrders = filteredOrders.filter((order) =>
|
||||
order.customerEmail.toLowerCase().includes(customerEmail.toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
// Sort by creation date (newest first)
|
||||
filteredOrders.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
filteredOrders.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
)
|
||||
|
||||
// Pagination
|
||||
const startIndex = (page - 1) * limit
|
||||
|
|
@ -81,13 +84,13 @@ export async function GET(request: NextRequest) {
|
|||
page,
|
||||
limit,
|
||||
total: filteredOrders.length,
|
||||
totalPages: Math.ceil(filteredOrders.length / limit)
|
||||
}
|
||||
totalPages: Math.ceil(filteredOrders.length / limit),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching orders:', error)
|
||||
console.error("Error fetching orders:", error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch orders' },
|
||||
{ error: "Failed to fetch orders" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
|
@ -101,40 +104,43 @@ export async function POST(request: NextRequest) {
|
|||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { items, customerEmail, paymentIntentId, stripeSessionId, shippingAddress } = body
|
||||
const {
|
||||
items,
|
||||
customerEmail,
|
||||
paymentIntentId,
|
||||
stripeSessionId,
|
||||
shippingAddress,
|
||||
} = body
|
||||
|
||||
// Validate required fields
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Items are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
return NextResponse.json({ error: "Items are required" }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!customerEmail) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Customer email is required' },
|
||||
{ error: "Customer email is required" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!paymentIntentId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Payment intent ID is required' },
|
||||
{ error: "Payment intent ID is required" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!stripeSessionId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Stripe session ID is required' },
|
||||
{ error: "Stripe session ID is required" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Calculate total
|
||||
const totalAmount = items.reduce((total: number, item: OrderItem) => {
|
||||
return total + (item.price * item.quantity)
|
||||
return total + item.price * item.quantity
|
||||
}, 0)
|
||||
|
||||
// Create order
|
||||
|
|
@ -144,22 +150,22 @@ export async function POST(request: NextRequest) {
|
|||
customerEmail,
|
||||
items,
|
||||
totalAmount,
|
||||
currency: 'usd',
|
||||
status: 'paid', // Assume payment was successful since webhook was triggered
|
||||
currency: "usd",
|
||||
status: "paid", // Assume payment was successful since webhook was triggered
|
||||
paymentIntentId,
|
||||
stripeSessionId,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
shippingAddress
|
||||
shippingAddress,
|
||||
}
|
||||
|
||||
orders.unshift(newOrder) // Add to beginning of array
|
||||
|
||||
return NextResponse.json(newOrder, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Error creating order:', error)
|
||||
console.error("Error creating order:", error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create order' },
|
||||
{ error: "Failed to create order" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getStripeClient } from '@/lib/stripe/client'
|
||||
import { requireAdminToken } from '@/lib/server/admin-auth'
|
||||
import {
|
||||
fetchAllProducts,
|
||||
fetchProductById,
|
||||
createProductInStripe,
|
||||
updateProductInStripe,
|
||||
deactivateProductInStripe
|
||||
} from '@/lib/stripe/products'
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getStripeClient } from "@/lib/stripe/client"
|
||||
import { requireAdminToken } from "@/lib/server/admin-auth"
|
||||
import {
|
||||
fetchAllProducts,
|
||||
fetchProductById,
|
||||
createProductInStripe,
|
||||
updateProductInStripe,
|
||||
deactivateProductInStripe,
|
||||
} from "@/lib/stripe/products"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const authError = requireAdminToken(request)
|
||||
|
|
@ -17,10 +17,10 @@ export async function GET(request: NextRequest) {
|
|||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const page = parseInt(searchParams.get('page') || '1')
|
||||
const limit = parseInt(searchParams.get('limit') || '20')
|
||||
const search = searchParams.get('search') || undefined
|
||||
const category = searchParams.get('category') || undefined
|
||||
const page = parseInt(searchParams.get("page") || "1")
|
||||
const limit = parseInt(searchParams.get("limit") || "20")
|
||||
const search = searchParams.get("search") || undefined
|
||||
const category = searchParams.get("category") || undefined
|
||||
|
||||
// Get all products from Stripe
|
||||
const products = await fetchAllProducts()
|
||||
|
|
@ -30,15 +30,16 @@ export async function GET(request: NextRequest) {
|
|||
|
||||
if (search) {
|
||||
const searchTerm = search.toLowerCase()
|
||||
filteredProducts = filteredProducts.filter(product =>
|
||||
product.name.toLowerCase().includes(searchTerm) ||
|
||||
product.description?.toLowerCase().includes(searchTerm)
|
||||
filteredProducts = filteredProducts.filter(
|
||||
(product) =>
|
||||
product.name.toLowerCase().includes(searchTerm) ||
|
||||
product.description?.toLowerCase().includes(searchTerm)
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: Implement category filtering based on metadata
|
||||
// if (category) {
|
||||
// filteredProducts = filteredProducts.filter(product =>
|
||||
// filteredProducts = filteredProducts.filter(product =>
|
||||
// product.metadata?.category === category
|
||||
// )
|
||||
// }
|
||||
|
|
@ -57,13 +58,13 @@ export async function GET(request: NextRequest) {
|
|||
page,
|
||||
limit,
|
||||
total: filteredProducts.length,
|
||||
totalPages: Math.ceil(filteredProducts.length / limit)
|
||||
}
|
||||
totalPages: Math.ceil(filteredProducts.length / limit),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error fetching admin products:', error)
|
||||
console.error("Error fetching admin products:", error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch products' },
|
||||
{ error: "Failed to fetch products" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
|
@ -82,14 +83,14 @@ export async function POST(request: NextRequest) {
|
|||
// Validate required fields
|
||||
if (!name || !price) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Name and price are required' },
|
||||
{ error: "Name and price are required" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof price !== 'number' || price <= 0) {
|
||||
if (typeof price !== "number" || price <= 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Price must be a positive number' },
|
||||
{ error: "Price must be a positive number" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
|
@ -97,25 +98,25 @@ export async function POST(request: NextRequest) {
|
|||
// Create product in Stripe
|
||||
const result = await createProductInStripe({
|
||||
name,
|
||||
description: description || '',
|
||||
description: description || "",
|
||||
price,
|
||||
currency: currency || 'usd',
|
||||
currency: currency || "usd",
|
||||
images: images || [],
|
||||
metadata: metadata || {}
|
||||
metadata: metadata || {},
|
||||
})
|
||||
|
||||
if (!result) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create product in Stripe' },
|
||||
{ error: "Failed to create product in Stripe" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json(result, { status: 201 })
|
||||
} catch (error) {
|
||||
console.error('Error creating product:', error)
|
||||
console.error("Error creating product:", error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create product' },
|
||||
{ error: "Failed to create product" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
|
@ -134,7 +135,7 @@ export async function PUT(request: NextRequest) {
|
|||
|
||||
if (!action || !Array.isArray(productIds) || productIds.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Action and product IDs are required' },
|
||||
{ error: "Action and product IDs are required" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
|
@ -143,10 +144,10 @@ export async function PUT(request: NextRequest) {
|
|||
const results = []
|
||||
|
||||
switch (action) {
|
||||
case 'update':
|
||||
case "update":
|
||||
if (!updates) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Updates are required for action: update' },
|
||||
{ error: "Updates are required for action: update" },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
|
@ -157,31 +158,31 @@ export async function PUT(request: NextRequest) {
|
|||
results.push({
|
||||
productId,
|
||||
success: true,
|
||||
data: result
|
||||
data: result,
|
||||
})
|
||||
} catch (error) {
|
||||
results.push({
|
||||
productId,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
})
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'deactivate':
|
||||
case "deactivate":
|
||||
for (const productId of productIds) {
|
||||
try {
|
||||
const success = await deactivateProductInStripe(productId)
|
||||
results.push({
|
||||
productId,
|
||||
success
|
||||
success,
|
||||
})
|
||||
} catch (error) {
|
||||
results.push({
|
||||
productId,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -199,14 +200,14 @@ export async function PUT(request: NextRequest) {
|
|||
results,
|
||||
summary: {
|
||||
total: productIds.length,
|
||||
successful: results.filter(r => r.success).length,
|
||||
failed: results.filter(r => !r.success).length
|
||||
}
|
||||
successful: results.filter((r) => r.success).length,
|
||||
failed: results.filter((r) => !r.success).length,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error bulk updating products:', error)
|
||||
console.error("Error bulk updating products:", error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to bulk update products' },
|
||||
{ error: "Failed to bulk update products" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ import { NextRequest, NextResponse } from "next/server"
|
|||
import { getGSCConfig } from "@/lib/google-search-console"
|
||||
|
||||
// API routes are not supported in static export (GHL hosting)
|
||||
export const dynamic = 'force-static'
|
||||
export const dynamic = "force-static"
|
||||
|
||||
/**
|
||||
* API Route for requesting URL indexing in Google Search Console
|
||||
* POST /api/request-indexing
|
||||
*
|
||||
*
|
||||
* Body: { url: string }
|
||||
* NOTE: This route is disabled for static export.
|
||||
*/
|
||||
|
|
@ -68,13 +68,14 @@ export async function POST(request: NextRequest) {
|
|||
success: true,
|
||||
message: "Indexing request endpoint ready",
|
||||
url,
|
||||
note: "See SEO_SETUP.md for complete Google Search Console API setup instructions",
|
||||
note: "See docs/operations/SEO_SETUP.md for complete Google Search Console API setup instructions",
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error occurred",
|
||||
error:
|
||||
error instanceof Error ? error.message : "Unknown error occurred",
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
|
|
@ -90,9 +91,7 @@ export async function GET() {
|
|||
return NextResponse.json({
|
||||
message: "Google Search Console URL Indexing Request API",
|
||||
configured: !!(config.serviceAccountEmail && config.privateKey),
|
||||
instructions: "POST to this endpoint with { url: 'https://example.com/page' } in body",
|
||||
instructions:
|
||||
"POST to this endpoint with { url: 'https://example.com/page' } in body",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { handleLeadRequest } from "@/lib/server/contact-submission";
|
||||
import { handleLeadRequest } from "@/lib/server/contact-submission"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return handleLeadRequest(request, "request-machine");
|
||||
return handleLeadRequest(request, "request-machine")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@ import { getGSCConfig } from "@/lib/google-search-console"
|
|||
import { businessConfig } from "@/lib/seo-config"
|
||||
|
||||
// API routes are not supported in static export (GHL hosting)
|
||||
export const dynamic = 'force-static'
|
||||
export const dynamic = "force-static"
|
||||
|
||||
/**
|
||||
* API Route for submitting sitemap to Google Search Console
|
||||
* POST /api/sitemap-submit
|
||||
*
|
||||
*
|
||||
* Body: { sitemapUrl?: string, siteUrl?: string }
|
||||
* NOTE: This route is disabled for static export.
|
||||
*/
|
||||
|
|
@ -21,7 +21,8 @@ export async function generateStaticParams() {
|
|||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const sitemapUrl = body.sitemapUrl || `${businessConfig.website}/sitemap.xml`
|
||||
const sitemapUrl =
|
||||
body.sitemapUrl || `${businessConfig.website}/sitemap.xml`
|
||||
const siteUrl = body.siteUrl || businessConfig.website
|
||||
|
||||
const config = getGSCConfig()
|
||||
|
|
@ -60,13 +61,14 @@ export async function POST(request: NextRequest) {
|
|||
message: "Sitemap submission endpoint ready",
|
||||
sitemapUrl,
|
||||
siteUrl,
|
||||
note: "See SEO_SETUP.md for complete Google Search Console API setup instructions",
|
||||
note: "See docs/operations/SEO_SETUP.md for complete Google Search Console API setup instructions",
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error occurred",
|
||||
error:
|
||||
error instanceof Error ? error.message : "Unknown error occurred",
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
|
|
@ -84,9 +86,7 @@ export async function GET() {
|
|||
message: "Google Search Console Sitemap Submission API",
|
||||
sitemapUrl,
|
||||
configured: !!(config.serviceAccountEmail && config.privateKey),
|
||||
instructions: "POST to this endpoint with optional sitemapUrl and siteUrl in body",
|
||||
instructions:
|
||||
"POST to this endpoint with optional sitemapUrl and siteUrl in body",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getStripeClient } from '@/lib/stripe/client'
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getStripeClient } from "@/lib/stripe/client"
|
||||
|
||||
/**
|
||||
* POST /api/stripe/checkout
|
||||
|
|
@ -13,24 +13,25 @@ export async function POST(request: NextRequest) {
|
|||
const { items, successUrl, cancelUrl } = body
|
||||
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Items are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
return NextResponse.json({ error: "Items are required" }, { status: 400 })
|
||||
}
|
||||
|
||||
// Build line items for Stripe Checkout
|
||||
const lineItems = items.map((item: { priceId: string; quantity: number }) => ({
|
||||
price: item.priceId,
|
||||
quantity: item.quantity,
|
||||
}))
|
||||
const lineItems = items.map(
|
||||
(item: { priceId: string; quantity: number }) => ({
|
||||
price: item.priceId,
|
||||
quantity: item.quantity,
|
||||
})
|
||||
)
|
||||
|
||||
// Create Stripe Checkout session
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
payment_method_types: ['card'],
|
||||
payment_method_types: ["card"],
|
||||
line_items: lineItems,
|
||||
mode: 'payment',
|
||||
success_url: successUrl || `${request.nextUrl.origin}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||
mode: "payment",
|
||||
success_url:
|
||||
successUrl ||
|
||||
`${request.nextUrl.origin}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: cancelUrl || `${request.nextUrl.origin}/checkout/cancel`,
|
||||
metadata: {
|
||||
// Add any additional metadata here
|
||||
|
|
@ -39,12 +40,10 @@ export async function POST(request: NextRequest) {
|
|||
|
||||
return NextResponse.json({ sessionId: session.id, url: session.url })
|
||||
} catch (error) {
|
||||
console.error('Error creating checkout session:', error)
|
||||
console.error("Error creating checkout session:", error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to create checkout session' },
|
||||
{ error: "Failed to create checkout session" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { fetchAllProducts } from '@/lib/stripe/products'
|
||||
import { NextResponse } from "next/server"
|
||||
import { fetchAllProducts } from "@/lib/stripe/products"
|
||||
|
||||
/**
|
||||
* GET /api/stripe/products
|
||||
|
|
@ -10,12 +10,10 @@ export async function GET() {
|
|||
const products = await fetchAllProducts()
|
||||
return NextResponse.json({ products }, { status: 200 })
|
||||
} catch (error) {
|
||||
console.error('Error in /api/stripe/products:', error)
|
||||
console.error("Error in /api/stripe/products:", error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch products' },
|
||||
{ error: "Failed to fetch products" },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
import { getStripeClient } from '@/lib/stripe/client'
|
||||
import { requireAdminToken } from '@/lib/server/admin-auth'
|
||||
import { NextResponse } from "next/server"
|
||||
import { getStripeClient, STRIPE_API_VERSION } from "@/lib/stripe/client"
|
||||
import { requireAdminToken } from "@/lib/server/admin-auth"
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const authError = requireAdminToken(request)
|
||||
|
|
@ -10,20 +10,20 @@ export async function POST(request: Request) {
|
|||
|
||||
try {
|
||||
const stripe = getStripeClient()
|
||||
|
||||
|
||||
// Test basic Stripe connectivity
|
||||
const account = await stripe.accounts.retrieve()
|
||||
|
||||
|
||||
// Get products and prices
|
||||
const products = await stripe.products.list({
|
||||
active: true,
|
||||
expand: ['data.default_price'],
|
||||
expand: ["data.default_price"],
|
||||
limit: 50,
|
||||
})
|
||||
|
||||
// Get payment methods available
|
||||
const paymentMethods = await stripe.paymentMethods.list({
|
||||
type: 'card',
|
||||
type: "card",
|
||||
})
|
||||
|
||||
// Get upcoming invoices (to test webhook functionality)
|
||||
|
|
@ -36,7 +36,7 @@ export async function POST(request: Request) {
|
|||
timestamp: new Date().toISOString(),
|
||||
account: {
|
||||
id: account.id,
|
||||
name: account.business_profile?.name || 'N/A',
|
||||
name: account.business_profile?.name || "N/A",
|
||||
email: account.email,
|
||||
country: account.country,
|
||||
charges_enabled: account.charges_enabled,
|
||||
|
|
@ -44,33 +44,37 @@ export async function POST(request: Request) {
|
|||
},
|
||||
products: {
|
||||
total: products.data.length,
|
||||
active: products.data.filter(p => p.active).length,
|
||||
sample: products.data.slice(0, 3).map(p => ({
|
||||
active: products.data.filter((p) => p.active).length,
|
||||
sample: products.data.slice(0, 3).map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
price: p.default_price ? {
|
||||
id: (p.default_price as any).id,
|
||||
unit_amount: (p.default_price as any).unit_amount,
|
||||
currency: (p.default_price as any).currency,
|
||||
} : null,
|
||||
price: p.default_price
|
||||
? {
|
||||
id: (p.default_price as any).id,
|
||||
unit_amount: (p.default_price as any).unit_amount,
|
||||
currency: (p.default_price as any).currency,
|
||||
}
|
||||
: null,
|
||||
})),
|
||||
},
|
||||
paymentMethods: {
|
||||
total: paymentMethods.data.length,
|
||||
types: [...new Set(paymentMethods.data.map(pm => pm.type))],
|
||||
types: [...new Set(paymentMethods.data.map((pm) => pm.type))],
|
||||
},
|
||||
recentInvoices: invoices.data.length,
|
||||
environment: process.env.NODE_ENV,
|
||||
apiVersion: stripe.version,
|
||||
apiVersion: STRIPE_API_VERSION,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Stripe test error:', error)
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
timestamp: new Date().toISOString(),
|
||||
}, { status: 500 })
|
||||
console.error("Stripe test error:", error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,67 +1,62 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getStripeClient } from '@/lib/stripe/client'
|
||||
import Stripe from 'stripe'
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getStripeClient } from "@/lib/stripe/client"
|
||||
import Stripe from "stripe"
|
||||
|
||||
// This is your Stripe CLI webhook secret
|
||||
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET
|
||||
|
||||
async function handler(req: NextRequest) {
|
||||
const body = await req.text()
|
||||
const signature = req.headers.get('stripe-signature') || ''
|
||||
|
||||
const signature = req.headers.get("stripe-signature") || ""
|
||||
|
||||
const stripe = getStripeClient()
|
||||
|
||||
|
||||
let event: Stripe.Event
|
||||
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(body, signature, webhookSecret!)
|
||||
} catch (err) {
|
||||
console.error('Webhook signature verification failed:', err)
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid signature' },
|
||||
{ status: 400 }
|
||||
)
|
||||
console.error("Webhook signature verification failed:", err)
|
||||
return NextResponse.json({ error: "Invalid signature" }, { status: 400 })
|
||||
}
|
||||
|
||||
// Handle the event
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed':
|
||||
case "checkout.session.completed":
|
||||
const checkoutSession = event.data.object as Stripe.Checkout.Session
|
||||
console.log('Payment received for session:', checkoutSession.id)
|
||||
console.log('Customer email:', checkoutSession.customer_email)
|
||||
console.log('Amount:', checkoutSession.amount_total)
|
||||
console.log('Items:', checkoutSession.display_items)
|
||||
|
||||
console.log("Payment received for session:", checkoutSession.id)
|
||||
console.log("Customer email:", checkoutSession.customer_email)
|
||||
console.log("Amount:", checkoutSession.amount_total)
|
||||
// TODO: Create order record in database
|
||||
// TODO: Send confirmation email to customer
|
||||
// TODO: Update inventory for physical products
|
||||
break
|
||||
|
||||
case 'payment_intent.succeeded':
|
||||
|
||||
case "payment_intent.succeeded":
|
||||
const paymentIntent = event.data.object as Stripe.PaymentIntent
|
||||
console.log('Payment succeeded for intent:', paymentIntent.id)
|
||||
console.log("Payment succeeded for intent:", paymentIntent.id)
|
||||
// TODO: Handle any post-payment logic
|
||||
break
|
||||
|
||||
case 'payment_intent.payment_failed':
|
||||
|
||||
case "payment_intent.payment_failed":
|
||||
const failedPayment = event.data.object as Stripe.PaymentIntent
|
||||
console.log('Payment failed for intent:', failedPayment.id)
|
||||
console.log('Failure reason:', failedPayment.last_payment_error?.message)
|
||||
console.log("Payment failed for intent:", failedPayment.id)
|
||||
console.log("Failure reason:", failedPayment.last_payment_error?.message)
|
||||
// TODO: Handle failed payment (notify customer, etc.)
|
||||
break
|
||||
|
||||
case 'invoice.payment_succeeded':
|
||||
|
||||
case "invoice.payment_succeeded":
|
||||
const invoice = event.data.object as Stripe.Invoice
|
||||
console.log('Invoice payment succeeded for invoice:', invoice.id)
|
||||
console.log("Invoice payment succeeded for invoice:", invoice.id)
|
||||
// TODO: Handle recurring payment success
|
||||
break
|
||||
|
||||
case 'invoice.payment_failed':
|
||||
|
||||
case "invoice.payment_failed":
|
||||
const failedInvoice = event.data.object as Stripe.Invoice
|
||||
console.log('Invoice payment failed for invoice:', failedInvoice.id)
|
||||
console.log("Invoice payment failed for invoice:", failedInvoice.id)
|
||||
// TODO: Handle recurring payment failure
|
||||
break
|
||||
|
||||
|
||||
default:
|
||||
console.log(`Unhandled event type: ${event.type}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ function getContentType(pathValue: string) {
|
|||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ path: string[] }> },
|
||||
{ params }: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
try {
|
||||
const { path: pathArray } = await params
|
||||
|
|
@ -76,12 +76,18 @@ export async function GET(
|
|||
|
||||
if (
|
||||
invalidPath(filePath) ||
|
||||
(!lowerPath.endsWith(".jpg") && !lowerPath.endsWith(".jpeg") && !lowerPath.endsWith(".png") && !lowerPath.endsWith(".webp"))
|
||||
(!lowerPath.endsWith(".jpg") &&
|
||||
!lowerPath.endsWith(".jpeg") &&
|
||||
!lowerPath.endsWith(".png") &&
|
||||
!lowerPath.endsWith(".webp"))
|
||||
) {
|
||||
return new NextResponse("Invalid path", { status: 400 })
|
||||
}
|
||||
|
||||
const storageObject = await getManualAssetFromStorage("thumbnails", filePath)
|
||||
const storageObject = await getManualAssetFromStorage(
|
||||
"thumbnails",
|
||||
filePath
|
||||
)
|
||||
if (storageObject) {
|
||||
return new NextResponse(Buffer.from(storageObject.body), {
|
||||
headers: {
|
||||
|
|
@ -99,7 +105,9 @@ export async function GET(
|
|||
const normalizedThumbnailsDir = thumbnailsDir.replace(/\\/g, "/")
|
||||
|
||||
if (!existsSync(normalizedFullPath) && !existsSync(fullPath)) {
|
||||
const label = decodedPath.at(-1)?.replace(/\.(jpg|jpeg|png|webp)$/i, "") || "Rocky Mountain Vending"
|
||||
const label =
|
||||
decodedPath.at(-1)?.replace(/\.(jpg|jpeg|png|webp)$/i, "") ||
|
||||
"Rocky Mountain Vending"
|
||||
return new NextResponse(buildPlaceholderSvg(label), {
|
||||
headers: {
|
||||
"Content-Type": "image/svg+xml; charset=utf-8",
|
||||
|
|
@ -109,7 +117,9 @@ export async function GET(
|
|||
})
|
||||
}
|
||||
|
||||
const fileToRead = existsSync(normalizedFullPath) ? normalizedFullPath : fullPath
|
||||
const fileToRead = existsSync(normalizedFullPath)
|
||||
? normalizedFullPath
|
||||
: fullPath
|
||||
const resolvedPath = fileToRead.replace(/\\/g, "/")
|
||||
if (!resolvedPath.startsWith(normalizedThumbnailsDir)) {
|
||||
return new NextResponse("Invalid path", { status: 400 })
|
||||
|
|
|
|||
|
|
@ -1,77 +1,60 @@
|
|||
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 { cleanWordPressContent } from '@/lib/clean-wordPress-content';
|
||||
import { WhoWeServePage } from '@/components/who-we-serve-page';
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from "next/navigation"
|
||||
import { loadImageMapping } from "@/lib/wordpress-content"
|
||||
import { generateRegistryMetadata, generateRegistryStructuredData } from "@/lib/seo"
|
||||
import { getPageBySlug } from "@/lib/wordpress-data-loader"
|
||||
import { cleanWordPressContent } from "@/lib/clean-wordPress-content"
|
||||
import { WhoWeServePage } from "@/components/who-we-serve-page"
|
||||
import type { Metadata } from "next"
|
||||
|
||||
const WORDPRESS_SLUG = 'enhancing-auto-repair-facilities-with-convenient-vending-solutions';
|
||||
const WORDPRESS_SLUG =
|
||||
"enhancing-auto-repair-facilities-with-convenient-vending-solutions"
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
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 || 'Auto Repair Vending Solutions',
|
||||
description: page.seoDescription || page.excerpt || '',
|
||||
excerpt: page.excerpt,
|
||||
return generateRegistryMetadata("autoRepair", {
|
||||
date: page.date,
|
||||
modified: page.modified,
|
||||
image: page.images?.[0]?.localPath,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export default async function AutoRepairPage() {
|
||||
try {
|
||||
const page = getPageBySlug(WORDPRESS_SLUG);
|
||||
const page = getPageBySlug(WORDPRESS_SLUG)
|
||||
|
||||
if (!page) {
|
||||
notFound();
|
||||
notFound()
|
||||
}
|
||||
|
||||
let imageMapping: any = {};
|
||||
let imageMapping: any = {}
|
||||
try {
|
||||
imageMapping = loadImageMapping();
|
||||
imageMapping = loadImageMapping()
|
||||
} catch (e) {
|
||||
imageMapping = {};
|
||||
imageMapping = {}
|
||||
}
|
||||
|
||||
const content = page.content ? (
|
||||
<div className="max-w-none">
|
||||
{cleanWordPressContent(String(page.content), {
|
||||
{cleanWordPressContent(String(page.content), {
|
||||
imageMapping,
|
||||
pageTitle: page.title
|
||||
pageTitle: page.title,
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">No content available.</p>
|
||||
);
|
||||
)
|
||||
|
||||
let structuredData;
|
||||
try {
|
||||
structuredData = generateStructuredData({
|
||||
title: page.title || 'Auto Repair Vending Solutions',
|
||||
description: page.seoDescription || page.excerpt || '',
|
||||
url: page.link || page.urlPath || `https://rockymountainvending.com/auto-repair/`,
|
||||
datePublished: page.date,
|
||||
dateModified: page.modified || page.date,
|
||||
type: 'WebPage',
|
||||
});
|
||||
} catch (e) {
|
||||
structuredData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
headline: page.title || 'Auto Repair Vending Solutions',
|
||||
description: page.seoDescription || '',
|
||||
url: `https://rockymountainvending.com/auto-repair/`,
|
||||
};
|
||||
}
|
||||
const structuredData = generateRegistryStructuredData("autoRepair", {
|
||||
datePublished: page.date,
|
||||
dateModified: page.modified || page.date,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -79,14 +62,17 @@ export default async function AutoRepairPage() {
|
|||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
/>
|
||||
<WhoWeServePage title={page.title || 'Auto Repair Vending Solutions'} content={content} />
|
||||
<WhoWeServePage
|
||||
title={page.title || "Auto Repair Vending Solutions"}
|
||||
description={page.seoDescription || page.excerpt || undefined}
|
||||
content={content}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
)
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Error rendering Auto Repair page:', error);
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.error("Error rendering Auto Repair page:", error)
|
||||
}
|
||||
notFound();
|
||||
notFound()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,76 +1,85 @@
|
|||
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 { cleanWordPressContent } from '@/lib/clean-wordPress-content';
|
||||
import { Breadcrumbs } from '@/components/breadcrumbs';
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from "next/navigation"
|
||||
import { loadImageMapping } from "@/lib/wordpress-content"
|
||||
import { buildAbsoluteUrl } from "@/lib/seo-registry"
|
||||
import { generateSEOMetadata, generateStructuredData } from "@/lib/seo"
|
||||
import { getPageBySlug } from "@/lib/wordpress-data-loader"
|
||||
import { cleanWordPressContent } from "@/lib/clean-wordPress-content"
|
||||
import { Breadcrumbs } from "@/components/breadcrumbs"
|
||||
import {
|
||||
PublicInset,
|
||||
PublicPageHeader,
|
||||
PublicProse,
|
||||
PublicSurface,
|
||||
} from "@/components/public-surface"
|
||||
import type { Metadata } from "next"
|
||||
import Link from "next/link"
|
||||
|
||||
const WORDPRESS_SLUG = 'abandoned-vending-machines';
|
||||
const WORDPRESS_SLUG = "abandoned-vending-machines"
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
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 || 'Abandoned Vending Machines',
|
||||
description: page.seoDescription || page.excerpt || '',
|
||||
title: page.title || "Abandoned Vending Machines",
|
||||
description: page.seoDescription || page.excerpt || "",
|
||||
excerpt: page.excerpt,
|
||||
date: page.date,
|
||||
modified: page.modified,
|
||||
image: page.images?.[0]?.localPath,
|
||||
});
|
||||
path: "/blog/abandoned-vending-machines",
|
||||
})
|
||||
}
|
||||
|
||||
export default async function AbandonedVendingMachinesPage() {
|
||||
try {
|
||||
const page = getPageBySlug(WORDPRESS_SLUG);
|
||||
const page = getPageBySlug(WORDPRESS_SLUG)
|
||||
|
||||
if (!page) {
|
||||
notFound();
|
||||
notFound()
|
||||
}
|
||||
|
||||
let imageMapping: any = {};
|
||||
let imageMapping: any = {}
|
||||
try {
|
||||
imageMapping = loadImageMapping();
|
||||
imageMapping = loadImageMapping()
|
||||
} catch (e) {
|
||||
imageMapping = {};
|
||||
imageMapping = {}
|
||||
}
|
||||
|
||||
const content = page.content ? (
|
||||
<div className="max-w-none">
|
||||
{cleanWordPressContent(String(page.content), {
|
||||
{cleanWordPressContent(String(page.content), {
|
||||
imageMapping,
|
||||
pageTitle: page.title
|
||||
pageTitle: page.title,
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">No content available.</p>
|
||||
);
|
||||
)
|
||||
|
||||
let structuredData;
|
||||
let structuredData
|
||||
try {
|
||||
structuredData = generateStructuredData({
|
||||
title: page.title || 'Abandoned Vending Machines',
|
||||
description: page.seoDescription || page.excerpt || '',
|
||||
url: page.link || page.urlPath || `https://rockymountainvending.com/abandoned-vending-machines/`,
|
||||
title: page.title || "Abandoned Vending Machines",
|
||||
description: page.seoDescription || page.excerpt || "",
|
||||
url: buildAbsoluteUrl("/blog/abandoned-vending-machines"),
|
||||
datePublished: page.date,
|
||||
dateModified: page.modified || page.date,
|
||||
type: 'WebPage',
|
||||
});
|
||||
type: "WebPage",
|
||||
})
|
||||
} catch (e) {
|
||||
structuredData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
headline: page.title || 'Abandoned Vending Machines',
|
||||
description: page.seoDescription || '',
|
||||
url: `https://rockymountainvending.com/abandoned-vending-machines/`,
|
||||
};
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
headline: page.title || "Abandoned Vending Machines",
|
||||
description: page.seoDescription || "",
|
||||
url: buildAbsoluteUrl("/blog/abandoned-vending-machines"),
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -79,21 +88,68 @@ export default async function AbandonedVendingMachinesPage() {
|
|||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
/>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Blog', href: '/blog' },
|
||||
{ label: page.title || 'Abandoned Vending Machines', href: '/blog/abandoned-vending-machines' },
|
||||
]}
|
||||
/>
|
||||
<article className="container mx-auto px-4 py-8 md:py-12 max-w-4xl">
|
||||
{content}
|
||||
<article className="container mx-auto max-w-5xl px-4 py-10 md:py-14">
|
||||
<Breadcrumbs
|
||||
className="mb-6"
|
||||
items={[
|
||||
{ label: "Blog", href: "/blog" },
|
||||
{
|
||||
label: page.title || "Abandoned Vending Machines",
|
||||
href: "/blog/abandoned-vending-machines",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<PublicPageHeader
|
||||
eyebrow="Article"
|
||||
title={page.title || "Abandoned Vending Machines"}
|
||||
description={
|
||||
page.seoDescription ||
|
||||
page.excerpt ||
|
||||
"Guidance, next steps, and practical considerations from Rocky Mountain Vending."
|
||||
}
|
||||
align="center"
|
||||
className="mx-auto mb-10 max-w-3xl"
|
||||
/>
|
||||
<PublicSurface className="p-5 md:p-7 lg:p-9">
|
||||
<PublicProse className="mx-auto max-w-3xl">{content}</PublicProse>
|
||||
</PublicSurface>
|
||||
<PublicInset className="mx-auto mt-8 max-w-4xl border-primary/12 bg-[linear-gradient(180deg,rgba(41,160,71,0.06),rgba(255,255,255,0.84))] p-5 md:p-6">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary/80">
|
||||
Need Help With A Machine Situation?
|
||||
</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-foreground">
|
||||
Get the right kind of support quickly
|
||||
</h2>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
|
||||
Reach out if you need help with abandoned machines, service questions,
|
||||
moving help, or figuring out the right next step for your location.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Link
|
||||
href="/contact-us#contact-form"
|
||||
className="inline-flex min-h-11 items-center justify-center rounded-full bg-primary px-5 text-sm font-medium text-primary-foreground transition hover:bg-primary/90"
|
||||
>
|
||||
Talk to Our Team
|
||||
</Link>
|
||||
<Link
|
||||
href="/services/repairs"
|
||||
className="inline-flex min-h-11 items-center justify-center rounded-full border border-border bg-white px-5 text-sm font-medium text-foreground transition hover:border-primary/35 hover:text-primary"
|
||||
>
|
||||
Explore Repair Help
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</PublicInset>
|
||||
</article>
|
||||
</>
|
||||
);
|
||||
)
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Error rendering Abandoned Vending Machines page:', error);
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.error("Error rendering Abandoned Vending Machines page:", error)
|
||||
}
|
||||
notFound();
|
||||
notFound()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,53 +1,56 @@
|
|||
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 { cleanWordPressContent } from '@/lib/clean-wordPress-content';
|
||||
import { Breadcrumbs } from '@/components/breadcrumbs';
|
||||
import { PublicPageHeader, PublicSurface } from '@/components/public-surface';
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from "next/navigation"
|
||||
import { loadImageMapping } from "@/lib/wordpress-content"
|
||||
import { buildAbsoluteUrl } from "@/lib/seo-registry"
|
||||
import { generateSEOMetadata, generateStructuredData } from "@/lib/seo"
|
||||
import { getPageBySlug } from "@/lib/wordpress-data-loader"
|
||||
import { cleanWordPressContent } from "@/lib/clean-wordPress-content"
|
||||
import { Breadcrumbs } from "@/components/breadcrumbs"
|
||||
import { PublicPageHeader, PublicSurface } from "@/components/public-surface"
|
||||
import type { Metadata } from "next"
|
||||
|
||||
const WORDPRESS_SLUG = 'best-vending-machine-supplier-in-salt-lake-city-utah';
|
||||
const DISPLAY_TITLE = 'The Best Vending Machine Supplier in Salt Lake City, Utah';
|
||||
const WORDPRESS_SLUG = "best-vending-machine-supplier-in-salt-lake-city-utah"
|
||||
const DISPLAY_TITLE =
|
||||
"The Best Vending Machine Supplier in Salt Lake City, Utah"
|
||||
const DISPLAY_DESCRIPTION =
|
||||
'A closer look at how Rocky Mountain Vending supports Utah businesses with free placement, machine sales, repairs, manuals, and responsive local service.';
|
||||
"A closer look at how Rocky Mountain Vending supports Utah businesses with free placement, machine sales, repairs, manuals, and responsive local service."
|
||||
|
||||
function stripLeadingH1(html: string) {
|
||||
return html.replace(/<h1[^>]*>[\s\S]*?<\/h1>/i, '');
|
||||
return html.replace(/<h1[^>]*>[\s\S]*?<\/h1>/i, "")
|
||||
}
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
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: DISPLAY_TITLE,
|
||||
description: page.seoDescription || page.excerpt || '',
|
||||
description: DISPLAY_DESCRIPTION,
|
||||
excerpt: page.excerpt,
|
||||
date: page.date,
|
||||
modified: page.modified,
|
||||
image: page.images?.[0]?.localPath,
|
||||
});
|
||||
path: "/blog/best-vending-machine-supplier-in-salt-lake-city-utah",
|
||||
})
|
||||
}
|
||||
|
||||
export default async function BestVendingMachineSupplierPage() {
|
||||
try {
|
||||
const page = getPageBySlug(WORDPRESS_SLUG);
|
||||
const page = getPageBySlug(WORDPRESS_SLUG)
|
||||
|
||||
if (!page) {
|
||||
notFound();
|
||||
notFound()
|
||||
}
|
||||
|
||||
let imageMapping: any = {};
|
||||
let imageMapping: any = {}
|
||||
try {
|
||||
imageMapping = loadImageMapping();
|
||||
imageMapping = loadImageMapping()
|
||||
} catch (e) {
|
||||
imageMapping = {};
|
||||
imageMapping = {}
|
||||
}
|
||||
|
||||
const content = page.content ? (
|
||||
|
|
@ -60,26 +63,30 @@ export default async function BestVendingMachineSupplierPage() {
|
|||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">No content available.</p>
|
||||
);
|
||||
)
|
||||
|
||||
let structuredData;
|
||||
let structuredData
|
||||
try {
|
||||
structuredData = generateStructuredData({
|
||||
title: DISPLAY_TITLE,
|
||||
description: page.seoDescription || page.excerpt || '',
|
||||
url: page.link || page.urlPath || `https://rockymountainvending.com/best-vending-machine-supplier-in-salt-lake-city-utah/`,
|
||||
description: DISPLAY_DESCRIPTION,
|
||||
url: buildAbsoluteUrl(
|
||||
"/blog/best-vending-machine-supplier-in-salt-lake-city-utah"
|
||||
),
|
||||
datePublished: page.date,
|
||||
dateModified: page.modified || page.date,
|
||||
type: 'WebPage',
|
||||
});
|
||||
type: "WebPage",
|
||||
})
|
||||
} catch (e) {
|
||||
structuredData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
headline: DISPLAY_TITLE,
|
||||
description: page.seoDescription || '',
|
||||
url: `https://rockymountainvending.com/best-vending-machine-supplier-in-salt-lake-city-utah/`,
|
||||
};
|
||||
description: DISPLAY_DESCRIPTION,
|
||||
url: buildAbsoluteUrl(
|
||||
"/blog/best-vending-machine-supplier-in-salt-lake-city-utah"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -91,8 +98,11 @@ export default async function BestVendingMachineSupplierPage() {
|
|||
<Breadcrumbs
|
||||
className="container mx-auto max-w-6xl px-4 pt-6"
|
||||
items={[
|
||||
{ label: 'Blog', href: '/blog' },
|
||||
{ label: DISPLAY_TITLE, href: '/blog/best-vending-machine-supplier-in-salt-lake-city-utah' },
|
||||
{ label: "Blog", href: "/blog" },
|
||||
{
|
||||
label: DISPLAY_TITLE,
|
||||
href: "/blog/best-vending-machine-supplier-in-salt-lake-city-utah",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<article className="container mx-auto max-w-6xl px-4 py-8 md:py-12">
|
||||
|
|
@ -108,11 +118,14 @@ export default async function BestVendingMachineSupplierPage() {
|
|||
</PublicSurface>
|
||||
</article>
|
||||
</>
|
||||
);
|
||||
)
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Error rendering Best Vending Machine Supplier page:', error);
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.error(
|
||||
"Error rendering Best Vending Machine Supplier page:",
|
||||
error
|
||||
)
|
||||
}
|
||||
notFound();
|
||||
notFound()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,52 +1,60 @@
|
|||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { Breadcrumbs } from '@/components/breadcrumbs'
|
||||
import { PublicInset, PublicPageHeader, PublicSurface } from '@/components/public-surface'
|
||||
import type { Metadata } from 'next'
|
||||
import Link from "next/link"
|
||||
import Image from "next/image"
|
||||
import { Breadcrumbs } from "@/components/breadcrumbs"
|
||||
import {
|
||||
PublicInset,
|
||||
PublicPageHeader,
|
||||
PublicSurface,
|
||||
} from "@/components/public-surface"
|
||||
import type { Metadata } from "next"
|
||||
import { generateSEOMetadata } from "@/lib/seo"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Blog | Rocky Mountain Vending',
|
||||
description: 'Read our latest blog posts about vending machines, services, and industry insights from Rocky Mountain Vending.',
|
||||
alternates: {
|
||||
canonical: 'https://rockymountainvending.com/blog/',
|
||||
},
|
||||
}
|
||||
export const metadata: Metadata = generateSEOMetadata({
|
||||
title: "Utah Vending Blog | Rocky Mountain Vending",
|
||||
description:
|
||||
"Read Rocky Mountain Vending guides, reviews, and Utah-focused vending insights for businesses and property managers.",
|
||||
path: "/blog",
|
||||
keywords: [
|
||||
"Utah vending blog",
|
||||
"vending machine guides Utah",
|
||||
"Rocky Mountain Vending blog",
|
||||
],
|
||||
})
|
||||
|
||||
const blogPosts = [
|
||||
{
|
||||
title: 'How to Remove an Abandoned Vending Machine in Utah',
|
||||
description: 'A comprehensive guide for Utah businesses dealing with unwanted vending machines on their property.',
|
||||
slug: 'abandoned-vending-machines',
|
||||
date: 'March 20, 2025',
|
||||
image: '/images/abandoned-vending-machine-guide.jpg',
|
||||
imageAlt: 'Abandoned vending machine guide',
|
||||
title: "How to Remove an Abandoned Vending Machine in Utah",
|
||||
description:
|
||||
"A comprehensive guide for Utah businesses dealing with unwanted vending machines on their property.",
|
||||
slug: "abandoned-vending-machines",
|
||||
date: "March 20, 2025",
|
||||
image: "/images/abandoned-vending-machine-guide.jpg",
|
||||
imageAlt: "Abandoned vending machine guide",
|
||||
},
|
||||
{
|
||||
title: 'Rocky Mountain Vending Reviews & Testimonials',
|
||||
description: 'Read customer reviews and testimonials about our vending machine services in Utah.',
|
||||
slug: 'reviews',
|
||||
date: 'March 20, 2025',
|
||||
image: '/images/customer-reviews.jpg',
|
||||
imageAlt: 'Customer reviews and testimonials',
|
||||
title: "Rocky Mountain Vending Reviews & Testimonials",
|
||||
description:
|
||||
"Read customer reviews and testimonials about our vending machine services in Utah.",
|
||||
slug: "reviews",
|
||||
date: "March 20, 2025",
|
||||
image: "/images/customer-reviews.jpg",
|
||||
imageAlt: "Customer reviews and testimonials",
|
||||
},
|
||||
{
|
||||
title: 'The Best Vending Machine Supplier in Salt Lake City, Utah',
|
||||
description: 'Why Rocky Mountain Vending is the top choice for vending machine services in Salt Lake City.',
|
||||
slug: 'best-vending-machine-supplier-in-salt-lake-city-utah',
|
||||
date: 'March 20, 2025',
|
||||
image: '/images/salt-lake-city-vending.jpg',
|
||||
imageAlt: 'Vending machine supplier in Salt Lake City',
|
||||
title: "The Best Vending Machine Supplier in Salt Lake City, Utah",
|
||||
description:
|
||||
"Why Rocky Mountain Vending is the top choice for vending machine services in Salt Lake City.",
|
||||
slug: "best-vending-machine-supplier-in-salt-lake-city-utah",
|
||||
date: "March 20, 2025",
|
||||
image: "/images/salt-lake-city-vending.jpg",
|
||||
imageAlt: "Vending machine supplier in Salt Lake City",
|
||||
},
|
||||
]
|
||||
|
||||
export default function BlogPage() {
|
||||
return (
|
||||
<>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Blog', href: '/blog' },
|
||||
]}
|
||||
/>
|
||||
<Breadcrumbs items={[{ label: "Blog", href: "/blog" }]} />
|
||||
<article className="container mx-auto max-w-5xl px-4 py-10 md:py-14">
|
||||
<PublicPageHeader
|
||||
align="center"
|
||||
|
|
@ -74,7 +82,10 @@ export default function BlogPage() {
|
|||
</div>
|
||||
<div className="md:w-2/3">
|
||||
<h2 className="text-2xl md:text-3xl font-semibold mb-2 tracking-tight text-balance">
|
||||
<Link href={`/blog/${post.slug}`} className="transition-colors hover:text-primary">
|
||||
<Link
|
||||
href={`/blog/${post.slug}`}
|
||||
className="transition-colors hover:text-primary"
|
||||
>
|
||||
{post.title}
|
||||
</Link>
|
||||
</h2>
|
||||
|
|
|
|||
|
|
@ -1,76 +1,80 @@
|
|||
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 { cleanWordPressContent } from '@/lib/clean-wordPress-content';
|
||||
import { Breadcrumbs } from '@/components/breadcrumbs';
|
||||
import type { Metadata } from 'next';
|
||||
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 { cleanWordPressContent } from "@/lib/clean-wordPress-content"
|
||||
import { DropdownPageShell } from "@/components/dropdown-page-shell"
|
||||
import type { Metadata } from "next"
|
||||
|
||||
const WORDPRESS_SLUG = 'reviews';
|
||||
const WORDPRESS_SLUG = "reviews"
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
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 || 'Reviews',
|
||||
description: page.seoDescription || page.excerpt || '',
|
||||
title: page.title || "Reviews",
|
||||
description: page.seoDescription || page.excerpt || "",
|
||||
excerpt: page.excerpt,
|
||||
date: page.date,
|
||||
modified: page.modified,
|
||||
image: page.images?.[0]?.localPath,
|
||||
});
|
||||
path: "/blog/reviews",
|
||||
})
|
||||
}
|
||||
|
||||
export default async function ReviewsPage() {
|
||||
try {
|
||||
const page = getPageBySlug(WORDPRESS_SLUG);
|
||||
const page = getPageBySlug(WORDPRESS_SLUG)
|
||||
|
||||
if (!page) {
|
||||
notFound();
|
||||
notFound()
|
||||
}
|
||||
|
||||
let imageMapping: any = {};
|
||||
let imageMapping: any = {}
|
||||
try {
|
||||
imageMapping = loadImageMapping();
|
||||
imageMapping = loadImageMapping()
|
||||
} catch (e) {
|
||||
imageMapping = {};
|
||||
imageMapping = {}
|
||||
}
|
||||
|
||||
const content = page.content ? (
|
||||
<div className="max-w-none">
|
||||
{cleanWordPressContent(String(page.content), {
|
||||
{cleanWordPressContent(String(page.content), {
|
||||
imageMapping,
|
||||
pageTitle: page.title
|
||||
pageTitle: page.title,
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">No content available.</p>
|
||||
);
|
||||
)
|
||||
|
||||
let structuredData;
|
||||
let structuredData
|
||||
try {
|
||||
structuredData = generateStructuredData({
|
||||
title: page.title || 'Reviews',
|
||||
description: page.seoDescription || page.excerpt || '',
|
||||
url: page.link || page.urlPath || `https://rockymountainvending.com/reviews/`,
|
||||
title: page.title || "Reviews",
|
||||
description: page.seoDescription || page.excerpt || "",
|
||||
url:
|
||||
page.link ||
|
||||
page.urlPath ||
|
||||
`https://rockymountainvending.com/reviews/`,
|
||||
datePublished: page.date,
|
||||
dateModified: page.modified || page.date,
|
||||
type: 'WebPage',
|
||||
});
|
||||
type: "WebPage",
|
||||
})
|
||||
} catch (e) {
|
||||
structuredData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
headline: page.title || 'Reviews',
|
||||
description: page.seoDescription || '',
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebPage",
|
||||
headline: page.title || "Reviews",
|
||||
description: page.seoDescription || "",
|
||||
url: `https://rockymountainvending.com/reviews/`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -79,21 +83,41 @@ export default async function ReviewsPage() {
|
|||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
/>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: 'Blog', href: '/blog' },
|
||||
{ label: page.title || 'Reviews', href: '/blog/reviews' },
|
||||
<DropdownPageShell
|
||||
breadcrumbs={[
|
||||
{ label: "Blog", href: "/blog" },
|
||||
{ label: page.title || "Reviews", href: "/blog/reviews" },
|
||||
]}
|
||||
eyebrow="Blog Posts"
|
||||
title={page.title || "Reviews"}
|
||||
description={
|
||||
page.seoDescription ||
|
||||
page.excerpt ||
|
||||
"Read Rocky Mountain Vending reviews, testimonials, and customer feedback gathered from businesses across Utah."
|
||||
}
|
||||
content={content}
|
||||
contentClassName="prose prose-lg max-w-none prose-headings:text-foreground prose-p:text-muted-foreground prose-a:text-foreground prose-a:underline prose-a:decoration-primary/35 prose-a:underline-offset-4 hover:prose-a:decoration-primary prose-strong:text-foreground"
|
||||
cta={{
|
||||
eyebrow: "Live Review Feed",
|
||||
title: "Want the current public review stream instead?",
|
||||
description:
|
||||
"For the live Google review feed and the latest customer feedback, head to the main reviews page.",
|
||||
actions: [
|
||||
{ label: "View Live Reviews", href: "/reviews" },
|
||||
{
|
||||
label: "Talk to Our Team",
|
||||
href: "/contact-us#contact-form",
|
||||
variant: "outline",
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
<article className="container mx-auto px-4 py-8 md:py-12 max-w-4xl">
|
||||
{content}
|
||||
</article>
|
||||
</>
|
||||
);
|
||||
)
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Error rendering Reviews page:', error);
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.error("Error rendering Reviews page:", error)
|
||||
}
|
||||
notFound();
|
||||
notFound()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,77 +1,59 @@
|
|||
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 { cleanWordPressContent } from '@/lib/clean-wordPress-content';
|
||||
import { WhoWeServePage } from '@/components/who-we-serve-page';
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from "next/navigation"
|
||||
import { loadImageMapping } from "@/lib/wordpress-content"
|
||||
import { generateRegistryMetadata, generateRegistryStructuredData } from "@/lib/seo"
|
||||
import { getPageBySlug } from "@/lib/wordpress-data-loader"
|
||||
import { cleanWordPressContent } from "@/lib/clean-wordPress-content"
|
||||
import { WhoWeServePage } from "@/components/who-we-serve-page"
|
||||
import type { Metadata } from "next"
|
||||
|
||||
const WORDPRESS_SLUG = 'vending-machines-for-your-car-wash';
|
||||
const WORDPRESS_SLUG = "vending-machines-for-your-car-wash"
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
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 || 'Car Wash Vending Solutions',
|
||||
description: page.seoDescription || page.excerpt || '',
|
||||
excerpt: page.excerpt,
|
||||
return generateRegistryMetadata("carWashes", {
|
||||
date: page.date,
|
||||
modified: page.modified,
|
||||
image: page.images?.[0]?.localPath,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export default async function CarWashesPage() {
|
||||
try {
|
||||
const page = getPageBySlug(WORDPRESS_SLUG);
|
||||
const page = getPageBySlug(WORDPRESS_SLUG)
|
||||
|
||||
if (!page) {
|
||||
notFound();
|
||||
notFound()
|
||||
}
|
||||
|
||||
let imageMapping: any = {};
|
||||
let imageMapping: any = {}
|
||||
try {
|
||||
imageMapping = loadImageMapping();
|
||||
imageMapping = loadImageMapping()
|
||||
} catch (e) {
|
||||
imageMapping = {};
|
||||
imageMapping = {}
|
||||
}
|
||||
|
||||
const content = page.content ? (
|
||||
<div className="max-w-none">
|
||||
{cleanWordPressContent(String(page.content), {
|
||||
{cleanWordPressContent(String(page.content), {
|
||||
imageMapping,
|
||||
pageTitle: page.title
|
||||
pageTitle: page.title,
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">No content available.</p>
|
||||
);
|
||||
)
|
||||
|
||||
let structuredData;
|
||||
try {
|
||||
structuredData = generateStructuredData({
|
||||
title: page.title || 'Car Wash Vending Solutions',
|
||||
description: page.seoDescription || page.excerpt || '',
|
||||
url: page.link || page.urlPath || `https://rockymountainvending.com/car-washes/`,
|
||||
datePublished: page.date,
|
||||
dateModified: page.modified || page.date,
|
||||
type: 'WebPage',
|
||||
});
|
||||
} catch (e) {
|
||||
structuredData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
headline: page.title || 'Car Wash Vending Solutions',
|
||||
description: page.seoDescription || '',
|
||||
url: `https://rockymountainvending.com/car-washes/`,
|
||||
};
|
||||
}
|
||||
const structuredData = generateRegistryStructuredData("carWashes", {
|
||||
datePublished: page.date,
|
||||
dateModified: page.modified || page.date,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -79,14 +61,17 @@ export default async function CarWashesPage() {
|
|||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
/>
|
||||
<WhoWeServePage title={page.title || 'Car Wash Vending Solutions'} content={content} />
|
||||
<WhoWeServePage
|
||||
title={page.title || "Car Wash Vending Solutions"}
|
||||
description={page.seoDescription || page.excerpt || undefined}
|
||||
content={content}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
)
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Error rendering Car Washes page:', error);
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.error("Error rendering Car Washes page:", error)
|
||||
}
|
||||
notFound();
|
||||
notFound()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { XCircle } from 'lucide-react'
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XCircle } from "lucide-react"
|
||||
|
||||
export const metadata = {
|
||||
title: 'Checkout Cancelled | Rocky Mountain Vending',
|
||||
description: 'Your checkout was cancelled',
|
||||
title: "Checkout Cancelled | Rocky Mountain Vending",
|
||||
description: "Your checkout was cancelled",
|
||||
}
|
||||
|
||||
export default function CheckoutCancelPage() {
|
||||
|
|
@ -12,7 +12,9 @@ export default function CheckoutCancelPage() {
|
|||
<div className="container mx-auto px-4 py-16">
|
||||
<div className="max-w-2xl mx-auto text-center">
|
||||
<XCircle className="h-16 w-16 text-muted-foreground mx-auto mb-6" />
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-balance mb-4">Checkout Cancelled</h1>
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-balance mb-4">
|
||||
Checkout Cancelled
|
||||
</h1>
|
||||
<p className="text-lg text-muted-foreground mb-8">
|
||||
Your checkout was cancelled. No charges were made.
|
||||
</p>
|
||||
|
|
@ -28,5 +30,3 @@ export default function CheckoutCancelPage() {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import Link from 'next/link'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CheckCircle } from 'lucide-react'
|
||||
import Link from "next/link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { CheckCircle } from "lucide-react"
|
||||
|
||||
export const metadata = {
|
||||
title: 'Checkout Success | Rocky Mountain Vending',
|
||||
description: 'Your order has been placed successfully',
|
||||
title: "Checkout Success | Rocky Mountain Vending",
|
||||
description: "Your order has been placed successfully",
|
||||
}
|
||||
|
||||
export default function CheckoutSuccessPage() {
|
||||
|
|
@ -12,10 +12,12 @@ export default function CheckoutSuccessPage() {
|
|||
<div className="container mx-auto px-4 py-16">
|
||||
<div className="max-w-2xl mx-auto text-center">
|
||||
<CheckCircle className="h-16 w-16 text-green-500 mx-auto mb-6" />
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-balance mb-4">Order Confirmed!</h1>
|
||||
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-balance mb-4">
|
||||
Order Confirmed!
|
||||
</h1>
|
||||
<p className="text-lg text-muted-foreground mb-8">
|
||||
Thank you for your purchase. Your order has been received and we'll
|
||||
be in touch soon regarding shipping and delivery details.
|
||||
Thank you for your purchase. Your order has been received and we'll be
|
||||
in touch soon regarding shipping and delivery details.
|
||||
</p>
|
||||
<div className="flex gap-4 justify-center">
|
||||
<Button asChild variant="brand">
|
||||
|
|
@ -29,5 +31,3 @@ export default function CheckoutSuccessPage() {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,77 +1,59 @@
|
|||
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 { cleanWordPressContent } from '@/lib/clean-wordPress-content';
|
||||
import { WhoWeServePage } from '@/components/who-we-serve-page';
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from "next/navigation"
|
||||
import { loadImageMapping } from "@/lib/wordpress-content"
|
||||
import { generateRegistryMetadata, generateRegistryStructuredData } from "@/lib/seo"
|
||||
import { getPageBySlug } from "@/lib/wordpress-data-loader"
|
||||
import { cleanWordPressContent } from "@/lib/clean-wordPress-content"
|
||||
import { WhoWeServePage } from "@/components/who-we-serve-page"
|
||||
import type { Metadata } from "next"
|
||||
|
||||
const WORDPRESS_SLUG = 'vending-for-your-community-centers';
|
||||
const WORDPRESS_SLUG = "vending-for-your-community-centers"
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
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 || 'Community Center Vending Solutions',
|
||||
description: page.seoDescription || page.excerpt || '',
|
||||
excerpt: page.excerpt,
|
||||
return generateRegistryMetadata("communityCenters", {
|
||||
date: page.date,
|
||||
modified: page.modified,
|
||||
image: page.images?.[0]?.localPath,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export default async function CommunityCentersPage() {
|
||||
try {
|
||||
const page = getPageBySlug(WORDPRESS_SLUG);
|
||||
const page = getPageBySlug(WORDPRESS_SLUG)
|
||||
|
||||
if (!page) {
|
||||
notFound();
|
||||
notFound()
|
||||
}
|
||||
|
||||
let imageMapping: any = {};
|
||||
let imageMapping: any = {}
|
||||
try {
|
||||
imageMapping = loadImageMapping();
|
||||
imageMapping = loadImageMapping()
|
||||
} catch (e) {
|
||||
imageMapping = {};
|
||||
imageMapping = {}
|
||||
}
|
||||
|
||||
const content = page.content ? (
|
||||
<div className="max-w-none">
|
||||
{cleanWordPressContent(String(page.content), {
|
||||
{cleanWordPressContent(String(page.content), {
|
||||
imageMapping,
|
||||
pageTitle: page.title
|
||||
pageTitle: page.title,
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">No content available.</p>
|
||||
);
|
||||
)
|
||||
|
||||
let structuredData;
|
||||
try {
|
||||
structuredData = generateStructuredData({
|
||||
title: page.title || 'Community Center Vending Solutions',
|
||||
description: page.seoDescription || page.excerpt || '',
|
||||
url: page.link || page.urlPath || `https://rockymountainvending.com/community-centers/`,
|
||||
datePublished: page.date,
|
||||
dateModified: page.modified || page.date,
|
||||
type: 'WebPage',
|
||||
});
|
||||
} catch (e) {
|
||||
structuredData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
headline: page.title || 'Community Center Vending Solutions',
|
||||
description: page.seoDescription || '',
|
||||
url: `https://rockymountainvending.com/community-centers/`,
|
||||
};
|
||||
}
|
||||
const structuredData = generateRegistryStructuredData("communityCenters", {
|
||||
datePublished: page.date,
|
||||
dateModified: page.modified || page.date,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -79,14 +61,17 @@ export default async function CommunityCentersPage() {
|
|||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
/>
|
||||
<WhoWeServePage title={page.title || 'Community Center Vending Solutions'} content={content} />
|
||||
<WhoWeServePage
|
||||
title={page.title || "Community Center Vending Solutions"}
|
||||
description={page.seoDescription || page.excerpt || undefined}
|
||||
content={content}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
)
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Error rendering Community Centers page:', error);
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.error("Error rendering Community Centers page:", error)
|
||||
}
|
||||
notFound();
|
||||
notFound()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,57 +1,39 @@
|
|||
import { notFound } from 'next/navigation';
|
||||
import { generateSEOMetadata, generateStructuredData } from '@/lib/seo';
|
||||
import { getPageBySlug } from '@/lib/wordpress-data-loader';
|
||||
import { ContactPage } from '@/components/contact-page';
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from "next/navigation"
|
||||
import { generateRegistryMetadata, generateRegistryStructuredData } from "@/lib/seo"
|
||||
import { getPageBySlug } from "@/lib/wordpress-data-loader"
|
||||
import { ContactPage } from "@/components/contact-page"
|
||||
import type { Metadata } from "next"
|
||||
|
||||
const WORDPRESS_SLUG = 'contact-us';
|
||||
const WORDPRESS_SLUG = "contact-us"
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
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 || 'Contact Us',
|
||||
description: page.seoDescription || page.excerpt || '',
|
||||
excerpt: page.excerpt,
|
||||
return generateRegistryMetadata("contactUs", {
|
||||
date: page.date,
|
||||
modified: page.modified,
|
||||
image: page.images?.[0]?.localPath,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export default async function ContactUsPage() {
|
||||
try {
|
||||
const page = getPageBySlug(WORDPRESS_SLUG);
|
||||
const page = getPageBySlug(WORDPRESS_SLUG)
|
||||
|
||||
if (!page) {
|
||||
notFound();
|
||||
notFound()
|
||||
}
|
||||
|
||||
let structuredData;
|
||||
try {
|
||||
structuredData = generateStructuredData({
|
||||
title: page.title || 'Contact Us',
|
||||
description: page.seoDescription || page.excerpt || '',
|
||||
url: page.link || page.urlPath || `https://rockymountainvending.com/contact-us/`,
|
||||
datePublished: page.date,
|
||||
dateModified: page.modified || page.date,
|
||||
type: 'WebPage',
|
||||
});
|
||||
} catch (e) {
|
||||
structuredData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
headline: page.title || 'Contact Us',
|
||||
description: page.seoDescription || '',
|
||||
url: `https://rockymountainvending.com/contact-us/`,
|
||||
};
|
||||
}
|
||||
const structuredData = generateRegistryStructuredData("contactUs", {
|
||||
datePublished: page.date,
|
||||
dateModified: page.modified || page.date,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -61,19 +43,11 @@ export default async function ContactUsPage() {
|
|||
/>
|
||||
<ContactPage />
|
||||
</>
|
||||
);
|
||||
)
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Error rendering Contact Us page:', error);
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.error("Error rendering Contact Us page:", error)
|
||||
}
|
||||
notFound();
|
||||
notFound()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,77 +1,59 @@
|
|||
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 { cleanWordPressContent } from '@/lib/clean-wordPress-content';
|
||||
import { WhoWeServePage } from '@/components/who-we-serve-page';
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from "next/navigation"
|
||||
import { loadImageMapping } from "@/lib/wordpress-content"
|
||||
import { generateRegistryMetadata, generateRegistryStructuredData } from "@/lib/seo"
|
||||
import { getPageBySlug } from "@/lib/wordpress-data-loader"
|
||||
import { cleanWordPressContent } from "@/lib/clean-wordPress-content"
|
||||
import { WhoWeServePage } from "@/components/who-we-serve-page"
|
||||
import type { Metadata } from "next"
|
||||
|
||||
const WORDPRESS_SLUG = 'vending-machine-for-your-dance-studio';
|
||||
const WORDPRESS_SLUG = "vending-machine-for-your-dance-studio"
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
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 || 'Dance Studio Vending Solutions',
|
||||
description: page.seoDescription || page.excerpt || '',
|
||||
excerpt: page.excerpt,
|
||||
return generateRegistryMetadata("danceStudios", {
|
||||
date: page.date,
|
||||
modified: page.modified,
|
||||
image: page.images?.[0]?.localPath,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export default async function DanceStudiosPage() {
|
||||
try {
|
||||
const page = getPageBySlug(WORDPRESS_SLUG);
|
||||
const page = getPageBySlug(WORDPRESS_SLUG)
|
||||
|
||||
if (!page) {
|
||||
notFound();
|
||||
notFound()
|
||||
}
|
||||
|
||||
let imageMapping: any = {};
|
||||
let imageMapping: any = {}
|
||||
try {
|
||||
imageMapping = loadImageMapping();
|
||||
imageMapping = loadImageMapping()
|
||||
} catch (e) {
|
||||
imageMapping = {};
|
||||
imageMapping = {}
|
||||
}
|
||||
|
||||
const content = page.content ? (
|
||||
<div className="max-w-none">
|
||||
{cleanWordPressContent(String(page.content), {
|
||||
{cleanWordPressContent(String(page.content), {
|
||||
imageMapping,
|
||||
pageTitle: page.title
|
||||
pageTitle: page.title,
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">No content available.</p>
|
||||
);
|
||||
)
|
||||
|
||||
let structuredData;
|
||||
try {
|
||||
structuredData = generateStructuredData({
|
||||
title: page.title || 'Dance Studio Vending Solutions',
|
||||
description: page.seoDescription || page.excerpt || '',
|
||||
url: page.link || page.urlPath || `https://rockymountainvending.com/dance-studios/`,
|
||||
datePublished: page.date,
|
||||
dateModified: page.modified || page.date,
|
||||
type: 'WebPage',
|
||||
});
|
||||
} catch (e) {
|
||||
structuredData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
headline: page.title || 'Dance Studio Vending Solutions',
|
||||
description: page.seoDescription || '',
|
||||
url: `https://rockymountainvending.com/dance-studios/`,
|
||||
};
|
||||
}
|
||||
const structuredData = generateRegistryStructuredData("danceStudios", {
|
||||
datePublished: page.date,
|
||||
dateModified: page.modified || page.date,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -79,14 +61,17 @@ export default async function DanceStudiosPage() {
|
|||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
/>
|
||||
<WhoWeServePage title={page.title || 'Dance Studio Vending Solutions'} content={content} />
|
||||
<WhoWeServePage
|
||||
title={page.title || "Dance Studio Vending Solutions"}
|
||||
description={page.seoDescription || page.excerpt || undefined}
|
||||
content={content}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
)
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Error rendering Dance Studios page:', error);
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.error("Error rendering Dance Studios page:", error)
|
||||
}
|
||||
notFound();
|
||||
notFound()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,76 +1,59 @@
|
|||
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 { cleanWordPressContent } from '@/lib/clean-wordPress-content';
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from "next/navigation"
|
||||
import { loadImageMapping } from "@/lib/wordpress-content"
|
||||
import { generateRegistryMetadata, generateRegistryStructuredData } from "@/lib/seo"
|
||||
import { getPageBySlug } from "@/lib/wordpress-data-loader"
|
||||
import { cleanWordPressContent } from "@/lib/clean-wordPress-content"
|
||||
import { DropdownPageShell } from "@/components/dropdown-page-shell"
|
||||
import type { Metadata } from "next"
|
||||
|
||||
const WORDPRESS_SLUG = 'healthy-vending';
|
||||
const WORDPRESS_SLUG = "healthy-vending"
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
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 || 'Healthy Vending Options',
|
||||
description: page.seoDescription || page.excerpt || '',
|
||||
excerpt: page.excerpt,
|
||||
return generateRegistryMetadata("healthyOptions", {
|
||||
date: page.date,
|
||||
modified: page.modified,
|
||||
image: page.images?.[0]?.localPath,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export default async function HealthyOptionsPage() {
|
||||
try {
|
||||
const page = getPageBySlug(WORDPRESS_SLUG);
|
||||
const page = getPageBySlug(WORDPRESS_SLUG)
|
||||
|
||||
if (!page) {
|
||||
notFound();
|
||||
notFound()
|
||||
}
|
||||
|
||||
let imageMapping: any = {};
|
||||
let imageMapping: any = {}
|
||||
try {
|
||||
imageMapping = loadImageMapping();
|
||||
imageMapping = loadImageMapping()
|
||||
} catch (e) {
|
||||
imageMapping = {};
|
||||
imageMapping = {}
|
||||
}
|
||||
|
||||
const content = page.content ? (
|
||||
<div className="max-w-none">
|
||||
{cleanWordPressContent(String(page.content), {
|
||||
{cleanWordPressContent(String(page.content), {
|
||||
imageMapping,
|
||||
pageTitle: page.title
|
||||
pageTitle: page.title,
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">No content available.</p>
|
||||
);
|
||||
)
|
||||
|
||||
let structuredData;
|
||||
try {
|
||||
structuredData = generateStructuredData({
|
||||
title: page.title || 'Healthy Vending Options',
|
||||
description: page.seoDescription || page.excerpt || '',
|
||||
url: page.link || page.urlPath || `https://rockymountainvending.com/food-and-beverage/healthy-options/`,
|
||||
datePublished: page.date,
|
||||
dateModified: page.modified || page.date,
|
||||
type: 'WebPage',
|
||||
});
|
||||
} catch (e) {
|
||||
structuredData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
headline: page.title || 'Healthy Vending Options',
|
||||
description: page.seoDescription || '',
|
||||
url: `https://rockymountainvending.com/food-and-beverage/healthy-options/`,
|
||||
};
|
||||
}
|
||||
const structuredData = generateRegistryStructuredData("healthyOptions", {
|
||||
datePublished: page.date,
|
||||
dateModified: page.modified || page.date,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -78,26 +61,41 @@ export default async function HealthyOptionsPage() {
|
|||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
/>
|
||||
<article className="container mx-auto px-4 py-8 md:py-12 max-w-4xl">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-6">{page.title || 'Healthy Vending Options'}</h1>
|
||||
</header>
|
||||
{content}
|
||||
</article>
|
||||
<DropdownPageShell
|
||||
breadcrumbs={[
|
||||
{ label: "Food & Beverage" },
|
||||
{ label: "Healthy Options" },
|
||||
]}
|
||||
eyebrow="Food & Beverage"
|
||||
title={page.title || "Healthy Vending Options"}
|
||||
description={
|
||||
page.seoDescription ||
|
||||
page.excerpt ||
|
||||
"Explore healthier snack and beverage options, how we balance product variety, and what a better-for-you vending mix can look like at your location."
|
||||
}
|
||||
content={content}
|
||||
contentClassName="prose prose-lg max-w-none prose-headings:text-foreground prose-p:text-muted-foreground prose-a:text-foreground prose-a:underline prose-a:decoration-primary/35 prose-a:underline-offset-4 hover:prose-a:decoration-primary prose-strong:text-foreground"
|
||||
cta={{
|
||||
eyebrow: "Next Step",
|
||||
title: "Need help planning the right mix?",
|
||||
description:
|
||||
"We can help you balance healthier products, traditional favorites, and machine type so the lineup works for your traffic instead of just looking good on paper.",
|
||||
actions: [
|
||||
{ label: "Talk to Our Team", href: "/contact-us#contact-form" },
|
||||
{
|
||||
label: "See If You Qualify",
|
||||
href: "/#request-machine",
|
||||
variant: "outline",
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
)
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Error rendering Healthy Options page:', error);
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.error("Error rendering Healthy Options page:", error)
|
||||
}
|
||||
notFound();
|
||||
notFound()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,76 +1,60 @@
|
|||
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 { cleanWordPressContent } from '@/lib/clean-wordPress-content';
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from "next/navigation"
|
||||
import { loadImageMapping } from "@/lib/wordpress-content"
|
||||
import { generateRegistryMetadata, generateRegistryStructuredData } from "@/lib/seo"
|
||||
import { getPageBySlug } from "@/lib/wordpress-data-loader"
|
||||
import { cleanWordPressContent } from "@/lib/clean-wordPress-content"
|
||||
import { DropdownPageShell } from "@/components/dropdown-page-shell"
|
||||
import type { Metadata } from "next"
|
||||
|
||||
const WORDPRESS_SLUG = 'diverse-vending-options-with-rocky-mountain-vendings-exclusive-wholesale-accounts';
|
||||
const WORDPRESS_SLUG =
|
||||
"diverse-vending-options-with-rocky-mountain-vendings-exclusive-wholesale-accounts"
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
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 || 'Food & Beverage Suppliers',
|
||||
description: page.seoDescription || page.excerpt || '',
|
||||
excerpt: page.excerpt,
|
||||
return generateRegistryMetadata("suppliers", {
|
||||
date: page.date,
|
||||
modified: page.modified,
|
||||
image: page.images?.[0]?.localPath,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export default async function SuppliersPage() {
|
||||
try {
|
||||
const page = getPageBySlug(WORDPRESS_SLUG);
|
||||
const page = getPageBySlug(WORDPRESS_SLUG)
|
||||
|
||||
if (!page) {
|
||||
notFound();
|
||||
notFound()
|
||||
}
|
||||
|
||||
let imageMapping: any = {};
|
||||
let imageMapping: any = {}
|
||||
try {
|
||||
imageMapping = loadImageMapping();
|
||||
imageMapping = loadImageMapping()
|
||||
} catch (e) {
|
||||
imageMapping = {};
|
||||
imageMapping = {}
|
||||
}
|
||||
|
||||
const content = page.content ? (
|
||||
<div className="max-w-none">
|
||||
{cleanWordPressContent(String(page.content), {
|
||||
{cleanWordPressContent(String(page.content), {
|
||||
imageMapping,
|
||||
pageTitle: page.title
|
||||
pageTitle: page.title,
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">No content available.</p>
|
||||
);
|
||||
)
|
||||
|
||||
let structuredData;
|
||||
try {
|
||||
structuredData = generateStructuredData({
|
||||
title: page.title || 'Food & Beverage Suppliers',
|
||||
description: page.seoDescription || page.excerpt || '',
|
||||
url: page.link || page.urlPath || `https://rockymountainvending.com/food-and-beverage/suppliers/`,
|
||||
datePublished: page.date,
|
||||
dateModified: page.modified || page.date,
|
||||
type: 'WebPage',
|
||||
});
|
||||
} catch (e) {
|
||||
structuredData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
headline: page.title || 'Food & Beverage Suppliers',
|
||||
description: page.seoDescription || '',
|
||||
url: `https://rockymountainvending.com/food-and-beverage/suppliers/`,
|
||||
};
|
||||
}
|
||||
const structuredData = generateRegistryStructuredData("suppliers", {
|
||||
datePublished: page.date,
|
||||
dateModified: page.modified || page.date,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -78,26 +62,41 @@ export default async function SuppliersPage() {
|
|||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
/>
|
||||
<article className="container mx-auto px-4 py-8 md:py-12 max-w-4xl">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-6">{page.title || 'Food & Beverage Suppliers'}</h1>
|
||||
</header>
|
||||
{content}
|
||||
</article>
|
||||
<DropdownPageShell
|
||||
breadcrumbs={[
|
||||
{ label: "Food & Beverage" },
|
||||
{ label: "Suppliers" },
|
||||
]}
|
||||
eyebrow="Food & Beverage"
|
||||
title={page.title || "Food & Beverage Suppliers"}
|
||||
description={
|
||||
page.seoDescription ||
|
||||
page.excerpt ||
|
||||
"Learn how Rocky Mountain Vending sources products, keeps variety available, and builds a mix around both customer demand and dependable supply."
|
||||
}
|
||||
content={content}
|
||||
contentClassName="prose prose-lg max-w-none prose-headings:text-foreground prose-p:text-muted-foreground prose-a:text-foreground prose-a:underline prose-a:decoration-primary/35 prose-a:underline-offset-4 hover:prose-a:decoration-primary prose-strong:text-foreground"
|
||||
cta={{
|
||||
eyebrow: "Need a Mix Review?",
|
||||
title: "Talk through product options for your location",
|
||||
description:
|
||||
"If you want help choosing a better snack and beverage mix, we can help with audience fit, healthier options, and machine planning.",
|
||||
actions: [
|
||||
{ label: "Contact Us", href: "/contact-us#contact-form" },
|
||||
{
|
||||
label: "View Traditional Options",
|
||||
href: "/food-and-beverage/traditional-options",
|
||||
variant: "outline",
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
)
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Error rendering Suppliers page:', error);
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.error("Error rendering Suppliers page:", error)
|
||||
}
|
||||
notFound();
|
||||
notFound()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,76 +1,59 @@
|
|||
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 { cleanWordPressContent } from '@/lib/clean-wordPress-content';
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from "next/navigation"
|
||||
import { loadImageMapping } from "@/lib/wordpress-content"
|
||||
import { generateRegistryMetadata, generateRegistryStructuredData } from "@/lib/seo"
|
||||
import { getPageBySlug } from "@/lib/wordpress-data-loader"
|
||||
import { cleanWordPressContent } from "@/lib/clean-wordPress-content"
|
||||
import { DropdownPageShell } from "@/components/dropdown-page-shell"
|
||||
import type { Metadata } from "next"
|
||||
|
||||
const WORDPRESS_SLUG = 'traditional-vending';
|
||||
const WORDPRESS_SLUG = "traditional-vending"
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
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 || 'Traditional Vending Options',
|
||||
description: page.seoDescription || page.excerpt || '',
|
||||
excerpt: page.excerpt,
|
||||
return generateRegistryMetadata("traditionalOptions", {
|
||||
date: page.date,
|
||||
modified: page.modified,
|
||||
image: page.images?.[0]?.localPath,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export default async function TraditionalOptionsPage() {
|
||||
try {
|
||||
const page = getPageBySlug(WORDPRESS_SLUG);
|
||||
const page = getPageBySlug(WORDPRESS_SLUG)
|
||||
|
||||
if (!page) {
|
||||
notFound();
|
||||
notFound()
|
||||
}
|
||||
|
||||
let imageMapping: any = {};
|
||||
let imageMapping: any = {}
|
||||
try {
|
||||
imageMapping = loadImageMapping();
|
||||
imageMapping = loadImageMapping()
|
||||
} catch (e) {
|
||||
imageMapping = {};
|
||||
imageMapping = {}
|
||||
}
|
||||
|
||||
const content = page.content ? (
|
||||
<div className="max-w-none">
|
||||
{cleanWordPressContent(String(page.content), {
|
||||
{cleanWordPressContent(String(page.content), {
|
||||
imageMapping,
|
||||
pageTitle: page.title
|
||||
pageTitle: page.title,
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">No content available.</p>
|
||||
);
|
||||
)
|
||||
|
||||
let structuredData;
|
||||
try {
|
||||
structuredData = generateStructuredData({
|
||||
title: page.title || 'Traditional Vending Options',
|
||||
description: page.seoDescription || page.excerpt || '',
|
||||
url: page.link || page.urlPath || `https://rockymountainvending.com/food-and-beverage/traditional-options/`,
|
||||
datePublished: page.date,
|
||||
dateModified: page.modified || page.date,
|
||||
type: 'WebPage',
|
||||
});
|
||||
} catch (e) {
|
||||
structuredData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
headline: page.title || 'Traditional Vending Options',
|
||||
description: page.seoDescription || '',
|
||||
url: `https://rockymountainvending.com/food-and-beverage/traditional-options/`,
|
||||
};
|
||||
}
|
||||
const structuredData = generateRegistryStructuredData("traditionalOptions", {
|
||||
datePublished: page.date,
|
||||
dateModified: page.modified || page.date,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -78,26 +61,41 @@ export default async function TraditionalOptionsPage() {
|
|||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
/>
|
||||
<article className="container mx-auto px-4 py-8 md:py-12 max-w-4xl">
|
||||
<header className="mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-6">{page.title || 'Traditional Vending Options'}</h1>
|
||||
</header>
|
||||
{content}
|
||||
</article>
|
||||
<DropdownPageShell
|
||||
breadcrumbs={[
|
||||
{ label: "Food & Beverage" },
|
||||
{ label: "Traditional Options" },
|
||||
]}
|
||||
eyebrow="Food & Beverage"
|
||||
title={page.title || "Traditional Vending Options"}
|
||||
description={
|
||||
page.seoDescription ||
|
||||
page.excerpt ||
|
||||
"See the traditional snack and beverage options Rocky Mountain Vending can stock, and how we shape a lineup around the people using the location."
|
||||
}
|
||||
content={content}
|
||||
contentClassName="prose prose-lg max-w-none prose-headings:text-foreground prose-p:text-muted-foreground prose-a:text-foreground prose-a:underline prose-a:decoration-primary/35 prose-a:underline-offset-4 hover:prose-a:decoration-primary prose-strong:text-foreground"
|
||||
cta={{
|
||||
eyebrow: "Product Planning",
|
||||
title: "Want a lineup that actually fits your location?",
|
||||
description:
|
||||
"We can help you mix traditional favorites, healthier options, drinks, and pricing tiers in a way that makes sense for your audience and traffic.",
|
||||
actions: [
|
||||
{ label: "Talk to Our Team", href: "/contact-us#contact-form" },
|
||||
{
|
||||
label: "View Healthy Options",
|
||||
href: "/food-and-beverage/healthy-options",
|
||||
variant: "outline",
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
)
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Error rendering Traditional Options page:', error);
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.error("Error rendering Traditional Options page:", error)
|
||||
}
|
||||
notFound();
|
||||
notFound()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
178
app/globals.css
178
app/globals.css
|
|
@ -10,20 +10,20 @@
|
|||
--card-foreground: oklch(0.178 0.014 275.627);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.178 0.014 275.627);
|
||||
--primary: #29A047; /* Primary brand color (green from logo) */
|
||||
--primary: #29a047; /* Primary brand color (green from logo) */
|
||||
--primary-foreground: oklch(0.989 0.003 106.423);
|
||||
--primary-dark: #1d7a35; /* Darker primary for gradients and hover states */
|
||||
--secondary: #54595F; /* Secondary color (gray) */
|
||||
--secondary: #54595f; /* Secondary color (gray) */
|
||||
--secondary-foreground: oklch(1 0 0);
|
||||
--muted: oklch(0.961 0.004 106.423);
|
||||
--muted-foreground: oklch(0.556 0.014 275.627);
|
||||
--accent: #C4142C; /* Accent color (red - matches link hover) */
|
||||
--accent: #c4142c; /* Accent color (red - matches link hover) */
|
||||
--accent-foreground: oklch(1 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(0.922 0.004 106.423);
|
||||
--input: oklch(0.922 0.004 106.423);
|
||||
--ring: #29A047; /* Primary color for focus rings */
|
||||
--ring: #29a047; /* Primary color for focus rings */
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
|
|
@ -38,26 +38,33 @@
|
|||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
|
||||
|
||||
/* Link Colors - Master Style Guide */
|
||||
--link-color: var(--foreground);
|
||||
--link-hover-color: #c4142c; /* Red: rgb(196, 20, 44) */
|
||||
--link-hover-color-dark: #a01020; /* Darker red for gradients and hover states */
|
||||
--link-hover-bg: rgba(196, 20, 44, 0.1);
|
||||
|
||||
--header-bg: #ffffff;
|
||||
--footer-bg: #fef3e0;
|
||||
--shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
--header-bg: #ffffff;
|
||||
--footer-bg: #fef3e0;
|
||||
--shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
|
||||
/* Custom brand colors */
|
||||
--yellow: #FCBA09;
|
||||
--orange: #F79611;
|
||||
--mountain-bubbles: #FCBA0924; /* Yellow with transparency */
|
||||
/* Custom brand colors */
|
||||
--yellow: #fcba09;
|
||||
--orange: #f79611;
|
||||
--mountain-bubbles: #fcba0924; /* Yellow with transparency */
|
||||
--public-shell-max: 80rem;
|
||||
--public-section-space: clamp(4.5rem, 7vw, 7rem);
|
||||
--public-surface-radius: 2rem;
|
||||
--public-inset-radius: 1.5rem;
|
||||
--public-surface-shadow: 0 22px 56px rgba(15, 23, 42, 0.08);
|
||||
--public-surface-shadow-hover: 0 28px 72px rgba(15, 23, 42, 0.12);
|
||||
--header-height: 5.25rem;
|
||||
|
||||
/* Increased spacing variables */
|
||||
--spacing-xs: 0.75rem;
|
||||
--spacing-sm: 1.25rem;
|
||||
}
|
||||
/* Increased spacing variables */
|
||||
--spacing-xs: 0.75rem;
|
||||
--spacing-sm: 1.25rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0.01 275.627);
|
||||
|
|
@ -92,7 +99,7 @@
|
|||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(0.269 0 0);
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
|
||||
|
||||
/* Link Colors - Dark Mode */
|
||||
--link-color: var(--foreground);
|
||||
--link-hover-color: #ff4d6d; /* Lighter red for dark mode visibility */
|
||||
|
|
@ -101,8 +108,11 @@
|
|||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: var(--font-inter), "Inter", "Inter Fallback", system-ui, -apple-system, sans-serif;
|
||||
--font-mono: var(--font-geist-mono), "Geist Mono", "Geist Mono Fallback", monospace;
|
||||
--font-sans:
|
||||
var(--font-inter), "Inter", "Inter Fallback", system-ui, -apple-system,
|
||||
sans-serif;
|
||||
--font-mono:
|
||||
var(--font-geist-mono), "Geist Mono", "Geist Mono Fallback", monospace;
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
|
|
@ -147,44 +157,95 @@
|
|||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: var(--font-inter), "Inter", system-ui, -apple-system, sans-serif;
|
||||
font-family:
|
||||
var(--font-inter),
|
||||
"Inter",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
html {
|
||||
scroll-behavior: smooth; /* Added smooth scroll behavior for Apple-like experience */
|
||||
scroll-padding-top: calc(var(--header-height) + 1.25rem);
|
||||
}
|
||||
|
||||
|
||||
/* Global Link Styling - Master Style Guide */
|
||||
/* All hyperlinks should highlight in red on hover */
|
||||
a {
|
||||
color: var(--link-color);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease, background-color 0.2s ease;
|
||||
text-decoration-color: transparent;
|
||||
text-decoration-thickness: 0.08em;
|
||||
text-underline-offset: 0.18em;
|
||||
transition:
|
||||
color 0.2s ease,
|
||||
text-decoration-color 0.2s ease,
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
|
||||
a:hover,
|
||||
a:focus {
|
||||
color: var(--link-hover-color);
|
||||
background-color: var(--link-hover-bg);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
|
||||
a:active {
|
||||
color: var(--link-hover-color);
|
||||
}
|
||||
|
||||
|
||||
/* Next.js Link components inherit the same styling */
|
||||
a[href] {
|
||||
color: var(--link-color);
|
||||
transition: color 0.2s ease, background-color 0.2s ease;
|
||||
transition:
|
||||
color 0.2s ease,
|
||||
text-decoration-color 0.2s ease,
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
|
||||
a[href]:hover,
|
||||
a[href]:focus {
|
||||
color: var(--link-hover-color);
|
||||
background-color: var(--link-hover-bg);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
|
||||
button a,
|
||||
[role="button"] a,
|
||||
.bg-primary a,
|
||||
.text-primary-foreground a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button a:hover,
|
||||
button a:focus,
|
||||
[role="button"] a:hover,
|
||||
[role="button"] a:focus,
|
||||
.bg-primary a:hover,
|
||||
.bg-primary a:focus,
|
||||
.text-primary-foreground a:hover,
|
||||
.text-primary-foreground a:focus {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
p,
|
||||
li {
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
a:focus-visible,
|
||||
button:focus-visible,
|
||||
[role="button"]:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--primary) 18%, transparent);
|
||||
}
|
||||
|
||||
/* Hide Next.js dev tools portal */
|
||||
nextjs-portal,
|
||||
nextjs-portal * {
|
||||
|
|
@ -193,7 +254,7 @@
|
|||
opacity: 0 !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
|
||||
/* WordPress content styling */
|
||||
.prose strong,
|
||||
article strong,
|
||||
|
|
@ -201,13 +262,13 @@
|
|||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
|
||||
.prose em,
|
||||
article em,
|
||||
.wordpress-content em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
|
||||
.prose ul,
|
||||
.prose ol,
|
||||
article ul,
|
||||
|
|
@ -218,7 +279,7 @@
|
|||
margin-bottom: 1rem;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
|
||||
.prose li,
|
||||
article li,
|
||||
.wordpress-content li {
|
||||
|
|
@ -239,6 +300,26 @@
|
|||
color: var(--link-hover-color);
|
||||
}
|
||||
|
||||
.public-page {
|
||||
margin-inline: auto;
|
||||
width: 100%;
|
||||
max-width: var(--public-shell-max);
|
||||
padding-inline: 1rem;
|
||||
padding-block: 2.5rem 3.75rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.public-page {
|
||||
padding-inline: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.public-page {
|
||||
padding-block: 3rem 4.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.wordpress-content h1,
|
||||
.wordpress-content h2,
|
||||
.wordpress-content h3,
|
||||
|
|
@ -271,7 +352,7 @@
|
|||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
|
||||
.wordpress-content > div img,
|
||||
.wordpress-content figure img {
|
||||
@apply mx-auto block;
|
||||
|
|
@ -285,44 +366,49 @@
|
|||
.wordpress-content > div {
|
||||
@apply space-y-6;
|
||||
}
|
||||
|
||||
|
||||
/* Visual Separation for Page Sections */
|
||||
section {
|
||||
@apply relative;
|
||||
}
|
||||
|
||||
|
||||
/* Main content wrapper for visual separation from header */
|
||||
main {
|
||||
@apply relative;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.05);
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
|
||||
/* Improved spacing and readability */
|
||||
p {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.2;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
|
||||
/* Footer Styling - Only apply to top-level footer, not article footers */
|
||||
body > div > footer {
|
||||
background: var(--footer-bg);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
|
||||
/* Hide scrollbar for horizontal scrolling galleries */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari and Opera */
|
||||
display: none; /* Chrome, Safari and Opera */
|
||||
}
|
||||
|
||||
/* Focus visible styling for keyboard navigation - Following Vercel Web Design Guidelines */
|
||||
|
|
|
|||
|
|
@ -1,77 +1,59 @@
|
|||
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 { cleanWordPressContent } from '@/lib/clean-wordPress-content';
|
||||
import { WhoWeServePage } from '@/components/who-we-serve-page';
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from "next/navigation"
|
||||
import { loadImageMapping } from "@/lib/wordpress-content"
|
||||
import { generateRegistryMetadata, generateRegistryStructuredData } from "@/lib/seo"
|
||||
import { getPageBySlug } from "@/lib/wordpress-data-loader"
|
||||
import { cleanWordPressContent } from "@/lib/clean-wordPress-content"
|
||||
import { WhoWeServePage } from "@/components/who-we-serve-page"
|
||||
import type { Metadata } from "next"
|
||||
|
||||
const WORDPRESS_SLUG = 'vending-machine-for-your-gym';
|
||||
const WORDPRESS_SLUG = "vending-machine-for-your-gym"
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
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 || 'Gym Vending Solutions',
|
||||
description: page.seoDescription || page.excerpt || '',
|
||||
excerpt: page.excerpt,
|
||||
return generateRegistryMetadata("gyms", {
|
||||
date: page.date,
|
||||
modified: page.modified,
|
||||
image: page.images?.[0]?.localPath,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export default async function GymsPage() {
|
||||
try {
|
||||
const page = getPageBySlug(WORDPRESS_SLUG);
|
||||
const page = getPageBySlug(WORDPRESS_SLUG)
|
||||
|
||||
if (!page) {
|
||||
notFound();
|
||||
notFound()
|
||||
}
|
||||
|
||||
let imageMapping: any = {};
|
||||
let imageMapping: any = {}
|
||||
try {
|
||||
imageMapping = loadImageMapping();
|
||||
imageMapping = loadImageMapping()
|
||||
} catch (e) {
|
||||
imageMapping = {};
|
||||
imageMapping = {}
|
||||
}
|
||||
|
||||
const content = page.content ? (
|
||||
<div className="max-w-none">
|
||||
{cleanWordPressContent(String(page.content), {
|
||||
{cleanWordPressContent(String(page.content), {
|
||||
imageMapping,
|
||||
pageTitle: page.title
|
||||
pageTitle: page.title,
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground">No content available.</p>
|
||||
);
|
||||
)
|
||||
|
||||
let structuredData;
|
||||
try {
|
||||
structuredData = generateStructuredData({
|
||||
title: page.title || 'Gym Vending Solutions',
|
||||
description: page.seoDescription || page.excerpt || '',
|
||||
url: page.link || page.urlPath || `https://rockymountainvending.com/gyms/`,
|
||||
datePublished: page.date,
|
||||
dateModified: page.modified || page.date,
|
||||
type: 'WebPage',
|
||||
});
|
||||
} catch (e) {
|
||||
structuredData = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebPage',
|
||||
headline: page.title || 'Gym Vending Solutions',
|
||||
description: page.seoDescription || '',
|
||||
url: `https://rockymountainvending.com/gyms/`,
|
||||
};
|
||||
}
|
||||
const structuredData = generateRegistryStructuredData("gyms", {
|
||||
datePublished: page.date,
|
||||
dateModified: page.modified || page.date,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -79,14 +61,17 @@ export default async function GymsPage() {
|
|||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||
/>
|
||||
<WhoWeServePage title={page.title || 'Gym Vending Solutions'} content={content} />
|
||||
<WhoWeServePage
|
||||
title={page.title || "Gym Vending Solutions"}
|
||||
description={page.seoDescription || page.excerpt || undefined}
|
||||
content={content}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
)
|
||||
} catch (error) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('Error rendering Gyms page:', error);
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.error("Error rendering Gyms page:", error)
|
||||
}
|
||||
notFound();
|
||||
notFound()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,19 +3,17 @@ import type { Metadata } from "next"
|
|||
import { Inter, Geist_Mono } from "next/font/google"
|
||||
import { Header } from "@/components/header"
|
||||
import { Footer } from "@/components/footer"
|
||||
import { StructuredData } from "@/components/structured-data"
|
||||
import { OrganizationSchema } from "@/components/organization-schema"
|
||||
import { SiteChatWidget } from "@/components/site-chat-widget"
|
||||
import { CartProvider } from "@/lib/cart/context"
|
||||
import { businessConfig } from "@/lib/seo-config"
|
||||
import "./globals.css"
|
||||
|
||||
const inter = Inter({
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-inter",
|
||||
display: "swap",
|
||||
})
|
||||
const geistMono = Geist_Mono({
|
||||
const geistMono = Geist_Mono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-geist-mono",
|
||||
display: "swap",
|
||||
|
|
@ -23,21 +21,12 @@ const geistMono = Geist_Mono({
|
|||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(businessConfig.website),
|
||||
title: "Free Vending Machines Utah | Rocky Mountain Vending | Salt Lake City, Ogden, Provo",
|
||||
description:
|
||||
"Get a FREE vending machine for your Utah business! No cost installation. Serving Salt Lake City, Ogden, Provo, and surrounding areas since 2019. 100+ machines installed. Call (435) 233-9668.",
|
||||
title: {
|
||||
default: businessConfig.name,
|
||||
template: "%s",
|
||||
},
|
||||
description: businessConfig.description,
|
||||
generator: "Next.js",
|
||||
keywords: [
|
||||
"vending machines",
|
||||
"vending machine supplier",
|
||||
"free vending machines",
|
||||
"Utah vending",
|
||||
"Salt Lake City vending",
|
||||
"Ogden vending",
|
||||
"Provo vending",
|
||||
"vending machine repair",
|
||||
"vending machine service",
|
||||
],
|
||||
authors: [{ name: businessConfig.name }],
|
||||
creator: businessConfig.name,
|
||||
publisher: businessConfig.name,
|
||||
|
|
@ -71,11 +60,7 @@ export const metadata: Metadata = {
|
|||
openGraph: {
|
||||
type: "website",
|
||||
locale: "en_US",
|
||||
url: businessConfig.website,
|
||||
siteName: businessConfig.name,
|
||||
title: "Free Vending Machines Utah | Rocky Mountain Vending",
|
||||
description:
|
||||
"Get a FREE vending machine for your Utah business! No cost installation. Serving Salt Lake City, Ogden, Provo, and surrounding areas since 2019. 100+ machines installed.",
|
||||
images: [
|
||||
{
|
||||
url: `${businessConfig.website}/images/rocky-mountain-vending-service-area-926x1024.webp`,
|
||||
|
|
@ -87,15 +72,11 @@ export const metadata: Metadata = {
|
|||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Free Vending Machines Utah | Rocky Mountain Vending",
|
||||
description:
|
||||
"Get a FREE vending machine for your Utah business! No cost installation. Serving Salt Lake City, Ogden, Provo, and surrounding areas since 2019.",
|
||||
images: [`${businessConfig.website}/images/rocky-mountain-vending-service-area-926x1024.webp`],
|
||||
images: [
|
||||
`${businessConfig.website}/images/rocky-mountain-vending-service-area-926x1024.webp`,
|
||||
],
|
||||
creator: "@RMVVending",
|
||||
},
|
||||
alternates: {
|
||||
canonical: businessConfig.website,
|
||||
},
|
||||
verification: {
|
||||
// Google Search Console verification
|
||||
// To enable: Add your verification code from Google Search Console
|
||||
|
|
@ -112,28 +93,24 @@ export default function RootLayout({
|
|||
}>) {
|
||||
return (
|
||||
<html lang="en" className={`${inter.variable} ${geistMono.variable}`}>
|
||||
<head>
|
||||
<StructuredData />
|
||||
<OrganizationSchema />
|
||||
</head>
|
||||
<body className="font-sans antialiased">
|
||||
<CartProvider>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{/* Skip to main content link for keyboard users */}
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-background focus:border focus:border-primary focus:rounded-md focus:text-primary focus:font-medium"
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
<Header />
|
||||
<main id="main-content" className="flex-1">
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
<SiteChatWidget />
|
||||
</CartProvider>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{/* Skip to main content link for keyboard users */}
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-background focus:border focus:border-primary focus:rounded-md focus:text-primary focus:font-medium"
|
||||
>
|
||||
Skip to main content
|
||||
</a>
|
||||
<Header />
|
||||
<main id="main-content" className="flex-1">
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
<SiteChatWidget />
|
||||
</CartProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ manuals/
|
|||
## Adding Manuals
|
||||
|
||||
Simply add PDF files to the appropriate manufacturer directory. The system will:
|
||||
|
||||
- Automatically detect new files
|
||||
- Extract categories from filenames
|
||||
- Make them searchable and filterable
|
||||
|
|
@ -56,6 +57,3 @@ Simply add PDF files to the appropriate manufacturer directory. The system will:
|
|||
- Client-side filtering ensures fast interactions
|
||||
- PDFs are cached with long-term headers
|
||||
- Supports hundreds of manuals without performance degradation
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
export const dynamic = "force-dynamic"
|
||||
|
||||
import { Metadata } from 'next'
|
||||
import { ManualsDashboardClient } from '@/components/manuals-dashboard-client'
|
||||
import { PublicInset, PublicPageHeader } from '@/components/public-surface'
|
||||
import { getConvexManualDashboard } from '@/lib/convex-service'
|
||||
import { businessConfig } from '@/lib/seo-config'
|
||||
import { readFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { Metadata } from "next"
|
||||
import { ManualsDashboardClient } from "@/components/manuals-dashboard-client"
|
||||
import { PublicInset, PublicPageHeader } from "@/components/public-surface"
|
||||
import { getConvexManualDashboard } from "@/lib/convex-service"
|
||||
import { businessConfig } from "@/lib/seo-config"
|
||||
import { readFileSync } from "fs"
|
||||
import { join } from "path"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Manual Processing Dashboard | Rocky Mountain Vending',
|
||||
description: 'Comprehensive dashboard showing processed manual data, statistics, and analytics',
|
||||
title: "Manual Processing Dashboard | Rocky Mountain Vending",
|
||||
description:
|
||||
"Comprehensive dashboard showing processed manual data, statistics, and analytics",
|
||||
alternates: {
|
||||
canonical: `${businessConfig.website}/manuals/dashboard`,
|
||||
},
|
||||
|
|
@ -31,37 +32,37 @@ interface DashboardData {
|
|||
}
|
||||
|
||||
async function loadDashboardData(): Promise<DashboardData> {
|
||||
const projectRoot = join(process.cwd(), '..')
|
||||
|
||||
const projectRoot = join(process.cwd(), "..")
|
||||
|
||||
try {
|
||||
const missingManuals = JSON.parse(
|
||||
readFileSync(join(projectRoot, 'missing_manuals_report.json'), 'utf-8')
|
||||
readFileSync(join(projectRoot, "missing_manuals_report.json"), "utf-8")
|
||||
)
|
||||
|
||||
|
||||
const qaData = JSON.parse(
|
||||
readFileSync(join(projectRoot, 'manuals_qa_comprehensive.json'), 'utf-8')
|
||||
readFileSync(join(projectRoot, "manuals_qa_comprehensive.json"), "utf-8")
|
||||
)
|
||||
|
||||
|
||||
const metadata = JSON.parse(
|
||||
readFileSync(join(projectRoot, 'manuals_enhanced_metadata.json'), 'utf-8')
|
||||
readFileSync(join(projectRoot, "manuals_enhanced_metadata.json"), "utf-8")
|
||||
)
|
||||
|
||||
|
||||
const structuredData = JSON.parse(
|
||||
readFileSync(join(projectRoot, 'manuals_structured_data.jsonld'), 'utf-8')
|
||||
readFileSync(join(projectRoot, "manuals_structured_data.jsonld"), "utf-8")
|
||||
)
|
||||
|
||||
|
||||
const semanticIndex = JSON.parse(
|
||||
readFileSync(join(projectRoot, 'manuals_semantic_index.json'), 'utf-8')
|
||||
readFileSync(join(projectRoot, "manuals_semantic_index.json"), "utf-8")
|
||||
)
|
||||
|
||||
|
||||
const acquisitionList = JSON.parse(
|
||||
readFileSync(join(projectRoot, 'manual_acquisition_list.json'), 'utf-8')
|
||||
readFileSync(join(projectRoot, "manual_acquisition_list.json"), "utf-8")
|
||||
)
|
||||
|
||||
|
||||
const nameMapping = JSON.parse(
|
||||
readFileSync(join(projectRoot, 'manual_name_mapping.json'), 'utf-8')
|
||||
readFileSync(join(projectRoot, "manual_name_mapping.json"), "utf-8")
|
||||
)
|
||||
|
||||
|
||||
return {
|
||||
missingManuals,
|
||||
qaData,
|
||||
|
|
@ -72,7 +73,7 @@ async function loadDashboardData(): Promise<DashboardData> {
|
|||
nameMapping,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading dashboard data:', error)
|
||||
console.error("Error loading dashboard data:", error)
|
||||
return {
|
||||
missingManuals: { summary: {} },
|
||||
qaData: [],
|
||||
|
|
@ -106,6 +107,3 @@ export default async function ManualsDashboardPage() {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,125 +1,122 @@
|
|||
export const dynamic = "force-dynamic"
|
||||
|
||||
import { existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { Metadata } from 'next'
|
||||
import { businessConfig } from '@/lib/seo-config'
|
||||
import { ManualsPageExperience } from '@/components/manuals-page-experience'
|
||||
import { listConvexManuals } from '@/lib/convex-service'
|
||||
import { scanManuals } from '@/lib/manuals'
|
||||
import { selectManualsForSite } from '@/lib/manuals-site-selection'
|
||||
import { generateStructuredData } from '@/lib/seo'
|
||||
import { getManualsThumbnailsRoot } from '@/lib/manuals-paths'
|
||||
import { createHash } from "node:crypto"
|
||||
import { existsSync } from "node:fs"
|
||||
import { Metadata } from "next"
|
||||
import { headers } from "next/headers"
|
||||
import { businessConfig } from "@/lib/seo-config"
|
||||
import { ManualsPageExperience } from "@/components/manuals-page-experience"
|
||||
import { listConvexManuals } from "@/lib/convex-service"
|
||||
import { scanManuals } from "@/lib/manuals"
|
||||
import { selectManualsForSite } from "@/lib/manuals-site-selection"
|
||||
import { generateSEOMetadata, generateStructuredData } from "@/lib/seo"
|
||||
import { getManualsThumbnailsRoot } from "@/lib/manuals-paths"
|
||||
import { resolveManualsTenantDomain } from "@/lib/manuals-tenant"
|
||||
import { sanitizeManualThumbnailsForRuntime } from "@/lib/manuals-render-safety"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Vending Machine Manuals | Download PDF Guides | Rocky Mountain Vending',
|
||||
export const metadata: Metadata = generateSEOMetadata({
|
||||
title: "Vending Machine Manuals | Rocky Mountain Vending",
|
||||
description:
|
||||
'Download free PDF manuals, service guides, and parts documentation for hundreds of vending machine models from Royal Vendors, Dixie-Narco, Vendo, Crane, BevMax, Merchant Series, AP, GPL, Seaga, USI, and more. Find service manuals, parts catalogs, installation instructions, troubleshooting guides, and maintenance documentation for snack, beverage, combo, coffee, and food vending machines. Many manuals include available replacement parts with purchase links.',
|
||||
"Browse vending machine manuals, service guides, and support documentation for snack, beverage, combo, coffee, and food machines.",
|
||||
path: "/manuals",
|
||||
keywords: [
|
||||
'vending machine manuals',
|
||||
'vending machine PDF',
|
||||
'vending machine service manual',
|
||||
'vending machine parts catalog',
|
||||
'vending machine repair manual',
|
||||
'vending machine installation guide',
|
||||
'vending machine troubleshooting guide',
|
||||
'Royal Vendors manual',
|
||||
'Dixie-Narco manual',
|
||||
'Vendo manual',
|
||||
'Crane vending manual',
|
||||
'BevMax manual',
|
||||
'Merchant Series manual',
|
||||
'AP vending manual',
|
||||
'GPL vending manual',
|
||||
'Seaga vending manual',
|
||||
'USI vending manual',
|
||||
'snack machine manual',
|
||||
'beverage machine manual',
|
||||
'combo vending machine manual',
|
||||
'coffee vending machine manual',
|
||||
'food vending machine manual',
|
||||
'frozen food vending manual',
|
||||
'vending machine parts',
|
||||
'vending machine replacement parts',
|
||||
'vending machine wiring diagram',
|
||||
'vending machine maintenance',
|
||||
"vending machine manuals",
|
||||
"vending machine PDF",
|
||||
"vending machine service manual",
|
||||
"vending machine repair manual",
|
||||
"vending machine troubleshooting guide",
|
||||
"Royal Vendors manual",
|
||||
"Dixie-Narco manual",
|
||||
"Vendo manual",
|
||||
"Crane vending manual",
|
||||
"Seaga vending manual",
|
||||
],
|
||||
openGraph: {
|
||||
title: 'Vending Machine Manuals | Download PDF Guides | Rocky Mountain Vending',
|
||||
description:
|
||||
'Download free PDF manuals, service guides, and parts documentation for hundreds of vending machine models from leading manufacturers. Find service manuals, parts catalogs, installation instructions, troubleshooting guides, and maintenance documentation for snack, beverage, combo, coffee, and food vending machines.',
|
||||
type: 'website',
|
||||
url: `${businessConfig.website}/manuals`,
|
||||
images: [
|
||||
{
|
||||
url: `${businessConfig.website}/images/rocky-mountain-vending-service-area-926x1024.webp`,
|
||||
width: 926,
|
||||
height: 1024,
|
||||
alt: 'Rocky Mountain Vending Manuals',
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'Vending Machine Manuals | Download PDF Guides',
|
||||
description:
|
||||
'Download free PDF manuals, service guides, and parts documentation for hundreds of vending machine models from Royal Vendors, Dixie-Narco, Vendo, Crane, BevMax, and more. Find service manuals, parts catalogs, installation instructions, and troubleshooting guides.',
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${businessConfig.website}/manuals`,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
export default async function ManualsPage() {
|
||||
// Prefer Convex-backed metadata, but keep filesystem fallback in place until the shared catalog is fully populated.
|
||||
const convexManuals = await listConvexManuals()
|
||||
const allManuals = convexManuals.length > 0 ? convexManuals : await scanManuals()
|
||||
let manuals = convexManuals.length > 0 ? convexManuals : selectManualsForSite(allManuals).manuals
|
||||
|
||||
// Hide broken local thumbnails so the public manuals page doesn't spam 404s.
|
||||
const thumbnailsRoot = getManualsThumbnailsRoot()
|
||||
manuals = manuals.map((manual) => {
|
||||
if (!manual.thumbnailUrl || /^https?:\/\//i.test(manual.thumbnailUrl)) {
|
||||
return manual
|
||||
}
|
||||
|
||||
const relativeThumbnailPath = manual.thumbnailUrl.includes('/thumbnails/')
|
||||
? manual.thumbnailUrl.replace(/^.*\/thumbnails\//, '')
|
||||
: manual.thumbnailUrl
|
||||
|
||||
return existsSync(join(thumbnailsRoot, relativeThumbnailPath))
|
||||
? manual
|
||||
: { ...manual, thumbnailUrl: undefined }
|
||||
const requestHeaders = await headers()
|
||||
const requestHost =
|
||||
requestHeaders.get("x-forwarded-host") || requestHeaders.get("host")
|
||||
const manualsDomain = resolveManualsTenantDomain({
|
||||
requestHost,
|
||||
envTenantDomain: process.env.MANUALS_TENANT_DOMAIN,
|
||||
envSiteDomain: process.env.NEXT_PUBLIC_SITE_DOMAIN,
|
||||
})
|
||||
|
||||
|
||||
const convexManuals = manualsDomain
|
||||
? await listConvexManuals(manualsDomain)
|
||||
: []
|
||||
|
||||
const isLocalDevelopment = process.env.NODE_ENV === "development"
|
||||
const shouldUseFilesystemFallback = isLocalDevelopment
|
||||
|
||||
const allManuals =
|
||||
convexManuals.length > 0 || !shouldUseFilesystemFallback
|
||||
? convexManuals
|
||||
: await scanManuals()
|
||||
let manuals =
|
||||
convexManuals.length > 0
|
||||
? convexManuals
|
||||
: shouldUseFilesystemFallback
|
||||
? selectManualsForSite(allManuals, manualsDomain).manuals
|
||||
: []
|
||||
|
||||
const shouldShowDegradedState =
|
||||
!shouldUseFilesystemFallback && manuals.length === 0
|
||||
|
||||
if (shouldShowDegradedState) {
|
||||
const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL || ""
|
||||
const convexUrlHash = convexUrl
|
||||
? createHash("sha256").update(convexUrl).digest("hex").slice(0, 12)
|
||||
: "missing"
|
||||
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
event: "manuals.degraded_empty_tenant",
|
||||
severity: "error",
|
||||
domain: manualsDomain || "missing",
|
||||
host: requestHost || "missing",
|
||||
manualCount: manuals.length,
|
||||
convexManualCount: convexManuals.length,
|
||||
convexUrlHash,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
manuals = sanitizeManualThumbnailsForRuntime(manuals, {
|
||||
isLocalDevelopment,
|
||||
thumbnailsRoot: getManualsThumbnailsRoot(),
|
||||
fileExists: existsSync,
|
||||
})
|
||||
|
||||
// Generate structured data for SEO
|
||||
const structuredData = generateStructuredData({
|
||||
title: 'Vending Machine Manuals',
|
||||
title: "Vending Machine Manuals",
|
||||
description:
|
||||
'Download free PDF manuals, service guides, and parts documentation for hundreds of vending machine models from Royal Vendors, Dixie-Narco, Vendo, Crane, BevMax, Merchant Series, AP, GPL, Seaga, USI, and more. Find service manuals, parts catalogs, installation instructions, troubleshooting guides, and maintenance documentation for snack, beverage, combo, coffee, and food vending machines. Many manuals include available replacement parts with purchase links.',
|
||||
"Download free PDF manuals, service guides, and parts documentation for hundreds of vending machine models from Royal Vendors, Dixie-Narco, Vendo, Crane, BevMax, Merchant Series, AP, GPL, Seaga, USI, and more. Find service manuals, parts catalogs, installation instructions, troubleshooting guides, and maintenance documentation for snack, beverage, combo, coffee, and food vending machines. Many manuals include available replacement parts with purchase links.",
|
||||
url: `${businessConfig.website}/manuals`,
|
||||
type: 'WebPage',
|
||||
type: "WebPage",
|
||||
})
|
||||
|
||||
// Add CollectionPage schema for better SEO
|
||||
const collectionSchema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CollectionPage',
|
||||
name: 'Vending Machine Manuals',
|
||||
"@context": "https://schema.org",
|
||||
"@type": "CollectionPage",
|
||||
name: "Vending Machine Manuals",
|
||||
description:
|
||||
'A comprehensive collection of vending machine manuals, service guides, and parts documentation from leading manufacturers including Royal Vendors, Dixie-Narco, Vendo, Crane Merchandising, BevMax, Merchant Series, AP, GPL, Seaga, USI, and more. Includes service manuals, parts catalogs, installation instructions, troubleshooting guides, wiring diagrams, and maintenance documentation for snack machines, beverage machines, combo vending machines, coffee machines, food machines, and frozen food machines. Many manuals feature available replacement parts with direct purchase links.',
|
||||
"A comprehensive collection of vending machine manuals, service guides, and parts documentation from leading manufacturers including Royal Vendors, Dixie-Narco, Vendo, Crane Merchandising, BevMax, Merchant Series, AP, GPL, Seaga, USI, and more. Includes service manuals, parts catalogs, installation instructions, troubleshooting guides, wiring diagrams, and maintenance documentation for snack machines, beverage machines, combo vending machines, coffee machines, food machines, and frozen food machines. Many manuals feature available replacement parts with direct purchase links.",
|
||||
url: `${businessConfig.website}/manuals`,
|
||||
mainEntity: {
|
||||
'@type': 'ItemList',
|
||||
"@type": "ItemList",
|
||||
numberOfItems: manuals.length,
|
||||
itemListElement: manuals.slice(0, 50).map((manual, index) => ({
|
||||
'@type': 'ListItem',
|
||||
"@type": "ListItem",
|
||||
position: index + 1,
|
||||
item: {
|
||||
'@type': 'DigitalDocument',
|
||||
name: manual.filename.replace(/\.pdf$/i, ''),
|
||||
"@type": "DigitalDocument",
|
||||
name: manual.filename.replace(/\.pdf$/i, ""),
|
||||
description: `${manual.manufacturer} ${manual.category} Manual`,
|
||||
encodingFormat: 'application/pdf',
|
||||
encodingFormat: "application/pdf",
|
||||
},
|
||||
})),
|
||||
},
|
||||
|
|
@ -135,8 +132,22 @@ export default async function ManualsPage() {
|
|||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(collectionSchema) }}
|
||||
/>
|
||||
<div className="container mx-auto px-4 py-8 md:py-12">
|
||||
<ManualsPageExperience initialManuals={manuals} />
|
||||
<div className="public-page" data-manuals-domain={manualsDomain}>
|
||||
{shouldShowDegradedState ? (
|
||||
<div className="mx-auto max-w-[var(--public-shell-max)] px-4 py-10 sm:px-5 lg:px-6">
|
||||
<div className="rounded-2xl border border-destructive/30 bg-destructive/5 p-6">
|
||||
<h1 className="text-xl font-semibold text-foreground">
|
||||
Manuals Library Temporarily Unavailable
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
We are restoring tenant catalog data for this domain. Please
|
||||
refresh shortly or contact support if this persists.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ManualsPageExperience initialManuals={manuals} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { OrderTracking } from '@/components/order-tracking'
|
||||
import { OrderTracking } from "@/components/order-tracking"
|
||||
|
||||
export default function OrdersPage() {
|
||||
return (
|
||||
|
|
@ -9,6 +9,6 @@ export default function OrdersPage() {
|
|||
}
|
||||
|
||||
export const metadata = {
|
||||
title: 'Track Your Order | Rocky Mountain Vending',
|
||||
description: 'Track your order status and view delivery information',
|
||||
title: "Track Your Order | Rocky Mountain Vending",
|
||||
description: "Track your order status and view delivery information",
|
||||
}
|
||||
|
|
|
|||
16
app/page.tsx
16
app/page.tsx
|
|
@ -1,3 +1,4 @@
|
|||
import type { Metadata } from "next"
|
||||
import { HeroSection } from "@/components/hero-section"
|
||||
import { StatsSection } from "@/components/stats-section"
|
||||
import { FeaturesSection } from "@/components/features-section"
|
||||
|
|
@ -8,10 +9,25 @@ import { ServiceAreasSection } from "@/components/service-areas-section"
|
|||
import { ReviewsSection } from "@/components/reviews-section"
|
||||
import { RequestMachineSection } from "@/components/request-machine-section"
|
||||
import { ContactSection } from "@/components/contact-section"
|
||||
import { StructuredData } from "@/components/structured-data"
|
||||
import { OrganizationSchema } from "@/components/organization-schema"
|
||||
import { generateSEOMetadata } from "@/lib/seo"
|
||||
import { getSeoPageDefinition } from "@/lib/seo-registry"
|
||||
|
||||
const homeSeo = getSeoPageDefinition("home")
|
||||
|
||||
export const metadata: Metadata = generateSEOMetadata({
|
||||
title: homeSeo.title,
|
||||
description: homeSeo.description,
|
||||
path: homeSeo.path,
|
||||
keywords: [...homeSeo.keywords],
|
||||
})
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<StructuredData />
|
||||
<OrganizationSchema />
|
||||
<HeroSection />
|
||||
<StatsSection />
|
||||
<FeaturesSection />
|
||||
|
|
|
|||
|
|
@ -1,25 +1,26 @@
|
|||
import { generateSEOMetadata, generateStructuredData } from '@/lib/seo';
|
||||
import { PrivacyPolicyPage } from '@/components/privacy-policy-page';
|
||||
import type { Metadata } from 'next';
|
||||
import { generateSEOMetadata, generateStructuredData } from "@/lib/seo"
|
||||
import { PrivacyPolicyPage } from "@/components/privacy-policy-page"
|
||||
import type { Metadata } from "next"
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return generateSEOMetadata({
|
||||
title: 'Privacy Policy | Rocky Mountain Vending',
|
||||
description: 'Privacy Policy for Rocky Mountain Vending',
|
||||
title: "Privacy Policy | Rocky Mountain Vending",
|
||||
description: "Privacy Policy for Rocky Mountain Vending",
|
||||
path: "/privacy-policy",
|
||||
robots: {
|
||||
index: false,
|
||||
follow: false,
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
export default function PrivacyPolicy() {
|
||||
const structuredData = generateStructuredData({
|
||||
title: 'Privacy Policy',
|
||||
description: 'Privacy Policy for Rocky Mountain Vending',
|
||||
url: 'https://rockymountainvending.com/privacy-policy/',
|
||||
type: 'WebPage',
|
||||
});
|
||||
title: "Privacy Policy",
|
||||
description: "Privacy Policy for Rocky Mountain Vending",
|
||||
url: "https://rockymountainvending.com/privacy-policy/",
|
||||
type: "WebPage",
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -29,5 +30,5 @@ export default function PrivacyPolicy() {
|
|||
/>
|
||||
<PrivacyPolicyPage />
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,35 +1,37 @@
|
|||
import { notFound } from 'next/navigation'
|
||||
import Image from 'next/image'
|
||||
import { fetchProductById, fetchAllProducts } from '@/lib/stripe/products'
|
||||
import { AddToCartButton } from '@/components/add-to-cart-button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { notFound } from "next/navigation"
|
||||
import Image from "next/image"
|
||||
import { fetchProductById, fetchAllProducts } from "@/lib/stripe/products"
|
||||
import { AddToCartButton } from "@/components/add-to-cart-button"
|
||||
import { PublicInset, PublicSurface } from "@/components/public-surface"
|
||||
|
||||
interface ProductPageProps {
|
||||
params: Promise<{ id: string }>
|
||||
}
|
||||
|
||||
// Required for static export
|
||||
export const dynamic = 'force-static';
|
||||
export const dynamicParams = false;
|
||||
export const dynamic = "force-static"
|
||||
export const dynamicParams = false
|
||||
|
||||
// Generate static params for all products
|
||||
export async function generateStaticParams() {
|
||||
try {
|
||||
const products = await fetchAllProducts();
|
||||
|
||||
const products = await fetchAllProducts()
|
||||
|
||||
// Ensure we have products
|
||||
if (!products || products.length === 0) {
|
||||
console.warn('No products found for static generation. Product pages will not be pre-rendered.');
|
||||
return [];
|
||||
console.warn(
|
||||
"No products found for static generation. Product pages will not be pre-rendered."
|
||||
)
|
||||
return []
|
||||
}
|
||||
|
||||
|
||||
return products.map((product) => ({
|
||||
id: product.id,
|
||||
}));
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('Error generating static params for products:', error);
|
||||
console.error("Error generating static params for products:", error)
|
||||
// Return empty array - product pages won't be pre-rendered but won't break the build
|
||||
return [];
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -39,7 +41,7 @@ export async function generateMetadata({ params }: ProductPageProps) {
|
|||
|
||||
if (!product) {
|
||||
return {
|
||||
title: 'Product Not Found | Rocky Mountain Vending',
|
||||
title: "Product Not Found | Rocky Mountain Vending",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -57,69 +59,70 @@ export default async function ProductPage({ params }: ProductPageProps) {
|
|||
notFound()
|
||||
}
|
||||
|
||||
const imageUrl = product.images?.[0] || '/placeholder.svg'
|
||||
const imageUrl = product.images?.[0] || "/placeholder.svg"
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 md:py-16">
|
||||
<div className="grid gap-8 md:grid-cols-2 lg:gap-12">
|
||||
{/* Product Image */}
|
||||
<Card className="overflow-hidden border-border/50 hover:border-secondary/50 transition-all">
|
||||
<CardContent className="p-0">
|
||||
<div className="aspect-square relative overflow-hidden bg-muted">
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={product.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 768px) 100vw, 50vw"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Product Details */}
|
||||
<div className="flex flex-col">
|
||||
<h1 className="text-3xl md:text-4xl font-bold mb-4">{product.name}</h1>
|
||||
<div className="public-page">
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_0.95fr] lg:gap-8">
|
||||
<PublicSurface className="overflow-hidden p-0">
|
||||
<div className="relative aspect-square overflow-hidden bg-muted/60">
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={product.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="(max-width: 1024px) 100vw, 52vw"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</PublicSurface>
|
||||
|
||||
<PublicSurface className="flex flex-col">
|
||||
<div className="mb-6">
|
||||
<p className="text-3xl font-bold text-[var(--link-hover-color)] mb-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-balance md:text-4xl">
|
||||
{product.name}
|
||||
</h1>
|
||||
<p className="mt-4 text-3xl font-bold text-[var(--link-hover-color)]">
|
||||
${product.price.toFixed(2)} {product.currency.toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{product.description && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-3">Description</h2>
|
||||
<PublicInset className="mb-5">
|
||||
<h2 className="text-lg font-semibold">Description</h2>
|
||||
<div
|
||||
className="text-muted-foreground leading-relaxed whitespace-pre-line"
|
||||
className="mt-3 whitespace-pre-line text-muted-foreground leading-7"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: product.description.replace(/\n/g, '<br />'),
|
||||
__html: product.description.replace(/\n/g, "<br />"),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PublicInset>
|
||||
)}
|
||||
|
||||
{product.metadata && Object.keys(product.metadata).length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-xl font-semibold mb-3">Specifications</h2>
|
||||
<dl className="space-y-2">
|
||||
<PublicInset className="mb-5">
|
||||
<h2 className="text-lg font-semibold">Specifications</h2>
|
||||
<dl className="mt-3 space-y-3">
|
||||
{Object.entries(product.metadata).map(([key, value]) => (
|
||||
<div key={key} className="flex">
|
||||
<dt className="font-medium mr-2">{key}:</dt>
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-col gap-1 border-b border-border/50 pb-3 last:border-b-0 last:pb-0 sm:flex-row sm:gap-3"
|
||||
>
|
||||
<dt className="font-medium text-foreground sm:min-w-32">
|
||||
{key}
|
||||
</dt>
|
||||
<dd className="text-muted-foreground">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
</PublicInset>
|
||||
)}
|
||||
|
||||
<div className="mt-auto pt-6">
|
||||
<div className="mt-auto pt-3">
|
||||
<AddToCartButton product={product} />
|
||||
</div>
|
||||
</div>
|
||||
</PublicSurface>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,15 @@
|
|||
import { fetchAllProducts } from '@/lib/stripe/products'
|
||||
import { PublicInset, PublicPageHeader, PublicSurface } from '@/components/public-surface'
|
||||
import { ProductGrid } from '@/components/product-grid'
|
||||
import Link from 'next/link'
|
||||
import { fetchAllProducts } from "@/lib/stripe/products"
|
||||
import {
|
||||
PublicInset,
|
||||
PublicPageHeader,
|
||||
PublicSurface,
|
||||
} from "@/components/public-surface"
|
||||
import { ProductGrid } from "@/components/product-grid"
|
||||
import Link from "next/link"
|
||||
|
||||
export const metadata = {
|
||||
title: 'Products | Rocky Mountain Vending',
|
||||
description: 'Shop our selection of vending machines and equipment',
|
||||
title: "Products | Rocky Mountain Vending",
|
||||
description: "Shop our selection of vending machines and equipment",
|
||||
}
|
||||
|
||||
export default async function ProductsPage() {
|
||||
|
|
@ -16,13 +20,14 @@ export default async function ProductsPage() {
|
|||
products = await fetchAllProducts()
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
if (err.message.includes('STRIPE_SECRET_KEY')) {
|
||||
error = 'Our product catalog is temporarily unavailable. Please contact us for current machine options.'
|
||||
if (err.message.includes("STRIPE_SECRET_KEY")) {
|
||||
error =
|
||||
"Our product catalog is temporarily unavailable. Please contact us for current machine options."
|
||||
} else {
|
||||
error = 'Failed to load products. Please try again later.'
|
||||
error = "Failed to load products. Please try again later."
|
||||
}
|
||||
} else {
|
||||
error = 'Failed to load products. Please try again later.'
|
||||
error = "Failed to load products. Please try again later."
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -58,12 +63,16 @@ export default async function ProductsPage() {
|
|||
No products available yet
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Our product catalog is being prepared. Please check back soon or contact us directly for current offerings.
|
||||
Our product catalog is being prepared. Please check back soon or
|
||||
contact us directly for current offerings.
|
||||
</p>
|
||||
</div>
|
||||
<PublicInset className="mx-auto max-w-md text-sm text-muted-foreground">
|
||||
<p className="font-medium mb-1">For Vending Machine Sales:</p>
|
||||
<p>Call us at (435) 233-9668 or visit our contact page for immediate assistance.</p>
|
||||
<p>
|
||||
Call us at (435) 233-9668 or visit our contact page for immediate
|
||||
assistance.
|
||||
</p>
|
||||
</PublicInset>
|
||||
</PublicSurface>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,21 +1,13 @@
|
|||
import { generateSEOMetadata, generateStructuredData } from '@/lib/seo';
|
||||
import { ReviewsPage } from '@/components/reviews-page';
|
||||
import type { Metadata } from 'next';
|
||||
import { generateRegistryMetadata, generateRegistryStructuredData } from "@/lib/seo"
|
||||
import { ReviewsPage } from "@/components/reviews-page"
|
||||
import type { Metadata } from "next"
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return generateSEOMetadata({
|
||||
title: 'Customer Reviews | Rocky Mountain Vending',
|
||||
description: 'Read authentic customer reviews and testimonials about Rocky Mountain Vending\'s exceptional vending services in Utah. See why businesses trust us for free vending machines.',
|
||||
});
|
||||
return generateRegistryMetadata("reviews")
|
||||
}
|
||||
|
||||
export default function Reviews() {
|
||||
const structuredData = generateStructuredData({
|
||||
title: 'Customer Reviews',
|
||||
description: 'See what our customers are saying about Rocky Mountain Vending\'s exceptional service',
|
||||
url: 'https://rockymountainvending.com/reviews/',
|
||||
type: 'WebPage',
|
||||
});
|
||||
const structuredData = generateRegistryStructuredData("reviews")
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -25,5 +17,5 @@ export default function Reviews() {
|
|||
/>
|
||||
<ReviewsPage />
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,39 +0,0 @@
|
|||
import { MetadataRoute } from "next"
|
||||
import { businessConfig } from "@/lib/seo-config"
|
||||
|
||||
// Required for static export
|
||||
export const dynamic = 'force-static'
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: [
|
||||
"/api/",
|
||||
"/admin/",
|
||||
"/_next/",
|
||||
"/test-page/",
|
||||
"/manuals/dashboard/",
|
||||
],
|
||||
},
|
||||
{
|
||||
userAgent: "Googlebot",
|
||||
allow: "/",
|
||||
disallow: [
|
||||
"/api/",
|
||||
"/admin/",
|
||||
"/_next/",
|
||||
"/test-page/",
|
||||
"/manuals/dashboard/",
|
||||
],
|
||||
},
|
||||
],
|
||||
sitemap: `${businessConfig.website}/sitemap.xml`,
|
||||
host: businessConfig.website,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
40
app/robots.txt/route.ts
Normal file
40
app/robots.txt/route.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import type { NextRequest } from "next/server"
|
||||
import {
|
||||
PRODUCTION_HOST,
|
||||
PRODUCTION_WEBSITE,
|
||||
isProductionHost,
|
||||
normalizeHost,
|
||||
} from "@/lib/seo-config"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const productionRobots = `User-agent: *
|
||||
Allow: /
|
||||
Disallow: /api/
|
||||
Disallow: /admin/
|
||||
Disallow: /_next/
|
||||
Disallow: /test-page/
|
||||
Disallow: /manuals/dashboard/
|
||||
|
||||
Sitemap: ${PRODUCTION_WEBSITE}/sitemap.xml
|
||||
Host: ${PRODUCTION_HOST}
|
||||
`
|
||||
|
||||
const previewRobots = `User-agent: *
|
||||
Disallow: /
|
||||
|
||||
Sitemap: ${PRODUCTION_WEBSITE}/sitemap.xml
|
||||
Host: ${PRODUCTION_HOST}
|
||||
`
|
||||
|
||||
export function GET(request: NextRequest) {
|
||||
const host = normalizeHost(request.headers.get("host"))
|
||||
const body = isProductionHost(host) ? productionRobots : previewRobots
|
||||
|
||||
return new NextResponse(body, {
|
||||
headers: {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
},
|
||||
})
|
||||
}
|
||||
16
app/seaga-hy900-support/layout.tsx
Normal file
16
app/seaga-hy900-support/layout.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import type { Metadata } from "next"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Seaga HY900 Support | Rocky Mountain Vending",
|
||||
description:
|
||||
"Watch Seaga HY900 support videos and access the owner manual from Rocky Mountain Vending.",
|
||||
}
|
||||
|
||||
export default function SeagaSupportLayout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode
|
||||
}) {
|
||||
return children
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue