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