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/ tmp/
temp/ temp/
.pnpm-store/ .pnpm-store/
.formatting-backups/
.cursor/ .cursor/
.playwright-cli/
output/
docs/
artifacts/
# Logs # Logs
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 # Direct phone-call visibility
PHONE_AGENT_INTERNAL_TOKEN= PHONE_AGENT_INTERNAL_TOKEN=
PHONE_CALL_SUMMARY_FROM_EMAIL= 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 # Placeholder for a later LiveKit rollout
LIVEKIT_URL= LIVEKIT_URL=

View file

@ -1,81 +1,28 @@
# Current Rocky Mountain Vending staging env contract. NEXT_PUBLIC_SITE_DOMAIN=rmv.abundancepartners.app
# Fill these in through Coolify-managed environment variables only. NEXT_PUBLIC_SITE_URL=https://rmv.abundancepartners.app
# Core site CONVEX_URL=
NEXT_PUBLIC_SITE_URL=https://rockymountainvending.com CONVEX_SELF_HOSTED_URL=
NEXT_PUBLIC_SITE_DOMAIN=rockymountainvending.com CONVEX_SELF_HOSTED_ADMIN_KEY=
NEXT_PUBLIC_CONVEX_URL= 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_URL=
LIVEKIT_API_KEY= LIVEKIT_API_KEY=
LIVEKIT_API_SECRET= LIVEKIT_API_SECRET=
XAI_API_KEY= VOICE_ASSISTANT_SITE_URL=https://rmv.abundancepartners.app
XAI_REALTIME_MODEL=grok-4-1-fast-non-reasoning
VOICE_ASSISTANT_SITE_URL=https://rockymountainvending.com
PHONE_AGENT_INTERNAL_TOKEN=
NEXT_PUBLIC_CALL_PHONE_DISPLAY=(435) 233-9668
NEXT_PUBLIC_CALL_PHONE_E164=+14352339668
NEXT_PUBLIC_SMS_PHONE_DISPLAY=(435) 233-9668
NEXT_PUBLIC_SMS_PHONE_E164=+14352339668
NEXT_PUBLIC_MANUALS_BASE_URL=
NEXT_PUBLIC_THUMBNAILS_BASE_URL=
VOICE_RECORDING_ENABLED=false
VOICE_RECORDING_BUCKET=
VOICE_RECORDING_ENDPOINT=
VOICE_RECORDING_PUBLIC_BASE_URL=
VOICE_RECORDING_ACCESS_KEY_ID=
VOICE_RECORDING_SECRET_ACCESS_KEY=
VOICE_RECORDING_REGION=auto
# Admin and auth
ADMIN_EMAIL=
ADMIN_PASSWORD=
RESEND_API_KEY=
PHONE_CALL_SUMMARY_FROM_EMAIL=
ENABLE_GHL_SYNC=false
GOOGLE_CALENDAR_CLIENT_ID=
GOOGLE_CALENDAR_CLIENT_SECRET=
GOOGLE_CALENDAR_REFRESH_TOKEN=
GOOGLE_CALENDAR_ID=
GOOGLE_CALENDAR_TIMEZONE=America/Denver
GOOGLE_CALENDAR_CALLBACK_SLOT_MINUTES=15
GOOGLE_CALENDAR_CALLBACK_START_HOUR=8
GOOGLE_CALENDAR_CALLBACK_END_HOUR=17
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
# Stripe
STRIPE_SECRET_KEY=
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
STRIPE_WEBHOOK_SECRET=
# GHL handoff + sync (API-only mode)
GHL_PRIVATE_INTEGRATION_TOKEN=
GHL_LOCATION_ID=YAoWLgNSid8oG44j9BjG
GHL_API_VERSION=2021-07-28
GHL_API_BASE_URL=https://services.leadconnectorhq.com
GHL_SYNC_CRON_TOKEN=
GHL_SYNC_INTERVAL_MINUTES=15
GHL_SYNC_PAGE_SIZE=100
# Optional/deprecated in API-only mode
GHL_WEBHOOK_SHARED_SECRET=
GHL_CONTACT_WEBHOOK_URL=
GHL_REQUEST_MACHINE_WEBHOOK_URL=
# eBay API credentials for manuals-side fallback
EBAY_APP_ID=
EBAY_DEV_ID=
EBAY_CERT_ID=
EBAY_SANDBOX_TOKEN=
EBAY_AFFILIATE_CAMPAIGN_ID=
# eBay marketplace account deletion notifications
# Use the exact public HTTPS endpoint that eBay validates in the developer portal.
EBAY_NOTIFICATION_ENDPOINT=https://rmv.abundancepartners.app/api/ebay/notifications
EBAY_NOTIFICATION_VERIFICATION_TOKEN=
EBAY_NOTIFICATION_APP_ID=
EBAY_NOTIFICATION_CERT_ID=
EBAY_NOTIFICATION_API_BASE_URL=https://api.ebay.com
EBAY_NOTIFICATION_SCOPE=https://api.ebay.com/oauth/api_scope

12
.gitignore vendored
View file

@ -1,10 +1,11 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies # dependencies
/node_modules /node_modules
# next.js # next.js
/.next/ /.next/
/out/ /out/
/output/
# production # production
/build /build
@ -14,12 +15,10 @@ npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
.pnpm-debug.log* .pnpm-debug.log*
/dev.log
# env files # env files
.env* .env*
!.env.example !.env.example
!.env.staging.example
# vercel # vercel
.vercel .vercel
@ -27,10 +26,3 @@ yarn-error.log*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts 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 = { module.exports = {
ci: { ci: {
collect: { collect: {
url: ["http://localhost:3000"], url: ['http://localhost:3000'],
numberOfRuns: 3, numberOfRuns: 3,
startServerCommand: "npm run start", startServerCommand: 'npm run start',
startServerReadyPattern: "ready", startServerReadyPattern: 'ready',
startServerReadyTimeout: 30000, startServerReadyTimeout: 30000,
}, },
assert: { assert: {
assertions: { assertions: {
"categories:performance": ["error", { minScore: 1 }], 'categories:performance': ['error', { minScore: 1 }],
"categories:accessibility": ["error", { minScore: 1 }], 'categories:accessibility': ['error', { minScore: 1 }],
"categories:best-practices": ["error", { minScore: 1 }], 'categories:best-practices': ['error', { minScore: 1 }],
"categories:seo": ["error", { minScore: 1 }], 'categories:seo': ['error', { minScore: 1 }],
// Core Web Vitals // Core Web Vitals
"first-contentful-paint": ["error", { maxNumericValue: 1800 }], 'first-contentful-paint': ['error', { maxNumericValue: 1800 }],
"largest-contentful-paint": ["error", { maxNumericValue: 2500 }], 'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
"cumulative-layout-shift": ["error", { maxNumericValue: 0.1 }], 'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
"total-blocking-time": ["error", { maxNumericValue: 200 }], 'total-blocking-time': ['error', { maxNumericValue: 200 }],
"speed-index": ["error", { maxNumericValue: 3400 }], 'speed-index': ['error', { maxNumericValue: 3400 }],
// Performance metrics // Performance metrics
interactive: ["error", { maxNumericValue: 3800 }], 'interactive': ['error', { maxNumericValue: 3800 }],
"uses-optimized-images": "error", 'uses-optimized-images': 'error',
"uses-text-compression": "error", 'uses-text-compression': 'error',
"uses-responsive-images": "error", 'uses-responsive-images': 'error',
"modern-image-formats": "error", 'modern-image-formats': 'error',
"offscreen-images": "error", 'offscreen-images': 'error',
"render-blocking-resources": "error", 'render-blocking-resources': 'error',
"unused-css-rules": "error", 'unused-css-rules': 'error',
"unused-javascript": "error", 'unused-javascript': 'error',
"efficient-animated-content": "error", 'efficient-animated-content': 'error',
"preload-lcp-image": "error", 'preload-lcp-image': 'error',
"uses-long-cache-ttl": "error", 'uses-long-cache-ttl': 'error',
"total-byte-weight": ["error", { maxNumericValue: 1600000 }], 'total-byte-weight': ['error', { maxNumericValue: 1600000 }],
}, },
}, },
upload: { 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 ## Where It Went
All WordPress-related code has been archived to: All WordPress-related code has been archived to:
- `_archive/wordpress/code-lib/` - `_archive/wordpress/code-lib/`
## Current State ## 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 3. **DO NOT** add WordPress route handling back to catch-all routes
If you need specific content from WordPress: If you need specific content from WordPress:
- Extract the content manually - Extract the content manually
- Create new pages/components following the style guide - Create new pages/components following the style guide
- Use the existing component patterns from `code/components/` - Use the existing component patterns from `code/components/`
@ -42,3 +40,13 @@ If you need specific content from WordPress:
## Last Updated ## Last Updated
December 2, 2025 December 2, 2025

View file

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

View file

@ -1,7 +1,6 @@
# Rocky Mountain Coolify Launch # Rocky Mountain Coolify Launch
## Staging ## Staging
- Coolify project: `Rocky Mountain Vending` - Coolify project: `Rocky Mountain Vending`
- Coolify app UUID: `bsowk840kccg08coocwwc44c` - Coolify app UUID: `bsowk840kccg08coocwwc44c`
- Environment UUID: `ew8k8og0gw48swck4ckk84kk` - Environment UUID: `ew8k8og0gw48swck4ckk84kk`
@ -18,12 +17,10 @@
- Manual Gitea secret: `deploy123` - Manual Gitea secret: `deploy123`
## DNS Note ## DNS Note
- `rmv.abundancepartners.app` is managed in Cloudflare. - `rmv.abundancepartners.app` is managed in Cloudflare.
- The active DNS record is an unproxied `A` record to `85.239.237.247`. - The active DNS record is an unproxied `A` record to `85.239.237.247`.
## Required App Env ## Required App Env
- `NEXT_PUBLIC_SITE_DOMAIN=rockymountainvending.com` - `NEXT_PUBLIC_SITE_DOMAIN=rockymountainvending.com`
- `NEXT_PUBLIC_SITE_URL=https://rockymountainvending.com` - `NEXT_PUBLIC_SITE_URL=https://rockymountainvending.com`
- `CONVEX_URL` - `CONVEX_URL`
@ -46,29 +43,24 @@
- `LIVEKIT_API_SECRET` - `LIVEKIT_API_SECRET`
## Current Runtime Defaults ## Current Runtime Defaults
- Email is launch-ready through AWS SES fallback with `AWS_ACCESS_KEY`, `AWS_SECRET_KEY`, and `AWS_DEFAULT_REGION`. - 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. - 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. - 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. - 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 ## Email Notes
- The app prefers Usesend when `USESEND_API_KEY` is present. - 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`. - 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. - Keep all email secrets in Coolify-managed env vars.
## Local Handoff Files ## Local Handoff Files
- `.env.rocky-workstation` contains the other-workstation operational variables and webhook details. - `.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. - `.env.rocky-coolify` contains the current Coolify app env bundle with blanks for the still-missing secrets.
## Canonical Handoff Variables ## Canonical Handoff Variables
Use this markdown section as the source of truth if you need to recreate the handoff elsewhere. Use this markdown section as the source of truth if you need to recreate the handoff elsewhere.
### Other Workstation ### Other Workstation
```env ```env
COOLIFY_API_URL=http://85.239.237.247:8000/api/v1 COOLIFY_API_URL=http://85.239.237.247:8000/api/v1
COOLIFY_API_TOKEN=4|ScZUAuJPRPWBSdXWhtW3tkYBcoALayIYnw5zsgCse0a02bb5 COOLIFY_API_TOKEN=4|ScZUAuJPRPWBSdXWhtW3tkYBcoALayIYnw5zsgCse0a02bb5
@ -89,7 +81,6 @@ ADMIN_API_TOKEN=
``` ```
### Coolify App Env ### Coolify App Env
```env ```env
NEXT_PUBLIC_SITE_DOMAIN=rockymountainvending.com NEXT_PUBLIC_SITE_DOMAIN=rockymountainvending.com
NEXT_PUBLIC_SITE_URL=https://rockymountainvending.com NEXT_PUBLIC_SITE_URL=https://rockymountainvending.com
@ -117,7 +108,6 @@ ADMIN_API_TOKEN=
``` ```
## Validation Snapshot ## Validation Snapshot
- `https://rmv.abundancepartners.app` returns `200 OK` - `https://rmv.abundancepartners.app` returns `200 OK`
- `POST /api/contact` with `{}` on `https://rmv.abundancepartners.app` returns `400` - `POST /api/contact` with `{}` on `https://rmv.abundancepartners.app` returns `400`
- Pushes to Forgejo `main` queue Coolify deployments automatically - 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 ## Prerequisites
1. Install dependencies: 1. Install dependencies:
```bash ```bash
npm install npm install
# or # or
@ -25,7 +24,6 @@ npm run lighthouse:build
``` ```
This command will: This command will:
1. Build the production bundle (`next build`) 1. Build the production bundle (`next build`)
2. Start the production server (`next start`) 2. Start the production server (`next start`)
3. Run Lighthouse tests on multiple pages 3. Run Lighthouse tests on multiple pages
@ -66,7 +64,6 @@ The script also reports these critical metrics:
### Target Scores ### Target Scores
For 100% scores, all categories must achieve: For 100% scores, all categories must achieve:
- Performance: 100/100 - Performance: 100/100
- Accessibility: 100/100 - Accessibility: 100/100
- Best Practices: 100/100 - Best Practices: 100/100
@ -112,7 +109,6 @@ For 100% scores, all categories must achieve:
### Images Not Optimizing ### Images Not Optimizing
If images aren't being optimized: If images aren't being optimized:
- Ensure `unoptimized: false` in `next.config.mjs` - Ensure `unoptimized: false` in `next.config.mjs`
- Check that images are using Next.js `Image` component, not `<img>` tags - Check that images are using Next.js `Image` component, not `<img>` tags
- Verify image domains are in `remotePatterns` if using external images - 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: The `.lighthouserc.js` file is configured for Lighthouse CI. To use it:
1. Install Lighthouse CI: 1. Install Lighthouse CI:
```bash ```bash
npm install -g @lhci/cli 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) - [Next.js Image Optimization](https://nextjs.org/docs/app/api-reference/components/image)
- [Core Web Vitals](https://web.dev/vitals/) - [Core Web Vitals](https://web.dev/vitals/)
- [Web Performance Best Practices](https://web.dev/performance/) - [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 ## Overview
This guide covers: This guide covers:
1. Creating R2 buckets 1. Creating R2 buckets
2. Generating API credentials 2. Generating API credentials
3. Uploading files to R2 3. Uploading files to R2
@ -72,14 +71,12 @@ wrangler r2 bucket create vending-vm-thumbnails
## Step 3: Configure Environment Variables ## Step 3: Configure Environment Variables
1. Copy `.env.example` to `.env.local`: 1. Copy `.env.example` to `.env.local`:
```bash ```bash
cd code cd code
cp .env.example .env.local cp .env.example .env.local
``` ```
2. Edit `.env.local` and fill in: 2. Edit `.env.local` and fill in:
```bash ```bash
CLOUDFLARE_R2_ACCESS_KEY_ID=your_access_key_id_here CLOUDFLARE_R2_ACCESS_KEY_ID=your_access_key_id_here
CLOUDFLARE_R2_SECRET_ACCESS_KEY=your_secret_access_key_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 ### Get Public URLs
After enabling public access, you'll get URLs like: After enabling public access, you'll get URLs like:
- Manuals: `https://pub-xxxxx.r2.dev` (or custom domain) - Manuals: `https://pub-xxxxx.r2.dev` (or custom domain)
- Thumbnails: `https://pub-yyyyy.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 ```json
[ [
{ {
"AllowedOrigins": [ "AllowedOrigins": ["https://rockymountainvending.com", "https://www.rockymountainvending.com"],
"https://rockymountainvending.com",
"https://www.rockymountainvending.com"
],
"AllowedMethods": ["GET", "HEAD"], "AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["*"], "AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag"], "ExposeHeaders": ["ETag"],
@ -228,7 +221,6 @@ NEXT_PUBLIC_SITE_DOMAIN=rockymountainvending.com
``` ```
This will: This will:
1. Upload manuals and thumbnails to R2 1. Upload manuals and thumbnails to R2
2. Build the Next.js app 2. Build the Next.js app
3. Create deployment ZIP (for GHL if needed) 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 ### Upload Script Fails
**Error**: "CLOUDFLARE_R2_ACCESS_KEY_ID must be set" **Error**: "CLOUDFLARE_R2_ACCESS_KEY_ID must be set"
- **Solution**: Make sure `.env.local` exists and has correct credentials - **Solution**: Make sure `.env.local` exists and has correct credentials
**Error**: "Bucket not found" **Error**: "Bucket not found"
- **Solution**: Create buckets in Cloudflare Dashboard first - **Solution**: Create buckets in Cloudflare Dashboard first
### Files Not Accessible ### Files Not Accessible
**Issue**: 403 Forbidden when accessing R2 URLs **Issue**: 403 Forbidden when accessing R2 URLs
- **Solution**: Enable public access in bucket settings - **Solution**: Enable public access in bucket settings
**Issue**: CORS errors **Issue**: CORS errors
- **Solution**: Configure CORS policy in bucket settings - **Solution**: Configure CORS policy in bucket settings
### Build Fails in Pages ### Build Fails in Pages
**Error**: Build command fails **Error**: Build command fails
- **Solution**: Check build logs in Pages dashboard - **Solution**: Check build logs in Pages dashboard
- Verify `package.json` has all dependencies - Verify `package.json` has all dependencies
- Ensure build output directory is correct (`code/out`) - Ensure build output directory is correct (`code/out`)
@ -282,8 +269,7 @@ Visit `http://localhost:3000` and verify manuals load from R2 URLs.
### Manuals Not Loading ### Manuals Not Loading
**Issue**: Manuals show 404 **Issue**: Manuals show 404
- **Solution**:
- **Solution**:
1. Verify R2 buckets have files uploaded 1. Verify R2 buckets have files uploaded
2. Check `NEXT_PUBLIC_MANUALS_BASE_URL` is set correctly 2. Check `NEXT_PUBLIC_MANUALS_BASE_URL` is set correctly
3. Verify public access is enabled on buckets 3. Verify public access is enabled on buckets
@ -320,8 +306,9 @@ For large-scale migrations, consider:
## Support ## Support
For issues or questions: For issues or questions:
1. Check Cloudflare Dashboard for error messages 1. Check Cloudflare Dashboard for error messages
2. Review build logs in Pages dashboard 2. Review build logs in Pages dashboard
3. Check R2 bucket settings and permissions 3. Check R2 bucket settings and permissions
4. Verify environment variables are set correctly 4. Verify environment variables are set correctly

View file

@ -114,7 +114,6 @@ Open the downloaded JSON file and extract:
### Current Implementation ### Current Implementation
The API endpoints are set up at: The API endpoints are set up at:
- `/api/sitemap-submit` - Submit sitemap to Google Search Console - `/api/sitemap-submit` - Submit sitemap to Google Search Console
- `/api/request-indexing` - Request URL indexing - `/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: 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/sitemap-submit/route.ts`
- `code/app/api/request-indexing/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` Visit: `https://rockymountainvending.com/robots.txt`
You should see: You should see:
``` ```
User-agent: * User-agent: *
Allow: / Allow: /
@ -230,7 +227,7 @@ Before deploying to production:
- Mobile usability issues - Mobile usability issues
- Core Web Vitals - Core Web Vitals
2. **Monthly**: 2. **Monthly**:
- Review search performance - Review search performance
- Update sitemap if new pages are added - Update sitemap if new pages are added
- Verify NAP consistency - Verify NAP consistency
@ -277,7 +274,7 @@ Before deploying to production:
## Support ## Support
For issues or questions: For issues or questions:
- Check the troubleshooting section above - Check the troubleshooting section above
- Review Google Search Console documentation - Review Google Search Console documentation
- Contact your development team - Contact your development team

View file

@ -1,7 +1,6 @@
# Shadcn MCP Server Troubleshooting Guide # Shadcn MCP Server Troubleshooting Guide
## Current Status ## Current Status
- ✅ MCP configuration file exists at: `code/.cursor/mcp.json` (original) - ✅ MCP configuration file exists at: `code/.cursor/mcp.json` (original)
- ✅ MCP configuration file created at: `.cursor/mcp.json` (workspace root) - **FIXED** - ✅ MCP configuration file created at: `.cursor/mcp.json` (workspace root) - **FIXED**
- ✅ Configuration looks correct - ✅ Configuration looks correct
@ -11,7 +10,6 @@
## Configuration Found ## Configuration Found
The MCP configuration file at `code/.cursor/mcp.json` contains: The MCP configuration file at `code/.cursor/mcp.json` contains:
```json ```json
{ {
"mcpServers": { "mcpServers": {
@ -26,7 +24,6 @@ The MCP configuration file at `code/.cursor/mcp.json` contains:
## Troubleshooting Steps ## Troubleshooting Steps
### 1. ✅ FIXED: MCP Configuration Location ### 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. **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. **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. **Next Step**: Restart Cursor completely to load the MCP server.
### 2. Restart Cursor ### 2. Restart Cursor
After any configuration changes: After any configuration changes:
1. Completely quit Cursor (not just close the window) 1. Completely quit Cursor (not just close the window)
2. Reopen Cursor 2. Reopen Cursor
3. Wait a few seconds for MCP servers to initialize 3. Wait a few seconds for MCP servers to initialize
### 3. Check MCP Server Logs ### 3. Check MCP Server Logs
1. Open Cursor 1. Open Cursor
2. Go to `View``Output` 2. Go to `View``Output`
3. In the dropdown, select `MCP: project-*` or look for shadcn-related logs 3. In the dropdown, select `MCP: project-*` or look for shadcn-related logs
4. Check for any error messages 4. Check for any error messages
### 4. Test MCP Server Manually ### 4. Test MCP Server Manually
Test if the shadcn MCP server can run independently: Test if the shadcn MCP server can run independently:
```bash ```bash
cd code cd code
npx shadcn@latest mcp npx shadcn@latest mcp
``` ```
If you have a GitHub token (for higher rate limits): If you have a GitHub token (for higher rate limits):
```bash ```bash
npx shadcn@latest mcp --github-api-key YOUR_GITHUB_TOKEN npx shadcn@latest mcp --github-api-key YOUR_GITHUB_TOKEN
``` ```
### 5. Verify Project Structure ### 5. Verify Project Structure
Ensure `components.json` exists and is properly configured: Ensure `components.json` exists and is properly configured:
- Location: `code/components.json` - Location: `code/components.json`
- Should reference the correct paths for your project - Should reference the correct paths for your project
### 6. Check Node.js and npm ### 6. Check Node.js and npm
Ensure you have the required versions: Ensure you have the required versions:
```bash ```bash
node --version # Should be v18 or higher node --version # Should be v18 or higher
npm --version npm --version
@ -81,9 +68,7 @@ npx --version
``` ```
### 7. Alternative Configuration ### 7. Alternative Configuration
If the current configuration doesn't work, try specifying the full path: If the current configuration doesn't work, try specifying the full path:
```json ```json
{ {
"mcpServers": { "mcpServers": {
@ -97,16 +82,13 @@ If the current configuration doesn't work, try specifying the full path:
``` ```
### 8. Check Cursor Settings ### 8. Check Cursor Settings
1. Open Cursor Settings (Cmd+, on Mac) 1. Open Cursor Settings (Cmd+, on Mac)
2. Search for "MCP" or "Model Context Protocol" 2. Search for "MCP" or "Model Context Protocol"
3. Verify that MCP servers are enabled 3. Verify that MCP servers are enabled
4. Check if there are any error indicators 4. Check if there are any error indicators
## Expected Tools ## Expected Tools
When working correctly, the shadcn MCP server should provide these tools: When working correctly, the shadcn MCP server should provide these tools:
1. `get_project_registries` - List available component registries 1. `get_project_registries` - List available component registries
2. `list_items_in_registries` - List components in registries 2. `list_items_in_registries` - List components in registries
3. `search_items_in_registries` - Search for components 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 7. `get_audit_checklist` - Get component audit checklist
## Verification ## Verification
To verify the MCP server is working: To verify the MCP server is working:
1. Ask the AI assistant to use shadcn MCP tools 1. Ask the AI assistant to use shadcn MCP tools
2. The assistant should be able to call functions like `get_project_registries` 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 3. If tools are not available, the server is not properly connected
## Next Steps ## Next Steps
1. Try moving the MCP configuration to workspace root 1. Try moving the MCP configuration to workspace root
2. Restart Cursor completely 2. Restart Cursor completely
3. Check MCP server logs for errors 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 5. If issues persist, check the shadcn documentation: https://ui.shadcn.com/docs/mcp
## Additional Resources ## Additional Resources
- [Shadcn MCP Documentation](https://ui.shadcn.com/docs/mcp) - [Shadcn MCP Documentation](https://ui.shadcn.com/docs/mcp)
- [Cursor MCP Setup Guide](https://docs.cursor.com/context/model-context-protocol) - [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. Set the `NEXT_PUBLIC_SITE_DOMAIN` environment variable to configure which site tier to use.
### For rockymountainvending.com (Tier 1) ### For rockymountainvending.com (Tier 1)
```bash ```bash
NEXT_PUBLIC_SITE_DOMAIN=rockymountainvending.com NEXT_PUBLIC_SITE_DOMAIN=rockymountainvending.com
``` ```
**Manufacturers included:** **Manufacturers included:**
- Crane (includes BevMax, Merchant-Series) - Crane (includes BevMax, Merchant-Series)
- Royal Vendors - Royal Vendors
- GPL - GPL
@ -34,13 +32,11 @@ NEXT_PUBLIC_SITE_DOMAIN=rockymountainvending.com
**Minimum manual count:** 3 per manufacturer **Minimum manual count:** 3 per manufacturer
### For vending.support (Tier 2) ### For vending.support (Tier 2)
```bash ```bash
NEXT_PUBLIC_SITE_DOMAIN=vending.support NEXT_PUBLIC_SITE_DOMAIN=vending.support
``` ```
**Manufacturers included:** **Manufacturers included:**
- All Tier 1 manufacturers, plus: - All Tier 1 manufacturers, plus:
- Merchant-Series - Merchant-Series
- BevMax - BevMax
@ -53,13 +49,11 @@ NEXT_PUBLIC_SITE_DOMAIN=vending.support
**Minimum manual count:** 2 per manufacturer **Minimum manual count:** 2 per manufacturer
### For quickfreshvending.com (Tier 3) ### For quickfreshvending.com (Tier 3)
```bash ```bash
NEXT_PUBLIC_SITE_DOMAIN=quickfreshvending.com NEXT_PUBLIC_SITE_DOMAIN=quickfreshvending.com
``` ```
**Manufacturers included:** **Manufacturers included:**
- ALL manufacturers including: - ALL manufacturers including:
- All from Tier 1 and Tier 2 - All from Tier 1 and Tier 2
- Other folder (uncategorized) - Other folder (uncategorized)
@ -70,17 +64,13 @@ NEXT_PUBLIC_SITE_DOMAIN=quickfreshvending.com
## Configuration Files ## Configuration Files
### site-manufacturer-mapping.json ### site-manufacturer-mapping.json
This file contains the complete mapping of manufacturers to each site tier. It's located at: This file contains the complete mapping of manufacturers to each site tier. It's located at:
``` ```
code/lib/site-manufacturer-mapping.json code/lib/site-manufacturer-mapping.json
``` ```
### site-config.ts ### site-config.ts
This file provides helper functions to: This file provides helper functions to:
- Get the current site domain - Get the current site domain
- Get allowed manufacturers for a site - Get allowed manufacturers for a site
- Get minimum manual count - Get minimum manual count
@ -101,16 +91,12 @@ This file provides helper functions to:
For each site deployment, set the appropriate environment variable: For each site deployment, set the appropriate environment variable:
### Vercel/Netlify ### Vercel/Netlify
Add the environment variable in your deployment platform's settings: Add the environment variable in your deployment platform's settings:
- Key: `NEXT_PUBLIC_SITE_DOMAIN` - Key: `NEXT_PUBLIC_SITE_DOMAIN`
- Value: `rockymountainvending.com`, `vending.support`, or `quickfreshvending.com` - Value: `rockymountainvending.com`, `vending.support`, or `quickfreshvending.com`
### Local Development ### Local Development
Create a `.env.local` file in the `code` directory: Create a `.env.local` file in the `code` directory:
``` ```
NEXT_PUBLIC_SITE_DOMAIN=rockymountainvending.com NEXT_PUBLIC_SITE_DOMAIN=rockymountainvending.com
``` ```
@ -118,7 +104,6 @@ NEXT_PUBLIC_SITE_DOMAIN=rockymountainvending.com
## Manufacturer Aliases ## Manufacturer Aliases
The system automatically handles manufacturer name variations using aliases defined in `site-manufacturer-mapping.json`. For example: The system automatically handles manufacturer name variations using aliases defined in `site-manufacturer-mapping.json`. For example:
- "Royal-Vendors" (directory name) → "Royal Vendors" (canonical) - "Royal-Vendors" (directory name) → "Royal Vendors" (canonical)
- "BevMax" → "Crane" - "BevMax" → "Crane"
- "Merchant-Series" → "Crane" - "Merchant-Series" → "Crane"
@ -128,3 +113,6 @@ This ensures manuals are correctly matched regardless of how the manufacturer na
## Payment Components ## 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. 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 ## Color Palette
### Primary Colors ### Primary Colors
- **Background**: `#fff8eb` (Warm cream) - **Background**: `#fff8eb` (Warm cream)
- **Foreground**: Dark text color for body text - **Foreground**: Dark text color for body text
- **Primary**: Purple/blue accent color - **Primary**: Purple/blue accent color
- **Secondary**: Green accent color - **Secondary**: Green accent color
### Link Colors ### Link Colors
- **Link Default**: Standard foreground color - **Link Default**: Standard foreground color
- **Link Hover/Active**: `#c4142c` (Red - RGB: 196, 20, 44) - **Link Hover/Active**: `#c4142c` (Red - RGB: 196, 20, 44)
- **Link Highlight**: Red background highlight on hover - **Link Highlight**: Red background highlight on hover
@ -18,12 +16,10 @@
## Typography ## Typography
### Fonts ### Fonts
- **Body Font**: Inter (with fallback) - **Body Font**: Inter (with fallback)
- **Mono Font**: Geist Mono (with fallback) - **Mono Font**: Geist Mono (with fallback)
### Font Sizes ### Font Sizes
- Base: 16px - Base: 16px
- Small: 14px - Small: 14px
- Medium: 16px - Medium: 16px
@ -32,10 +28,9 @@
## Hyperlinks ## Hyperlinks
### Standard Link Styling ### Standard Link Styling
All hyperlinks (`<a>` tags and Next.js `<Link>` components) should follow these rules: All hyperlinks (`<a>` tags and Next.js `<Link>` components) should follow these rules:
1. **Default State**: 1. **Default State**:
- Color: Foreground color (readable text color) - Color: Foreground color (readable text color)
- No underline - No underline
- Smooth transition on hover - Smooth transition on hover
@ -50,7 +45,6 @@ All hyperlinks (`<a>` tags and Next.js `<Link>` components) should follow these
- May include underline for emphasis - May include underline for emphasis
### Implementation ### Implementation
- Use CSS variable `--link-color` for default state - Use CSS variable `--link-color` for default state
- Use CSS variable `--link-hover-color` for hover/active states - Use CSS variable `--link-hover-color` for hover/active states
- Apply globally via `@layer base` in `globals.css` - 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 ## Components
### Navigation Links ### Navigation Links
- Follow standard link styling - Follow standard link styling
- Hover state changes to red - Hover state changes to red
- Smooth transitions - Smooth transitions
### Footer Links ### Footer Links
- Follow standard link styling - Follow standard link styling
- May include underline on hover - May include underline on hover
### Button Links ### Button Links
- Use button component styling - Use button component styling
- May override link colors for consistency - May override link colors for consistency
## Spacing ## Spacing
### Standard Spacing Scale ### Standard Spacing Scale
- XS: 0.5rem (8px) - XS: 0.5rem (8px)
- SM: 1rem (16px) - SM: 1rem (16px)
- MD: 1.5rem (24px) - MD: 1.5rem (24px)
@ -94,13 +84,11 @@ All hyperlinks (`<a>` tags and Next.js `<Link>` components) should follow these
## Layout & Containers ## Layout & Containers
### Page Containers ### Page Containers
- **Standard pages**: `container mx-auto px-4 py-8 md:py-12 max-w-4xl` - **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` - **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) - **Full-width pages**: `container mx-auto px-4 py-8 md:py-12` (no max-width)
### Section Containers ### Section Containers
- **Standard sections**: `py-20 md:py-28` with optional `bg-muted/30` for alternating backgrounds - **Standard sections**: `py-20 md:py-28` with optional `bg-muted/30` for alternating backgrounds
- **Hero sections**: `py-20 md:py-32` - **Hero sections**: `py-20 md:py-32`
- **Container wrapper**: Always use `container mx-auto px-4` inside sections - **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 ### Headings
#### H1 (Page Titles) #### H1 (Page Titles)
- **Classes**: `text-4xl md:text-5xl font-bold mb-4` (or `mb-6` for more spacing) - **Classes**: `text-4xl md:text-5xl font-bold mb-4` (or `mb-6` for more spacing)
- **Usage**: Only one H1 per page (page title) - **Usage**: Only one H1 per page (page title)
- **Example**: `<h1 className="text-4xl md:text-5xl font-bold mb-4">Page Title</h1>` - **Example**: `<h1 className="text-4xl md:text-5xl font-bold mb-4">Page Title</h1>`
#### H2 (Section Headers) #### H2 (Section Headers)
- **Section headers** (centered): `text-3xl font-bold tracking-tight md:text-4xl lg:text-5xl mb-4 text-balance` - **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` - **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 - **Header wrapper**: `text-center mb-12 md:mb-16` for section headers
#### H3 #### H3
- **Standard**: `text-xl font-semibold mb-2` or `mb-3` - **Standard**: `text-xl font-semibold mb-2` or `mb-3`
### Paragraphs ### Paragraphs
- **Standard text**: `text-lg text-muted-foreground text-pretty leading-relaxed` - **Standard text**: `text-lg text-muted-foreground text-pretty leading-relaxed`
- **Centered text**: Add `max-w-2xl mx-auto` for centered paragraphs - **Centered text**: Add `max-w-2xl mx-auto` for centered paragraphs
- **Small text**: `text-sm text-muted-foreground` - **Small text**: `text-sm text-muted-foreground`
@ -134,14 +118,12 @@ All hyperlinks (`<a>` tags and Next.js `<Link>` components) should follow these
## Cards ## Cards
### Standard Card Styling ### Standard Card Styling
- **Border**: `border-border/50` (preferred) or `border-2` for emphasis - **Border**: `border-border/50` (preferred) or `border-2` for emphasis
- **Hover states**: `hover:border-secondary/50 transition-colors` - **Hover states**: `hover:border-secondary/50 transition-colors`
- **Shadow**: `shadow-lg` or `shadow-md` as needed - **Shadow**: `shadow-lg` or `shadow-md` as needed
- **Padding**: `p-6` or `p-6 md:p-8` for CardContent - **Padding**: `p-6` or `p-6 md:p-8` for CardContent
### Card Examples ### Card Examples
```tsx ```tsx
// Standard card // Standard card
<Card className="border-border/50 hover:border-secondary/50 transition-colors"> <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 ### Image Sizing Standards
#### Maximum Dimensions #### Maximum Dimensions
- **Grid/Card images**: Maximum 300px per dimension - **Grid/Card images**: Maximum 300px per dimension
- **Full-width images**: Maximum 600px per dimension - **Full-width images**: Maximum 600px per dimension
- **Always preserve aspect ratio** when constraining - **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 #### Image Layout Patterns
**Single Image (Centered)** **Single Image (Centered)**
```tsx ```tsx
<div className="max-w-md mx-auto"> <div className="max-w-md mx-auto">
<div className="relative w-full overflow-hidden rounded-lg bg-muted shadow-sm"> <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)** **Grid Images (2-4 images)**
```tsx ```tsx
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
{images.map((img) => ( {images.map((img) => (
@ -207,18 +186,23 @@ All hyperlinks (`<a>` tags and Next.js `<Link>` components) should follow these
``` ```
**Card Images (Aspect Ratio)** **Card Images (Aspect Ratio)**
```tsx ```tsx
<Card> <Card>
<div className="aspect-video relative bg-muted"> <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> </div>
<CardContent>{/* content */}</CardContent> <CardContent>
{/* content */}
</CardContent>
</Card> </Card>
``` ```
### Image Requirements ### Image Requirements
1. **Always constrain large images**: Use `Math.min(width, MAX)` pattern 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) 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 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` 5. **Wrapper classes**: `relative w-full overflow-hidden rounded-lg bg-muted shadow-sm`
### Image Size Constraints by Context ### Image Size Constraints by Context
- **In grid layouts**: Max 300px per dimension - **In grid layouts**: Max 300px per dimension
- **In cards**: Use `aspect-video` or `aspect-square` with `fill` prop - **In cards**: Use `aspect-video` or `aspect-square` with `fill` prop
- **Standalone images**: Max 600px per dimension with `max-w-md mx-auto` wrapper - **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 ## Spacing Standards
### Section Spacing ### Section Spacing
- **Section padding**: `py-20 md:py-28` (standard sections) - **Section padding**: `py-20 md:py-28` (standard sections)
- **Section margin bottom**: `mb-12 md:mb-16` for section headers - **Section margin bottom**: `mb-12 md:mb-16` for section headers
- **Page padding**: `py-8 md:py-12` (page containers) - **Page padding**: `py-8 md:py-12` (page containers)
### Element Spacing ### Element Spacing
- **Header margin**: `mb-12 md:mb-16` for section headers, `mb-8` for page headers - **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 - **Card gaps**: `gap-6` or `gap-8` in grid layouts
- **Paragraph spacing**: `mb-4` or `mb-6` between paragraphs - **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. A formatting standardization script is available at `scripts/standardize-formatting.py` that automatically applies these standards across all TSX files.
### Usage ### Usage
```bash ```bash
# Test mode (dry run) # Test mode (dry run)
python3 scripts/standardize-formatting.py --dry-run python3 scripts/standardize-formatting.py --dry-run
@ -273,7 +253,6 @@ python3 scripts/standardize-formatting.py
``` ```
The script will: The script will:
- Standardize container classes and spacing - Standardize container classes and spacing
- Fix typography classes (H1, H2, paragraphs) - Fix typography classes (H1, H2, paragraphs)
- Standardize card borders and shadows - Standardize card borders and shadows

File diff suppressed because it is too large Load diff

View file

@ -1,39 +1,58 @@
import { notFound } from "next/navigation" import { notFound } from 'next/navigation';
import { generateRegistryMetadata, generateRegistryStructuredData } from "@/lib/seo" import { loadImageMapping } from '@/lib/wordpress-content';
import { getPageBySlug } from "@/lib/wordpress-data-loader" import { generateSEOMetadata, generateStructuredData } from '@/lib/seo';
import { AboutPage } from "@/components/about-page" import { getPageBySlug } from '@/lib/wordpress-data-loader';
import type { Metadata } from "next" 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> { export async function generateMetadata(): Promise<Metadata> {
const page = getPageBySlug(WORDPRESS_SLUG) const page = getPageBySlug(WORDPRESS_SLUG);
if (!page) { if (!page) {
return { 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, date: page.date,
modified: page.modified, modified: page.modified,
image: page.images?.[0]?.localPath, image: page.images?.[0]?.localPath,
}) });
} }
export default async function AboutUsPage() { export default async function AboutUsPage() {
try { try {
const page = getPageBySlug(WORDPRESS_SLUG) const page = getPageBySlug(WORDPRESS_SLUG);
if (!page) { if (!page) {
notFound() notFound();
} }
const structuredData = generateRegistryStructuredData("aboutUs", { let structuredData;
datePublished: page.date, try {
dateModified: page.modified || page.date, 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 ( return (
<> <>
@ -43,11 +62,19 @@ export default async function AboutUsPage() {
/> />
<AboutPage /> <AboutPage />
</> </>
) );
} catch (error) { } catch (error) {
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === 'development') {
console.error("Error rendering About Us page:", error) console.error('Error rendering About Us page:', error);
} }
notFound() notFound();
} }
} }

View file

@ -1,74 +1,83 @@
import { notFound } from "next/navigation" import { notFound } from 'next/navigation';
import { buildAbsoluteUrl } from "@/lib/seo-registry" import { loadImageMapping } from '@/lib/wordpress-content';
import { generateRegistryMetadata, generateRegistryStructuredData } from "@/lib/seo" import { generateSEOMetadata, generateStructuredData } from '@/lib/seo';
import { Breadcrumbs } from "@/components/breadcrumbs" import { getPageBySlug } from '@/lib/wordpress-data-loader';
import { getPageBySlug } from "@/lib/wordpress-data-loader" import { FAQSchema } from '@/components/faq-schema';
import { FAQSchema } from "@/components/faq-schema" import { FAQSection } from '@/components/faq-section';
import { FAQSection } from "@/components/faq-section" import type { Metadata } from 'next';
import type { Metadata } from "next"
const WORDPRESS_SLUG = "faqs" const WORDPRESS_SLUG = 'faqs';
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const page = getPageBySlug(WORDPRESS_SLUG) const page = getPageBySlug(WORDPRESS_SLUG);
if (!page) { if (!page) {
return { 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, date: page.date,
modified: page.modified, modified: page.modified,
image: page.images?.[0]?.localPath, image: page.images?.[0]?.localPath,
}) });
} }
export default async function FAQsPage() { export default async function FAQsPage() {
try { try {
const page = getPageBySlug(WORDPRESS_SLUG) const page = getPageBySlug(WORDPRESS_SLUG);
if (!page) { if (!page) {
notFound() notFound();
} }
// Extract FAQs from content // Extract FAQs from content
const faqs: Array<{ question: string; answer: string }> = [] const faqs: Array<{ question: string; answer: string }> = [];
if (page.content) { if (page.content) {
const contentStr = String(page.content) const contentStr = String(page.content);
// Extract FAQ items from accordion structure // Extract FAQ items from accordion structure
const questionMatches = contentStr.matchAll( const questionMatches = contentStr.matchAll(/<span class="ekit-accordion-title">([^<]+)<\/span>/g);
/<span class="ekit-accordion-title">([^<]+)<\/span>/g
)
// Extract full answer content // Extract full answer content
const answerMatches = contentStr.matchAll( const answerMatches = contentStr.matchAll(/<div class="elementskit-card-body ekit-accordion--content">([\s\S]*?)<\/div>\s*<\/div>\s*<!-- \.elementskit-card END -->/g);
/<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()) let answer = m[1].trim();
const answers = Array.from(answerMatches).map((m) => { answer = answer.replace(/\n\s*\n/g, '\n').replace(/>\s+</g, '><').trim();
let answer = m[1].trim() return answer;
answer = answer });
.replace(/\n\s*\n/g, "\n")
.replace(/>\s+</g, "><")
.trim()
return answer
})
// Match questions with answers // Match questions with answers
questions.forEach((question, index) => { questions.forEach((question, index) => {
if (answers[index]) { if (answers[index]) {
faqs.push({ question, answer: answers[index] }) faqs.push({ question, answer: answers[index] });
} }
}) });
} }
const pageUrl = buildAbsoluteUrl("/about/faqs") let structuredData;
const structuredData = generateRegistryStructuredData("faqs", { try {
datePublished: page.date, structuredData = generateStructuredData({
dateModified: page.modified || page.date, 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 ( return (
<> <>
@ -77,27 +86,28 @@ export default async function FAQsPage() {
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }} dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/> />
{faqs.length > 0 && ( {faqs.length > 0 && (
<div className="public-page"> <>
<Breadcrumbs <FAQSchema
className="mb-6" faqs={faqs}
items={[ pageUrl={page.link || page.urlPath || `https://rockymountainvending.com/about/faqs/`}
{ label: "About", href: "/about" },
{ label: "FAQs", href: "/about/faqs" },
]}
/> />
<FAQSchema <FAQSection faqs={faqs} />
faqs={faqs} </>
pageUrl={pageUrl}
/>
<FAQSection faqs={faqs} className="pt-0" />
</div>
)} )}
</> </>
) );
} catch (error) { } catch (error) {
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === 'development') {
console.error("Error rendering FAQs page:", error) console.error('Error rendering FAQs page:', error);
} }
notFound() notFound();
} }
} }

View file

@ -1,23 +1,21 @@
import { generateRegistryMetadata, generateRegistryStructuredData } from "@/lib/seo" import { generateSEOMetadata, generateStructuredData } from '@/lib/seo';
import { AboutPage } from "@/components/about-page" import { AboutPage } from '@/components/about-page';
import type { Metadata } from "next" import type { Metadata } from 'next';
import { businessConfig } from "@/lib/seo-config"
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
return { return generateSEOMetadata({
...generateRegistryMetadata("aboutLegacy"), title: 'About Us | Rocky Mountain Vending',
alternates: { description: 'Learn more about Rocky Mountain Vending, a family-owned business dedicated to providing exceptional vending services across Utah',
canonical: `${businessConfig.website}/about-us`, });
},
robots: {
index: false,
follow: true,
},
}
} }
export default function About() { 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 ( return (
<> <>
@ -27,5 +25,5 @@ export default function About() {
/> />
<AboutPage /> <AboutPage />
</> </>
) );
} }

View file

@ -1,37 +1,31 @@
import Link from "next/link" import Link from "next/link";
import { notFound } from "next/navigation" import { notFound } from "next/navigation";
import { fetchQuery } from "convex/nextjs" import { fetchQuery } from "convex/nextjs";
import { ArrowLeft, ExternalLink, Phone } from "lucide-react" import { ArrowLeft, ExternalLink, Phone } from "lucide-react";
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api";
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { import {
formatPhoneCallDuration, formatPhoneCallDuration,
formatPhoneCallTimestamp, formatPhoneCallTimestamp,
normalizePhoneFromIdentity, normalizePhoneFromIdentity,
} from "@/lib/phone-calls" } from "@/lib/phone-calls";
type PageProps = { type PageProps = {
params: Promise<{ params: Promise<{
id: string id: string;
}> }>;
} };
export default async function AdminCallDetailPage({ params }: PageProps) { export default async function AdminCallDetailPage({ params }: PageProps) {
const { id } = await params const { id } = await params;
const detail = await fetchQuery(api.voiceSessions.getAdminPhoneCallDetail, { const detail = await fetchQuery(api.voiceSessions.getAdminPhoneCallDetail, {
callId: id, callId: id,
}) });
if (!detail) { if (!detail) {
notFound() notFound();
} }
return ( return (
@ -39,20 +33,13 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
<div className="space-y-8"> <div className="space-y-8">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between"> <div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="space-y-2"> <div className="space-y-2">
<Link <Link href="/admin/calls" className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
href="/admin/calls"
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
Back to calls Back to calls
</Link> </Link>
<h1 className="text-4xl font-bold tracking-tight text-balance"> <h1 className="text-4xl font-bold tracking-tight text-balance">Phone Call Detail</h1>
Phone Call Detail
</h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{detail.call.contactDisplayName || {normalizePhoneFromIdentity(detail.call.participantIdentity) || detail.call.participantIdentity}
normalizePhoneFromIdentity(detail.call.participantIdentity) ||
detail.call.participantIdentity}
</p> </p>
</div> </div>
</div> </div>
@ -64,159 +51,58 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
<Phone className="h-5 w-5" /> <Phone className="h-5 w-5" />
Call Status Call Status
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>Operational detail for this direct phone session.</CardDescription>
Operational detail for this direct phone session.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2"> <CardContent className="grid gap-4 md:grid-cols-2">
<div> <div>
<p className="text-xs uppercase tracking-wide text-muted-foreground"> <p className="text-xs uppercase tracking-wide text-muted-foreground">Started</p>
Started <p className="font-medium">{formatPhoneCallTimestamp(detail.call.startedAt)}</p>
</p>
<p className="font-medium">
{formatPhoneCallTimestamp(detail.call.startedAt)}
</p>
</div> </div>
<div> <div>
<p className="text-xs uppercase tracking-wide text-muted-foreground"> <p className="text-xs uppercase tracking-wide text-muted-foreground">Room</p>
Room
</p>
<p className="font-medium break-all">{detail.call.roomName}</p> <p className="font-medium break-all">{detail.call.roomName}</p>
</div> </div>
<div> <div>
<p className="text-xs uppercase tracking-wide text-muted-foreground"> <p className="text-xs uppercase tracking-wide text-muted-foreground">Duration</p>
Duration <p className="font-medium">{formatPhoneCallDuration(detail.call.durationMs)}</p>
</p>
<p className="font-medium">
{formatPhoneCallDuration(detail.call.durationMs)}
</p>
</div> </div>
<div> <div>
<p className="text-xs uppercase tracking-wide text-muted-foreground"> <p className="text-xs uppercase tracking-wide text-muted-foreground">Participant Identity</p>
Participant Identity <p className="font-medium break-all">{detail.call.participantIdentity || "Unknown"}</p>
</p>
<p className="font-medium break-all">
{detail.call.participantIdentity || "Unknown"}
</p>
</div> </div>
<div> <div>
<p className="text-xs uppercase tracking-wide text-muted-foreground"> <p className="text-xs uppercase tracking-wide text-muted-foreground">Call Status</p>
Caller Phone <Badge className="mt-1" variant={detail.call.callStatus === "failed" ? "destructive" : detail.call.callStatus === "started" ? "secondary" : "default"}>
</p>
<p className="font-medium">
{detail.call.callerPhone ||
normalizePhoneFromIdentity(detail.call.participantIdentity) ||
"Unknown"}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Company
</p>
<p className="font-medium">{detail.call.contactCompany || "—"}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Call Status
</p>
<Badge
className="mt-1"
variant={
detail.call.callStatus === "failed"
? "destructive"
: detail.call.callStatus === "started"
? "secondary"
: "default"
}
>
{detail.call.callStatus} {detail.call.callStatus}
</Badge> </Badge>
</div> </div>
<div> <div>
<p className="text-xs uppercase tracking-wide text-muted-foreground"> <p className="text-xs uppercase tracking-wide text-muted-foreground">Jessica Answered</p>
Jessica Answered <p className="font-medium">{detail.call.answered ? "Yes" : "No"}</p>
</p>
<p className="font-medium">
{detail.call.answered ? "Yes" : "No"}
</p>
</div> </div>
<div> <div>
<p className="text-xs uppercase tracking-wide text-muted-foreground"> <p className="text-xs uppercase tracking-wide text-muted-foreground">Lead Outcome</p>
Lead Outcome
</p>
<p className="font-medium">{detail.call.leadOutcome}</p> <p className="font-medium">{detail.call.leadOutcome}</p>
</div> </div>
<div> <div>
<p className="text-xs uppercase tracking-wide text-muted-foreground"> <p className="text-xs uppercase tracking-wide text-muted-foreground">Email Summary</p>
Email Summary
</p>
<p className="font-medium">{detail.call.notificationStatus}</p> <p className="font-medium">{detail.call.notificationStatus}</p>
</div> </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"> <div className="md:col-span-2">
<p className="text-xs uppercase tracking-wide text-muted-foreground"> <p className="text-xs uppercase tracking-wide text-muted-foreground">Summary</p>
Summary <p className="text-sm whitespace-pre-wrap">{detail.call.summaryText || "No summary available yet."}</p>
</p>
<p className="text-sm whitespace-pre-wrap">
{detail.call.summaryText || "No summary available yet."}
</p>
</div> </div>
<div> <div>
<p className="text-xs uppercase tracking-wide text-muted-foreground"> <p className="text-xs uppercase tracking-wide text-muted-foreground">Recording Status</p>
Recording Status <p className="font-medium">{detail.call.recordingStatus || "Unavailable"}</p>
</p>
<p className="font-medium">
{detail.call.recordingStatus || "Unavailable"}
</p>
</div> </div>
<div> <div>
<p className="text-xs uppercase tracking-wide text-muted-foreground"> <p className="text-xs uppercase tracking-wide text-muted-foreground">Transcript Turns</p>
Transcript Turns
</p>
<p className="font-medium">{detail.call.transcriptTurnCount}</p> <p className="font-medium">{detail.call.transcriptTurnCount}</p>
</div> </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 ? ( {detail.call.recordingUrl ? (
<div className="md:col-span-2"> <div className="md:col-span-2">
<Link <Link href={detail.call.recordingUrl} target="_blank" className="inline-flex items-center gap-2 text-sm text-primary hover:underline">
href={detail.call.recordingUrl}
target="_blank"
className="inline-flex items-center gap-2 text-sm text-primary hover:underline"
>
Open recording Open recording
<ExternalLink className="h-4 w-4" /> <ExternalLink className="h-4 w-4" />
</Link> </Link>
@ -224,24 +110,8 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
) : null} ) : null}
{detail.call.notificationError ? ( {detail.call.notificationError ? (
<div className="md:col-span-2"> <div className="md:col-span-2">
<p className="text-xs uppercase tracking-wide text-muted-foreground"> <p className="text-xs uppercase tracking-wide text-muted-foreground">Email Error</p>
Email Error <p className="text-sm text-destructive">{detail.call.notificationError}</p>
</p>
<p className="text-sm text-destructive">
{detail.call.notificationError}
</p>
</div>
) : null}
{detail.call.reminderCalendarHtmlLink ? (
<div className="md:col-span-2">
<Link
href={detail.call.reminderCalendarHtmlLink}
target="_blank"
className="inline-flex items-center gap-2 text-sm text-primary hover:underline"
>
Open reminder
<ExternalLink className="h-4 w-4" />
</Link>
</div> </div>
) : null} ) : null}
</CardContent> </CardContent>
@ -251,74 +121,38 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
<CardHeader> <CardHeader>
<CardTitle>Linked Lead</CardTitle> <CardTitle>Linked Lead</CardTitle>
<CardDescription> <CardDescription>
{detail.linkedLead {detail.linkedLead ? "Lead created from this phone call." : "No lead was created from this call."}
? "Lead created from this phone call."
: "No lead was created from this call."}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{detail.linkedLead ? ( {detail.linkedLead ? (
<> <>
<div> <div>
<p className="text-xs uppercase tracking-wide text-muted-foreground"> <p className="text-xs uppercase tracking-wide text-muted-foreground">Contact</p>
Contact
</p>
<p className="font-medium"> <p className="font-medium">
{detail.linkedLead.firstName} {detail.linkedLead.lastName} {detail.linkedLead.firstName} {detail.linkedLead.lastName}
</p> </p>
</div> </div>
<div> <div>
<p className="text-xs uppercase tracking-wide text-muted-foreground"> <p className="text-xs uppercase tracking-wide text-muted-foreground">Lead Type</p>
Lead Type
</p>
<p className="font-medium">{detail.linkedLead.type}</p> <p className="font-medium">{detail.linkedLead.type}</p>
</div> </div>
<div> <div>
<p className="text-xs uppercase tracking-wide text-muted-foreground"> <p className="text-xs uppercase tracking-wide text-muted-foreground">Email</p>
Email <p className="font-medium break-all">{detail.linkedLead.email}</p>
</p>
<p className="font-medium break-all">
{detail.linkedLead.email}
</p>
</div> </div>
<div> <div>
<p className="text-xs uppercase tracking-wide text-muted-foreground"> <p className="text-xs uppercase tracking-wide text-muted-foreground">Phone</p>
Phone
</p>
<p className="font-medium">{detail.linkedLead.phone}</p> <p className="font-medium">{detail.linkedLead.phone}</p>
</div> </div>
<div> <div>
<p className="text-xs uppercase tracking-wide text-muted-foreground"> <p className="text-xs uppercase tracking-wide text-muted-foreground">Message</p>
Message <p className="text-sm whitespace-pre-wrap">{detail.linkedLead.message || "—"}</p>
</p>
<p className="text-sm whitespace-pre-wrap">
{detail.linkedLead.message || "—"}
</p>
</div> </div>
</> </>
) : ( ) : (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">Jessica handled the call, but it did not result in a submitted lead.</p>
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> </CardContent>
</Card> </Card>
</div> </div>
@ -326,15 +160,11 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Transcript</CardTitle> <CardTitle>Transcript</CardTitle>
<CardDescription> <CardDescription>Complete mirrored transcript for this phone call.</CardDescription>
Complete mirrored transcript for this phone call.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{detail.turns.length === 0 ? ( {detail.turns.length === 0 ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">No transcript turns were captured for this call.</p>
No transcript turns were captured for this call.
</p>
) : ( ) : (
detail.turns.map((turn: any) => ( detail.turns.map((turn: any) => (
<div key={turn.id} className="rounded-lg border p-3"> <div key={turn.id} className="rounded-lg border p-3">
@ -350,11 +180,10 @@ export default async function AdminCallDetailPage({ params }: PageProps) {
</Card> </Card>
</div> </div>
</div> </div>
) );
} }
export const metadata = { export const metadata = {
title: "Phone Call Detail | Admin", title: "Phone Call Detail | Admin",
description: description: "Review a mirrored direct phone call transcript and linked lead details",
"Review a mirrored direct phone call transcript and linked lead details", };
}

View file

@ -1,67 +1,58 @@
import Link from "next/link" import Link from "next/link";
import { fetchQuery } from "convex/nextjs" import { fetchQuery } from "convex/nextjs";
import { Phone, Search } from "lucide-react" import { Phone, Search } from "lucide-react";
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api";
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
Card, import { Input } from "@/components/ui/input";
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { import {
formatPhoneCallDuration, formatPhoneCallDuration,
formatPhoneCallTimestamp, formatPhoneCallTimestamp,
normalizePhoneFromIdentity, normalizePhoneFromIdentity,
} from "@/lib/phone-calls" } from "@/lib/phone-calls";
type PageProps = { type PageProps = {
searchParams: Promise<{ searchParams: Promise<{
search?: string search?: string;
status?: "started" | "completed" | "failed" status?: "started" | "completed" | "failed";
page?: string page?: string;
}> }>;
} };
function getStatusVariant(status: "started" | "completed" | "failed") { function getStatusVariant(status: "started" | "completed" | "failed") {
if (status === "failed") { if (status === "failed") {
return "destructive" as const return "destructive" as const;
} }
if (status === "started") { 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) { export default async function AdminCallsPage({ searchParams }: PageProps) {
const params = await searchParams const params = await searchParams;
const page = Math.max(1, Number.parseInt(params.page || "1", 10) || 1) const page = Math.max(1, Number.parseInt(params.page || "1", 10) || 1);
const status = params.status const status = params.status;
const search = params.search?.trim() || undefined const search = params.search?.trim() || undefined;
const data = await fetchQuery(api.voiceSessions.listAdminPhoneCalls, { const data = await fetchQuery(api.voiceSessions.listAdminPhoneCalls, {
search, search,
status, status,
page, page,
limit: 25, limit: 25,
}) });
return ( return (
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-4 py-8">
<div className="space-y-8"> <div className="space-y-8">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between"> <div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div> <div>
<h1 className="text-4xl font-bold tracking-tight text-balance"> <h1 className="text-4xl font-bold tracking-tight text-balance">Phone Calls</h1>
Phone Calls
</h1>
<p className="mt-2 text-muted-foreground"> <p className="mt-2 text-muted-foreground">
Every direct LiveKit phone call mirrored into RMV admin, including Every direct LiveKit phone call mirrored into RMV admin, including partial and non-lead calls.
partial and non-lead calls.
</p> </p>
</div> </div>
<Link href="/admin"> <Link href="/admin">
@ -75,20 +66,13 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
<Phone className="h-5 w-5" /> <Phone className="h-5 w-5" />
Call Inbox Call Inbox
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>Search by caller number, room, summary, or linked lead ID.</CardDescription>
Search by caller number, room, summary, or linked lead ID.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<form className="grid gap-3 md:grid-cols-[minmax(0,1fr)_180px_auto]"> <form className="grid gap-3 md:grid-cols-[minmax(0,1fr)_180px_auto]">
<div className="relative"> <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" /> <Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input <Input name="search" defaultValue={search || ""} placeholder="Search calls" className="pl-9" />
name="search"
defaultValue={search || ""}
placeholder="Search calls"
className="pl-9"
/>
</div> </div>
<select <select
name="status" name="status"
@ -104,7 +88,7 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
</form> </form>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full min-w-[1240px] text-sm"> <table className="w-full min-w-[1050px] text-sm">
<thead> <thead>
<tr className="border-b text-left text-muted-foreground"> <tr className="border-b text-left text-muted-foreground">
<th className="py-3 pr-4 font-medium">Caller</th> <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">Recording</th>
<th className="py-3 pr-4 font-medium">Lead</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">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 pr-4 font-medium">Summary</th>
<th className="py-3 font-medium">Open</th> <th className="py-3 font-medium">Open</th>
</tr> </tr>
@ -125,81 +107,35 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
<tbody> <tbody>
{data.items.length === 0 ? ( {data.items.length === 0 ? (
<tr> <tr>
<td <td colSpan={11} className="py-8 text-center text-muted-foreground">
colSpan={13}
className="py-8 text-center text-muted-foreground"
>
No phone calls matched this filter. No phone calls matched this filter.
</td> </td>
</tr> </tr>
) : ( ) : (
data.items.map((call: any) => ( data.items.map((call: any) => (
<tr <tr key={call.id} className="border-b align-top last:border-b-0">
key={call.id}
className="border-b align-top last:border-b-0"
>
<td className="py-3 pr-4 font-medium"> <td className="py-3 pr-4 font-medium">
<div> <div>{normalizePhoneFromIdentity(call.participantIdentity) || call.participantIdentity}</div>
{call.contactDisplayName || <div className="text-xs text-muted-foreground">{call.roomName}</div>
normalizePhoneFromIdentity(
call.participantIdentity
) ||
call.participantIdentity}
</div>
<div className="text-xs text-muted-foreground">
{call.contactCompany ||
normalizePhoneFromIdentity(
call.participantIdentity
) ||
call.roomName}
</div>
</td> </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"> <td className="py-3 pr-4">
{formatPhoneCallTimestamp(call.startedAt)} <Badge variant={getStatusVariant(call.callStatus)}>{call.callStatus}</Badge>
</td> </td>
<td className="py-3 pr-4">{call.answered ? "Yes" : "No"}</td>
<td className="py-3 pr-4"> <td className="py-3 pr-4">
{formatPhoneCallDuration(call.durationMs)} {call.transcriptTurnCount > 0 ? `${call.transcriptTurnCount} turns` : "No transcript"}
</td>
<td className="py-3 pr-4">
<Badge variant={getStatusVariant(call.callStatus)}>
{call.callStatus}
</Badge>
</td>
<td className="py-3 pr-4">
{call.answered ? "Yes" : "No"}
</td>
<td className="py-3 pr-4">
{call.transcriptTurnCount > 0
? `${call.transcriptTurnCount} turns`
: "No transcript"}
</td>
<td className="py-3 pr-4">
{call.recordingStatus || "Unavailable"}
</td>
<td className="py-3 pr-4">
{call.leadOutcome === "none" ? "—" : call.leadOutcome}
</td> </td>
<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.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"> <td className="max-w-[320px] py-3 pr-4 text-muted-foreground">
<span className="line-clamp-2"> <span className="line-clamp-2">{call.summaryText || "No summary yet"}</span>
{call.summaryText || "No summary yet"}
</span>
</td> </td>
<td className="py-3"> <td className="py-3">
<Link href={`/admin/calls/${call.id}`}> <Link href={`/admin/calls/${call.id}`}>
<Button size="sm" variant="outline"> <Button size="sm" variant="outline">View</Button>
View
</Button>
</Link> </Link>
</td> </td>
</tr> </tr>
@ -211,8 +147,7 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Showing page {data.pagination.page} of{" "} Showing page {data.pagination.page} of {data.pagination.totalPages} ({data.pagination.total} calls)
{data.pagination.totalPages} ({data.pagination.total} calls)
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
{data.pagination.page > 1 ? ( {data.pagination.page > 1 ? (
@ -223,9 +158,7 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
page: String(data.pagination.page - 1), page: String(data.pagination.page - 1),
}).toString()}`} }).toString()}`}
> >
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">Previous</Button>
Previous
</Button>
</Link> </Link>
) : null} ) : null}
{data.pagination.page < data.pagination.totalPages ? ( {data.pagination.page < data.pagination.totalPages ? (
@ -236,9 +169,7 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
page: String(data.pagination.page + 1), page: String(data.pagination.page + 1),
}).toString()}`} }).toString()}`}
> >
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">Next</Button>
Next
</Button>
</Link> </Link>
) : null} ) : null}
</div> </div>
@ -247,10 +178,10 @@ export default async function AdminCallsPage({ searchParams }: PageProps) {
</Card> </Card>
</div> </div>
</div> </div>
) );
} }
export const metadata = { export const metadata = {
title: "Phone Calls | Admin", title: "Phone Calls | Admin",
description: "View direct phone calls, transcript history, and lead outcomes", 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 { redirect } from "next/navigation" import { isAdminUiEnabled } from "@/lib/server/admin-auth";
import {
getAdminUserFromCookies,
isAdminUiEnabled,
} from "@/lib/server/admin-auth"
export default async function AdminLayout({ export default async function AdminLayout({
children, children,
@ -11,32 +7,8 @@ export default async function AdminLayout({
children: React.ReactNode children: React.ReactNode
}) { }) {
if (!isAdminUiEnabled()) { if (!isAdminUiEnabled()) {
redirect("/") redirect("/");
} }
const adminUser = await getAdminUserFromCookies() return <>{children}</>;
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>
)
} }

View file

@ -1,4 +1,4 @@
import { OrderManagement } from "@/components/order-management" import { OrderManagement } from '@/components/order-management'
export default function AdminOrdersPage() { export default function AdminOrdersPage() {
return ( return (
@ -9,6 +9,6 @@ export default function AdminOrdersPage() {
} }
export const metadata = { export const metadata = {
title: "Order Management | Admin", title: 'Order Management | Admin',
description: "View and manage customer orders", description: 'View and manage customer orders',
} }

View file

@ -1,32 +1,22 @@
import Link from "next/link" import Link from 'next/link'
import { fetchQuery } from "convex/nextjs" import { Button } from '@/components/ui/button'
import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { api } from "@/convex/_generated/api" import { Badge } from '@/components/ui/badge'
import { import {
Card, ShoppingCart,
CardContent, Package,
CardDescription, Users,
CardHeader, TrendingUp,
CardTitle, DollarSign,
} from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import {
ShoppingCart,
Package,
Users,
TrendingUp,
DollarSign,
Clock, Clock,
CheckCircle, CheckCircle,
Truck, Truck,
AlertTriangle, AlertTriangle,
Settings, Settings,
BarChart3, BarChart3,
Phone, Phone
MessageSquare, } from 'lucide-react'
ContactRound, import { fetchAllProducts } from '@/lib/stripe/products'
} from "lucide-react"
import { fetchAllProducts } from "@/lib/stripe/products"
// Mock analytics data for demo // Mock analytics data for demo
const mockAnalytics = { const mockAnalytics = {
@ -37,7 +27,7 @@ const mockAnalytics = {
lowStockProducts: 3, lowStockProducts: 3,
avgOrderValue: 311.46, avgOrderValue: 311.46,
conversionRate: 2.8, conversionRate: 2.8,
monthlyGrowth: 15.2, monthlyGrowth: 15.2
} }
async function getProductsCount() { async function getProductsCount() {
@ -51,7 +41,7 @@ async function getProductsCount() {
async function getOrdersCount() { async function getOrdersCount() {
try { try {
const response = await fetch("/api/orders") const response = await fetch('/api/orders')
if (response.ok) { if (response.ok) {
const data = await response.json() const data = await response.json()
return data.pagination.total || 0 return data.pagination.total || 0
@ -60,145 +50,130 @@ async function getOrdersCount() {
return mockAnalytics.totalOrders 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() { export default async function AdminDashboard() {
const [productsCount, ordersCount, sync] = await Promise.all([ const [productsCount, ordersCount] = await Promise.all([
getProductsCount(), getProductsCount(),
getOrdersCount(), getOrdersCount()
fetchQuery(api.crm.getAdminSyncOverview, {}),
]) ])
const dashboardCards = [ const dashboardCards = [
{ {
title: "Total Revenue", title: 'Total Revenue',
value: `$${mockAnalytics.totalRevenue.toLocaleString()}`, value: `$${mockAnalytics.totalRevenue.toLocaleString()}`,
description: "Total revenue from all orders", description: 'Total revenue from all orders',
icon: DollarSign, icon: DollarSign,
trend: "+15.2%", trend: '+15.2%',
trendPositive: true, trendPositive: true,
color: "text-green-600", color: 'text-green-600'
}, },
{ {
title: "Total Orders", title: 'Total Orders',
value: mockAnalytics.totalOrders.toString(), value: mockAnalytics.totalOrders.toString(),
description: "Total number of orders", description: 'Total number of orders',
icon: ShoppingCart, icon: ShoppingCart,
trend: "+12.8%", trend: '+12.8%',
trendPositive: true, trendPositive: true,
color: "text-blue-600", color: 'text-blue-600'
}, },
{ {
title: "Products", title: 'Products',
value: productsCount.toString(), value: productsCount.toString(),
description: "Active products in inventory", description: 'Active products in inventory',
icon: Package, icon: Package,
trend: "+5", trend: '+5',
trendPositive: true, trendPositive: true,
color: "text-purple-600", color: 'text-purple-600'
}, },
{ {
title: "Pending Orders", title: 'Pending Orders',
value: mockAnalytics.pendingOrders.toString(), value: mockAnalytics.pendingOrders.toString(),
description: "Orders awaiting processing", description: 'Orders awaiting processing',
icon: Clock, icon: Clock,
trend: "-3", trend: '-3',
trendPositive: false, trendPositive: false,
color: "text-orange-600", color: 'text-orange-600'
}, }
] ]
const quickStats = [ const quickStats = [
{ {
title: "Average Order Value", title: 'Average Order Value',
value: `$${mockAnalytics.avgOrderValue.toFixed(2)}`, value: `$${mockAnalytics.avgOrderValue.toFixed(2)}`,
description: "Average value per order", description: 'Average value per order',
icon: TrendingUp, icon: TrendingUp
}, },
{ {
title: "Conversion Rate", title: 'Conversion Rate',
value: `${mockAnalytics.conversionRate}%`, value: `${mockAnalytics.conversionRate}%`,
description: "Visitors to orders ratio", description: 'Visitors to orders ratio',
icon: Users, icon: Users
}, },
{ {
title: "Monthly Growth", title: 'Monthly Growth',
value: `${mockAnalytics.monthlyGrowth}%`, value: `${mockAnalytics.monthlyGrowth}%`,
description: "Revenue growth this month", description: 'Revenue growth this month',
icon: BarChart3, icon: BarChart3
}, },
{ {
title: "Low Stock Alert", title: 'Low Stock Alert',
value: mockAnalytics.lowStockProducts.toString(), value: mockAnalytics.lowStockProducts.toString(),
description: "Products need restocking", description: 'Products need restocking',
icon: AlertTriangle, icon: AlertTriangle
}, }
] ]
const recentOrders = [ const recentOrders = [
{ {
id: "ORD-001234", id: 'ORD-001234',
customer: "john.doe@email.com", customer: 'john.doe@email.com',
amount: 2799.98, amount: 2799.98,
status: "paid", status: 'paid',
date: "2024-01-15 10:30", date: '2024-01-15 10:30'
}, },
{ {
id: "ORD-001233", id: 'ORD-001233',
customer: "jane.smith@email.com", customer: 'jane.smith@email.com',
amount: 1499.99, amount: 1499.99,
status: "fulfilled", status: 'fulfilled',
date: "2024-01-15 09:45", date: '2024-01-15 09:45'
}, },
{ {
id: "ORD-001232", id: 'ORD-001232',
customer: "bob.johnson@email.com", customer: 'bob.johnson@email.com',
amount: 899.97, amount: 899.97,
status: "pending", status: 'pending',
date: "2024-01-15 08:20", date: '2024-01-15 08:20'
}, },
{ {
id: "ORD-001231", id: 'ORD-001231',
customer: "alice.wilson@email.com", customer: 'alice.wilson@email.com',
amount: 3499.99, amount: 3499.99,
status: "cancelled", status: 'cancelled',
date: "2024-01-14 16:15", date: '2024-01-14 16:15'
}, }
] ]
const popularProducts = [ const popularProducts = [
{ {
name: "SEAGA HY900 Vending Machine", name: 'SEAGA HY900 Vending Machine',
orders: 45, orders: 45,
revenue: 112499.55, revenue: 112499.55
}, },
{ {
name: "Vending Machine Stand", name: 'Vending Machine Stand',
orders: 38, orders: 38,
revenue: 11399.62, revenue: 11399.62
}, },
{ {
name: "Snack Vending Machine Combo", name: 'Snack Vending Machine Combo',
orders: 23, orders: 23,
revenue: 45999.77, revenue: 45999.77
}, },
{ {
name: "Drink Vending Machine", name: 'Drink Vending Machine',
orders: 19, orders: 19,
revenue: 37999.81, revenue: 37999.81
}, }
] ]
return ( return (
@ -207,26 +182,12 @@ export default async function AdminDashboard() {
{/* Header */} {/* Header */}
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<div> <div>
<h1 className="text-4xl md:text-5xl font-bold tracking-tight text-balance"> <h1 className="text-4xl md:text-5xl font-bold tracking-tight text-balance">Admin Dashboard</h1>
Admin Dashboard
</h1>
<p className="text-muted-foreground mt-2"> <p className="text-muted-foreground mt-2">
Manage orders, contacts, conversations, and calls Overview of your store performance and management tools
</p> </p>
</div> </div>
<div className="flex gap-2"> <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"> <Link href="/admin/calls">
<Button variant="outline"> <Button variant="outline">
<Phone className="h-4 w-4 mr-2" /> <Phone className="h-4 w-4 mr-2" />
@ -243,25 +204,6 @@ export default async function AdminDashboard() {
</div> </div>
</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 */} {/* Main Stats */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{dashboardCards.map((card, index) => { {dashboardCards.map((card, index) => {
@ -278,7 +220,7 @@ export default async function AdminDashboard() {
<div className="text-2xl font-bold">{card.value}</div> <div className="text-2xl font-bold">{card.value}</div>
<div className="flex items-center gap-1 mt-2"> <div className="flex items-center gap-1 mt-2">
<span className={`text-sm ${card.color}`}> <span className={`text-sm ${card.color}`}>
{card.trend} {card.trendPositive ? "↑" : "↓"} {card.trend} {card.trendPositive ? '↑' : '↓'}
</span> </span>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
from last month from last month
@ -324,9 +266,7 @@ export default async function AdminDashboard() {
<CardTitle className="flex items-center justify-between"> <CardTitle className="flex items-center justify-between">
Recent Orders Recent Orders
<Link href="/admin/orders"> <Link href="/admin/orders">
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">View All</Button>
View All
</Button>
</Link> </Link>
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
@ -336,10 +276,7 @@ export default async function AdminDashboard() {
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
{recentOrders.map((order) => ( {recentOrders.map((order) => (
<div <div key={order.id} className="flex items-center justify-between py-3 border-b last:border-b-0">
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="flex items-center gap-3">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="font-medium">{order.id}</div> <div className="font-medium">{order.id}</div>
@ -352,23 +289,16 @@ export default async function AdminDashboard() {
</div> </div>
</div> </div>
<div className="text-right"> <div className="text-right">
<div className="font-medium"> <div className="font-medium">${order.amount.toFixed(2)}</div>
${order.amount.toFixed(2)}
</div>
<Badge <Badge
variant={ variant={
order.status === "paid" order.status === 'paid' ? 'default' :
? "default" order.status === 'fulfilled' ? 'default' :
: order.status === "fulfilled" order.status === 'pending' ? 'secondary' : 'destructive'
? "default"
: order.status === "pending"
? "secondary"
: "destructive"
} }
className="mt-1" className="mt-1"
> >
{order.status.charAt(0).toUpperCase() + {order.status.charAt(0).toUpperCase() + order.status.slice(1)}
order.status.slice(1)}
</Badge> </Badge>
</div> </div>
</div> </div>
@ -383,20 +313,17 @@ export default async function AdminDashboard() {
<CardTitle className="flex items-center justify-between"> <CardTitle className="flex items-center justify-between">
Popular Products Popular Products
<Link href="/admin/products"> <Link href="/admin/products">
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">View All</Button>
View All
</Button>
</Link> </Link>
</CardTitle> </CardTitle>
<CardDescription>Top-selling products this month</CardDescription> <CardDescription>
Top-selling products this month
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
{popularProducts.map((product, index) => ( {popularProducts.map((product, index) => (
<div <div key={index} className="flex items-center justify-between py-3 border-b last:border-b-0">
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="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"> <div className="w-8 h-8 rounded-md bg-muted flex items-center justify-center text-xs font-bold text-muted-foreground">
{index + 1} {index + 1}
@ -411,9 +338,7 @@ export default async function AdminDashboard() {
</div> </div>
</div> </div>
<div className="text-right"> <div className="text-right">
<div className="font-medium"> <div className="font-medium">${product.revenue.toLocaleString()}</div>
${product.revenue.toLocaleString()}
</div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
${(product.revenue / product.orders).toFixed(2)} avg ${(product.revenue / product.orders).toFixed(2)} avg
</div> </div>
@ -446,7 +371,7 @@ export default async function AdminDashboard() {
</CardContent> </CardContent>
</Card> </Card>
</Link> </Link>
<Link href="/admin/products"> <Link href="/admin/products">
<Card className="h-full cursor-pointer hover:shadow-md transition-shadow"> <Card className="h-full cursor-pointer hover:shadow-md transition-shadow">
<CardContent className="p-6 flex flex-col items-center text-center"> <CardContent className="p-6 flex flex-col items-center text-center">
@ -458,7 +383,7 @@ export default async function AdminDashboard() {
</CardContent> </CardContent>
</Card> </Card>
</Link> </Link>
<Link href="/orders"> <Link href="/orders">
<Card className="h-full cursor-pointer hover:shadow-md transition-shadow"> <Card className="h-full cursor-pointer hover:shadow-md transition-shadow">
<CardContent className="p-6 flex flex-col items-center text-center"> <CardContent className="p-6 flex flex-col items-center text-center">
@ -470,7 +395,7 @@ export default async function AdminDashboard() {
</CardContent> </CardContent>
</Card> </Card>
</Link> </Link>
<Card className="h-full hover:shadow-md transition-shadow"> <Card className="h-full hover:shadow-md transition-shadow">
<CardContent className="p-6 flex flex-col items-center text-center"> <CardContent className="p-6 flex flex-col items-center text-center">
<CheckCircle className="h-8 w-8 text-orange-600 mb-3" /> <CheckCircle className="h-8 w-8 text-orange-600 mb-3" />
@ -489,7 +414,6 @@ export default async function AdminDashboard() {
} }
export const metadata = { export const metadata = {
title: "Admin Dashboard | Rocky Mountain Vending", title: 'Admin Dashboard | Rocky Mountain Vending',
description: description: 'Administrative dashboard for managing your vending machine business',
"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() { export default function AdminProductsPage() {
return ( return (
@ -9,6 +9,6 @@ export default function AdminProductsPage() {
} }
export const metadata = { export const metadata = {
title: "Product Management | Admin", title: 'Product Management | Admin',
description: "Manage your Stripe products and inventory", 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 { NextResponse } from "next/server";
import { fetchQuery } from "convex/nextjs" import { fetchQuery } from "convex/nextjs";
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api";
import { requireAdminToken } from "@/lib/server/admin-auth" import { requireAdminToken } from "@/lib/server/admin-auth";
type RouteContext = { type RouteContext = {
params: Promise<{ params: Promise<{
id: string id: string;
}> }>;
} };
export async function GET(request: Request, { params }: RouteContext) { export async function GET(request: Request, { params }: RouteContext) {
const authError = requireAdminToken(request) const authError = requireAdminToken(request);
if (authError) { if (authError) {
return authError return authError;
} }
try { try {
const { id } = await params const { id } = await params;
const detail = await fetchQuery(api.voiceSessions.getAdminPhoneCallDetail, { const detail = await fetchQuery(api.voiceSessions.getAdminPhoneCallDetail, {
callId: id, callId: id,
}) });
if (!detail) { if (!detail) {
return NextResponse.json( return NextResponse.json({ error: "Phone call not found" }, { status: 404 });
{ error: "Phone call not found" },
{ status: 404 }
)
} }
return NextResponse.json(detail) return NextResponse.json(detail);
} catch (error) { } catch (error) {
console.error("Failed to load admin phone call detail:", error) console.error("Failed to load admin phone call detail:", error);
return NextResponse.json( return NextResponse.json({ error: "Failed to load phone call detail" }, { status: 500 });
{ error: "Failed to load phone call detail" },
{ status: 500 }
)
} }
} }

View file

@ -1,37 +1,31 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server";
import { fetchQuery } from "convex/nextjs" import { fetchQuery } from "convex/nextjs";
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api";
import { requireAdminToken } from "@/lib/server/admin-auth" import { requireAdminToken } from "@/lib/server/admin-auth";
export async function GET(request: Request) { export async function GET(request: Request) {
const authError = requireAdminToken(request) const authError = requireAdminToken(request);
if (authError) { if (authError) {
return authError return authError;
} }
try { try {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url);
const search = searchParams.get("search")?.trim() || undefined const search = searchParams.get("search")?.trim() || undefined;
const status = searchParams.get("status") const status = searchParams.get("status");
const page = Number.parseInt(searchParams.get("page") || "1", 10) || 1 const page = Number.parseInt(searchParams.get("page") || "1", 10) || 1;
const limit = Number.parseInt(searchParams.get("limit") || "25", 10) || 25 const limit = Number.parseInt(searchParams.get("limit") || "25", 10) || 25;
const data = await fetchQuery(api.voiceSessions.listAdminPhoneCalls, { const data = await fetchQuery(api.voiceSessions.listAdminPhoneCalls, {
search, search,
status: status: status === "started" || status === "completed" || status === "failed" ? status : undefined,
status === "started" || status === "completed" || status === "failed"
? status
: undefined,
page, page,
limit, limit,
}) });
return NextResponse.json(data) return NextResponse.json(data);
} catch (error) { } catch (error) {
console.error("Failed to load admin phone calls:", error) console.error("Failed to load admin phone calls:", error);
return NextResponse.json( return NextResponse.json({ error: "Failed to load phone calls" }, { status: 500 });
{ 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, SITE_CHAT_TEMPERATURE,
isSiteChatSuppressedRoute, isSiteChatSuppressedRoute,
} from "@/lib/site-chat/config" } from "@/lib/site-chat/config"
import { buildSiteChatSystemPrompt } from "@/lib/site-chat/prompt" import { SITE_CHAT_SYSTEM_PROMPT } from "@/lib/site-chat/prompt"
import { import { consumeChatOutput, consumeChatRequest, getChatRateLimitStatus } from "@/lib/site-chat/rate-limit"
consumeChatOutput,
consumeChatRequest,
getChatRateLimitStatus,
} from "@/lib/site-chat/rate-limit"
import {
formatManualContextForPrompt,
retrieveManualContext,
shouldUseManualKnowledgeForChat,
summarizeManualRetrieval,
} from "@/lib/manuals-knowledge"
import { createSmsConsentPayload } from "@/lib/sms-compliance" import { createSmsConsentPayload } from "@/lib/sms-compliance"
type ChatRole = "user" | "assistant" type ChatRole = "user" | "assistant"
@ -91,10 +81,7 @@ function normalizeSessionId(rawSessionId: string | undefined | null) {
} }
function normalizePathname(rawPathname: string | undefined) { function normalizePathname(rawPathname: string | undefined) {
const pathname = const pathname = typeof rawPathname === "string" && rawPathname.trim() ? rawPathname.trim() : "/"
typeof rawPathname === "string" && rawPathname.trim()
? rawPathname.trim()
: "/"
return pathname.startsWith("/") ? pathname : `/${pathname}` return pathname.startsWith("/") ? pathname : `/${pathname}`
} }
@ -102,46 +89,24 @@ function normalizeMessages(messages: ChatMessage[] | undefined) {
const safeMessages = Array.isArray(messages) ? messages : [] const safeMessages = Array.isArray(messages) ? messages : []
return safeMessages return safeMessages
.filter( .filter((message) => message && (message.role === "user" || message.role === "assistant"))
(message) =>
message && (message.role === "user" || message.role === "assistant")
)
.map((message) => ({ .map((message) => ({
role: message.role, role: message.role,
content: String(message.content || "") content: String(message.content || "").replace(/\s+/g, " ").trim().slice(0, SITE_CHAT_MAX_MESSAGE_CHARS),
.replace(/\s+/g, " ")
.trim()
.slice(0, SITE_CHAT_MAX_MESSAGE_CHARS),
})) }))
.filter((message) => message.content.length > 0) .filter((message) => message.content.length > 0)
.slice(-SITE_CHAT_MAX_HISTORY_MESSAGES) .slice(-SITE_CHAT_MAX_HISTORY_MESSAGES)
} }
function normalizeVisitorProfile( function normalizeVisitorProfile(rawVisitor: ChatRequestBody["visitor"], pathname: string): ChatVisitorProfile | null {
rawVisitor: ChatRequestBody["visitor"],
pathname: string
): ChatVisitorProfile | null {
if (!rawVisitor) { if (!rawVisitor) {
return null return null
} }
const name = String(rawVisitor.name || "") const name = String(rawVisitor.name || "").replace(/\s+/g, " ").trim().slice(0, 80)
.replace(/\s+/g, " ") const phone = String(rawVisitor.phone || "").replace(/\s+/g, " ").trim().slice(0, 40)
.trim() const email = String(rawVisitor.email || "").replace(/\s+/g, " ").trim().slice(0, 120).toLowerCase()
.slice(0, 80) const intent = String(rawVisitor.intent || "").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) { if (!name || !phone || !email || !intent) {
return null return null
@ -214,15 +179,6 @@ function extractAssistantText(data: any) {
return "" 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) { export async function POST(request: NextRequest) {
const responseHeaders: Record<string, string> = { const responseHeaders: Record<string, string> = {
"Cache-Control": "no-store", "Cache-Control": "no-store",
@ -234,36 +190,25 @@ export async function POST(request: NextRequest) {
const visitor = normalizeVisitorProfile(body.visitor, pathname) const visitor = normalizeVisitorProfile(body.visitor, pathname)
if (isSiteChatSuppressedRoute(pathname)) { if (isSiteChatSuppressedRoute(pathname)) {
return NextResponse.json( return NextResponse.json({ error: "Chat is not available on this route." }, { status: 403, headers: responseHeaders })
{ error: "Chat is not available on this route." },
{ status: 403, headers: responseHeaders }
)
} }
if (!visitor) { if (!visitor) {
return NextResponse.json( return NextResponse.json(
{ {
error: error: "Name, phone, email, intent, and required service SMS consent are needed to start chat.",
"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( const sessionId = normalizeSessionId(body.sessionId || request.cookies.get(SITE_CHAT_SESSION_COOKIE)?.value)
body.sessionId || request.cookies.get(SITE_CHAT_SESSION_COOKIE)?.value
)
const ip = getClientIp(request) const ip = getClientIp(request)
const messages = normalizeMessages(body.messages) const messages = normalizeMessages(body.messages)
const latestUserMessage = [...messages] const latestUserMessage = [...messages].reverse().find((message) => message.role === "user")
.reverse()
.find((message) => message.role === "user")
if (!latestUserMessage) { if (!latestUserMessage) {
return NextResponse.json( return NextResponse.json({ error: "A user message is required.", sessionId }, { status: 400, headers: responseHeaders })
{ error: "A user message is required.", sessionId },
{ status: 400, headers: responseHeaders }
)
} }
if (latestUserMessage.content.length > SITE_CHAT_MAX_INPUT_CHARS) { 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.`, error: `Please keep each message under ${SITE_CHAT_MAX_INPUT_CHARS} characters.`,
sessionId, sessionId,
}, },
{ status: 400, headers: responseHeaders } { status: 400, headers: responseHeaders },
) )
} }
@ -289,12 +234,11 @@ export async function POST(request: NextRequest) {
if (limitStatus.blocked) { if (limitStatus.blocked) {
const blockedResponse = NextResponse.json( const blockedResponse = NextResponse.json(
{ {
error: error: "Chat is temporarily limited right now. Please wait a bit or call Rocky Mountain Vending directly.",
"Chat is temporarily limited right now. Please wait a bit or call Rocky Mountain Vending directly.",
sessionId, sessionId,
limits: limitStatus, limits: limitStatus,
}, },
{ status: 429, headers: responseHeaders } { status: 429, headers: responseHeaders },
) )
blockedResponse.cookies.set(SITE_CHAT_SESSION_COOKIE, sessionId, { blockedResponse.cookies.set(SITE_CHAT_SESSION_COOKIE, sessionId, {
@ -308,41 +252,7 @@ export async function POST(request: NextRequest) {
return blockedResponse return blockedResponse
} }
consumeChatRequest({ consumeChatRequest({ ip, requestWindowMs: SITE_CHAT_REQUEST_WINDOW_MS, sessionId })
ip,
requestWindowMs: SITE_CHAT_REQUEST_WINDOW_MS,
sessionId,
})
const manualKnowledgeQuery = buildManualKnowledgeQuery(messages)
const shouldUseManualKnowledge = shouldUseManualKnowledgeForChat(
visitor.intent,
manualKnowledgeQuery
)
let manualKnowledge = null
let manualKnowledgeError: unknown = null
if (shouldUseManualKnowledge) {
try {
manualKnowledge = await retrieveManualContext(manualKnowledgeQuery)
} catch (error) {
manualKnowledgeError = error
console.error("[site-chat] manuals knowledge lookup failed", {
pathname,
sessionId,
error,
})
}
}
console.info(
"[site-chat] manuals retrieval",
summarizeManualRetrieval({
ran: shouldUseManualKnowledge,
query: manualKnowledgeQuery,
result: manualKnowledge,
error: manualKnowledgeError,
})
)
const systemPrompt = buildSiteChatSystemPrompt()
const xaiApiKey = getOptionalEnv("XAI_API_KEY") const xaiApiKey = getOptionalEnv("XAI_API_KEY")
if (!xaiApiKey) { if (!xaiApiKey) {
@ -353,46 +263,32 @@ export async function POST(request: NextRequest) {
return NextResponse.json( return NextResponse.json(
{ {
error: error: "Jessica is temporarily unavailable right now. Please call us or use the contact form.",
"Jessica is temporarily unavailable right now. Please call us or use the contact form.",
sessionId, sessionId,
}, },
{ status: 503, headers: responseHeaders } { status: 503, headers: responseHeaders },
) )
} }
const completionResponse = await fetch( const completionResponse = await fetch("https://api.x.ai/v1/chat/completions", {
"https://api.x.ai/v1/chat/completions", method: "POST",
{ headers: {
method: "POST", Authorization: `Bearer ${xaiApiKey}`,
headers: { "Content-Type": "application/json",
Authorization: `Bearer ${xaiApiKey}`, },
"Content-Type": "application/json", body: JSON.stringify({
}, model: SITE_CHAT_MODEL,
body: JSON.stringify({ temperature: SITE_CHAT_TEMPERATURE,
model: SITE_CHAT_MODEL, max_tokens: SITE_CHAT_MAX_OUTPUT_TOKENS,
temperature: SITE_CHAT_TEMPERATURE, messages: [
max_tokens: SITE_CHAT_MAX_OUTPUT_TOKENS, {
messages: [ role: "system",
{ content: `${SITE_CHAT_SYSTEM_PROMPT}\n\nConversation context:\n- Current pathname: ${pathname}\n- Source: ${SITE_CHAT_SOURCE}\n- Visitor name: ${visitor.name}\n- Visitor email: ${visitor.email}\n- Visitor phone: ${visitor.phone}\n- Visitor intent: ${visitor.intent}\n- Service SMS consent: ${visitor.serviceTextConsent ? "yes" : "no"}\n- Marketing SMS consent: ${visitor.marketingTextConsent ? "yes" : "no"}`,
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"}`, ...messages,
}, ],
...(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(() => ({})) const completionData = await completionResponse.json().catch(() => ({}))
@ -406,11 +302,10 @@ export async function POST(request: NextRequest) {
return NextResponse.json( return NextResponse.json(
{ {
error: error: "Jessica is having trouble replying right now. Please try again or call us directly.",
"Jessica is having trouble replying right now. Please try again or call us directly.",
sessionId, 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.", error: "Jessica did not return a usable reply. Please try again.",
sessionId, sessionId,
}, },
{ status: 502, headers: responseHeaders } { status: 502, headers: responseHeaders },
) )
} }
consumeChatOutput({ consumeChatOutput({ chars: assistantReply.length, outputWindowMs: SITE_CHAT_OUTPUT_WINDOW_MS, sessionId })
chars: assistantReply.length,
outputWindowMs: SITE_CHAT_OUTPUT_WINDOW_MS,
sessionId,
})
const nextLimitStatus = getChatRateLimitStatus({ const nextLimitStatus = getChatRateLimitStatus({
ip, ip,
@ -448,7 +339,7 @@ export async function POST(request: NextRequest) {
sessionId, sessionId,
limits: nextLimitStatus, limits: nextLimitStatus,
}, },
{ headers: responseHeaders } { headers: responseHeaders },
) )
response.cookies.set(SITE_CHAT_SESSION_COOKIE, sessionId, { 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) console.error("[site-chat] request failed", error)
const safeError = const safeError =
error instanceof Error && error instanceof Error && error.message.startsWith("Missing required site chat environment variable:")
error.message.startsWith(
"Missing required site chat environment variable:"
)
? "Jessica is temporarily unavailable right now. Please call us or use the contact form." ? "Jessica is temporarily unavailable right now. Please call us or use the contact form."
: error instanceof Error : error instanceof Error
? error.message ? error.message
@ -477,7 +365,7 @@ export async function POST(request: NextRequest) {
{ {
error: safeError, error: safeError,
}, },
{ status: 500, headers: responseHeaders } { status: 500, headers: responseHeaders },
) )
} }
} }

View file

@ -1,13 +1,13 @@
import assert from "node:assert/strict" import assert from "node:assert/strict";
import test from "node:test" import test from "node:test";
import { import {
processLeadSubmission, processLeadSubmission,
type ContactLeadPayload, type ContactLeadPayload,
type RequestMachineLeadPayload, type RequestMachineLeadPayload,
} from "@/lib/server/contact-submission" } from "@/lib/server/contact-submission";
test("processLeadSubmission stores and syncs a contact lead", async () => { test("processLeadSubmission stores and syncs a contact lead", async () => {
const calls: string[] = [] const calls: string[] = [];
const payload: ContactLeadPayload = { const payload: ContactLeadPayload = {
kind: "contact", kind: "contact",
firstName: "John", firstName: "John",
@ -26,7 +26,7 @@ test("processLeadSubmission stores and syncs a contact lead", async () => {
page: "/contact", page: "/contact",
timestamp: "2026-03-25T00:00:00.000Z", timestamp: "2026-03-25T00:00:00.000Z",
url: "https://rmv.example/contact", url: "https://rmv.example/contact",
} };
const result = await processLeadSubmission(payload, "rmv.example", { const result = await processLeadSubmission(payload, "rmv.example", {
storageConfigured: true, storageConfigured: true,
@ -36,37 +36,37 @@ test("processLeadSubmission stores and syncs a contact lead", async () => {
tenantName: "Rocky Mountain Vending", tenantName: "Rocky Mountain Vending",
tenantDomains: ["rockymountainvending.com"], tenantDomains: ["rockymountainvending.com"],
ingest: async () => { ingest: async () => {
calls.push("ingest") calls.push("ingest");
return { return {
inserted: true, inserted: true,
leadId: "lead_123", leadId: "lead_123",
idempotencyKey: "abc", idempotencyKey: "abc",
tenantId: "tenant_123", tenantId: "tenant_123",
} };
}, },
updateLeadStatus: async () => { updateLeadStatus: async () => {
calls.push("update") calls.push("update");
return { ok: true } return { ok: true };
}, },
sendEmail: async () => { sendEmail: async () => {
calls.push("email") calls.push("email");
return {} return {};
}, },
createContact: async () => { createContact: async () => {
calls.push("ghl") calls.push("ghl");
return { contact: { id: "ghl_123" } } return { contact: { id: "ghl_123" } };
}, },
logger: console, logger: console,
}) });
assert.equal(result.status, 200) assert.equal(result.status, 200);
assert.equal(result.body.success, true) assert.equal(result.body.success, true);
assert.deepEqual(result.body.deliveredVia, ["convex", "email", "ghl"]) assert.deepEqual(result.body.deliveredVia, ["convex", "email", "ghl"]);
assert.equal(calls.filter((call) => call === "email").length, 2) assert.equal(calls.filter((call) => call === "email").length, 2);
assert.ok(calls.includes("ingest")) assert.ok(calls.includes("ingest"));
assert.ok(calls.includes("update")) assert.ok(calls.includes("update"));
assert.ok(calls.includes("ghl")) assert.ok(calls.includes("ghl"));
}) });
test("processLeadSubmission validates request-machine submissions", async () => { test("processLeadSubmission validates request-machine submissions", async () => {
const payload: RequestMachineLeadPayload = { const payload: RequestMachineLeadPayload = {
@ -84,7 +84,7 @@ test("processLeadSubmission validates request-machine submissions", async () =>
consentVersion: "sms-consent-v1-2026-03-26", consentVersion: "sms-consent-v1-2026-03-26",
consentCapturedAt: "2026-03-25T00:00:00.000Z", consentCapturedAt: "2026-03-25T00:00:00.000Z",
consentSourcePage: "/", consentSourcePage: "/",
} };
const result = await processLeadSubmission(payload, "rmv.example", { const result = await processLeadSubmission(payload, "rmv.example", {
storageConfigured: false, storageConfigured: false,
@ -94,24 +94,24 @@ test("processLeadSubmission validates request-machine submissions", async () =>
tenantName: "Rocky Mountain Vending", tenantName: "Rocky Mountain Vending",
tenantDomains: [], tenantDomains: [],
ingest: async () => { ingest: async () => {
throw new Error("should not run") throw new Error("should not run");
}, },
updateLeadStatus: async () => { updateLeadStatus: async () => {
throw new Error("should not run") throw new Error("should not run");
}, },
sendEmail: async () => { sendEmail: async () => {
throw new Error("should not run") throw new Error("should not run");
}, },
createContact: async () => { createContact: async () => {
throw new Error("should not run") throw new Error("should not run");
}, },
logger: console, logger: console,
}) });
assert.equal(result.status, 400) assert.equal(result.status, 400);
assert.equal(result.body.success, false) assert.equal(result.body.success, false);
assert.match(result.body.error || "", /Invalid number of employees/) assert.match(result.body.error || "", /Invalid number of employees/);
}) });
test("processLeadSubmission returns deduped success when Convex already has the lead", async () => { test("processLeadSubmission returns deduped success when Convex already has the lead", async () => {
const payload: ContactLeadPayload = { const payload: ContactLeadPayload = {
@ -126,7 +126,7 @@ test("processLeadSubmission returns deduped success when Convex already has the
consentVersion: "sms-consent-v1-2026-03-26", consentVersion: "sms-consent-v1-2026-03-26",
consentCapturedAt: "2026-03-25T00:00:00.000Z", consentCapturedAt: "2026-03-25T00:00:00.000Z",
consentSourcePage: "/contact-us", consentSourcePage: "/contact-us",
} };
const result = await processLeadSubmission(payload, "rmv.example", { const result = await processLeadSubmission(payload, "rmv.example", {
storageConfigured: true, storageConfigured: true,
@ -145,10 +145,10 @@ test("processLeadSubmission returns deduped success when Convex already has the
sendEmail: async () => ({}), sendEmail: async () => ({}),
createContact: async () => null, createContact: async () => null,
logger: console, logger: console,
}) });
assert.equal(result.status, 200) assert.equal(result.status, 200);
assert.equal(result.body.success, true) assert.equal(result.body.success, true);
assert.equal(result.body.deduped, true) assert.equal(result.body.deduped, true);
assert.deepEqual(result.body.deliveredVia, ["convex"]) 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) { 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 { NextResponse } from "next/server";
import { fetchMutation, fetchQuery } from "convex/nextjs" import { fetchMutation, fetchQuery } from "convex/nextjs";
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api";
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared" import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared";
import { import { buildPhoneCallSummary, sendPhoneCallSummaryEmail } from "@/lib/phone-calls";
buildPhoneCallSummary,
sendPhoneCallSummaryEmail,
} from "@/lib/phone-calls"
export async function POST(request: Request) { export async function POST(request: Request) {
const authError = await requirePhoneAgentInternalAuth(request) const authError = await requirePhoneAgentInternalAuth(request);
if (authError) { if (authError) {
return authError return authError;
} }
try { try {
const body = await request.json() const body = await request.json();
const callId = String(body.sessionId || body.roomName || "") const callId = String(body.sessionId || body.roomName || "");
if (!callId) { if (!callId) {
return NextResponse.json( return NextResponse.json({ error: "sessionId or roomName is required" }, { status: 400 });
{ error: "sessionId or roomName is required" },
{ status: 400 }
)
} }
const detail = await fetchQuery(api.voiceSessions.getAdminPhoneCallDetail, { const detail = await fetchQuery(api.voiceSessions.getAdminPhoneCallDetail, {
callId, callId,
}) });
if (!detail) { if (!detail) {
return NextResponse.json( return NextResponse.json({ error: "Phone call not found" }, { status: 404 });
{ error: "Phone call not found" },
{ status: 404 }
)
} }
const url = new URL(request.url) const url = new URL(request.url);
const summaryText = buildPhoneCallSummary(detail) const summaryText = buildPhoneCallSummary(detail);
const notificationResult = await sendPhoneCallSummaryEmail({ const notificationResult = await sendPhoneCallSummaryEmail({
detail: { detail: {
...detail, ...detail,
@ -45,7 +36,7 @@ export async function POST(request: Request) {
}, },
}, },
adminUrl: url.origin, adminUrl: url.origin,
}) });
const result = await fetchMutation(api.voiceSessions.completeSession, { const result = await fetchMutation(api.voiceSessions.completeSession, {
sessionId: detail.call.id, sessionId: detail.call.id,
@ -54,26 +45,20 @@ export async function POST(request: Request) {
recordingStatus: body.recordingStatus, recordingStatus: body.recordingStatus,
recordingId: body.recordingId ? String(body.recordingId) : undefined, recordingId: body.recordingId ? String(body.recordingId) : undefined,
recordingUrl: body.recordingUrl ? String(body.recordingUrl) : undefined, recordingUrl: body.recordingUrl ? String(body.recordingUrl) : undefined,
recordingError: body.recordingError recordingError: body.recordingError ? String(body.recordingError) : undefined,
? String(body.recordingError)
: undefined,
summaryText, summaryText,
notificationStatus: notificationResult.status, notificationStatus: notificationResult.status,
notificationSentAt: notificationSentAt: notificationResult.status === "sent" ? Date.now() : undefined,
notificationResult.status === "sent" ? Date.now() : undefined,
notificationError: notificationResult.error, notificationError: notificationResult.error,
}) });
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
call: result, call: result,
notification: notificationResult, notification: notificationResult,
}) });
} catch (error) { } catch (error) {
console.error("Failed to complete phone call sync:", error) console.error("Failed to complete phone call sync:", error);
return NextResponse.json( return NextResponse.json({ error: "Failed to complete phone call sync" }, { status: 500 });
{ error: "Failed to complete phone call sync" },
{ status: 500 }
)
} }
} }

View file

@ -1,77 +1,27 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server";
import { fetchMutation } from "convex/nextjs" import { fetchMutation } from "convex/nextjs";
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api";
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared" import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared";
export async function POST(request: Request) { export async function POST(request: Request) {
const authError = await requirePhoneAgentInternalAuth(request) const authError = await requirePhoneAgentInternalAuth(request);
if (authError) { if (authError) {
return authError return authError;
} }
try { try {
const body = await request.json() const body = await request.json();
const result = await fetchMutation(api.voiceSessions.linkPhoneCallLead, { const result = await fetchMutation(api.voiceSessions.linkPhoneCallLead, {
sessionId: body.sessionId, sessionId: body.sessionId,
linkedLeadId: body.linkedLeadId ? String(body.linkedLeadId) : undefined, 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", leadOutcome: body.leadOutcome || "none",
handoffRequested: handoffRequested: typeof body.handoffRequested === "boolean" ? body.handoffRequested : undefined,
typeof body.handoffRequested === "boolean" handoffReason: body.handoffReason ? String(body.handoffReason) : undefined,
? body.handoffRequested });
: undefined,
handoffReason: body.handoffReason
? String(body.handoffReason)
: undefined,
reminderStatus: body.reminderStatus || undefined,
reminderRequestedAt:
typeof body.reminderRequestedAt === "number"
? body.reminderRequestedAt
: undefined,
reminderStartAt:
typeof body.reminderStartAt === "number"
? body.reminderStartAt
: undefined,
reminderEndAt:
typeof body.reminderEndAt === "number"
? body.reminderEndAt
: undefined,
reminderCalendarEventId: body.reminderCalendarEventId
? String(body.reminderCalendarEventId)
: undefined,
reminderCalendarHtmlLink: body.reminderCalendarHtmlLink
? String(body.reminderCalendarHtmlLink)
: undefined,
reminderNote: body.reminderNote ? String(body.reminderNote) : undefined,
warmTransferStatus: body.warmTransferStatus || undefined,
warmTransferTarget: body.warmTransferTarget
? String(body.warmTransferTarget)
: undefined,
warmTransferAttemptedAt:
typeof body.warmTransferAttemptedAt === "number"
? body.warmTransferAttemptedAt
: undefined,
warmTransferConnectedAt:
typeof body.warmTransferConnectedAt === "number"
? body.warmTransferConnectedAt
: undefined,
warmTransferFailureReason: body.warmTransferFailureReason
? String(body.warmTransferFailureReason)
: undefined,
})
return NextResponse.json({ success: true, call: result }) return NextResponse.json({ success: true, call: result });
} catch (error) { } catch (error) {
console.error("Failed to link phone call lead:", error) console.error("Failed to link phone call lead:", error);
return NextResponse.json( return NextResponse.json({ error: "Failed to link phone call lead" }, { status: 500 });
{ error: "Failed to link phone call lead" },
{ status: 500 }
)
} }
} }

View file

@ -1,51 +1,51 @@
import { timingSafeEqual } from "node:crypto" import { timingSafeEqual } from "node:crypto";
import { NextResponse } from "next/server" import { NextResponse } from "next/server";
import { hasConvexUrl } from "@/lib/convex-config" import { hasConvexUrl } from "@/lib/convex-config";
function readBearerToken(request: Request) { function readBearerToken(request: Request) {
const authHeader = request.headers.get("authorization") || "" const authHeader = request.headers.get("authorization") || "";
if (!authHeader.toLowerCase().startsWith("bearer ")) { 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) { function tokensMatch(expected: string, provided: string) {
const expectedBuffer = Buffer.from(expected) const expectedBuffer = Buffer.from(expected);
const providedBuffer = Buffer.from(provided) const providedBuffer = Buffer.from(provided);
if (expectedBuffer.length !== providedBuffer.length) { if (expectedBuffer.length !== providedBuffer.length) {
return false return false;
} }
return timingSafeEqual(expectedBuffer, providedBuffer) return timingSafeEqual(expectedBuffer, providedBuffer);
} }
export function getPhoneAgentInternalToken() { 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) { export async function requirePhoneAgentInternalAuth(request: Request) {
if (!hasConvexUrl()) { if (!hasConvexUrl()) {
return NextResponse.json( return NextResponse.json(
{ error: "Convex is not configured for phone call sync" }, { error: "Convex is not configured for phone call sync" },
{ status: 503 } { status: 503 },
) );
} }
const configuredToken = getPhoneAgentInternalToken() const configuredToken = getPhoneAgentInternalToken();
if (!configuredToken) { if (!configuredToken) {
return NextResponse.json( return NextResponse.json(
{ error: "Phone call sync token is not configured" }, { 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)) { 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 { NextResponse } from "next/server";
import { fetchMutation, fetchQuery } from "convex/nextjs" import { fetchMutation } from "convex/nextjs";
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api";
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared" import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared";
import { normalizePhoneE164 } from "@/lib/phone-normalization"
export async function POST(request: Request) { export async function POST(request: Request) {
const authError = await requirePhoneAgentInternalAuth(request) const authError = await requirePhoneAgentInternalAuth(request);
if (authError) { if (authError) {
return authError return authError;
} }
try { try {
const body = await request.json() const body = await request.json();
let metadata: Record<string, unknown> = {} const result = await fetchMutation(api.voiceSessions.upsertPhoneCallSession, {
if (typeof body.metadata === "string" && body.metadata.trim()) { roomName: String(body.roomName || ""),
try { participantIdentity: String(body.participantIdentity || ""),
metadata = JSON.parse(body.metadata) siteUrl: body.siteUrl ? String(body.siteUrl) : undefined,
} catch { pathname: body.pathname ? String(body.pathname) : undefined,
metadata = {} pageUrl: body.pageUrl ? String(body.pageUrl) : undefined,
} source: "phone-agent",
} metadata: body.metadata ? String(body.metadata) : undefined,
const callerPhone = normalizePhoneE164( startedAt: typeof body.startedAt === "number" ? body.startedAt : undefined,
metadata.participantPhone || body.participantIdentity recordingDisclosureAt:
) typeof body.recordingDisclosureAt === "number" ? body.recordingDisclosureAt : undefined,
const contactContext = callerPhone recordingStatus: body.recordingStatus || "pending",
? await fetchQuery(api.voiceSessions.getPhoneAgentContextByPhone, { });
normalizedPhone: callerPhone,
})
: null
const result = await fetchMutation(
api.voiceSessions.upsertPhoneCallSession,
{
roomName: String(body.roomName || ""),
participantIdentity: String(body.participantIdentity || ""),
callerPhone: callerPhone || undefined,
siteUrl: body.siteUrl ? String(body.siteUrl) : undefined,
pathname: body.pathname ? String(body.pathname) : undefined,
pageUrl: body.pageUrl ? String(body.pageUrl) : undefined,
source: "phone-agent",
metadata: body.metadata ? String(body.metadata) : undefined,
contactProfileId: contactContext?.contactProfile?.id,
contactDisplayName:
contactContext?.contactProfile?.displayName ||
(contactContext?.recentLead
? `${contactContext.recentLead.firstName} ${contactContext.recentLead.lastName}`.trim()
: undefined),
contactCompany:
contactContext?.contactProfile?.company ||
contactContext?.recentLead?.company ||
undefined,
startedAt:
typeof body.startedAt === "number" ? body.startedAt : undefined,
recordingDisclosureAt:
typeof body.recordingDisclosureAt === "number"
? body.recordingDisclosureAt
: undefined,
recordingStatus: body.recordingStatus || "pending",
}
)
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
sessionId: result?._id, sessionId: result?._id,
roomName: result?.roomName, roomName: result?.roomName,
callerPhone, });
contactProfile: contactContext?.contactProfile || null,
recentLead: contactContext?.recentLead || null,
recentSession: contactContext?.recentSession || null,
})
} catch (error) { } catch (error) {
console.error("Failed to start phone call sync:", error) console.error("Failed to start phone call sync:", error);
return NextResponse.json( return NextResponse.json({ error: "Failed to start phone call sync" }, { status: 500 });
{ error: "Failed to start phone call sync" },
{ status: 500 }
)
} }
} }

View file

@ -1,16 +1,16 @@
import { NextResponse } from "next/server" import { NextResponse } from "next/server";
import { fetchMutation } from "convex/nextjs" import { fetchMutation } from "convex/nextjs";
import { api } from "@/convex/_generated/api" import { api } from "@/convex/_generated/api";
import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared" import { requirePhoneAgentInternalAuth } from "@/app/api/internal/phone-calls/shared";
export async function POST(request: Request) { export async function POST(request: Request) {
const authError = await requirePhoneAgentInternalAuth(request) const authError = await requirePhoneAgentInternalAuth(request);
if (authError) { if (authError) {
return authError return authError;
} }
try { try {
const body = await request.json() const body = await request.json();
await fetchMutation(api.voiceSessions.addTranscriptTurn, { await fetchMutation(api.voiceSessions.addTranscriptTurn, {
sessionId: body.sessionId, sessionId: body.sessionId,
roomName: String(body.roomName || ""), roomName: String(body.roomName || ""),
@ -21,16 +21,12 @@ export async function POST(request: Request) {
isFinal: typeof body.isFinal === "boolean" ? body.isFinal : undefined, isFinal: typeof body.isFinal === "boolean" ? body.isFinal : undefined,
language: body.language ? String(body.language) : undefined, language: body.language ? String(body.language) : undefined,
source: "phone-agent", source: "phone-agent",
createdAt: createdAt: typeof body.createdAt === "number" ? body.createdAt : undefined,
typeof body.createdAt === "number" ? body.createdAt : undefined, });
})
return NextResponse.json({ success: true }) return NextResponse.json({ success: true });
} catch (error) { } catch (error) {
console.error("Failed to append phone call turn:", error) console.error("Failed to append phone call turn:", error);
return NextResponse.json( return NextResponse.json({ error: "Failed to append phone call turn" }, { status: 500 });
{ error: "Failed to append phone call turn" },
{ status: 500 }
)
} }
} }

View file

@ -11,19 +11,11 @@ type TokenRequestBody = {
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
const body = (await request.json().catch(() => ({}))) as TokenRequestBody const body = (await request.json().catch(() => ({}))) as TokenRequestBody
const pathname = const pathname = typeof body.pathname === "string" && body.pathname.trim() ? body.pathname.trim() : "/"
typeof body.pathname === "string" && body.pathname.trim()
? body.pathname.trim()
: "/"
if (isVoiceAssistantSuppressedRoute(pathname)) { if (isVoiceAssistantSuppressedRoute(pathname)) {
console.info("[voice-assistant/token] blocked on suppressed route", { console.info("[voice-assistant/token] blocked on suppressed route", { pathname })
pathname, return NextResponse.json({ error: "Voice assistant is not available on this route." }, { status: 403 })
})
return NextResponse.json(
{ error: "Voice assistant is not available on this route." },
{ status: 403 }
)
} }
const tokenResponse = await createVoiceAssistantTokenResponse(pathname) const tokenResponse = await createVoiceAssistantTokenResponse(pathname)
@ -38,12 +30,9 @@ export async function POST(request: NextRequest) {
return NextResponse.json( return NextResponse.json(
{ {
error: error: error instanceof Error ? error.message : "Failed to create voice assistant token",
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( export async function GET(
_request: Request, _request: Request,
{ params }: { params: Promise<{ path: string[] }> } { params }: { params: Promise<{ path: string[] }> },
) { ) {
try { try {
const { path: pathArray } = await params const { path: pathArray } = await params
@ -59,9 +59,7 @@ export async function GET(
return new NextResponse("File not found", { status: 404 }) return new NextResponse("File not found", { status: 404 })
} }
const fileToRead = existsSync(normalizedFullPath) const fileToRead = existsSync(normalizedFullPath) ? normalizedFullPath : fullPath
? normalizedFullPath
: fullPath
const resolvedPath = fileToRead.replace(/\\/g, "/") const resolvedPath = fileToRead.replace(/\\/g, "/")
if (!resolvedPath.startsWith(normalizedManualsDir)) { if (!resolvedPath.startsWith(normalizedManualsDir)) {
return new NextResponse("Invalid path", { status: 400 }) return new NextResponse("Invalid path", { status: 400 })

View file

@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server" import { NextRequest, NextResponse } from 'next/server'
import { requireAdminToken } from "@/lib/server/admin-auth" import { requireAdminToken } from '@/lib/server/admin-auth'
// Order types // Order types
interface OrderItem { interface OrderItem {
@ -17,7 +17,7 @@ interface Order {
items: OrderItem[] items: OrderItem[]
totalAmount: number totalAmount: number
currency: string currency: string
status: "pending" | "paid" | "fulfilled" | "cancelled" | "refunded" status: 'pending' | 'paid' | 'fulfilled' | 'cancelled' | 'refunded'
paymentIntentId: string | null paymentIntentId: string | null
stripeSessionId: string | null stripeSessionId: string | null
createdAt: string createdAt: string
@ -38,7 +38,7 @@ let orders: Order[] = []
// Generate a simple ID for demo // Generate a simple ID for demo
function generateOrderId(): string { 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) { export async function GET(request: NextRequest) {
@ -49,29 +49,26 @@ export async function GET(request: NextRequest) {
try { try {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get("page") || "1") const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get("limit") || "10") const limit = parseInt(searchParams.get('limit') || '10')
const status = searchParams.get("status") || undefined const status = searchParams.get('status') || undefined
const customerEmail = searchParams.get("customerEmail") || undefined const customerEmail = searchParams.get('customerEmail') || undefined
// Filter orders // Filter orders
let filteredOrders = [...orders] let filteredOrders = [...orders]
if (status) { if (status) {
filteredOrders = filteredOrders.filter((order) => order.status === status) filteredOrders = filteredOrders.filter(order => order.status === status)
} }
if (customerEmail) { if (customerEmail) {
filteredOrders = filteredOrders.filter((order) => filteredOrders = filteredOrders.filter(order =>
order.customerEmail.toLowerCase().includes(customerEmail.toLowerCase()) order.customerEmail.toLowerCase().includes(customerEmail.toLowerCase())
) )
} }
// Sort by creation date (newest first) // Sort by creation date (newest first)
filteredOrders.sort( filteredOrders.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)
// Pagination // Pagination
const startIndex = (page - 1) * limit const startIndex = (page - 1) * limit
@ -84,13 +81,13 @@ export async function GET(request: NextRequest) {
page, page,
limit, limit,
total: filteredOrders.length, total: filteredOrders.length,
totalPages: Math.ceil(filteredOrders.length / limit), totalPages: Math.ceil(filteredOrders.length / limit)
}, }
}) })
} catch (error) { } catch (error) {
console.error("Error fetching orders:", error) console.error('Error fetching orders:', error)
return NextResponse.json( return NextResponse.json(
{ error: "Failed to fetch orders" }, { error: 'Failed to fetch orders' },
{ status: 500 } { status: 500 }
) )
} }
@ -104,43 +101,40 @@ export async function POST(request: NextRequest) {
try { try {
const body = await request.json() const body = await request.json()
const { const { items, customerEmail, paymentIntentId, stripeSessionId, shippingAddress } = body
items,
customerEmail,
paymentIntentId,
stripeSessionId,
shippingAddress,
} = body
// Validate required fields // Validate required fields
if (!items || !Array.isArray(items) || items.length === 0) { 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) { if (!customerEmail) {
return NextResponse.json( return NextResponse.json(
{ error: "Customer email is required" }, { error: 'Customer email is required' },
{ status: 400 } { status: 400 }
) )
} }
if (!paymentIntentId) { if (!paymentIntentId) {
return NextResponse.json( return NextResponse.json(
{ error: "Payment intent ID is required" }, { error: 'Payment intent ID is required' },
{ status: 400 } { status: 400 }
) )
} }
if (!stripeSessionId) { if (!stripeSessionId) {
return NextResponse.json( return NextResponse.json(
{ error: "Stripe session ID is required" }, { error: 'Stripe session ID is required' },
{ status: 400 } { status: 400 }
) )
} }
// Calculate total // Calculate total
const totalAmount = items.reduce((total: number, item: OrderItem) => { const totalAmount = items.reduce((total: number, item: OrderItem) => {
return total + item.price * item.quantity return total + (item.price * item.quantity)
}, 0) }, 0)
// Create order // Create order
@ -150,22 +144,22 @@ export async function POST(request: NextRequest) {
customerEmail, customerEmail,
items, items,
totalAmount, totalAmount,
currency: "usd", currency: 'usd',
status: "paid", // Assume payment was successful since webhook was triggered status: 'paid', // Assume payment was successful since webhook was triggered
paymentIntentId, paymentIntentId,
stripeSessionId, stripeSessionId,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
shippingAddress, shippingAddress
} }
orders.unshift(newOrder) // Add to beginning of array orders.unshift(newOrder) // Add to beginning of array
return NextResponse.json(newOrder, { status: 201 }) return NextResponse.json(newOrder, { status: 201 })
} catch (error) { } catch (error) {
console.error("Error creating order:", error) console.error('Error creating order:', error)
return NextResponse.json( return NextResponse.json(
{ error: "Failed to create order" }, { error: 'Failed to create order' },
{ status: 500 } { status: 500 }
) )
} }

View file

@ -1,13 +1,13 @@
import { NextRequest, NextResponse } from "next/server" import { NextRequest, NextResponse } from 'next/server'
import { getStripeClient } from "@/lib/stripe/client" import { getStripeClient } from '@/lib/stripe/client'
import { requireAdminToken } from "@/lib/server/admin-auth" import { requireAdminToken } from '@/lib/server/admin-auth'
import { import {
fetchAllProducts, fetchAllProducts,
fetchProductById, fetchProductById,
createProductInStripe, createProductInStripe,
updateProductInStripe, updateProductInStripe,
deactivateProductInStripe, deactivateProductInStripe
} from "@/lib/stripe/products" } from '@/lib/stripe/products'
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const authError = requireAdminToken(request) const authError = requireAdminToken(request)
@ -17,10 +17,10 @@ export async function GET(request: NextRequest) {
try { try {
const { searchParams } = new URL(request.url) const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get("page") || "1") const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get("limit") || "20") const limit = parseInt(searchParams.get('limit') || '20')
const search = searchParams.get("search") || undefined const search = searchParams.get('search') || undefined
const category = searchParams.get("category") || undefined const category = searchParams.get('category') || undefined
// Get all products from Stripe // Get all products from Stripe
const products = await fetchAllProducts() const products = await fetchAllProducts()
@ -30,16 +30,15 @@ export async function GET(request: NextRequest) {
if (search) { if (search) {
const searchTerm = search.toLowerCase() const searchTerm = search.toLowerCase()
filteredProducts = filteredProducts.filter( filteredProducts = filteredProducts.filter(product =>
(product) => product.name.toLowerCase().includes(searchTerm) ||
product.name.toLowerCase().includes(searchTerm) || product.description?.toLowerCase().includes(searchTerm)
product.description?.toLowerCase().includes(searchTerm)
) )
} }
// TODO: Implement category filtering based on metadata // TODO: Implement category filtering based on metadata
// if (category) { // if (category) {
// filteredProducts = filteredProducts.filter(product => // filteredProducts = filteredProducts.filter(product =>
// product.metadata?.category === category // product.metadata?.category === category
// ) // )
// } // }
@ -58,13 +57,13 @@ export async function GET(request: NextRequest) {
page, page,
limit, limit,
total: filteredProducts.length, total: filteredProducts.length,
totalPages: Math.ceil(filteredProducts.length / limit), totalPages: Math.ceil(filteredProducts.length / limit)
}, }
}) })
} catch (error) { } catch (error) {
console.error("Error fetching admin products:", error) console.error('Error fetching admin products:', error)
return NextResponse.json( return NextResponse.json(
{ error: "Failed to fetch products" }, { error: 'Failed to fetch products' },
{ status: 500 } { status: 500 }
) )
} }
@ -83,14 +82,14 @@ export async function POST(request: NextRequest) {
// Validate required fields // Validate required fields
if (!name || !price) { if (!name || !price) {
return NextResponse.json( return NextResponse.json(
{ error: "Name and price are required" }, { error: 'Name and price are required' },
{ status: 400 } { status: 400 }
) )
} }
if (typeof price !== "number" || price <= 0) { if (typeof price !== 'number' || price <= 0) {
return NextResponse.json( return NextResponse.json(
{ error: "Price must be a positive number" }, { error: 'Price must be a positive number' },
{ status: 400 } { status: 400 }
) )
} }
@ -98,25 +97,25 @@ export async function POST(request: NextRequest) {
// Create product in Stripe // Create product in Stripe
const result = await createProductInStripe({ const result = await createProductInStripe({
name, name,
description: description || "", description: description || '',
price, price,
currency: currency || "usd", currency: currency || 'usd',
images: images || [], images: images || [],
metadata: metadata || {}, metadata: metadata || {}
}) })
if (!result) { if (!result) {
return NextResponse.json( return NextResponse.json(
{ error: "Failed to create product in Stripe" }, { error: 'Failed to create product in Stripe' },
{ status: 500 } { status: 500 }
) )
} }
return NextResponse.json(result, { status: 201 }) return NextResponse.json(result, { status: 201 })
} catch (error) { } catch (error) {
console.error("Error creating product:", error) console.error('Error creating product:', error)
return NextResponse.json( return NextResponse.json(
{ error: "Failed to create product" }, { error: 'Failed to create product' },
{ status: 500 } { status: 500 }
) )
} }
@ -135,7 +134,7 @@ export async function PUT(request: NextRequest) {
if (!action || !Array.isArray(productIds) || productIds.length === 0) { if (!action || !Array.isArray(productIds) || productIds.length === 0) {
return NextResponse.json( return NextResponse.json(
{ error: "Action and product IDs are required" }, { error: 'Action and product IDs are required' },
{ status: 400 } { status: 400 }
) )
} }
@ -144,10 +143,10 @@ export async function PUT(request: NextRequest) {
const results = [] const results = []
switch (action) { switch (action) {
case "update": case 'update':
if (!updates) { if (!updates) {
return NextResponse.json( return NextResponse.json(
{ error: "Updates are required for action: update" }, { error: 'Updates are required for action: update' },
{ status: 400 } { status: 400 }
) )
} }
@ -158,31 +157,31 @@ export async function PUT(request: NextRequest) {
results.push({ results.push({
productId, productId,
success: true, success: true,
data: result, data: result
}) })
} catch (error) { } catch (error) {
results.push({ results.push({
productId, productId,
success: false, success: false,
error: error instanceof Error ? error.message : "Unknown error", error: error instanceof Error ? error.message : 'Unknown error'
}) })
} }
} }
break break
case "deactivate": case 'deactivate':
for (const productId of productIds) { for (const productId of productIds) {
try { try {
const success = await deactivateProductInStripe(productId) const success = await deactivateProductInStripe(productId)
results.push({ results.push({
productId, productId,
success, success
}) })
} catch (error) { } catch (error) {
results.push({ results.push({
productId, productId,
success: false, 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, results,
summary: { summary: {
total: productIds.length, total: productIds.length,
successful: results.filter((r) => r.success).length, successful: results.filter(r => r.success).length,
failed: results.filter((r) => !r.success).length, failed: results.filter(r => !r.success).length
}, }
}) })
} catch (error) { } catch (error) {
console.error("Error bulk updating products:", error) console.error('Error bulk updating products:', error)
return NextResponse.json( return NextResponse.json(
{ error: "Failed to bulk update products" }, { error: 'Failed to bulk update products' },
{ status: 500 } { status: 500 }
) )
} }

View file

@ -2,12 +2,12 @@ import { NextRequest, NextResponse } from "next/server"
import { getGSCConfig } from "@/lib/google-search-console" import { getGSCConfig } from "@/lib/google-search-console"
// API routes are not supported in static export (GHL hosting) // 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 * API Route for requesting URL indexing in Google Search Console
* POST /api/request-indexing * POST /api/request-indexing
* *
* Body: { url: string } * Body: { url: string }
* NOTE: This route is disabled for static export. * NOTE: This route is disabled for static export.
*/ */
@ -68,14 +68,13 @@ export async function POST(request: NextRequest) {
success: true, success: true,
message: "Indexing request endpoint ready", message: "Indexing request endpoint ready",
url, 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) { } catch (error) {
return NextResponse.json( return NextResponse.json(
{ {
success: false, success: false,
error: error: error instanceof Error ? error.message : "Unknown error occurred",
error instanceof Error ? error.message : "Unknown error occurred",
}, },
{ status: 500 } { status: 500 }
) )
@ -91,7 +90,9 @@ export async function GET() {
return NextResponse.json({ return NextResponse.json({
message: "Google Search Console URL Indexing Request API", message: "Google Search Console URL Indexing Request API",
configured: !!(config.serviceAccountEmail && config.privateKey), configured: !!(config.serviceAccountEmail && config.privateKey),
instructions: instructions: "POST to this endpoint with { url: 'https://example.com/page' } in body",
"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