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
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,39 +1,58 @@
|
||||||
import { notFound } from "next/navigation"
|
import { notFound } from 'next/navigation';
|
||||||
import { generateRegistryMetadata, generateRegistryStructuredData } from "@/lib/seo"
|
import { loadImageMapping } from '@/lib/wordpress-content';
|
||||||
import { getPageBySlug } from "@/lib/wordpress-data-loader"
|
import { generateSEOMetadata, generateStructuredData } from '@/lib/seo';
|
||||||
import { AboutPage } from "@/components/about-page"
|
import { getPageBySlug } from '@/lib/wordpress-data-loader';
|
||||||
import type { Metadata } from "next"
|
import { AboutPage } from '@/components/about-page';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
const WORDPRESS_SLUG = "about-us"
|
const WORDPRESS_SLUG = 'about-us';
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const page = getPageBySlug(WORDPRESS_SLUG)
|
const page = getPageBySlug(WORDPRESS_SLUG);
|
||||||
|
|
||||||
if (!page) {
|
if (!page) {
|
||||||
return {
|
return {
|
||||||
title: "Page Not Found | Rocky Mountain Vending",
|
title: 'Page Not Found | Rocky Mountain Vending',
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return generateRegistryMetadata("aboutUs", {
|
return generateSEOMetadata({
|
||||||
|
title: page.title || 'About Us',
|
||||||
|
description: page.seoDescription || page.excerpt || '',
|
||||||
|
excerpt: page.excerpt,
|
||||||
date: page.date,
|
date: page.date,
|
||||||
modified: page.modified,
|
modified: page.modified,
|
||||||
image: page.images?.[0]?.localPath,
|
image: page.images?.[0]?.localPath,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function AboutUsPage() {
|
export default async function AboutUsPage() {
|
||||||
try {
|
try {
|
||||||
const page = getPageBySlug(WORDPRESS_SLUG)
|
const page = getPageBySlug(WORDPRESS_SLUG);
|
||||||
|
|
||||||
if (!page) {
|
if (!page) {
|
||||||
notFound()
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
const structuredData = generateRegistryStructuredData("aboutUs", {
|
let structuredData;
|
||||||
datePublished: page.date,
|
try {
|
||||||
dateModified: page.modified || page.date,
|
structuredData = generateStructuredData({
|
||||||
})
|
title: page.title || 'About Us',
|
||||||
|
description: page.seoDescription || page.excerpt || '',
|
||||||
|
url: page.link || page.urlPath || `https://rockymountainvending.com/about-us/`,
|
||||||
|
datePublished: page.date,
|
||||||
|
dateModified: page.modified || page.date,
|
||||||
|
type: 'WebPage',
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
structuredData = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'WebPage',
|
||||||
|
headline: page.title || 'About Us',
|
||||||
|
description: page.seoDescription || '',
|
||||||
|
url: `https://rockymountainvending.com/about-us/`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -43,11 +62,19 @@ export default async function AboutUsPage() {
|
||||||
/>
|
/>
|
||||||
<AboutPage />
|
<AboutPage />
|
||||||
</>
|
</>
|
||||||
)
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (process.env.NODE_ENV === "development") {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.error("Error rendering About Us page:", error)
|
console.error('Error rendering About Us page:', error);
|
||||||
}
|
}
|
||||||
notFound()
|
notFound();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
datePublished: page.date,
|
structuredData = generateStructuredData({
|
||||||
dateModified: page.modified || page.date,
|
title: page.title || 'FAQs',
|
||||||
})
|
description: page.seoDescription || page.excerpt || '',
|
||||||
|
url: page.link || page.urlPath || `https://rockymountainvending.com/about/faqs/`,
|
||||||
|
datePublished: page.date,
|
||||||
|
dateModified: page.modified || page.date,
|
||||||
|
type: 'WebPage',
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
structuredData = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'WebPage',
|
||||||
|
headline: page.title || 'FAQs',
|
||||||
|
description: page.seoDescription || '',
|
||||||
|
url: `https://rockymountainvending.com/about/faqs/`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -77,27 +86,28 @@ export default async function FAQsPage() {
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
|
||||||
/>
|
/>
|
||||||
{faqs.length > 0 && (
|
{faqs.length > 0 && (
|
||||||
<div className="public-page">
|
<>
|
||||||
<Breadcrumbs
|
|
||||||
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,46 +263,32 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error:
|
error: "Jessica is temporarily unavailable right now. Please call us or use the contact form.",
|
||||||
"Jessica is temporarily unavailable right now. Please call us or use the contact form.",
|
|
||||||
sessionId,
|
sessionId,
|
||||||
},
|
},
|
||||||
{ status: 503, headers: responseHeaders }
|
{ status: 503, headers: responseHeaders },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const completionResponse = await fetch(
|
const completionResponse = await fetch("https://api.x.ai/v1/chat/completions", {
|
||||||
"https://api.x.ai/v1/chat/completions",
|
method: "POST",
|
||||||
{
|
headers: {
|
||||||
method: "POST",
|
Authorization: `Bearer ${xaiApiKey}`,
|
||||||
headers: {
|
"Content-Type": "application/json",
|
||||||
Authorization: `Bearer ${xaiApiKey}`,
|
},
|
||||||
"Content-Type": "application/json",
|
body: JSON.stringify({
|
||||||
},
|
model: SITE_CHAT_MODEL,
|
||||||
body: JSON.stringify({
|
temperature: SITE_CHAT_TEMPERATURE,
|
||||||
model: SITE_CHAT_MODEL,
|
max_tokens: SITE_CHAT_MAX_OUTPUT_TOKENS,
|
||||||
temperature: SITE_CHAT_TEMPERATURE,
|
messages: [
|
||||||
max_tokens: SITE_CHAT_MAX_OUTPUT_TOKENS,
|
{
|
||||||
messages: [
|
role: "system",
|
||||||
{
|
content: `${SITE_CHAT_SYSTEM_PROMPT}\n\nConversation context:\n- Current pathname: ${pathname}\n- Source: ${SITE_CHAT_SOURCE}\n- Visitor name: ${visitor.name}\n- Visitor email: ${visitor.email}\n- Visitor phone: ${visitor.phone}\n- Visitor intent: ${visitor.intent}\n- Service SMS consent: ${visitor.serviceTextConsent ? "yes" : "no"}\n- Marketing SMS consent: ${visitor.marketingTextConsent ? "yes" : "no"}`,
|
||||||
role: "system",
|
},
|
||||||
content: `${systemPrompt}\n\nConversation context:\n- Current pathname: ${pathname}\n- Source: ${SITE_CHAT_SOURCE}\n- Visitor name: ${visitor.name}\n- Visitor email: ${visitor.email}\n- Visitor phone: ${visitor.phone}\n- Visitor intent: ${visitor.intent}\n- Service SMS consent: ${visitor.serviceTextConsent ? "yes" : "no"}\n- Marketing SMS consent: ${visitor.marketingTextConsent ? "yes" : "no"}`,
|
...messages,
|
||||||
},
|
],
|
||||||
...(shouldUseManualKnowledge
|
}),
|
||||||
? [
|
})
|
||||||
{
|
|
||||||
role: "system" as const,
|
|
||||||
content: manualKnowledge
|
|
||||||
? formatManualContextForPrompt(manualKnowledge)
|
|
||||||
: "Manual knowledge context:\n- A manual lookup was attempted, but no reliable manual context is available.\n- Do not guess. Ask for the brand, model sticker, or a clear photo/video that can be texted in.",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
...messages,
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
const completionData = await completionResponse.json().catch(() => ({}))
|
const completionData = await completionResponse.json().catch(() => ({}))
|
||||||
|
|
||||||
|
|
@ -406,11 +302,10 @@ export async function POST(request: NextRequest) {
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error:
|
error: "Jessica is having trouble replying right now. Please try again or call us directly.",
|
||||||
"Jessica is having trouble replying right now. Please try again or call us directly.",
|
|
||||||
sessionId,
|
sessionId,
|
||||||
},
|
},
|
||||||
{ status: 502, headers: responseHeaders }
|
{ status: 502, headers: responseHeaders },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -422,15 +317,11 @@ export async function POST(request: NextRequest) {
|
||||||
error: "Jessica did not return a usable reply. Please try again.",
|
error: "Jessica did not return a usable reply. Please try again.",
|
||||||
sessionId,
|
sessionId,
|
||||||
},
|
},
|
||||||
{ status: 502, headers: responseHeaders }
|
{ status: 502, headers: responseHeaders },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
consumeChatOutput({
|
consumeChatOutput({ chars: assistantReply.length, outputWindowMs: SITE_CHAT_OUTPUT_WINDOW_MS, sessionId })
|
||||||
chars: assistantReply.length,
|
|
||||||
outputWindowMs: SITE_CHAT_OUTPUT_WINDOW_MS,
|
|
||||||
sessionId,
|
|
||||||
})
|
|
||||||
|
|
||||||
const nextLimitStatus = getChatRateLimitStatus({
|
const nextLimitStatus = getChatRateLimitStatus({
|
||||||
ip,
|
ip,
|
||||||
|
|
@ -448,7 +339,7 @@ export async function POST(request: NextRequest) {
|
||||||
sessionId,
|
sessionId,
|
||||||
limits: nextLimitStatus,
|
limits: nextLimitStatus,
|
||||||
},
|
},
|
||||||
{ headers: responseHeaders }
|
{ headers: responseHeaders },
|
||||||
)
|
)
|
||||||
|
|
||||||
response.cookies.set(SITE_CHAT_SESSION_COOKIE, sessionId, {
|
response.cookies.set(SITE_CHAT_SESSION_COOKIE, sessionId, {
|
||||||
|
|
@ -464,10 +355,7 @@ export async function POST(request: NextRequest) {
|
||||||
console.error("[site-chat] request failed", error)
|
console.error("[site-chat] request failed", error)
|
||||||
|
|
||||||
const safeError =
|
const safeError =
|
||||||
error instanceof Error &&
|
error instanceof Error && error.message.startsWith("Missing required site chat environment variable:")
|
||||||
error.message.startsWith(
|
|
||||||
"Missing required site chat environment variable:"
|
|
||||||
)
|
|
||||||
? "Jessica is temporarily unavailable right now. Please call us or use the contact form."
|
? "Jessica is temporarily unavailable right now. Please call us or use the contact form."
|
||||||
: error instanceof Error
|
: error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
|
|
@ -477,7 +365,7 @@ export async function POST(request: NextRequest) {
|
||||||
{
|
{
|
||||||
error: safeError,
|
error: safeError,
|
||||||
},
|
},
|
||||||
{ status: 500, headers: responseHeaders }
|
{ status: 500, headers: responseHeaders },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()) {
|
roomName: String(body.roomName || ""),
|
||||||
try {
|
participantIdentity: String(body.participantIdentity || ""),
|
||||||
metadata = JSON.parse(body.metadata)
|
siteUrl: body.siteUrl ? String(body.siteUrl) : undefined,
|
||||||
} catch {
|
pathname: body.pathname ? String(body.pathname) : undefined,
|
||||||
metadata = {}
|
pageUrl: body.pageUrl ? String(body.pageUrl) : undefined,
|
||||||
}
|
source: "phone-agent",
|
||||||
}
|
metadata: body.metadata ? String(body.metadata) : undefined,
|
||||||
const callerPhone = normalizePhoneE164(
|
startedAt: typeof body.startedAt === "number" ? body.startedAt : undefined,
|
||||||
metadata.participantPhone || body.participantIdentity
|
recordingDisclosureAt:
|
||||||
)
|
typeof body.recordingDisclosureAt === "number" ? body.recordingDisclosureAt : undefined,
|
||||||
const contactContext = callerPhone
|
recordingStatus: body.recordingStatus || "pending",
|
||||||
? await fetchQuery(api.voiceSessions.getPhoneAgentContextByPhone, {
|
});
|
||||||
normalizedPhone: callerPhone,
|
|
||||||
})
|
|
||||||
: null
|
|
||||||
|
|
||||||
const result = await fetchMutation(
|
|
||||||
api.voiceSessions.upsertPhoneCallSession,
|
|
||||||
{
|
|
||||||
roomName: String(body.roomName || ""),
|
|
||||||
participantIdentity: String(body.participantIdentity || ""),
|
|
||||||
callerPhone: callerPhone || undefined,
|
|
||||||
siteUrl: body.siteUrl ? String(body.siteUrl) : undefined,
|
|
||||||
pathname: body.pathname ? String(body.pathname) : undefined,
|
|
||||||
pageUrl: body.pageUrl ? String(body.pageUrl) : undefined,
|
|
||||||
source: "phone-agent",
|
|
||||||
metadata: body.metadata ? String(body.metadata) : undefined,
|
|
||||||
contactProfileId: contactContext?.contactProfile?.id,
|
|
||||||
contactDisplayName:
|
|
||||||
contactContext?.contactProfile?.displayName ||
|
|
||||||
(contactContext?.recentLead
|
|
||||||
? `${contactContext.recentLead.firstName} ${contactContext.recentLead.lastName}`.trim()
|
|
||||||
: undefined),
|
|
||||||
contactCompany:
|
|
||||||
contactContext?.contactProfile?.company ||
|
|
||||||
contactContext?.recentLead?.company ||
|
|
||||||
undefined,
|
|
||||||
startedAt:
|
|
||||||
typeof body.startedAt === "number" ? body.startedAt : undefined,
|
|
||||||
recordingDisclosureAt:
|
|
||||||
typeof body.recordingDisclosureAt === "number"
|
|
||||||
? body.recordingDisclosureAt
|
|
||||||
: undefined,
|
|
||||||
recordingStatus: body.recordingStatus || "pending",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
sessionId: result?._id,
|
sessionId: result?._id,
|
||||||
roomName: result?.roomName,
|
roomName: result?.roomName,
|
||||||
callerPhone,
|
});
|
||||||
contactProfile: contactContext?.contactProfile || null,
|
|
||||||
recentLead: contactContext?.recentLead || null,
|
|
||||||
recentSession: contactContext?.recentSession || null,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to start phone call sync:", error)
|
console.error("Failed to start phone call sync:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json({ error: "Failed to start phone call sync" }, { status: 500 });
|
||||||
{ error: "Failed to start phone call sync" },
|
|
||||||
{ status: 500 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,10 +30,9 @@ 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