Compare commits

..

No commits in common. "main" and "codex/phone-call-visibility-release" have entirely different histories.

436 changed files with 13318 additions and 37978 deletions

View file

@ -73,11 +73,8 @@ jspm_packages/
tmp/
temp/
.pnpm-store/
.formatting-backups/
.cursor/
.playwright-cli/
output/
docs/
artifacts/
# Logs
logs

View file

@ -1,12 +0,0 @@
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

View file

@ -33,15 +33,6 @@ 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=

View file

@ -1,81 +1,28 @@
# Current Rocky Mountain Vending staging env contract.
# Fill these in through Coolify-managed environment variables only.
NEXT_PUBLIC_SITE_DOMAIN=rmv.abundancepartners.app
NEXT_PUBLIC_SITE_URL=https://rmv.abundancepartners.app
# Core site
NEXT_PUBLIC_SITE_URL=https://rockymountainvending.com
NEXT_PUBLIC_SITE_DOMAIN=rockymountainvending.com
NEXT_PUBLIC_CONVEX_URL=
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=
# Voice and chat
LIVEKIT_URL=
LIVEKIT_API_KEY=
LIVEKIT_API_SECRET=
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
VOICE_ASSISTANT_SITE_URL=https://rmv.abundancepartners.app

12
.gitignore vendored
View file

@ -1,10 +1,11 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
# next.js
/.next/
/out/
/output/
# production
/build
@ -14,12 +15,10 @@ npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
/dev.log
# env files
.env*
!.env.example
!.env.staging.example
# vercel
.vercel
@ -27,10 +26,3 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# local tooling caches
/.playwright-cli/
/.pnpm-store/
# package manager drift
/package-lock.json

View file

@ -2,42 +2,45 @@
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',
},
},
}
};

View file

@ -1,15 +0,0 @@
.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

View file

@ -1,5 +0,0 @@
{
"semi": false,
"singleQuote": false,
"trailingComma": "es5"
}

View file

@ -16,7 +16,6 @@ This file serves as a reminder that all WordPress content has been removed from
## Where It Went
All WordPress-related code has been archived to:
- `_archive/wordpress/code-lib/`
## Current State
@ -34,7 +33,6 @@ All WordPress-related code has been archived to:
3. **DO NOT** add WordPress route handling back to catch-all routes
If you need specific content from WordPress:
- Extract the content manually
- Create new pages/components following the style guide
- Use the existing component patterns from `code/components/`
@ -42,3 +40,13 @@ If you need specific content from WordPress:
## Last Updated
December 2, 2025

View file

@ -3,7 +3,6 @@
## Shopping Cart Components - ✅ Converted
### Cart Component (`code/components/cart.tsx`)
- **Status**: ✅ Converted to use shadcn Sheet component
- **Changes**:
- Replaced custom sidebar/backdrop with `Sheet`, `SheetContent`, `SheetHeader`, `SheetFooter`
@ -12,14 +11,12 @@
- Maintains all existing functionality
### Cart Button (`code/components/cart-button.tsx`)
- **Status**: ✅ Updated to use shadcn Badge component
- **Changes**:
- Replaced custom badge span with shadcn `Badge` component
- Uses Badge variant instead of CSS variables
### Add to Cart Button (`code/components/add-to-cart-button.tsx`)
- **Status**: ✅ Updated to use button variant
- **Changes**:
- Uses new `brand` variant instead of CSS variables
@ -27,7 +24,6 @@
## Button Component Updates
### Brand Variant Added (`code/components/ui/button.tsx`)
- **Status**: ✅ Added `brand` variant
- **Purpose**: Centralizes brand color (--link-hover-color) usage
- **Usage**: Use `variant="brand"` instead of CSS variables
@ -35,20 +31,17 @@
## Form Components Audit
### Standard Form Component (`code/components/ui/form.tsx`)
- **Status**: ✅ Keep - Standard shadcn form with react-hook-form integration
- **Usage**: Use for react-hook-form based forms
- **Components**: Form, FormField, FormItem, FormLabel, FormControl, FormDescription, FormMessage
### Field Component (`code/components/ui/field.tsx`)
- **Status**: ⚠️ Unused - Not imported anywhere in codebase
- **Recommendation**: Keep for now as alternative form system, but consider removing if not needed
- **Components**: Field, FieldLabel, FieldDescription, FieldError, FieldGroup, FieldLegend, FieldSeparator, FieldSet, FieldContent, FieldTitle
- **Note**: This appears to be an alternative form system that doesn't use react-hook-form
### Input Group Component (`code/components/ui/input-group.tsx`)
- **Status**: ⚠️ Unused - Not imported anywhere in codebase
- **Recommendation**: Keep for now, but consider removing if not needed
- **Components**: InputGroup, InputGroupAddon, InputGroupButton, InputGroupText, InputGroupInput, InputGroupTextarea
@ -57,7 +50,6 @@
## CSS Variable Usage Cleanup
### Components Updated to Use Brand Variant:
- ✅ `code/components/cart.tsx` - Checkout button
- ✅ `code/components/add-to-cart-button.tsx` - Add to cart button
- ✅ `code/components/error-boundary.tsx` - Reload button
@ -65,7 +57,6 @@
- ✅ `code/app/checkout/cancel/page.tsx` - Continue shopping button
### Remaining CSS Variable Usage:
- `code/components/ui/button.tsx` - Brand variant definition (intentional, centralized)
- Other components may use CSS variables for non-button purposes (gradients, text colors, etc.)
@ -93,3 +84,13 @@
- `code/components/ui/field.tsx` - Alternative form system, not currently used
- `code/components/ui/input-group.tsx` - Input grouping, not currently used

View file

@ -1,7 +1,6 @@
# Rocky Mountain Coolify Launch
## Staging
- Coolify project: `Rocky Mountain Vending`
- Coolify app UUID: `bsowk840kccg08coocwwc44c`
- Environment UUID: `ew8k8og0gw48swck4ckk84kk`
@ -18,12 +17,10 @@
- Manual Gitea secret: `deploy123`
## DNS Note
- `rmv.abundancepartners.app` is managed in Cloudflare.
- The active DNS record is an unproxied `A` record to `85.239.237.247`.
## Required App Env
- `NEXT_PUBLIC_SITE_DOMAIN=rockymountainvending.com`
- `NEXT_PUBLIC_SITE_URL=https://rockymountainvending.com`
- `CONVEX_URL`
@ -46,29 +43,24 @@
- `LIVEKIT_API_SECRET`
## Current Runtime Defaults
- Email is launch-ready through AWS SES fallback with `AWS_ACCESS_KEY`, `AWS_SECRET_KEY`, and `AWS_DEFAULT_REGION`.
- GHL is not complete until `GHL_API_TOKEN` is added.
- Dedicated Usesend remains optional follow-up until `USESEND_API_KEY` and `USESEND_BASE_URL` exist.
- Self-hosted Convex tenant deployment is still blocked upstream by a `502`, so live tenant-scoped backend writes are not fully confirmed yet.
## Email Notes
- The app prefers Usesend when `USESEND_API_KEY` is present.
- If Usesend is not ready yet, the lead pipeline can fall back to AWS SES using `AWS_ACCESS_KEY`, `AWS_SECRET_KEY`, and `AWS_DEFAULT_REGION`.
- Keep all email secrets in Coolify-managed env vars.
## Local Handoff Files
- `.env.rocky-workstation` contains the other-workstation operational variables and webhook details.
- `.env.rocky-coolify` contains the current Coolify app env bundle with blanks for the still-missing secrets.
## Canonical Handoff Variables
Use this markdown section as the source of truth if you need to recreate the handoff elsewhere.
### Other Workstation
```env
COOLIFY_API_URL=http://85.239.237.247:8000/api/v1
COOLIFY_API_TOKEN=4|ScZUAuJPRPWBSdXWhtW3tkYBcoALayIYnw5zsgCse0a02bb5
@ -89,7 +81,6 @@ ADMIN_API_TOKEN=
```
### Coolify App Env
```env
NEXT_PUBLIC_SITE_DOMAIN=rockymountainvending.com
NEXT_PUBLIC_SITE_URL=https://rockymountainvending.com
@ -117,7 +108,6 @@ ADMIN_API_TOKEN=
```
## Validation Snapshot
- `https://rmv.abundancepartners.app` returns `200 OK`
- `POST /api/contact` with `{}` on `https://rmv.abundancepartners.app` returns `400`
- Pushes to Forgejo `main` queue Coolify deployments automatically

View file

@ -5,7 +5,6 @@ This guide explains how to test Lighthouse performance scores locally and achiev
## Prerequisites
1. Install dependencies:
```bash
npm install
# or
@ -25,7 +24,6 @@ npm run lighthouse:build
```
This command will:
1. Build the production bundle (`next build`)
2. Start the production server (`next start`)
3. Run Lighthouse tests on multiple pages
@ -66,7 +64,6 @@ The script also reports these critical metrics:
### Target Scores
For 100% scores, all categories must achieve:
- Performance: 100/100
- Accessibility: 100/100
- Best Practices: 100/100
@ -112,7 +109,6 @@ For 100% scores, all categories must achieve:
### Images Not Optimizing
If images aren't being optimized:
- Ensure `unoptimized: false` in `next.config.mjs`
- Check that images are using Next.js `Image` component, not `<img>` tags
- Verify image domains are in `remotePatterns` if using external images
@ -128,7 +124,6 @@ If images aren't being optimized:
The `.lighthouserc.js` file is configured for Lighthouse CI. To use it:
1. Install Lighthouse CI:
```bash
npm install -g @lhci/cli
```
@ -152,3 +147,6 @@ The `.lighthouserc.js` file is configured for Lighthouse CI. To use it:
- [Next.js Image Optimization](https://nextjs.org/docs/app/api-reference/components/image)
- [Core Web Vitals](https://web.dev/vitals/)
- [Web Performance Best Practices](https://web.dev/performance/)

View file

@ -5,7 +5,6 @@ Complete guide for setting up Cloudflare R2 storage for vending machine manuals
## Overview
This guide covers:
1. Creating R2 buckets
2. Generating API credentials
3. Uploading files to R2
@ -72,14 +71,12 @@ wrangler r2 bucket create vending-vm-thumbnails
## Step 3: Configure Environment Variables
1. Copy `.env.example` to `.env.local`:
```bash
cd code
cp .env.example .env.local
```
2. Edit `.env.local` and fill in:
```bash
CLOUDFLARE_R2_ACCESS_KEY_ID=your_access_key_id_here
CLOUDFLARE_R2_SECRET_ACCESS_KEY=your_secret_access_key_here
@ -138,7 +135,6 @@ node scripts/upload-to-r2.js --type all --incremental
### Get Public URLs
After enabling public access, you'll get URLs like:
- Manuals: `https://pub-xxxxx.r2.dev` (or custom domain)
- Thumbnails: `https://pub-yyyyy.r2.dev` (or custom domain)
@ -161,10 +157,7 @@ If you need to access R2 from a different domain:
```json
[
{
"AllowedOrigins": [
"https://rockymountainvending.com",
"https://www.rockymountainvending.com"
],
"AllowedOrigins": ["https://rockymountainvending.com", "https://www.rockymountainvending.com"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag"],
@ -228,7 +221,6 @@ NEXT_PUBLIC_SITE_DOMAIN=rockymountainvending.com
```
This will:
1. Upload manuals and thumbnails to R2
2. Build the Next.js app
3. Create deployment ZIP (for GHL if needed)
@ -254,27 +246,22 @@ Visit `http://localhost:3000` and verify manuals load from R2 URLs.
### Upload Script Fails
**Error**: "CLOUDFLARE_R2_ACCESS_KEY_ID must be set"
- **Solution**: Make sure `.env.local` exists and has correct credentials
**Error**: "Bucket not found"
- **Solution**: Create buckets in Cloudflare Dashboard first
### Files Not Accessible
**Issue**: 403 Forbidden when accessing R2 URLs
- **Solution**: Enable public access in bucket settings
**Issue**: CORS errors
- **Solution**: Configure CORS policy in bucket settings
### Build Fails in Pages
**Error**: Build command fails
- **Solution**: Check build logs in Pages dashboard
- Verify `package.json` has all dependencies
- Ensure build output directory is correct (`code/out`)
@ -282,7 +269,6 @@ Visit `http://localhost:3000` and verify manuals load from R2 URLs.
### Manuals Not Loading
**Issue**: Manuals show 404
- **Solution**:
1. Verify R2 buckets have files uploaded
2. Check `NEXT_PUBLIC_MANUALS_BASE_URL` is set correctly
@ -320,8 +306,9 @@ For large-scale migrations, consider:
## Support
For issues or questions:
1. Check Cloudflare Dashboard for error messages
2. Review build logs in Pages dashboard
3. Check R2 bucket settings and permissions
4. Verify environment variables are set correctly

View file

@ -114,7 +114,6 @@ Open the downloaded JSON file and extract:
### Current Implementation
The API endpoints are set up at:
- `/api/sitemap-submit` - Submit sitemap to Google Search Console
- `/api/request-indexing` - Request URL indexing
@ -127,7 +126,6 @@ npm install googleapis
```
Then update the API route files to use the actual Google Search Console API. See the comments in:
- `code/app/api/sitemap-submit/route.ts`
- `code/app/api/request-indexing/route.ts`
@ -160,7 +158,6 @@ You should see a valid XML sitemap with all your pages listed.
Visit: `https://rockymountainvending.com/robots.txt`
You should see:
```
User-agent: *
Allow: /
@ -277,7 +274,7 @@ Before deploying to production:
## Support
For issues or questions:
- Check the troubleshooting section above
- Review Google Search Console documentation
- Contact your development team

View file

@ -1,7 +1,6 @@
# Shadcn MCP Server Troubleshooting Guide
## Current Status
- ✅ MCP configuration file exists at: `code/.cursor/mcp.json` (original)
- ✅ MCP configuration file created at: `.cursor/mcp.json` (workspace root) - **FIXED**
- ✅ Configuration looks correct
@ -11,7 +10,6 @@
## Configuration Found
The MCP configuration file at `code/.cursor/mcp.json` contains:
```json
{
"mcpServers": {
@ -26,7 +24,6 @@ The MCP configuration file at `code/.cursor/mcp.json` contains:
## Troubleshooting Steps
### 1. ✅ FIXED: MCP Configuration Location
**Issue Found**: The MCP configuration was in `code/.cursor/mcp.json`, but Cursor looks for it at the workspace root.
**Fix Applied**: Configuration has been copied to `.cursor/mcp.json` at the workspace root.
@ -34,46 +31,36 @@ The MCP configuration file at `code/.cursor/mcp.json` contains:
**Next Step**: Restart Cursor completely to load the MCP server.
### 2. Restart Cursor
After any configuration changes:
1. Completely quit Cursor (not just close the window)
2. Reopen Cursor
3. Wait a few seconds for MCP servers to initialize
### 3. Check MCP Server Logs
1. Open Cursor
2. Go to `View``Output`
3. In the dropdown, select `MCP: project-*` or look for shadcn-related logs
4. Check for any error messages
### 4. Test MCP Server Manually
Test if the shadcn MCP server can run independently:
```bash
cd code
npx shadcn@latest mcp
```
If you have a GitHub token (for higher rate limits):
```bash
npx shadcn@latest mcp --github-api-key YOUR_GITHUB_TOKEN
```
### 5. Verify Project Structure
Ensure `components.json` exists and is properly configured:
- Location: `code/components.json`
- Should reference the correct paths for your project
### 6. Check Node.js and npm
Ensure you have the required versions:
```bash
node --version # Should be v18 or higher
npm --version
@ -81,9 +68,7 @@ npx --version
```
### 7. Alternative Configuration
If the current configuration doesn't work, try specifying the full path:
```json
{
"mcpServers": {
@ -97,16 +82,13 @@ If the current configuration doesn't work, try specifying the full path:
```
### 8. Check Cursor Settings
1. Open Cursor Settings (Cmd+, on Mac)
2. Search for "MCP" or "Model Context Protocol"
3. Verify that MCP servers are enabled
4. Check if there are any error indicators
## Expected Tools
When working correctly, the shadcn MCP server should provide these tools:
1. `get_project_registries` - List available component registries
2. `list_items_in_registries` - List components in registries
3. `search_items_in_registries` - Search for components
@ -116,15 +98,12 @@ When working correctly, the shadcn MCP server should provide these tools:
7. `get_audit_checklist` - Get component audit checklist
## Verification
To verify the MCP server is working:
1. Ask the AI assistant to use shadcn MCP tools
2. The assistant should be able to call functions like `get_project_registries`
3. If tools are not available, the server is not properly connected
## Next Steps
1. Try moving the MCP configuration to workspace root
2. Restart Cursor completely
3. Check MCP server logs for errors
@ -132,6 +111,6 @@ To verify the MCP server is working:
5. If issues persist, check the shadcn documentation: https://ui.shadcn.com/docs/mcp
## Additional Resources
- [Shadcn MCP Documentation](https://ui.shadcn.com/docs/mcp)
- [Cursor MCP Setup Guide](https://docs.cursor.com/context/model-context-protocol)

View file

@ -15,13 +15,11 @@ The system supports three site tiers with different manufacturer counts:
Set the `NEXT_PUBLIC_SITE_DOMAIN` environment variable to configure which site tier to use.
### For rockymountainvending.com (Tier 1)
```bash
NEXT_PUBLIC_SITE_DOMAIN=rockymountainvending.com
```
**Manufacturers included:**
- Crane (includes BevMax, Merchant-Series)
- Royal Vendors
- GPL
@ -34,13 +32,11 @@ NEXT_PUBLIC_SITE_DOMAIN=rockymountainvending.com
**Minimum manual count:** 3 per manufacturer
### For vending.support (Tier 2)
```bash
NEXT_PUBLIC_SITE_DOMAIN=vending.support
```
**Manufacturers included:**
- All Tier 1 manufacturers, plus:
- Merchant-Series
- BevMax
@ -53,13 +49,11 @@ NEXT_PUBLIC_SITE_DOMAIN=vending.support
**Minimum manual count:** 2 per manufacturer
### For quickfreshvending.com (Tier 3)
```bash
NEXT_PUBLIC_SITE_DOMAIN=quickfreshvending.com
```
**Manufacturers included:**
- ALL manufacturers including:
- All from Tier 1 and Tier 2
- Other folder (uncategorized)
@ -70,17 +64,13 @@ NEXT_PUBLIC_SITE_DOMAIN=quickfreshvending.com
## Configuration Files
### site-manufacturer-mapping.json
This file contains the complete mapping of manufacturers to each site tier. It's located at:
```
code/lib/site-manufacturer-mapping.json
```
### site-config.ts
This file provides helper functions to:
- Get the current site domain
- Get allowed manufacturers for a site
- Get minimum manual count
@ -101,16 +91,12 @@ This file provides helper functions to:
For each site deployment, set the appropriate environment variable:
### Vercel/Netlify
Add the environment variable in your deployment platform's settings:
- Key: `NEXT_PUBLIC_SITE_DOMAIN`
- Value: `rockymountainvending.com`, `vending.support`, or `quickfreshvending.com`
### Local Development
Create a `.env.local` file in the `code` directory:
```
NEXT_PUBLIC_SITE_DOMAIN=rockymountainvending.com
```
@ -118,7 +104,6 @@ NEXT_PUBLIC_SITE_DOMAIN=rockymountainvending.com
## Manufacturer Aliases
The system automatically handles manufacturer name variations using aliases defined in `site-manufacturer-mapping.json`. For example:
- "Royal-Vendors" (directory name) → "Royal Vendors" (canonical)
- "BevMax" → "Crane"
- "Merchant-Series" → "Crane"
@ -128,3 +113,6 @@ This ensures manuals are correctly matched regardless of how the manufacturer na
## Payment Components
Payment components (Coin-Mechs, Bill-Mechs, Card-Readers) are included on all sites as they are universal and work with all vending machines.

View file

@ -3,14 +3,12 @@
## Color Palette
### Primary Colors
- **Background**: `#fff8eb` (Warm cream)
- **Foreground**: Dark text color for body text
- **Primary**: Purple/blue accent color
- **Secondary**: Green accent color
### Link Colors
- **Link Default**: Standard foreground color
- **Link Hover/Active**: `#c4142c` (Red - RGB: 196, 20, 44)
- **Link Highlight**: Red background highlight on hover
@ -18,12 +16,10 @@
## Typography
### Fonts
- **Body Font**: Inter (with fallback)
- **Mono Font**: Geist Mono (with fallback)
### Font Sizes
- Base: 16px
- Small: 14px
- Medium: 16px
@ -32,7 +28,6 @@
## Hyperlinks
### Standard Link Styling
All hyperlinks (`<a>` tags and Next.js `<Link>` components) should follow these rules:
1. **Default State**:
@ -50,7 +45,6 @@ All hyperlinks (`<a>` tags and Next.js `<Link>` components) should follow these
- May include underline for emphasis
### Implementation
- Use CSS variable `--link-color` for default state
- Use CSS variable `--link-hover-color` for hover/active states
- Apply globally via `@layer base` in `globals.css`
@ -58,25 +52,21 @@ All hyperlinks (`<a>` tags and Next.js `<Link>` components) should follow these
## Components
### Navigation Links
- Follow standard link styling
- Hover state changes to red
- Smooth transitions
### Footer Links
- Follow standard link styling
- May include underline on hover
### Button Links
- Use button component styling
- May override link colors for consistency
## Spacing
### Standard Spacing Scale
- XS: 0.5rem (8px)
- SM: 1rem (16px)
- MD: 1.5rem (24px)
@ -94,13 +84,11 @@ All hyperlinks (`<a>` tags and Next.js `<Link>` components) should follow these
## Layout & Containers
### Page Containers
- **Standard pages**: `container mx-auto px-4 py-8 md:py-12 max-w-4xl`
- **Wide pages**: `container mx-auto px-4 py-8 md:py-12 max-w-6xl`
- **Full-width pages**: `container mx-auto px-4 py-8 md:py-12` (no max-width)
### Section Containers
- **Standard sections**: `py-20 md:py-28` with optional `bg-muted/30` for alternating backgrounds
- **Hero sections**: `py-20 md:py-32`
- **Container wrapper**: Always use `container mx-auto px-4` inside sections
@ -110,23 +98,19 @@ All hyperlinks (`<a>` tags and Next.js `<Link>` components) should follow these
### Headings
#### H1 (Page Titles)
- **Classes**: `text-4xl md:text-5xl font-bold mb-4` (or `mb-6` for more spacing)
- **Usage**: Only one H1 per page (page title)
- **Example**: `<h1 className="text-4xl md:text-5xl font-bold mb-4">Page Title</h1>`
#### H2 (Section Headers)
- **Section headers** (centered): `text-3xl font-bold tracking-tight md:text-4xl lg:text-5xl mb-4 text-balance`
- **Page headers** (left-aligned): `text-2xl md:text-3xl font-semibold mb-6`
- **Header wrapper**: `text-center mb-12 md:mb-16` for section headers
#### H3
- **Standard**: `text-xl font-semibold mb-2` or `mb-3`
### Paragraphs
- **Standard text**: `text-lg text-muted-foreground text-pretty leading-relaxed`
- **Centered text**: Add `max-w-2xl mx-auto` for centered paragraphs
- **Small text**: `text-sm text-muted-foreground`
@ -134,14 +118,12 @@ All hyperlinks (`<a>` tags and Next.js `<Link>` components) should follow these
## Cards
### Standard Card Styling
- **Border**: `border-border/50` (preferred) or `border-2` for emphasis
- **Hover states**: `hover:border-secondary/50 transition-colors`
- **Shadow**: `shadow-lg` or `shadow-md` as needed
- **Padding**: `p-6` or `p-6 md:p-8` for CardContent
### Card Examples
```tsx
// Standard card
<Card className="border-border/50 hover:border-secondary/50 transition-colors">
@ -163,7 +145,6 @@ All hyperlinks (`<a>` tags and Next.js `<Link>` components) should follow these
### Image Sizing Standards
#### Maximum Dimensions
- **Grid/Card images**: Maximum 300px per dimension
- **Full-width images**: Maximum 600px per dimension
- **Always preserve aspect ratio** when constraining
@ -171,7 +152,6 @@ All hyperlinks (`<a>` tags and Next.js `<Link>` components) should follow these
#### Image Layout Patterns
**Single Image (Centered)**
```tsx
<div className="max-w-md mx-auto">
<div className="relative w-full overflow-hidden rounded-lg bg-muted shadow-sm">
@ -187,7 +167,6 @@ All hyperlinks (`<a>` tags and Next.js `<Link>` components) should follow these
```
**Grid Images (2-4 images)**
```tsx
<div className="grid gap-6 md:grid-cols-2">
{images.map((img) => (
@ -207,18 +186,23 @@ All hyperlinks (`<a>` tags and Next.js `<Link>` components) should follow these
```
**Card Images (Aspect Ratio)**
```tsx
<Card>
<div className="aspect-video relative bg-muted">
<Image src={src} alt={alt} fill className="object-cover" />
<Image
src={src}
alt={alt}
fill
className="object-cover"
/>
</div>
<CardContent>{/* content */}</CardContent>
<CardContent>
{/* content */}
</CardContent>
</Card>
```
### Image Requirements
1. **Always constrain large images**: Use `Math.min(width, MAX)` pattern
2. **Add max-width constraints**: Use `max-w-xs` (320px), `max-w-md` (448px), or `max-w-lg` (512px)
3. **Use proper object-fit**: `object-contain` for preserving aspect ratio, `object-cover` for filling containers
@ -226,7 +210,6 @@ All hyperlinks (`<a>` tags and Next.js `<Link>` components) should follow these
5. **Wrapper classes**: `relative w-full overflow-hidden rounded-lg bg-muted shadow-sm`
### Image Size Constraints by Context
- **In grid layouts**: Max 300px per dimension
- **In cards**: Use `aspect-video` or `aspect-square` with `fill` prop
- **Standalone images**: Max 600px per dimension with `max-w-md mx-auto` wrapper
@ -234,13 +217,11 @@ All hyperlinks (`<a>` tags and Next.js `<Link>` components) should follow these
## Spacing Standards
### Section Spacing
- **Section padding**: `py-20 md:py-28` (standard sections)
- **Section margin bottom**: `mb-12 md:mb-16` for section headers
- **Page padding**: `py-8 md:py-12` (page containers)
### Element Spacing
- **Header margin**: `mb-12 md:mb-16` for section headers, `mb-8` for page headers
- **Card gaps**: `gap-6` or `gap-8` in grid layouts
- **Paragraph spacing**: `mb-4` or `mb-6` between paragraphs
@ -260,7 +241,6 @@ All hyperlinks (`<a>` tags and Next.js `<Link>` components) should follow these
A formatting standardization script is available at `scripts/standardize-formatting.py` that automatically applies these standards across all TSX files.
### Usage
```bash
# Test mode (dry run)
python3 scripts/standardize-formatting.py --dry-run
@ -273,7 +253,6 @@ python3 scripts/standardize-formatting.py
```
The script will:
- Standardize container classes and spacing
- Fix typography classes (H1, H2, paragraphs)
- Standardize card borders and shadows

View file

@ -1,107 +1,100 @@
import { notFound } from "next/navigation"
import { loadImageMapping } from "@/lib/wordpress-content"
import { generateSEOMetadata, generateStructuredData } from "@/lib/seo"
import { getPageBySlug, getAllPageSlugs } from "@/lib/wordpress-data-loader"
import { cleanWordPressContent } from "@/lib/clean-wordPress-content"
import { getLocationBySlug, getAllLocationSlugs } from "@/lib/location-data"
import type { Metadata } from "next"
import { FAQSchema } from "@/components/faq-schema"
import { FAQSection } from "@/components/faq-section"
import { ContactPage } from "@/components/contact-page"
import { AboutPage } from "@/components/about-page"
import { WhoWeServePage } from "@/components/who-we-serve-page"
import { Breadcrumbs } from "@/components/breadcrumbs"
import {
PublicInset,
PublicPageHeader,
PublicProse,
PublicSurface,
} from "@/components/public-surface"
import {
generateLocationPageMetadata,
LocationLandingPage,
} from "@/components/location-landing-page"
import Link from "next/link"
import { notFound } from 'next/navigation';
import { loadImageMapping } from '@/lib/wordpress-content';
import { generateSEOMetadata, generateStructuredData } from '@/lib/seo';
import { getPageBySlug, getAllPageSlugs } from '@/lib/wordpress-data-loader';
import { cleanWordPressContent } from '@/lib/clean-wordPress-content';
import { getLocationBySlug, getAllLocationSlugs } from '@/lib/location-data';
import { businessConfig, socialProfiles } from '@/lib/seo-config';
import { Phone, Mail, Globe, Clock, CreditCard, MapPin } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { ReviewsSection } from '@/components/reviews-section';
import { Button } from '@/components/ui/button';
import Link from 'next/link';
import type { Metadata } from 'next';
import React from 'react';
import { FAQSchema } from '@/components/faq-schema';
import { FAQSection } from '@/components/faq-section';
import { ContactPage } from '@/components/contact-page';
import { AboutPage } from '@/components/about-page';
import { WhoWeServePage } from '@/components/who-we-serve-page';
import { PublicPageHeader, PublicSurface } from '@/components/public-surface';
import { GetFreeMachineCta } from '@/components/get-free-machine-cta';
// Required for static export - ensures this route is statically generated
export const dynamic = "force-static"
export const dynamicParams = false
export const dynamic = 'force-static';
export const dynamicParams = false;
interface PageProps {
params: Promise<{ slug: string[] }>
params: Promise<{ slug: string[] }>;
}
// Route mapping: navigation URLs -> WordPress page slugs
const routeMapping: Record<string, string> = {
// Services
"services/repairs": "vending-machine-repairs",
"services/moving": "vending-machine-repairs", // Placeholder - no moving page exists
"services/parts": "parts-and-support",
services: "vending-machine-repairs", // Default to repairs page
'services/repairs': 'vending-machine-repairs',
'services/moving': 'vending-machine-repairs', // Placeholder - no moving page exists
'services/parts': 'parts-and-support',
'services': 'vending-machine-repairs', // Default to repairs page
// Vending Machines
"vending-machines": "vending-machines", // Main vending machines page
"vending-machines/machines-we-use": "vending-machines", // Use main page
"vending-machines/machines-for-sale": "vending-machines-for-sale-in-utah",
'vending-machines': 'vending-machines', // Main vending machines page
'vending-machines/machines-we-use': 'vending-machines', // Use main page
'vending-machines/machines-for-sale': 'vending-machines-for-sale-in-utah',
// Who We Serve
warehouses:
"streamlining-snack-and-beverage-access-in-warehouse-environments",
"auto-repair":
"enhancing-auto-repair-facilities-with-convenient-vending-solutions",
gyms: "vending-machine-for-your-gym",
"community-centers": "vending-for-your-community-centers",
"dance-studios": "vending-machine-for-your-dance-studio",
"car-washes": "vending-machines-for-your-car-wash",
'warehouses': 'streamlining-snack-and-beverage-access-in-warehouse-environments',
'auto-repair': 'enhancing-auto-repair-facilities-with-convenient-vending-solutions',
'gyms': 'vending-machine-for-your-gym',
'community-centers': 'vending-for-your-community-centers',
'dance-studios': 'vending-machine-for-your-dance-studio',
'car-washes': 'vending-machines-for-your-car-wash',
// Food & Beverage
"food-and-beverage/healthy-options": "healthy-vending",
"food-and-beverage/snack-and-drink-delivery": "snack-and-drink-delivery",
"food-and-beverage/traditional-options": "traditional-vending",
"food-and-beverage/suppliers":
"diverse-vending-options-with-rocky-mountain-vendings-exclusive-wholesale-accounts",
'food-and-beverage/healthy-options': 'healthy-vending',
'food-and-beverage/traditional-options': 'traditional-vending',
'food-and-beverage/suppliers': 'diverse-vending-options-with-rocky-mountain-vendings-exclusive-wholesale-accounts',
// About
"about-us": "about-us",
"about/faqs": "faqs",
"contact-us": "contact-us",
}
'about-us': 'about-us',
'about/faqs': 'faqs',
'contact-us': 'contact-us',
};
// Helper function to resolve route to WordPress slug
function resolveRouteToSlug(slugArray: string[]): string | null {
const route = slugArray.join("/")
const route = slugArray.join('/');
// Check if this is a location page - if so, return null to let Next.js handle it
// (location pages are handled by vending-machines-[location] route)
if (isLocationRoute(slugArray)) {
return null // Let the location route handle it
return null; // Let the location route handle it
}
// Check direct mapping first
if (routeMapping[route]) {
return routeMapping[route]
return routeMapping[route];
}
// Check if it's a direct WordPress slug
const directSlug = slugArray.join("-")
const directSlug = slugArray.join('-');
if (getPageBySlug(directSlug)) {
return directSlug
return directSlug;
}
// Check last segment as fallback (for nested routes)
if (slugArray.length > 1) {
const lastSegment = slugArray[slugArray.length - 1]
const lastSegment = slugArray[slugArray.length - 1];
if (getPageBySlug(lastSegment)) {
return lastSegment
return lastSegment;
}
}
// Try the full route as-is
if (getPageBySlug(route)) {
return route
return route;
}
return null
return null;
}
// Helper function to check if a route is a location page
@ -109,166 +102,497 @@ function isLocationRoute(slugArray: string[]): boolean {
// Location pages follow pattern: vending-machines-{location}
// e.g., ["vending-machines-salt-lake-city-utah"] or ["vending-machines", "salt-lake-city-utah"]
if (slugArray.length === 1) {
const slug = slugArray[0]
const slug = slugArray[0];
// Check if it starts with "vending-machines-" and the rest is a valid location slug
if (slug.startsWith("vending-machines-")) {
const locationSlug = slug.replace("vending-machines-", "")
return !!getLocationBySlug(locationSlug)
if (slug.startsWith('vending-machines-')) {
const locationSlug = slug.replace('vending-machines-', '');
return !!getLocationBySlug(locationSlug);
}
} else if (slugArray.length === 2 && slugArray[0] === "vending-machines") {
return !!getLocationBySlug(slugArray[1])
} else if (slugArray.length === 2 && slugArray[0] === 'vending-machines') {
return !!getLocationBySlug(slugArray[1]);
}
return false
return false;
}
// Render location page component
function renderLocationPage(locationData: any, locationSlug: string) {
const structuredData = {
"@context": "https://schema.org",
"@type": "LocalBusiness",
name: businessConfig.name,
description: `Rocky Mountain Vending provides high-quality vending machines, vending machine sales, and vending machine repair services to businesses and schools across ${locationData.city}, ${locationData.state}.`,
url: `${businessConfig.website}/vending-machines-${locationSlug}`,
telephone: businessConfig.phoneFormatted,
priceRange: "$$",
foundingDate: businessConfig.openingDate,
areaServed: {
"@type": "City",
name: locationData.city,
address: {
"@type": "PostalAddress",
addressLocality: locationData.city,
addressRegion: locationData.stateAbbr,
postalCode: locationData.zipCode,
addressCountry: "US",
},
},
geo: {
"@type": "GeoCoordinates",
latitude: locationData.latitude,
longitude: locationData.longitude,
},
openingHoursSpecification: [
{
"@type": "OpeningHoursSpecification",
dayOfWeek: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
opens: "08:00",
closes: "17:00",
},
],
paymentAccepted: "Credit Card, Debit Card, American Express, Discover, MasterCard, Visa",
availableLanguage: ["English"],
sameAs: [
socialProfiles.linkedin,
socialProfiles.facebook,
socialProfiles.youtube,
socialProfiles.twitter,
locationData.chamberUrl,
locationData.cityWebsite,
...locationData.localLinks.map((link: any) => link.url),
],
};
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }} />
<article className="container mx-auto px-4 py-10 md:py-14">
{/* Hero Section */}
<PublicPageHeader
align="center"
eyebrow="Local Service Area"
title={`Vending Machine Supplier in ${locationData.city}, ${locationData.state}`}
description={`Need a vending machine supplier in ${locationData.city}, ${locationData.state}? Rocky Mountain Vending has been helping local businesses and schools since 2019 with quality vending solutions. We bring healthy snacks, cold drinks, and dependable service right to your door—no hassle, no fuss.`}
className="mb-12 md:mb-16"
/>
{/* Local Anecdote */}
<div className="mb-12 max-w-4xl mx-auto">
<PublicSurface>
<CardContent className="p-0 md:p-1">
<p className="text-base md:text-lg leading-relaxed">
A while back, we worked with a {locationData.anecdote.customer} near {locationData.anecdote.location}.
We set them up with a {locationData.anecdote.solution}. Now {locationData.anecdote.outcome}. That's
what we do bestmake vending simple.
</p>
</CardContent>
</PublicSurface>
</div>
{/* Services Section */}
<section className="mb-16 max-w-4xl mx-auto">
<h2 className="text-3xl font-bold mb-8 tracking-tight text-balance">{locationData.h2Variants.services}</h2>
<p className="text-muted-foreground mb-8">
We handle everything from picking the right machine to keeping it running smoothly. Here's what we offer:
</p>
<div className="grid gap-6 md:grid-cols-2">
<Card className="rounded-[1.75rem] border border-border/70 bg-background shadow-[0_18px_45px_rgba(15,23,42,0.08)] transition-all hover:-translate-y-0.5 hover:border-primary/35">
<CardContent className="p-6">
<h3 className="text-xl font-semibold mb-3">Vending Machine Sales</h3>
<p className="text-muted-foreground">
Need a new machine? We've got options that fit your space and budget. Whether you run a school,
office, or warehouse, we'll help you choose one that works.
</p>
</CardContent>
</Card>
<Card className="rounded-[1.75rem] border border-border/70 bg-background shadow-[0_18px_45px_rgba(15,23,42,0.08)] transition-all hover:-translate-y-0.5 hover:border-primary/35">
<CardContent className="p-6">
<h3 className="text-xl font-semibold mb-3">Vending Machine Repair</h3>
<p className="text-muted-foreground">
Machines break downit happens. When yours does, we fix it fast so you're not stuck with an empty
snack spot.
</p>
</CardContent>
</Card>
<Card className="rounded-[1.75rem] border border-border/70 bg-background shadow-[0_18px_45px_rgba(15,23,42,0.08)] transition-all hover:-translate-y-0.5 hover:border-primary/35">
<CardContent className="p-6">
<h3 className="text-xl font-semibold mb-3">Healthy Snack and Beverage Options</h3>
<p className="text-muted-foreground">
We stock machines with healthy choices like granola bars, fruit snacks, and sparkling water. Great
for schools and gyms that want better options.
</p>
</CardContent>
</Card>
<Card className="rounded-[1.75rem] border border-border/70 bg-background shadow-[0_18px_45px_rgba(15,23,42,0.08)] transition-all hover:-translate-y-0.5 hover:border-primary/35">
<CardContent className="p-6">
<h3 className="text-xl font-semibold mb-3">Maintenance Services</h3>
<p className="text-muted-foreground">
Regular checkups keep machines working right. We handle restocking, cleaning, and small fixes so you
don't have to think about it.
</p>
</CardContent>
</Card>
</div>
</section>
{/* Coverage Section */}
<section className="mb-16 max-w-4xl mx-auto">
<h2 className="text-3xl font-bold mb-6 tracking-tight text-balance">{locationData.h2Variants.coverage}</h2>
<div className="prose prose-lg max-w-none">
<p className="text-muted-foreground mb-4">
We service all of {locationData.city}, including{" "}
{locationData.neighborhoods.map((n: string, i: number) => (
<span key={n}>
{i > 0 && i === locationData.neighborhoods.length - 1 && ", and "}
{i > 0 && i < locationData.neighborhoods.length - 1 && ", "}
{n}
</span>
))}
. We also deliver to nearby cities like{" "}
{locationData.nearbyCities.map((c: string, i: number) => (
<span key={c}>
{i > 0 && i === locationData.nearbyCities.length - 1 && ", and "}
{i > 0 && i < locationData.nearbyCities.length - 1 && ", "}
{c}
</span>
))}
free delivery within 50 miles of {locationData.city}.
</p>
<p className="text-muted-foreground mb-6">
The{" "}
<a
href={locationData.chamberUrl}
target="_blank"
rel="noopener noreferrer"
className="hover:underline font-medium"
>
{locationData.chamberName}
</a>{" "}
connects us with local businesses, and we're proud to serve this community. Here are some helpful local
resources:
</p>
<ul className="space-y-2 mb-6">
{locationData.localLinks.map((link: any) => (
<li key={link.url}>
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="hover:underline flex items-center gap-2"
>
<MapPin className="h-4 w-4" />
{link.name}
</a>
</li>
))}
</ul>
</div>
</section>
{/* Contact Section */}
<section className="mb-16 max-w-4xl mx-auto">
<h2 className="text-3xl font-bold mb-6 tracking-tight text-balance">{locationData.h2Variants.contact}</h2>
<p className="text-muted-foreground mb-8">
We're open Monday through Friday, 8:00 AM to 5:00 PM. Closed on weekends, but you can always reach out by
phone or text.
</p>
<div className="grid gap-4 md:grid-cols-3 mb-8">
<Card className="rounded-[1.75rem] border border-border/70 bg-background shadow-[0_18px_45px_rgba(15,23,42,0.08)] transition-all hover:-translate-y-0.5 hover:border-primary/35">
<CardContent className="p-6 flex items-start gap-4">
<Phone className="h-6 w-6 text-secondary flex-shrink-0 mt-1" />
<div>
<div className="font-semibold mb-1">Phone</div>
<a href={businessConfig.phoneUrl} className="hover:underline">
{businessConfig.phone}
</a>
</div>
</CardContent>
</Card>
<Card className="rounded-[1.75rem] border border-border/70 bg-background shadow-[0_18px_45px_rgba(15,23,42,0.08)] transition-all hover:-translate-y-0.5 hover:border-primary/35">
<CardContent className="p-6 flex items-start gap-4">
<Mail className="h-6 w-6 text-secondary flex-shrink-0 mt-1" />
<div>
<div className="font-semibold mb-1">Text</div>
<a href={businessConfig.smsUrl} className="hover:underline">
{businessConfig.phone}
</a>
</div>
</CardContent>
</Card>
<Card className="rounded-[1.75rem] border border-border/70 bg-background shadow-[0_18px_45px_rgba(15,23,42,0.08)] transition-all hover:-translate-y-0.5 hover:border-primary/35">
<CardContent className="p-6 flex items-start gap-4">
<Globe className="h-6 w-6 text-secondary flex-shrink-0 mt-1" />
<div>
<div className="font-semibold mb-1">Website</div>
<a
href={businessConfig.website}
target="_blank"
rel="noopener noreferrer"
className="hover:underline text-sm"
>
rockymountainvending.com
</a>
</div>
</CardContent>
</Card>
</div>
{/* Placement CTA */}
<PublicSurface>
<div className="p-1 text-center">
<h3 className="text-2xl font-bold mb-2">Get Your Free Vending Machine</h3>
<p className="text-muted-foreground">
Tell us about your location and we&apos;ll follow up within one business day with recommendations for the right setup.
</p>
<div className="mt-6 flex flex-col items-center gap-3">
<GetFreeMachineCta buttonLabel="Get Free Placement" />
<a
href={businessConfig.publicCallUrl}
className="inline-flex min-h-11 items-center justify-center rounded-full border border-border bg-white px-4 text-sm font-medium text-foreground transition hover:border-primary/40 hover:text-primary"
>
Call Instead
</a>
</div>
</div>
</PublicSurface>
</section>
{/* Payment Options */}
<section className="mb-16 max-w-4xl mx-auto">
<h2 className="text-3xl font-bold mb-6 tracking-tight text-balance">{locationData.h2Variants.payments}</h2>
<PublicSurface>
<CardContent className="p-6">
<div className="flex items-start gap-4 mb-4">
<CreditCard className="h-6 w-6 text-secondary flex-shrink-0 mt-1" />
<div>
<div className="font-semibold mb-2">Payment Methods</div>
<p className="text-muted-foreground">
We accept credit cards, debit cards, American Express, Discover, MasterCard, and Visa.
</p>
</div>
</div>
<div className="flex items-start gap-4">
<Clock className="h-6 w-6 text-secondary flex-shrink-0 mt-1" />
<div>
<div className="font-semibold mb-2">Language</div>
<p className="text-muted-foreground">
Our team speaks English, and we're happy to answer any questions you have.
</p>
</div>
</div>
</CardContent>
</PublicSurface>
</section>
{/* Why Choose Us */}
<section className="mb-16 max-w-4xl mx-auto">
<h2 className="text-3xl font-bold mb-6 tracking-tight text-balance">{locationData.h2Variants.whyChoose}</h2>
<div className="prose prose-lg max-w-none">
<p className="text-muted-foreground mb-4">
Since 2019, we've been serving businesses and schools across Utah County, Salt Lake County, and Davis
County. We're local, we're fast, and we care about getting it right. About 95% of our {locationData.city}{" "}
clients stick with us because we show up when we say we will and fix problems quickly. That's based on our
own records, so we're confident in it.
</p>
<p className="text-muted-foreground mb-6">
We built our machines to handle Utah's weather—cold winters, hot summers, all of it. You won't have to
worry about breakdowns when the temperature drops.
</p>
</div>
<div className="text-center">
<GetFreeMachineCta buttonLabel="Get Your Free Machine Today" />
</div>
</section>
</article>
{/* Google Reviews Section */}
<ReviewsSection />
</>
);
}
// Generate static params for all pages
export async function generateStaticParams() {
try {
const slugs = getAllPageSlugs()
const params: Array<{ slug: string[] }> = []
const slugs = getAllPageSlugs();
const params: Array<{ slug: string[] }> = [];
// Add all WordPress page slugs
slugs.forEach((slug: string) => {
params.push({
slug: [slug], // Catch-all routes need arrays
})
})
});
});
// Add mapped routes (like /services, /services/repairs, etc.)
Object.keys(routeMapping).forEach((route) => {
const routeArray = route.split("/")
const routeArray = route.split('/');
// Only add if it's not already added as a WordPress slug
if (!slugs.includes(route)) {
params.push({
slug: routeArray,
})
});
}
})
});
// Add location routes (e.g., /vending-machines-salt-lake-city-utah)
const locationSlugs = getAllLocationSlugs()
const locationSlugs = getAllLocationSlugs();
locationSlugs.forEach((locationSlug: string) => {
if (locationSlug) {
params.push({
slug: [`vending-machines-${locationSlug}`],
})
});
}
})
});
return params
return params;
} catch (error) {
// Silently return empty array in production
if (process.env.NODE_ENV === "development") {
console.error("Error generating static params:", error)
if (process.env.NODE_ENV === 'development') {
console.error('Error generating static params:', error);
}
// Return at least one valid param to prevent build failure
return [{ slug: ["vending-machines-ogden-utah"] }]
return [{ slug: ['vending-machines-ogden-utah'] }];
}
}
// Generate metadata for a page
export async function generateMetadata({
params,
}: PageProps): Promise<Metadata> {
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
try {
const { slug } = await params
const slugArray = Array.isArray(slug) ? slug : [slug]
const { slug } = await params;
const slugArray = Array.isArray(slug) ? slug : [slug];
// Handle location routes
if (isLocationRoute(slugArray)) {
let locationSlug: string
let locationSlug: string;
if (slugArray.length === 1) {
locationSlug = slugArray[0].replace("vending-machines-", "")
locationSlug = slugArray[0].replace('vending-machines-', '');
} else {
locationSlug = slugArray[1]
locationSlug = slugArray[1];
}
const locationData = getLocationBySlug(locationSlug)
const locationData = getLocationBySlug(locationSlug);
if (!locationData) {
return {
title: "Location Not Found | Rocky Mountain Vending",
}
title: 'Location Not Found | Rocky Mountain Vending',
};
}
return generateLocationPageMetadata(locationData)
const title = `Vending Machine Supplier in ${locationData.city}, ${locationData.stateAbbr} | Rocky Mountain Vending`;
const description = `Get FREE vending machines for your ${locationData.city} business! Rocky Mountain Vending provides quality vending machine sales, repairs, and service in ${locationData.city}, ${locationData.state}. Call (435) 233-9668.`;
return {
title,
description,
keywords: [
`vending machines ${locationData.city}`,
`vending machine supplier ${locationData.city}`,
`free vending machines ${locationData.city}`,
`vending machine repair ${locationData.city}`,
`${locationData.city} vending`,
...locationData.neighborhoods.map((n) => `vending machines ${n}`),
],
openGraph: {
title,
description,
url: `${businessConfig.website}/vending-machines-${locationSlug}`,
type: "website",
locale: "en_US",
siteName: businessConfig.name,
},
twitter: {
card: "summary_large_image",
title,
description,
},
};
}
const pageSlug = resolveRouteToSlug(slugArray)
const pageSlug = resolveRouteToSlug(slugArray);
if (!pageSlug) {
return {
title: "Page Not Found | Rocky Mountain Vending",
}
title: 'Page Not Found | Rocky Mountain Vending',
};
}
const page = getPageBySlug(pageSlug)
const page = getPageBySlug(pageSlug);
if (!page) {
return {
title: "Page Not Found | Rocky Mountain Vending",
}
title: 'Page Not Found | Rocky Mountain Vending',
};
}
return generateSEOMetadata({
title: page.title || "Page",
description: page.seoDescription || page.excerpt || "",
title: page.title || 'Page',
description: page.seoDescription || page.excerpt || '',
excerpt: page.excerpt,
date: page.date,
modified: page.modified,
image: page.images?.[0]?.localPath,
path: `/${slugArray.join("/")}`,
})
});
} catch (error) {
// Silently return fallback metadata in production
if (process.env.NODE_ENV === "development") {
console.error("Error generating metadata:", error)
if (process.env.NODE_ENV === 'development') {
console.error('Error generating metadata:', error);
}
return {
title: "Rocky Mountain Vending",
description:
"Rocky Mountain Vending provides quality vending machine services in Utah.",
}
title: 'Rocky Mountain Vending',
description: 'Rocky Mountain Vending provides quality vending machine services in Utah.',
};
}
}
export default async function WordPressPage({ params }: PageProps) {
try {
const { slug } = await params
const slugArray = Array.isArray(slug) ? slug : [slug]
const { slug } = await params;
const slugArray = Array.isArray(slug) ? slug : [slug];
// If this is a location route, render the location page
if (isLocationRoute(slugArray)) {
let locationSlug: string
let locationSlug: string;
if (slugArray.length === 1) {
locationSlug = slugArray[0].replace("vending-machines-", "")
locationSlug = slugArray[0].replace('vending-machines-', '');
} else {
locationSlug = slugArray[1]
locationSlug = slugArray[1];
}
const locationData = getLocationBySlug(locationSlug)
const locationData = getLocationBySlug(locationSlug);
if (!locationData) {
notFound()
notFound();
}
return <LocationLandingPage locationData={locationData} />
// Render location page
return renderLocationPage(locationData, locationSlug);
}
const pageSlug = resolveRouteToSlug(slugArray)
const pageSlug = resolveRouteToSlug(slugArray);
if (!pageSlug) {
notFound()
notFound();
}
const page = getPageBySlug(pageSlug)
const page = getPageBySlug(pageSlug);
if (!page) {
notFound()
notFound();
}
// Load image mapping (optional, won't break if it fails)
let imageMapping: any = {}
let imageMapping: any = {};
try {
imageMapping = loadImageMapping()
imageMapping = loadImageMapping();
} catch (e) {
// Silently fail - image mapping is optional
}
@ -278,86 +602,75 @@ export default async function WordPressPage({ params }: PageProps) {
<div className="max-w-none">
{cleanWordPressContent(String(page.content), {
imageMapping,
pageTitle: page.title, // Pass page title to avoid duplicate headings
pageTitle: page.title // Pass page title to avoid duplicate headings
})}
</div>
) : (
<p className="text-muted-foreground">No content available.</p>
)
);
// Generate structured data
let structuredData
let structuredData;
try {
structuredData = generateStructuredData({
title: page.title || "Page",
description: page.seoDescription || page.excerpt || "",
url:
page.link ||
page.urlPath ||
`https://rockymountainvending.com/${pageSlug}/`,
title: page.title || 'Page',
description: page.seoDescription || page.excerpt || '',
url: page.link || page.urlPath || `https://rockymountainvending.com/${pageSlug}/`,
datePublished: page.date,
dateModified: page.modified || page.date,
type: "WebPage",
})
type: 'WebPage',
});
} catch (e) {
// Silently use fallback structured data in production
if (process.env.NODE_ENV === "development") {
console.error("Error generating structured data:", e)
if (process.env.NODE_ENV === 'development') {
console.error('Error generating structured data:', e);
}
structuredData = {
"@context": "https://schema.org",
"@type": "WebPage",
headline: page.title || "Page",
description: page.seoDescription || "",
'@context': 'https://schema.org',
'@type': 'WebPage',
headline: page.title || 'Page',
description: page.seoDescription || '',
url: `https://rockymountainvending.com/${pageSlug}/`,
}
};
}
// Extract FAQs from content if this is the FAQ page
const faqs: Array<{ question: string; answer: string }> = []
if (pageSlug === "faqs" && page.content) {
const contentStr = String(page.content)
const faqs: Array<{ question: string; answer: string }> = [];
if (pageSlug === 'faqs' && 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 - match everything inside the card-body div until the closing div
const answerMatches = contentStr.matchAll(
/<div class="elementskit-card-body ekit-accordion--content">([\s\S]*?)<\/div>\s*<\/div>\s*<!-- \.elementskit-card END -->/g
)
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) => {
const questions = Array.from(questionMatches).map(m => m[1].trim());
const answers = Array.from(answerMatches).map(m => {
// Keep HTML but clean up whitespace
let answer = m[1].trim()
let answer = m[1].trim();
// Remove the opening <p> and closing </p> if they wrap everything, but keep other HTML
// Clean up excessive whitespace but preserve HTML structure
answer = answer
.replace(/\n\s*\n/g, "\n")
.replace(/>\s+</g, "><")
.trim()
return answer
})
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] });
}
})
});
}
// Check if this is a "Who We Serve" page
const whoWeServeSlugs = [
"streamlining-snack-and-beverage-access-in-warehouse-environments",
"enhancing-auto-repair-facilities-with-convenient-vending-solutions",
"vending-machine-for-your-gym",
"vending-for-your-community-centers",
"vending-machine-for-your-dance-studio",
"vending-machines-for-your-car-wash",
]
const isWhoWeServePage = whoWeServeSlugs.includes(pageSlug)
const routePath = `/${slugArray.join("/")}`
'streamlining-snack-and-beverage-access-in-warehouse-environments',
'enhancing-auto-repair-facilities-with-convenient-vending-solutions',
'vending-machine-for-your-gym',
'vending-for-your-community-centers',
'vending-machine-for-your-dance-studio',
'vending-machines-for-your-car-wash',
];
const isWhoWeServePage = whoWeServeSlugs.includes(pageSlug);
return (
<>
@ -369,99 +682,45 @@ export default async function WordPressPage({ params }: PageProps) {
<>
<FAQSchema
faqs={faqs}
pageUrl={
page.link ||
page.urlPath ||
`https://rockymountainvending.com/${pageSlug}/`
}
pageUrl={page.link || page.urlPath || `https://rockymountainvending.com/${pageSlug}/`}
/>
<FAQSection faqs={faqs} />
</>
)}
{faqs.length === 0 && pageSlug === "contact-us" && <ContactPage />}
{faqs.length === 0 && pageSlug === "about-us" && <AboutPage />}
{faqs.length === 0 && isWhoWeServePage && (
<WhoWeServePage title={page.title || "Page"} content={content} />
{faqs.length === 0 && pageSlug === 'contact-us' && (
<ContactPage />
)}
{faqs.length === 0 &&
pageSlug !== "contact-us" &&
pageSlug !== "about-us" &&
!isWhoWeServePage && (
<article className="container mx-auto max-w-5xl px-4 py-10 md:py-14">
<Breadcrumbs
className="mb-6"
items={[
{ label: "Home", href: "/" },
{ label: page.title || "Page", href: routePath },
]}
/>
<PublicPageHeader
eyebrow={pageSlug.startsWith("blog") ? "Article" : "Information"}
title={page.title || "Page"}
description={
page.seoDescription ||
page.excerpt ||
"Explore the details, service guidance, and next steps from Rocky Mountain Vending."
}
className="mx-auto mb-10 max-w-3xl text-center"
align="center"
/>
<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 Choosing The Right Next Step?
</p>
<h2 className="mt-2 text-2xl font-semibold tracking-tight text-foreground">
Talk with Rocky Mountain Vending
</h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted-foreground">
Reach out about placement, machine sales, repairs, moving help,
manuals, or parts and we&apos;ll point you in the right direction.
</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="/#request-machine"
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"
>
See If You Qualify
</Link>
</div>
</div>
</PublicInset>
{faqs.length === 0 && pageSlug === 'about-us' && (
<AboutPage />
)}
{faqs.length === 0 && isWhoWeServePage && (
<WhoWeServePage title={page.title || 'Page'} content={content} />
)}
{faqs.length === 0 && pageSlug !== 'contact-us' && pageSlug !== 'about-us' && !isWhoWeServePage && (
<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 || 'Page'}</h1>
</header>
{content}
</article>
)}
</>
)
);
} catch (error) {
// Silently return error fallback in production
if (process.env.NODE_ENV === "development") {
console.error("Error rendering page:", error)
if (process.env.NODE_ENV === 'development') {
console.error('Error rendering page:', error);
}
return (
<div className="container mx-auto px-4 py-8 md:py-12">
<h1 className="text-4xl md:text-5xl font-bold mb-4">
Error Loading Page
</h1>
<p className="text-destructive">
There was an error loading this page. Please try again later.
</p>
{process.env.NODE_ENV === "development" && (
<h1 className="text-4xl md:text-5xl font-bold mb-4">Error Loading Page</h1>
<p className="text-destructive">There was an error loading this page. Please try again later.</p>
{process.env.NODE_ENV === 'development' && (
<pre className="mt-4 p-4 bg-muted rounded">
{error instanceof Error ? error.message : String(error)}
</pre>
)}
</div>
)
);
}
}

View file

@ -1,39 +1,58 @@
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"
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';
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 generateRegistryMetadata("aboutUs", {
return generateSEOMetadata({
title: page.title || 'About Us',
description: page.seoDescription || page.excerpt || '',
excerpt: page.excerpt,
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();
}
const structuredData = generateRegistryStructuredData("aboutUs", {
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/`,
};
}
return (
<>
@ -43,11 +62,19 @@ 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();
}
}

View file

@ -1,74 +1,83 @@
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"
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';
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 generateRegistryMetadata("faqs", {
return generateSEOMetadata({
title: page.title || 'FAQs',
description: page.seoDescription || page.excerpt || '',
excerpt: page.excerpt,
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 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 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] });
}
})
});
}
const pageUrl = buildAbsoluteUrl("/about/faqs")
const structuredData = generateRegistryStructuredData("faqs", {
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/`,
};
}
return (
<>
@ -77,27 +86,28 @@ export default async function FAQsPage() {
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/>
{faqs.length > 0 && (
<div className="public-page">
<Breadcrumbs
className="mb-6"
items={[
{ label: "About", href: "/about" },
{ label: "FAQs", href: "/about/faqs" },
]}
/>
<>
<FAQSchema
faqs={faqs}
pageUrl={pageUrl}
pageUrl={page.link || page.urlPath || `https://rockymountainvending.com/about/faqs/`}
/>
<FAQSection faqs={faqs} className="pt-0" />
</div>
<FAQSection faqs={faqs} />
</>
)}
</>
)
);
} 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();
}
}

View file

@ -1,23 +1,21 @@
import { generateRegistryMetadata, generateRegistryStructuredData } from "@/lib/seo"
import { AboutPage } from "@/components/about-page"
import type { Metadata } from "next"
import { businessConfig } from "@/lib/seo-config"
import { generateSEOMetadata, generateStructuredData } from '@/lib/seo';
import { AboutPage } from '@/components/about-page';
import type { Metadata } from 'next';
export async function generateMetadata(): Promise<Metadata> {
return {
...generateRegistryMetadata("aboutLegacy"),
alternates: {
canonical: `${businessConfig.website}/about-us`,
},
robots: {
index: false,
follow: true,
},
}
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',
});
}
export default function About() {
const structuredData = generateRegistryStructuredData("aboutUs")
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',
});
return (
<>
@ -27,5 +25,5 @@ export default function About() {
/>
<AboutPage />
</>
)
);
}

View file

@ -1,37 +1,31 @@
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 (
@ -39,20 +33,13 @@ 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">
{detail.call.contactDisplayName ||
normalizePhoneFromIdentity(detail.call.participantIdentity) ||
detail.call.participantIdentity}
{normalizePhoneFromIdentity(detail.call.participantIdentity) || detail.call.participantIdentity}
</p>
</div>
</div>
@ -64,159 +51,58 @@ 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">
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"
}
>
<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>
@ -224,24 +110,8 @@ 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>
</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>
<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}
</CardContent>
@ -251,74 +121,38 @@ 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>
@ -326,15 +160,11 @@ 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">
@ -350,11 +180,10 @@ 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",
};

View file

@ -1,67 +1,58 @@
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">
@ -75,20 +66,13 @@ 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"
@ -104,7 +88,7 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
</form>
<div className="overflow-x-auto">
<table className="w-full min-w-[1240px] text-sm">
<table className="w-full min-w-[1050px] text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="py-3 pr-4 font-medium">Caller</th>
@ -116,8 +100,6 @@ 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>
@ -125,81 +107,35 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
<tbody>
{data.items.length === 0 ? (
<tr>
<td
colSpan={13}
className="py-8 text-center text-muted-foreground"
>
<td colSpan={11} 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>
{call.contactDisplayName ||
normalizePhoneFromIdentity(
call.participantIdentity
) ||
call.participantIdentity}
</div>
<div className="text-xs text-muted-foreground">
{call.contactCompany ||
normalizePhoneFromIdentity(
call.participantIdentity
) ||
call.roomName}
</div>
<div>{normalizePhoneFromIdentity(call.participantIdentity) || call.participantIdentity}</div>
<div className="text-xs text-muted-foreground">{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">
{formatPhoneCallTimestamp(call.startedAt)}
<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">
{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}
{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.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>
@ -211,8 +147,7 @@ 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 ? (
@ -223,9 +158,7 @@ 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 ? (
@ -236,9 +169,7 @@ 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>
@ -247,10 +178,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",
}
};

View file

@ -1,199 +0,0 @@
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",
}

View file

@ -1,202 +0,0 @@
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",
}

View file

@ -1,19 +0,0 @@
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",
}

View file

@ -1,518 +0,0 @@
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",
}

View file

@ -1,9 +1,5 @@
import Link from "next/link"
import { redirect } from "next/navigation"
import {
getAdminUserFromCookies,
isAdminUiEnabled,
} from "@/lib/server/admin-auth"
import { redirect } from "next/navigation";
import { isAdminUiEnabled } from "@/lib/server/admin-auth";
export default async function AdminLayout({
children,
@ -11,32 +7,8 @@ export default async function AdminLayout({
children: React.ReactNode
}) {
if (!isAdminUiEnabled()) {
redirect("/")
redirect("/");
}
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>
)
return <>{children}</>;
}

View file

@ -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',
}

View file

@ -1,15 +1,7 @@
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 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,
@ -22,11 +14,9 @@ import {
AlertTriangle,
Settings,
BarChart3,
Phone,
MessageSquare,
ContactRound,
} from "lucide-react"
import { fetchAllProducts } from "@/lib/stripe/products"
Phone
} from 'lucide-react'
import { fetchAllProducts } from '@/lib/stripe/products'
// Mock analytics data for demo
const mockAnalytics = {
@ -37,7 +27,7 @@ const mockAnalytics = {
lowStockProducts: 3,
avgOrderValue: 311.46,
conversionRate: 2.8,
monthlyGrowth: 15.2,
monthlyGrowth: 15.2
}
async function getProductsCount() {
@ -51,7 +41,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
@ -60,145 +50,130 @@ 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, sync] = await Promise.all([
const [productsCount, ordersCount] = await Promise.all([
getProductsCount(),
getOrdersCount(),
fetchQuery(api.crm.getAdminSyncOverview, {}),
getOrdersCount()
])
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 (
@ -207,26 +182,12 @@ 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">
Manage orders, contacts, conversations, and calls
Overview of your store performance and management tools
</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" />
@ -243,25 +204,6 @@ 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) => {
@ -278,7 +220,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
@ -324,9 +266,7 @@ 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>
@ -336,10 +276,7 @@ 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>
@ -352,23 +289,16 @@ 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>
@ -383,20 +313,17 @@ 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}
@ -411,9 +338,7 @@ 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>
@ -489,7 +414,6 @@ 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',
}

View file

@ -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',
}

View file

@ -1,70 +0,0 @@
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
}

View file

@ -1,53 +0,0 @@
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
}

View file

@ -1,39 +1,33 @@
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 });
}
}

View file

@ -1,37 +1,31 @@
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 });
}
}

View file

@ -1,36 +0,0 @@
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 }
)
}
}

View file

@ -1,32 +0,0 @@
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 }
)
}
}

View file

@ -1,49 +0,0 @@
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
)
)
}
}

View file

@ -1,39 +0,0 @@
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 }
)
}
}

View file

@ -1,40 +0,0 @@
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
)
)
}
}

View file

@ -1,45 +0,0 @@
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 }
)
}
}

View file

@ -1,31 +0,0 @@
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 }
)
}
}

View file

@ -1,39 +0,0 @@
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 }
)
}
}

View file

@ -1,48 +0,0 @@
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)
})

View file

@ -1,79 +0,0 @@
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 }
)
}
}

View file

@ -1,181 +0,0 @@
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
)
})

View file

@ -17,18 +17,8 @@ import {
SITE_CHAT_TEMPERATURE,
isSiteChatSuppressedRoute,
} from "@/lib/site-chat/config"
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 { SITE_CHAT_SYSTEM_PROMPT } from "@/lib/site-chat/prompt"
import { consumeChatOutput, consumeChatRequest, getChatRateLimitStatus } from "@/lib/site-chat/rate-limit"
import { createSmsConsentPayload } from "@/lib/sms-compliance"
type ChatRole = "user" | "assistant"
@ -91,10 +81,7 @@ 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}`
}
@ -102,46 +89,24 @@ 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
@ -214,15 +179,6 @@ 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",
@ -234,36 +190,25 @@ 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) {
@ -272,7 +217,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 },
)
}
@ -289,12 +234,11 @@ 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, {
@ -308,41 +252,7 @@ export async function POST(request: NextRequest) {
return blockedResponse
}
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()
consumeChatRequest({ ip, requestWindowMs: SITE_CHAT_REQUEST_WINDOW_MS, sessionId })
const xaiApiKey = getOptionalEnv("XAI_API_KEY")
if (!xaiApiKey) {
@ -353,17 +263,14 @@ 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",
{
const completionResponse = await fetch("https://api.x.ai/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${xaiApiKey}`,
@ -376,23 +283,12 @@ export async function POST(request: NextRequest) {
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"}`,
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"}`,
},
...(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(() => ({}))
@ -406,11 +302,10 @@ 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 },
)
}
@ -422,15 +317,11 @@ 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,
@ -448,7 +339,7 @@ export async function POST(request: NextRequest) {
sessionId,
limits: nextLimitStatus,
},
{ headers: responseHeaders }
{ headers: responseHeaders },
)
response.cookies.set(SITE_CHAT_SESSION_COOKIE, sessionId, {
@ -464,10 +355,7 @@ 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
@ -477,7 +365,7 @@ export async function POST(request: NextRequest) {
{
error: safeError,
},
{ status: 500, headers: responseHeaders }
{ status: 500, headers: responseHeaders },
)
}
}

View file

@ -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"]);
});

View file

@ -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);
}

View file

@ -1,213 +0,0 @@
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 }
)
}
}

View file

@ -1,159 +0,0 @@
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 }
)
}
}

View file

@ -1,120 +0,0 @@
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 }
)
}
}

View file

@ -1,51 +0,0 @@
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
}

View file

@ -1,60 +0,0 @@
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 }
)
}
}

View file

@ -1,70 +0,0 @@
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 }
)
}
}

View file

@ -1,61 +0,0 @@
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 }
)
}
}

View file

@ -1,29 +0,0 @@
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 }
)
}
}

View file

@ -1,69 +0,0 @@
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 }
)
}
}

View file

@ -1,39 +0,0 @@
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 }
)
}
}

View file

@ -1,40 +0,0 @@
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 }
)
}
}

View file

@ -1,179 +0,0 @@
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 }
)
}
}

View file

@ -1,42 +0,0 @@
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 }
)
}
}

View file

@ -1,37 +0,0 @@
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 }
)
}
}

View file

@ -1,41 +1,32 @@
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,
@ -45,7 +36,7 @@ export async function POST(request: Request) {
},
},
adminUrl: url.origin,
})
});
const result = await fetchMutation(api.voiceSessions.completeSession, {
sessionId: detail.call.id,
@ -54,26 +45,20 @@ 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 });
}
}

View file

@ -1,77 +1,27 @@
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,
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,
})
handoffRequested: typeof body.handoffRequested === "boolean" ? body.handoffRequested : undefined,
handoffReason: body.handoffReason ? String(body.handoffReason) : 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 });
}
}

View file

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

View file

@ -1,79 +1,37 @@
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"
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()
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,
{
const body = await request.json();
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,
startedAt: typeof body.startedAt === "number" ? body.startedAt : undefined,
recordingDisclosureAt:
typeof body.recordingDisclosureAt === "number"
? body.recordingDisclosureAt
: undefined,
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 });
}
}

View file

@ -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,16 +21,12 @@ 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 });
}
}

View file

@ -11,19 +11,11 @@ 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)
@ -38,12 +30,9 @@ 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 },
)
}
}

View file

@ -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,9 +59,7 @@ 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 })

View file

@ -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,29 +49,26 @@ 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
@ -84,13 +81,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 }
)
}
@ -104,43 +101,40 @@ 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
@ -150,22 +144,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 }
)
}

View file

@ -1,13 +1,13 @@
import { NextRequest, NextResponse } from "next/server"
import { getStripeClient } from "@/lib/stripe/client"
import { requireAdminToken } from "@/lib/server/admin-auth"
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"
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,8 +30,7 @@ export async function GET(request: NextRequest) {
if (search) {
const searchTerm = search.toLowerCase()
filteredProducts = filteredProducts.filter(
(product) =>
filteredProducts = filteredProducts.filter(product =>
product.name.toLowerCase().includes(searchTerm) ||
product.description?.toLowerCase().includes(searchTerm)
)
@ -58,13 +57,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 }
)
}
@ -83,14 +82,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 }
)
}
@ -98,25 +97,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 }
)
}
@ -135,7 +134,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 }
)
}
@ -144,10 +143,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 }
)
}
@ -158,31 +157,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'
})
}
}
@ -200,14 +199,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 }
)
}

View file

@ -2,7 +2,7 @@ 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
@ -68,14 +68,13 @@ export async function POST(request: NextRequest) {
success: true,
message: "Indexing request endpoint ready",
url,
note: "See docs/operations/SEO_SETUP.md for complete Google Search Console API setup instructions",
note: "See 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 }
)
@ -91,7 +90,9 @@ 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",
})
}

Some files were not shown because too many files have changed in this diff Show more